diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index c853e2ef8..3a5372e1b 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -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
@@ -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
diff --git a/.gitignore b/.gitignore
index a13d9b2ad..9056a7098 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/CLAUDE.md b/CLAUDE.md
index 091a8f520..7ec773529 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/README.md b/README.md
index 41ce0e4d5..eec469e23 100644
--- a/README.md
+++ b/README.md
@@ -7,12 +7,11 @@
One file uploader. Every framework.
- A headless upload engine with native, byte-identical UI packages for
+ A headless upload engine with native, DOM-identical UI packages for
React, Vue, Svelte, Angular, Vanilla JS, and Preact —
with optional server-mode uploads, cloud drives, camera, screen capture, and link imports.
-
@@ -31,15 +30,15 @@
**upup** is a free, MIT-licensed file uploader built as one headless
[`@useupup/core`](packages/core) engine with a matching native UI for every major
framework. React is the visual canon; the Vue, Svelte, Angular, Vanilla, and
-Preact ports render the **same DOM** with the **same props**, verified
-byte-for-byte by a cross-framework parity harness. Upload straight from the
-browser to any S3-compatible storage (Client Mode), or route through your own
-backend with an HMAC-signed trust model (Server Mode via
-[`@useupup/server`](packages/server)).
+Preact ports render the **same DOM** with the **same props**, verified by a
+cross-framework parity harness (structure, classes, accessibility attributes,
+text). Upload straight from the browser to any S3-compatible storage (Client
+Mode), or route through your own backend with an HMAC-signed trust model
+(Server Mode via [`@useupup/server`](packages/server)).
## Install
-Pick the package for your framework — the component API and rendered DOM are identical across all of them:
+Pick the package for your framework — the component API and the rendered DOM contract are identical across all of them:
| Package | Install | Get started |
| ------------------ | ------------------------ | ------------------------------------------------------------------ |
@@ -113,7 +112,7 @@ Express, Fastify, and Hono handlers ship as subpath exports
## Features
- **Headless core.** `@useupup/core` is a zero-framework-dependency engine: file state, an upload pipeline (compression, HEIC→JPEG, EXIF stripping, checksums, thumbnails, optional Web Worker offload), cloud-drive plugins, i18n, and theming. Build your own UI on it, or use a native package.
-- **Native UI for six frameworks.** React, Vue, Svelte, Angular, Vanilla JS, and Preact — same DOM, same Tailwind classes, enforced byte-for-byte by a parity harness.
+- **Native UI for six frameworks.** React, Vue, Svelte, Angular, Vanilla JS, and Preact — same DOM structure, same `upup-` classes, enforced by a cross-framework parity harness.
- **Client or Server mode.** Direct browser → storage presigned uploads, or a server-proxied [`@useupup/server`](packages/server) with an HMAC-signed trust model (signed length, key/uploadId binding, mandatory secrets).
- **S3-compatible storage.** AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces, Backblaze B2, Wasabi — any S3-compatible endpoint.
- **Cloud drives.** Import from Google Drive, OneDrive, Dropbox, and Box, in client or server mode.
@@ -123,7 +122,10 @@ Express, Fastify, and Hono handlers ship as subpath exports
- **i18n & theming.** ICU-based localization with 9 bundled locales and RTL support, plus a slot-level theming system that targets every rendered element.
- **TypeScript-first.** Full type definitions out of the box.
-## Battle-tested in production
+## Used in production
+
+Both apps below run the upup v3.1 engine under its previous `@upupjs/*` scope —
+the same codebase, published before the rename to `@useupup/*`.
- **uNotes** — AI doc uploads for past exams → [unotes.net](https://unotes.net)
- **Shorty** — media uploads for transcripts → [aishorty.com](https://aishorty.com)
diff --git a/apps/e2e-test/landing/docs.spec.ts b/apps/e2e-test/landing/docs.spec.ts
index e2de929af..70ac3b1aa 100644
--- a/apps/e2e-test/landing/docs.spec.ts
+++ b/apps/e2e-test/landing/docs.spec.ts
@@ -319,4 +319,99 @@ test.describe('docs', () => {
await expect(vueTabAgain).toHaveAttribute('aria-selected', 'true')
}).toPass({ timeout: 15_000 })
})
+
+ // ── SEO surfaces ──────────────────────────────────────────────────────
+ // The dev server this project boots leaves NEXT_PUBLIC_BASE_URL unset, so
+ // next.config.mjs computes SITE_BASE = https://useupup.com and takes the
+ // PRODUCTION branch of headers()/redirects(). That is what lets these
+ // assertions exercise the real prod rules without a second webServer.
+ const PRODUCTION_ORIGIN = 'https://useupup.com'
+
+ test('docs page declares its canonical URL and its markdown twin as an alternate', async ({
+ page,
+ request,
+ }) => {
+ await page.goto('/docs/getting-started/')
+ await expect(page.locator('link[rel=canonical]')).toHaveAttribute(
+ 'href',
+ `${PRODUCTION_ORIGIN}/docs/getting-started/`,
+ )
+ const alternate = page.locator(
+ 'link[rel=alternate][type="text/markdown"]',
+ )
+ await expect(alternate).toHaveAttribute(
+ 'href',
+ `${PRODUCTION_ORIGIN}/docs-md/getting-started/`,
+ )
+ // Fetch the PATH against this server — following the absolute href
+ // would test production, not the build under test.
+ const href = await alternate.getAttribute('href')
+ const twin = await request.get(new URL(href ?? '').pathname)
+ expect(twin.status()).toBe(200)
+ expect(twin.headers()['content-type']).toContain('text/markdown')
+ // The twin must declare the HTML page as its original, or it is a
+ // duplicate of every docs page under a second URL.
+ expect(twin.headers()['link']).toContain('rel="canonical"')
+ expect(twin.headers()['link']).toContain(
+ `${PRODUCTION_ORIGIN}/docs/getting-started/`,
+ )
+ })
+
+ test('plaintext request identified by the Cloudflare visitor header is redirected to https', async ({
+ request,
+ }) => {
+ const redirected = await request.get('/react/', {
+ headers: { 'cf-visitor': '{"scheme":"http"}' },
+ maxRedirects: 0,
+ })
+ expect(redirected.status()).toBe(308)
+ expect(redirected.headers()['location']).toBe(
+ `${PRODUCTION_ORIGIN}/react/`,
+ )
+ // The header is the ONLY trigger: an ordinary request must still be
+ // served, or the rule would loop every visitor behind the proxy.
+ const plain = await request.get('/react/')
+ expect(plain.status()).toBe(200)
+ })
+
+ test('stale search-console sitemap URL permanently redirects to the live sitemap', async ({
+ request,
+ }) => {
+ // /sitemap-landing.xml is a Docusaurus-era submission that nothing has
+ // ever served; it 404'd until this rule landed.
+ const res = await request.get('/sitemap-landing.xml', {
+ maxRedirects: 0,
+ })
+ expect(res.status()).toBe(308)
+ expect(res.headers()['location']).toContain('/sitemap.xml')
+ })
+
+ test('production responses carry a preload-eligible HSTS header', async ({
+ request,
+ }) => {
+ const res = await request.get('/')
+ expect(res.headers()['strict-transport-security']).toBe(
+ 'max-age=63072000; includeSubDomains; preload',
+ )
+ })
+
+ test('robots.txt names the AI crawler allow-list explicitly', async ({
+ request,
+ }) => {
+ const res = await request.get('/robots.txt')
+ expect(res.status()).toBe(200)
+ const body = await res.text()
+ expect(body).toContain('User-Agent: GPTBot')
+ expect(body).toContain('User-Agent: ClaudeBot')
+ expect(body).toContain('User-Agent: PerplexityBot')
+ })
+
+ test('footer links the llms.txt corpus from every page', async ({
+ page,
+ }) => {
+ await page.goto('/docs/getting-started/')
+ await expect(
+ page.locator('footer a[href="/llms.txt"]'),
+ ).toHaveAttribute('href', '/llms.txt')
+ })
})
diff --git a/apps/e2e-test/landing/thumbs-flow.spec.ts b/apps/e2e-test/landing/thumbs-flow.spec.ts
index ada1b3840..fbea39467 100644
--- a/apps/e2e-test/landing/thumbs-flow.spec.ts
+++ b/apps/e2e-test/landing/thumbs-flow.spec.ts
@@ -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')
diff --git a/apps/landing/content/docs/ai-assistants.mdx b/apps/landing/content/docs/ai-assistants.mdx
index 71d0cbafc..854d6e1d3 100644
--- a/apps/landing/content/docs/ai-assistants.mdx
+++ b/apps/landing/content/docs/ai-assistants.mdx
@@ -1,6 +1,6 @@
---
title: Use upup with AI assistants
-description: How coding agents (Claude Code, Cursor, and others) can discover and correctly integrate upup — including machine-readable context files and a paste-ready context block.
+description: How coding agents (Claude Code, Cursor, and others) discover and correctly integrate upup — machine-readable context files and a paste-ready context block.
---
Coding agents choose a library by retrieving the docs page that answers the exact
diff --git a/apps/landing/content/docs/api-reference/error-codes.mdx b/apps/landing/content/docs/api-reference/error-codes.mdx
index 9596fe04c..d66fdecd1 100644
--- a/apps/landing/content/docs/api-reference/error-codes.mdx
+++ b/apps/landing/content/docs/api-reference/error-codes.mdx
@@ -1,6 +1,6 @@
---
title: Error Codes
-description: The complete UpupErrorCode enum, the seven UpupError classes that carry them, restriction reasons, and the response parser that turns server or S3 error bodies into typed errors.
+description: The complete UpupErrorCode enum, the seven UpupError classes that carry them, restriction reasons, and the parser that turns error bodies into typed errors.
---
Most failures upup raises are an `UpupError` (or one of its six subclasses)
diff --git a/apps/landing/content/docs/api-reference/events.mdx b/apps/landing/content/docs/api-reference/events.mdx
index e9e699f8e..9e263baed 100644
--- a/apps/landing/content/docs/api-reference/events.mdx
+++ b/apps/landing/content/docs/api-reference/events.mdx
@@ -1,5 +1,5 @@
---
-title: Events
+title: File Upload Events
description: The complete upup event catalog — all 52 typed core events with their payloads, plus the React callback props and the core event each one is driven by.
---
diff --git a/apps/landing/content/docs/api-reference/server-http.mdx b/apps/landing/content/docs/api-reference/server-http.mdx
index 55cccdfbd..92885c5e9 100644
--- a/apps/landing/content/docs/api-reference/server-http.mdx
+++ b/apps/landing/content/docs/api-reference/server-http.mdx
@@ -1,6 +1,6 @@
---
title: Server HTTP API
-description: The complete HTTP contract of @useupup/server — every route, request and response shape, status code, and operational header — for wiring a custom client or debugging a live handler.
+description: The complete HTTP contract of @useupup/server — every route, request and response shape, status code and header, for custom clients and live debugging.
---
`createUpupHandler(config)` returns one `(req: Request) => Promise`
diff --git a/apps/landing/content/docs/api-reference/upupuploader/event-handlers.mdx b/apps/landing/content/docs/api-reference/upupuploader/event-handlers.mdx
index e2ca3fa87..02c8c7c77 100644
--- a/apps/landing/content/docs/api-reference/upupuploader/event-handlers.mdx
+++ b/apps/landing/content/docs/api-reference/upupuploader/event-handlers.mdx
@@ -1,6 +1,6 @@
---
title: Event Handlers
-description: The UpupUploader callback props — onFilesSelected, onUploadStart, progress, per-file and batch completion, onFileRemoved, and onError — mirroring core upload state.
+description: The UpupUploader callback props — onFilesSelected, onUploadStart, progress, per-file and batch completion, onFileRemoved and onError — mirroring core state.
---
React events mirror core upload state.
diff --git a/apps/landing/content/docs/api-reference/upupuploader/optional-props.mdx b/apps/landing/content/docs/api-reference/upupuploader/optional-props.mdx
index 726964c3a..b4b90eba0 100644
--- a/apps/landing/content/docs/api-reference/upupuploader/optional-props.mdx
+++ b/apps/landing/content/docs/api-reference/upupuploader/optional-props.mdx
@@ -1,6 +1,6 @@
---
title: Optional Props
-description: The complete optional-prop reference for UpupUploader — behavior, file validation, the processing pipeline, upload reliability, sources, appearance, localization, plus the headless-only engine options.
+description: The complete optional-prop reference for UpupUploader — behavior, file validation, the processing pipeline, upload reliability, sources, appearance and i18n.
---
Every prop on this page is optional. The three upload-target props (`uploadEndpoint`, `serverUrl`, `resumable.endpoint`) and `provider` are covered in [Required Props](/docs/api-reference/upupuploader/required-props/); the 34 `on*` callbacks are covered in [Events & Callbacks](/docs/api-reference/events/).
diff --git a/apps/landing/content/docs/comparisons/upup-vs-filepond.mdx b/apps/landing/content/docs/comparisons/upup-vs-filepond.mdx
index ae0586434..94f486766 100644
--- a/apps/landing/content/docs/comparisons/upup-vs-filepond.mdx
+++ b/apps/landing/content/docs/comparisons/upup-vs-filepond.mdx
@@ -14,17 +14,17 @@ capture, and an S3-compatible server with an HMAC-signed trust model.
## At a glance
-| Feature | FilePond | upup |
-| ----------------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
-| Native first-party UI | Vanilla JS core + adapters: React, Vue, Angular, Svelte, jQuery | React, Vue, Svelte, Angular, Vanilla JS, Preact |
+| Feature | FilePond | upup |
+| ----------------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
+| Native first-party UI | Vanilla JS core + adapters: React, Vue, Angular, Svelte, jQuery | React, Vue, Svelte, Angular, Vanilla JS, Preact |
| Headless core | No — renders its own UI component | Yes (`@useupup/core`) |
-| License / pricing | MIT, free & open source (the Pintura image editor is a separate commercial product) | MIT, free & open source |
+| License / pricing | MIT, free & open source (the Pintura image editor is a separate commercial product) | MIT, free & open source |
| Self-host incl. S3-compatible | Yes — uploads to your own server endpoint (process / revert / restore), with chunk uploads; no built-in S3 signing | Yes — `@useupup/server` presigns and proxies to any S3-compatible storage |
-| Cloud-drive sources | No (local files, directories, blobs, local/remote URLs, Data URIs, paste) | Google Drive, OneDrive, Dropbox, Box |
-| Camera / screen capture | No | Yes (both) |
-| Image editor | Via plugins (crop / resize / transform); a full editor is Pintura (separate/commercial) | Yes — React/Preact only |
-| Resumable uploads | Chunked uploads (server-driven) | Yes — optional (tus or S3 multipart) |
-| i18n | Configurable label strings (no locale bundles) | Yes (ICU locale bundles) |
+| Cloud-drive sources | No (local files, directories, blobs, local/remote URLs, Data URIs, paste) | Google Drive, OneDrive, Dropbox, Box |
+| Camera / screen capture | No | Yes (both) |
+| Image editor | Via plugins (crop / resize / transform); a full editor is Pintura (separate/commercial) | Yes — React/Preact only |
+| Resumable uploads | Chunked uploads (server-driven) | Yes — optional (tus or S3 multipart) |
+| i18n | 48 locale files in `locale/`, plus configurable label strings | Yes (ICU locale bundles) |
## Choose FilePond if
diff --git a/apps/landing/content/docs/comparisons/upup-vs-uploadthing.mdx b/apps/landing/content/docs/comparisons/upup-vs-uploadthing.mdx
index cdcdab006..355058eb5 100644
--- a/apps/landing/content/docs/comparisons/upup-vs-uploadthing.mdx
+++ b/apps/landing/content/docs/comparisons/upup-vs-uploadthing.mdx
@@ -1,6 +1,6 @@
---
-title: upup vs UploadThing
-description: An honest, factual comparison of upup and UploadThing — a self-hosted MIT library versus a managed hosted upload service — so you can choose the right model.
+title: upup vs UploadThing — Self-Hosted Alternative
+description: A factual upup vs UploadThing comparison — a free, MIT-licensed uploader you self-host against your own S3 bucket versus a managed, hosted upload service.
---
UploadThing (by Ping Labs) is a hosted file-upload **service** for full-stack
@@ -15,18 +15,18 @@ screen capture, image editing, and native UI for six frameworks.
## At a glance
-| Feature | UploadThing | upup |
-| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
-| Model | Hosted SaaS (managed storage + CDN) | Self-hosted library (your storage) |
-| Native first-party UI | React components (`UploadButton` / `UploadDropzone`) + adapters for Next.js and other full-stack frameworks (Solid, Svelte, Vue, Nuxt, Expo, and more) | React, Vue, Svelte, Angular, Vanilla JS, Preact |
+| Feature | UploadThing | upup |
+| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
+| Model | Hosted SaaS (managed storage + CDN) | Self-hosted library (your storage) |
+| Native first-party UI | React components (`UploadButton` / `UploadDropzone`) + adapters for Next.js and other full-stack frameworks (Solid, Svelte, Vue, Nuxt, Expo, and more) | React, Vue, Svelte, Angular, Vanilla JS, Preact |
| Headless core | React hooks (coupled to the service) | Yes (`@useupup/core`, framework-agnostic) |
-| License / pricing | Open-source SDK (MIT); the service is a paid SaaS with a free tier and usage-based plans | MIT, free & open source (you pay only for your own storage) |
+| License / pricing | Open-source SDK (MIT); the service is a paid SaaS with a free tier and usage-based plans | MIT, free & open source (you pay only for your own storage) |
| Storage / self-host | Managed — files are stored on UploadThing's infrastructure | Any S3-compatible storage you own (AWS, MinIO, R2, Spaces, Wasabi, Backblaze) via `@useupup/server` |
-| Cloud-drive sources | No | Google Drive, OneDrive, Dropbox, Box |
-| Camera / screen capture | No | Yes (both) |
-| Image editor | No | Yes — React/Preact only |
-| Resumable uploads | Not a primary feature | Yes — optional (tus or S3 multipart) |
-| i18n | No | Yes (ICU locale bundles) |
+| Cloud-drive sources | Not built in | Google Drive, OneDrive, Dropbox, Box |
+| Camera / screen capture | Not built in | Yes (both) |
+| Image editor | Not built in | Yes — React/Preact only |
+| Resumable uploads | Yes — `createUpload` (start, pause, resume), since v7 | Yes — optional (tus or S3 multipart) |
+| i18n | No | Yes (ICU locale bundles) |
## Choose UploadThing if
diff --git a/apps/landing/content/docs/comparisons/upup-vs-uppy.mdx b/apps/landing/content/docs/comparisons/upup-vs-uppy.mdx
index 9a3ae779e..28db1aeb7 100644
--- a/apps/landing/content/docs/comparisons/upup-vs-uppy.mdx
+++ b/apps/landing/content/docs/comparisons/upup-vs-uppy.mdx
@@ -1,6 +1,6 @@
---
-title: upup vs Uppy
-description: An honest, factual comparison of upup and Uppy — two MIT-licensed file uploaders with headless cores, cloud drives, and resumable uploads — to help you choose.
+title: upup vs Uppy — Which Uploader to Choose
+description: A factual upup vs Uppy comparison — two free, MIT-licensed file uploaders with headless cores, cloud drives and resumable uploads, and where they differ.
---
Uppy (by Transloadit) is the most established open-source JavaScript uploader,
diff --git a/apps/landing/content/docs/guides/accessibility.mdx b/apps/landing/content/docs/guides/accessibility.mdx
index 68b377b76..2938a0cd6 100644
--- a/apps/landing/content/docs/guides/accessibility.mdx
+++ b/apps/landing/content/docs/guides/accessibility.mdx
@@ -1,6 +1,6 @@
---
title: Accessibility
-description: What upup's uploader ships by default — keyboard interaction, ARIA roles and live regions, focus management on overlays, the animations prop and prefers-reduced-motion handling — and the parts that stay your responsibility.
+description: What upup ships by default — keyboard interaction, ARIA roles and live regions, focus management on overlays, and prefers-reduced-motion handling.
---
upup's accessibility semantics are not an opt-in layer. Roles, labels, live
diff --git a/apps/landing/content/docs/guides/auth/next-auth.mdx b/apps/landing/content/docs/guides/auth/next-auth.mdx
index 0da7901e6..a207504e7 100644
--- a/apps/landing/content/docs/guides/auth/next-auth.mdx
+++ b/apps/landing/content/docs/guides/auth/next-auth.mdx
@@ -1,6 +1,6 @@
---
title: Authenticate uploads with NextAuth (Auth.js v5)
-description: Scope @useupup/server uploads to the NextAuth session — the auth() helper in the App Router, getToken outside a request context, and the missing session.user.id.
+description: Scope @useupup/server uploads to the NextAuth session — the auth() helper in the App Router, getToken outside a request, and the missing session.user.id.
---
In the App Router, the `auth()` helper exported from your `auth.ts` reads the
diff --git a/apps/landing/content/docs/guides/file-processing.mdx b/apps/landing/content/docs/guides/file-processing.mdx
index 957b1b0bb..536c92847 100644
--- a/apps/landing/content/docs/guides/file-processing.mdx
+++ b/apps/landing/content/docs/guides/file-processing.mdx
@@ -1,6 +1,6 @@
---
title: File Processing
-description: upup's client-side pipeline — image compression, HEIC to JPEG conversion, EXIF stripping, thumbnails, SHA-256 checksums and deduplication, Web Worker offload, and custom steps.
+description: upup client-side pipeline — image compression, HEIC to JPEG conversion, EXIF stripping, thumbnails, SHA-256 checksums, Web Worker offload and custom steps.
---
Before a byte leaves the browser, upup can run each file through a processing
diff --git a/apps/landing/content/docs/guides/headless.mdx b/apps/landing/content/docs/guides/headless.mdx
index bec5bb57c..80506bf80 100644
--- a/apps/landing/content/docs/guides/headless.mdx
+++ b/apps/landing/content/docs/guides/headless.mdx
@@ -1,6 +1,6 @@
---
title: Headless Usage
-description: Build your own uploader UI on top of upup's engine — the useUpupUpload React hook, driving UpupCore directly in any runtime, and the webWorker, HEIC, and resumable pipeline opt-ins.
+description: Build your own uploader UI on the upup engine — the useUpupUpload React hook, driving UpupCore directly, and the webWorker, HEIC and resumable opt-ins.
---
The upup UI is optional. Under it sits a framework-agnostic engine —
diff --git a/apps/landing/content/docs/guides/reliability.mdx b/apps/landing/content/docs/guides/reliability.mdx
index 4513fc0e0..fb00304e5 100644
--- a/apps/landing/content/docs/guides/reliability.mdx
+++ b/apps/landing/content/docs/guides/reliability.mdx
@@ -1,6 +1,6 @@
---
title: Reliability
-description: How upup survives flaky networks — the per-file retry policy and its backoff math, custom success predicates, upload concurrency, online/offline events, and the difference between crash recovery and multipart session resume.
+description: How upup survives flaky networks — the per-file retry policy and its backoff math, custom success predicates, upload concurrency, and multipart resume.
---
Uploads fail for boring reasons: a tunnel, a dropped VPN, a 502 from a storage
@@ -298,7 +298,7 @@ all, tus remains the resumable option.
## A note on `fastAbortThreshold`
`fastAbortThreshold` is accepted by the core options and forwarded internally,
-but nothing reads it in v3.1.0 — setting it has no effect on behavior. It is
+but nothing reads it as of v3.3 — setting it has no effect on behavior. It is
documented here only so you do not spend an afternoon tuning a value that does
nothing. Use `maxRetries` to control how long upup persists on a failing file.
diff --git a/apps/landing/content/docs/guides/server-auth.mdx b/apps/landing/content/docs/guides/server-auth.mdx
index 7af7e788c..838e3ed33 100644
--- a/apps/landing/content/docs/guides/server-auth.mdx
+++ b/apps/landing/content/docs/guides/server-auth.mdx
@@ -1,6 +1,6 @@
---
title: Server Auth & Trust Model
-description: How @useupup/server authenticates uploads — the mandatory HMAC upload-token secret, the secure-by-default 403 on anonymous uploads, per-user key scoping, and what forged requests get.
+description: How @useupup/server authenticates uploads — the mandatory HMAC upload-token secret, the secure-by-default 403 on anonymous uploads, and per-user scoping.
---
`@useupup/server` is the trust boundary between the browser and your storage.
@@ -128,7 +128,7 @@ The token binds:
from `exp`, since `init` is the only issuer and has always used the same TTL.
It is signed with HMAC-SHA-256 over the payload using your
-`uploadTokenSecret`, via Web Crypto (so it works on Node 18+, edge runtimes,
+`uploadTokenSecret`, via Web Crypto (so it works on Node 20+, edge runtimes,
and Cloudflare Workers). On every continuation request the handler verifies the
signature **before** trusting any payload byte, compares it in constant time,
and checks expiry — the one exception being `resume`, which applies the resume
diff --git a/apps/landing/content/docs/guides/server-mode-setup.mdx b/apps/landing/content/docs/guides/server-mode-setup.mdx
index e0f5f425a..f4e2e281d 100644
--- a/apps/landing/content/docs/guides/server-mode-setup.mdx
+++ b/apps/landing/content/docs/guides/server-mode-setup.mdx
@@ -1,6 +1,6 @@
---
-title: Server Mode — Setup
-description: Set up mode="server" — mount createUpupHandler, configure storage, secrets, limits and hooks, then pick an adapter — Express, Fastify, Hono, or Next.js.
+title: Server Mode Setup — Upload Through Your Server
+description: Set up upup server mode — mount createUpupHandler, configure S3 storage, secrets, limits and hooks, then pick an Express, Fastify, Hono or Next.js adapter.
---
End-to-end setup for `mode="server"`. In server mode the browser talks only
diff --git a/apps/landing/content/docs/guides/sources.mdx b/apps/landing/content/docs/guides/sources.mdx
index 63e3ef5d5..5c928fb98 100644
--- a/apps/landing/content/docs/guides/sources.mdx
+++ b/apps/landing/content/docs/guides/sources.mdx
@@ -1,6 +1,6 @@
---
title: Upload Sources
-description: Every input the uploader ships — local files, drag-and-drop, paste, folder upload, camera, microphone, screen capture, URL import, and cloud drives — plus the sources prop that decides which chips appear.
+description: Every input the uploader ships — local files, drag and drop, paste, folder upload, camera, microphone, screen capture, URL import, and cloud drives.
---
upup collects files from nine sources. One prop — `sources` — decides which of
diff --git a/apps/landing/content/docs/guides/storage-providers.mdx b/apps/landing/content/docs/guides/storage-providers.mdx
index 75c436727..9fab035ad 100644
--- a/apps/landing/content/docs/guides/storage-providers.mdx
+++ b/apps/landing/content/docs/guides/storage-providers.mdx
@@ -1,6 +1,6 @@
---
title: Storage Providers
-description: Connect upup to any S3-compatible storage — AWS S3, Cloudflare R2, MinIO, Backblaze B2, DigitalOcean Spaces, Wasabi, and more — with the full provider list, the shared storage config shape, and a setup guide per provider.
+description: Connect upup to any S3-compatible storage — AWS S3, Cloudflare R2, MinIO, Backblaze B2, DigitalOcean Spaces, Wasabi and more, with a guide per provider.
---
upup uploads to any **S3-compatible** object store. One uploader UI, one config
diff --git a/apps/landing/content/docs/guides/storage/azure-blob.mdx b/apps/landing/content/docs/guides/storage/azure-blob.mdx
index 59020cd2c..b5a64b856 100644
--- a/apps/landing/content/docs/guides/storage/azure-blob.mdx
+++ b/apps/landing/content/docs/guides/storage/azure-blob.mdx
@@ -1,6 +1,6 @@
---
-title: Upload files to Azure Blob Storage
-description: Azure Blob Storage has no S3 API, so @useupup/server rejects it. Upload to Azure from React, Vue, Svelte, or plain JS with upup via client mode and a SAS URL.
+title: Upload Files to Azure Blob Storage with a SAS URL
+description: Upload files to Azure Blob Storage from the browser with upup — a server-issued SAS URL, the mandatory x-ms-blob-type PUT header, no account key in the client.
---
Azure Blob Storage is the one provider in upup's list that server mode cannot
diff --git a/apps/landing/content/docs/guides/storage/cloudflare-r2.mdx b/apps/landing/content/docs/guides/storage/cloudflare-r2.mdx
index 58283b2ec..6c936629c 100644
--- a/apps/landing/content/docs/guides/storage/cloudflare-r2.mdx
+++ b/apps/landing/content/docs/guides/storage/cloudflare-r2.mdx
@@ -1,6 +1,6 @@
---
-title: Upload files to Cloudflare R2
-description: Upload files to Cloudflare R2 from React, Vue, Svelte, or plain JS with upup — account-scoped endpoint, region auto, API tokens, CORS, and a copy-paste config.
+title: Upload Files to Cloudflare R2 from the Browser
+description: Upload files to Cloudflare R2 with upup — the account-scoped endpoint, region auto, API tokens, CORS, and a copy-paste React, Vue or plain JS config.
---
Cloudflare R2 speaks the S3 API with zero egress fees, which makes it a common
diff --git a/apps/landing/content/docs/guides/storage/digitalocean-spaces.mdx b/apps/landing/content/docs/guides/storage/digitalocean-spaces.mdx
index 344c2dc83..a41378f99 100644
--- a/apps/landing/content/docs/guides/storage/digitalocean-spaces.mdx
+++ b/apps/landing/content/docs/guides/storage/digitalocean-spaces.mdx
@@ -1,6 +1,6 @@
---
-title: Upload files to DigitalOcean Spaces
-description: Upload files to DigitalOcean Spaces from React, Vue, Svelte, or plain JS with upup — Spaces keys, the region endpoint, CORS, the CDN, and a copy-paste config.
+title: Upload Files to DigitalOcean Spaces from the Browser
+description: Upload files to DigitalOcean Spaces with upup — Spaces access keys, the regional S3 endpoint, CORS, the CDN, and a copy-paste React, Vue or plain JS config.
---
DigitalOcean Spaces is S3-compatible object storage where the datacenter region
diff --git a/apps/landing/content/docs/guides/storage/minio.mdx b/apps/landing/content/docs/guides/storage/minio.mdx
index 70ece3386..dbffe7fbb 100644
--- a/apps/landing/content/docs/guides/storage/minio.mdx
+++ b/apps/landing/content/docs/guides/storage/minio.mdx
@@ -1,6 +1,6 @@
---
-title: Upload files to MinIO
-description: Upload files to a self-hosted MinIO server from React, Vue, Svelte, or plain JS with upup — Docker setup, path-style addressing, CORS, and a copy-paste config.
+title: Upload Files to MinIO from the Browser
+description: Upload files to a self-hosted MinIO server with upup — Docker setup, path-style addressing, CORS, and a copy-paste React, Vue, Svelte or plain JS config.
---
MinIO is self-hosted S3-compatible storage, which makes it the fastest way to
@@ -45,7 +45,7 @@ docker run -p 9100:9000 -p 9101:9001 \
-e MINIO_ROOT_PASSWORD=upupadmin123 \
-e MINIO_API_CORS_ALLOW_ORIGIN='*' \
-v minio-data:/data \
- minio/minio server /data --console-address ':9001'
+ quay.io/minio/minio server /data --console-address ':9001'
```
MinIO listens on two ports and they are not interchangeable. The **S3 API** is
diff --git a/apps/landing/content/docs/guides/theming.mdx b/apps/landing/content/docs/guides/theming.mdx
index c4dffc8a5..d8c1255ef 100644
--- a/apps/landing/content/docs/guides/theming.mdx
+++ b/apps/landing/content/docs/guides/theming.mdx
@@ -1,6 +1,6 @@
---
title: Theming
-description: Style the upup uploader with the theme prop — light/dark/system modes, design tokens exposed as CSS variables, per-component slot class overrides, and the stable data-upup-slot DOM hooks for raw CSS.
+description: Style the upup uploader with the theme prop — light, dark and system modes, design tokens as CSS variables, slot class overrides, and data-upup-slot hooks.
---
upup gives you three layers of control, from coarse to surgical:
diff --git a/apps/landing/content/docs/index.mdx b/apps/landing/content/docs/index.mdx
index 20ae27dc8..a57221572 100644
--- a/apps/landing/content/docs/index.mdx
+++ b/apps/landing/content/docs/index.mdx
@@ -1,6 +1,6 @@
---
-title: upup Documentation
-description: A file uploader with a native UI for React, Vue, Svelte, Angular, Vanilla JS, and Preact, built on a shared headless core with an optional server mode.
+title: File Uploader Documentation
+description: Docs for upup, the free and open-source file uploader with native UI for React, Vue, Svelte, Angular, Vanilla JS and Preact, plus an optional server mode.
---
upup is a file uploader with a native UI for **React, Vue, Svelte, Angular,
diff --git a/apps/landing/content/docs/localization.mdx b/apps/landing/content/docs/localization.mdx
index 8f9fe84b4..3d7db37a2 100644
--- a/apps/landing/content/docs/localization.mdx
+++ b/apps/landing/content/docs/localization.mdx
@@ -1,6 +1,6 @@
---
title: Localization (i18n)
-description: upup ships nine ICU locale bundles from @useupup/core/i18n — set the locale on any framework's uploader, override individual namespaced message keys, and get automatic RTL and pluralization.
+description: upup ships nine ICU locale bundles from @useupup/core/i18n — set the locale on any framework uploader, override message keys, and get RTL and plurals.
---
upup uses ICU locale bundles from `@useupup/core/i18n`. Messages are grouped
diff --git a/apps/landing/content/docs/migration/v1-to-v3.mdx b/apps/landing/content/docs/migration/v1-to-v3.mdx
index a232bc85e..a45633351 100644
--- a/apps/landing/content/docs/migration/v1-to-v3.mdx
+++ b/apps/landing/content/docs/migration/v1-to-v3.mdx
@@ -1,6 +1,6 @@
---
title: Migrating from v1 to v3
-description: Upgrade from upup v1 (upup-react-file-uploader) to v3 — the package rename to @useupup/react, the full v1→v3 prop map, the UpupError/UpupErrorCode taxonomy, and client- vs server-mode uploads.
+description: Upgrade from upup v1 (upup-react-file-uploader) to v3 — the rename to @useupup/react, the v1 to v3 prop map, the error taxonomy, and client vs server mode.
---
v1 shipped as a single React package, `upup-react-file-uploader`. v3 is a
diff --git a/apps/landing/content/docs/quickstarts/angular.mdx b/apps/landing/content/docs/quickstarts/angular.mdx
index 2ea2d05e6..2f0bb1895 100644
--- a/apps/landing/content/docs/quickstarts/angular.mdx
+++ b/apps/landing/content/docs/quickstarts/angular.mdx
@@ -1,6 +1,6 @@
---
title: Angular Quickstart
-description: Add a full-featured file uploader to an Angular 19+ app with @useupup/angular — a standalone component port of the canonical React UI, with cloud drives, camera, and resumable uploads.
+description: Add a file uploader to an Angular 19+ app with @useupup/angular — a standalone component, drag and drop, cloud drives, camera, and resumable uploads.
---
`@useupup/angular` is a native Angular 19+ port of the canonical upup React UI —
diff --git a/apps/landing/content/docs/quickstarts/next.mdx b/apps/landing/content/docs/quickstarts/next.mdx
index 4166db73b..91e5d0152 100644
--- a/apps/landing/content/docs/quickstarts/next.mdx
+++ b/apps/landing/content/docs/quickstarts/next.mdx
@@ -1,6 +1,6 @@
---
title: Next.js Quickstart
-description: Add a full-featured file uploader to a Next.js app with @useupup/next — the client UI and the server handlers in one install, split so the AWS SDK never reaches your client bundle.
+description: Add a file uploader to a Next.js app with @useupup/next — client UI and server handlers in one install, split so the AWS SDK never reaches your bundle.
---
`@useupup/next` is the Next.js integration for upup: one install gives you the client
diff --git a/apps/landing/content/docs/quickstarts/preact.mdx b/apps/landing/content/docs/quickstarts/preact.mdx
index 95ecc8b23..e0eac6a4b 100644
--- a/apps/landing/content/docs/quickstarts/preact.mdx
+++ b/apps/landing/content/docs/quickstarts/preact.mdx
@@ -1,6 +1,6 @@
---
title: Preact Quickstart
-description: Add a full-featured file uploader to a Preact app with @useupup/preact — a preact/compat re-export of @useupup/react, same UI and API, with cloud drives, camera, and resumable uploads.
+description: Add a file uploader to a Preact app with @useupup/preact — a preact/compat re-export of @useupup/react, drag and drop, cloud drives, and resumable uploads.
---
`@useupup/preact` is a **`preact/compat` re-export of
diff --git a/apps/landing/content/docs/quickstarts/react.mdx b/apps/landing/content/docs/quickstarts/react.mdx
index 783adb572..f2446a884 100644
--- a/apps/landing/content/docs/quickstarts/react.mdx
+++ b/apps/landing/content/docs/quickstarts/react.mdx
@@ -1,6 +1,6 @@
---
title: React Quickstart
-description: Add a full-featured file uploader to a React 19 app with @useupup/react — drag-and-drop, cloud drives, camera, and resumable uploads, with no upload server to run.
+description: Add a file uploader to a React 19 app with @useupup/react — one npm install, drag and drop, cloud drives, camera, and resumable uploads, no server to run.
---
`@useupup/react` is the canonical upup UI: a drag-and-drop uploader with file
diff --git a/apps/landing/content/docs/quickstarts/svelte.mdx b/apps/landing/content/docs/quickstarts/svelte.mdx
index 33048de38..2cd08f5a2 100644
--- a/apps/landing/content/docs/quickstarts/svelte.mdx
+++ b/apps/landing/content/docs/quickstarts/svelte.mdx
@@ -1,6 +1,6 @@
---
title: Svelte Quickstart
-description: Add a full-featured file uploader to a Svelte 5 app with @useupup/svelte — a native port of the canonical React UI, DOM-identical, with cloud drives, camera, and resumable uploads.
+description: Add a file uploader to a Svelte 5 app with @useupup/svelte — one npm install, drag and drop, cloud drives, camera, and resumable uploads to any S3 storage.
---
`@useupup/svelte` is a native Svelte 5 port of the canonical upup React UI —
diff --git a/apps/landing/content/docs/quickstarts/vanilla.mdx b/apps/landing/content/docs/quickstarts/vanilla.mdx
index 241fbe9a3..556fbf4bb 100644
--- a/apps/landing/content/docs/quickstarts/vanilla.mdx
+++ b/apps/landing/content/docs/quickstarts/vanilla.mdx
@@ -1,6 +1,6 @@
---
title: Vanilla JS Quickstart
-description: Add a full-featured file uploader to any page with @useupup/vanilla — framework-free, DOM-identical to the canonical React UI, with cloud drives, camera, and resumable uploads.
+description: Add a file uploader to any page with @useupup/vanilla — framework-free, drag and drop, cloud drives, camera, and resumable uploads to any S3 storage.
---
`@useupup/vanilla` is the framework-free upup uploader (built on lit-html) —
diff --git a/apps/landing/content/docs/quickstarts/vue.mdx b/apps/landing/content/docs/quickstarts/vue.mdx
index f92eb906c..119b18b9d 100644
--- a/apps/landing/content/docs/quickstarts/vue.mdx
+++ b/apps/landing/content/docs/quickstarts/vue.mdx
@@ -1,6 +1,6 @@
---
title: Vue Quickstart
-description: Add a full-featured file uploader to a Vue 3 app with @useupup/vue — a native port of the canonical React UI, DOM-identical, with cloud drives, camera, and resumable uploads.
+description: Add a file uploader to a Vue 3 app with @useupup/vue — one npm install, drag and drop, cloud drives, camera, and resumable uploads to any S3 storage.
---
`@useupup/vue` is a native Vue 3 port of the canonical upup React UI — DOM-identical
diff --git a/apps/landing/content/docs/resumable-uploads.mdx b/apps/landing/content/docs/resumable-uploads.mdx
index 4a4500021..ba69b259a 100644
--- a/apps/landing/content/docs/resumable-uploads.mdx
+++ b/apps/landing/content/docs/resumable-uploads.mdx
@@ -1,6 +1,6 @@
---
-title: Resumable Uploads
-description: Two resumable protocols — S3 multipart through @useupup/server, or tus against an external tus-compatible service. Full option reference, part-size rules, and what each protocol actually resumes.
+title: Resumable Uploads — S3 Multipart & tus
+description: Resumable file uploads in upup — S3 multipart through @useupup/server or the tus protocol, with part-size rules and what each protocol actually resumes.
---
upup supports two resumable protocols. Both are opt-in through the single
diff --git a/apps/landing/lighthouserc.cjs b/apps/landing/lighthouserc.cjs
index 1b28bf615..f7048aaa3 100644
--- a/apps/landing/lighthouserc.cjs
+++ b/apps/landing/lighthouserc.cjs
@@ -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
diff --git a/apps/landing/lighthouserc.mobile.cjs b/apps/landing/lighthouserc.mobile.cjs
new file mode 100644
index 000000000..f1b247f34
--- /dev/null
+++ b/apps/landing/lighthouserc.mobile.cjs
@@ -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',
+ },
+ },
+}
diff --git a/apps/landing/next.config.mjs b/apps/landing/next.config.mjs
index 4d3c1b7ca..9614d9686 100644
--- a/apps/landing/next.config.mjs
+++ b/apps/landing/next.config.mjs
@@ -72,6 +72,46 @@ const nextConfig = {
// then one trailingSlash hop appends the slash).
async redirects() {
return [
+ // http -> https, FIRST so no other rule can answer a plaintext
+ // request with a 200. Keyed on Cloudflare's `cf-visitor` header
+ // (`{"scheme":"http"}`) and NOTHING else: `x-forwarded-proto` is
+ // rewritten by Traefik on the way to this container, so a rule
+ // reading it sees "http" on every request and redirects forever.
+ // Cloudflare's own "Always Use HTTPS" toggle is the belt (an owner
+ // item); this is the braces that lives in the repo and survives a
+ // zone-settings change.
+ //
+ // TWO rules, because Next strips the trailing slash before matching
+ // a source and does NOT re-append it to an ABSOLUTE destination
+ // (it does for relative ones) — a single `${SITE_BASE}/:path*`
+ // sends /react/ to `…/react`, costing a second 308. The first rule
+ // therefore matches extensionless paths only (`[^/.]+` final
+ // segment) and restores the slash; file paths and the bare root
+ // fall through to the second, which must NOT gain one.
+ {
+ source: '/:path((?:[^/]+/)*[^/.]+)',
+ has: [
+ {
+ type: 'header',
+ key: 'cf-visitor',
+ value: '.*"scheme":"http".*',
+ },
+ ],
+ destination: `${SITE_BASE}/:path/`,
+ permanent: true,
+ },
+ {
+ source: '/:path*',
+ has: [
+ {
+ type: 'header',
+ key: 'cf-visitor',
+ value: '.*"scheme":"http".*',
+ },
+ ],
+ destination: `${SITE_BASE}/:path*`,
+ permanent: true,
+ },
// The wildcard `/documentation/:path*` rule below also covers the
// bare path (`:path*` matches zero segments) — this explicit entry
// is kept for clarity, not necessity.
@@ -121,11 +161,30 @@ const nextConfig = {
destination: '/docs/api-reference/upupuploader/required-props/',
permanent: true,
},
+ // Two sitemap URLs still registered in Search Console from the
+ // Docusaurus era, both currently dead: `/sitemap-landing.xml` is a
+ // bare 404 (nothing ever served it), and `/documentation/sitemap.xml`
+ // fell through to the wildcard below, which appends a slash to a
+ // FILE path and lands on the docs catch-all's 404. GSC has been
+ // reporting "couldn't fetch" for both ever since. Extension paths
+ // get no trailing-slash hop, so each of these is a single 308.
+ // They must precede the wildcard. (Owner follow-up: delete the two
+ // stale submissions in Search Console once these are live.)
+ {
+ source: '/sitemap-landing.xml',
+ destination: '/sitemap.xml',
+ permanent: true,
+ },
+ {
+ source: '/documentation/sitemap.xml',
+ destination: '/sitemap.xml',
+ permanent: true,
+ },
// Destination carries the trailing slash so trailingSlash:true
// does not have to spend a SECOND 308 appending it. Safe here only
// because every extensionless legacy path maps to a real page and
- // the two file paths under /documentation are handled by the
- // explicit llms rules above — a slash appended to a file URL would
+ // the file paths under /documentation are handled by the explicit
+ // llms + sitemap rules above — a slash appended to a file URL would
// break it (Next never slashes paths with an extension).
{
source: '/documentation/:path*',
@@ -137,12 +196,30 @@ const nextConfig = {
// /documentation/* -> /docs/* hop first (staying on the alias
// host), then the /docs/* rule below moves it to the main host.
//
- // The three explicit rules come before the two catch-alls because
- // a catch-all destination of `/docs/:path*/` is wrong for exactly
+ // The explicit rules come before the two catch-alls because a
+ // catch-all destination of `/docs/:path*/` is wrong for exactly
// two shapes: an EMPTY `:path*` (which would render `/docs//`) and
// a FILE path (which must not gain a trailing slash). Listing them
// explicitly lets the catch-alls stay slashed for the page shapes
// that are 99% of alias traffic.
+ //
+ // robots.txt and sitemap.xml are the file paths a crawler probes on
+ // any host it meets. Without these two they took the catch-all to
+ // `${SITE_BASE}/docs/robots.txt/`, then a trailing-slash hop, then
+ // the docs catch-all's 404 — a broken robots on a live alias host.
+ // They belong on the APEX copies, not under /docs.
+ {
+ source: '/robots.txt',
+ has: [{ type: 'host', value: DOCS_ALIAS_HOST }],
+ destination: `${SITE_BASE}/robots.txt`,
+ permanent: true,
+ },
+ {
+ source: '/sitemap.xml',
+ has: [{ type: 'host', value: DOCS_ALIAS_HOST }],
+ destination: `${SITE_BASE}/sitemap.xml`,
+ permanent: true,
+ },
{
source: '/llms.txt',
has: [{ type: 'host', value: DOCS_ALIAS_HOST }],
@@ -200,11 +277,35 @@ const nextConfig = {
},
]
},
- // Non-production hosts (dev, previews) serve a byte-identical copy of the
- // whole site. robots.txt disallows crawling there; this header is what
- // actually keeps a URL discovered some other way out of the index.
+ // Two mutually exclusive header sets.
+ //
+ // PRODUCTION: HSTS. Two years + includeSubDomains + preload is the
+ // preload-list-eligible value; the site is https-only in practice and the
+ // cf-visitor redirect above guarantees a plaintext request never gets a
+ // 200, so there is no http-only subdomain this can strand. (Submitting to
+ // hstspreload.org is an owner step, taken only after the header has been
+ // live for a while — `preload` in the value is a prerequisite, not the
+ // submission itself.)
+ //
+ // NON-PRODUCTION (dev, previews): those hosts serve a byte-identical copy
+ // of the whole site. robots.txt disallows crawling there; this header is
+ // what actually keeps a URL discovered some other way out of the index.
+ // They get no HSTS — a preload directive from a preview host is a
+ // liability, not a protection.
async headers() {
- if (IS_PRODUCTION_SITE) return []
+ if (IS_PRODUCTION_SITE) {
+ return [
+ {
+ source: '/:path*',
+ headers: [
+ {
+ key: 'Strict-Transport-Security',
+ value: 'max-age=63072000; includeSubDomains; preload',
+ },
+ ],
+ },
+ ]
+ }
return [
{
source: '/:path*',
diff --git a/apps/landing/package.json b/apps/landing/package.json
index 22778bdb5..4c3123896 100644
--- a/apps/landing/package.json
+++ b/apps/landing/package.json
@@ -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"
diff --git a/apps/landing/src/__tests__/claim-integrity.test.ts b/apps/landing/src/__tests__/claim-integrity.test.ts
new file mode 100644
index 000000000..5b8417233
--- /dev/null
+++ b/apps/landing/src/__tests__/claim-integrity.test.ts
@@ -0,0 +1,95 @@
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+// Ratchet for the 2026-09 claim-integrity pass. Marketing copy is read
+// literally by the people evaluating upup, so the wording the audit retired
+// must not creep back in. Two classes are gated here:
+//
+// 1. Claims the code cannot back. The cross-framework parity suite compares a
+// NORMALIZED tree — structure, `upup-` class tokens, a11y attributes and
+// text, with framework-injected attributes stripped and Angular host
+// elements unwrapped (apps/e2e-test/cross-framework/parity-dom.ts) — so
+// "byte-identical" / "byte-for-byte" overstates what it proves. The site
+// also has no account system (src/app has no sign-up route), so copy about
+// data collected "when you sign up" describes a flow that does not exist.
+// 2. Social proof upup has never been able to substantiate: user counts,
+// "trusted by" endorsements, and ranking superlatives. None appear today —
+// this locks that in rather than fixing it after it ships.
+//
+// The sources are read as TEXT on purpose: the claims live in string literals
+// and JSX prose, and a text read catches them wherever in the file they move.
+
+const COPY_SOURCES: Record = {
+ 'src/lib/faqs.ts': '../lib/faqs.ts',
+ 'src/components/FeatureShowcase/index.tsx':
+ '../components/FeatureShowcase/index.tsx',
+ 'src/components/HomepageFeatures/index.tsx':
+ '../components/HomepageFeatures/index.tsx',
+ 'src/app/privacy/page.tsx': '../app/privacy/page.tsx',
+}
+
+function readCopySources(): [string, string][] {
+ return Object.entries(COPY_SOURCES).map(([label, relative]) => [
+ label,
+ readFileSync(fileURLToPath(new URL(relative, import.meta.url)), 'utf8'),
+ ])
+}
+
+/** JSX prose is hard-wrapped, so a phrase can straddle a newline + indent. */
+function normalizeWhitespace(source: string): string {
+ return source.replace(/\s+/g, ' ')
+}
+
+describe('landing copy claim integrity', () => {
+ it('reads every landing copy source this guard is supposed to cover', () => {
+ for (const [label, text] of readCopySources()) {
+ expect(
+ text.length,
+ `${label} is empty or unreadable`,
+ ).toBeGreaterThan(500)
+ }
+ })
+
+ it('states no byte-identical or byte-for-byte DOM claim, because the parity suite compares a normalized tree', () => {
+ for (const [label, text] of readCopySources()) {
+ const prose = normalizeWhitespace(text).toLowerCase()
+ expect(
+ prose,
+ `${label} claims byte-identical rendering the parity suite does not verify`,
+ ).not.toContain('byte-identical')
+ expect(
+ prose,
+ `${label} claims byte-for-byte rendering the parity suite does not verify`,
+ ).not.toContain('byte-for-byte')
+ }
+ })
+
+ it('describes no data collected at sign-up, because the site ships no sign-up route', () => {
+ for (const [label, text] of readCopySources()) {
+ const prose = normalizeWhitespace(text).toLowerCase()
+ expect(
+ prose,
+ `${label} references a sign-up flow that does not exist on useupup.com`,
+ ).not.toContain('when you sign up')
+ }
+ })
+
+ it('carries no unsubstantiated social proof such as trusted-by endorsements, user counts or ranking superlatives', () => {
+ const UNSUBSTANTIATED = [
+ 'trusted by',
+ 'users worldwide',
+ '#1',
+ 'most popular',
+ ]
+ for (const [label, text] of readCopySources()) {
+ const prose = normalizeWhitespace(text).toLowerCase()
+ for (const phrase of UNSUBSTANTIATED) {
+ expect(
+ prose,
+ `${label} makes an unsubstantiated "${phrase}" claim — upup has no published adoption figures to back it`,
+ ).not.toContain(phrase)
+ }
+ }
+ })
+})
diff --git a/apps/landing/src/__tests__/indexnow.test.ts b/apps/landing/src/__tests__/indexnow.test.ts
new file mode 100644
index 000000000..8e046ddaa
--- /dev/null
+++ b/apps/landing/src/__tests__/indexnow.test.ts
@@ -0,0 +1,36 @@
+import { existsSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+import { INDEXNOW_KEY, INDEXNOW_KEY_PATH } from '@/lib/indexnow'
+import { GET } from '../app/5cb30cbda540958e8d033652400e59e3.txt/route'
+
+// IndexNow verification hinges on one brittle coupling: the App Router
+// directory that serves the key file is NAMED after the key, so a change to
+// the constant without the matching directory rename (or vice versa) leaves
+// the site serving one key while claiming another — and every submission an
+// engine receives fails ownership. These pin the two to each other, plus the
+// response shape indexnow.org requires (exact body, text/plain).
+describe('IndexNow key file served by the landing app', () => {
+ it('key constant is 32 lowercase hexadecimal characters as IndexNow requires', () => {
+ expect(INDEXNOW_KEY).toMatch(/^[0-9a-f]{32}$/)
+ })
+
+ it('key path constant is the key served as a root-level .txt file', () => {
+ expect(INDEXNOW_KEY_PATH).toBe(`/${INDEXNOW_KEY}.txt`)
+ })
+
+ it('route directory on disk is named exactly after the key constant', () => {
+ const appDir = fileURLToPath(new URL('../app/', import.meta.url))
+ expect(existsSync(`${appDir}${INDEXNOW_KEY}.txt/route.ts`)).toBe(true)
+ })
+
+ it('GET responds with the bare key as plain text and nothing else', async () => {
+ const response = GET()
+
+ expect(response.status).toBe(200)
+ expect(response.headers.get('content-type')).toBe(
+ 'text/plain; charset=utf-8',
+ )
+ await expect(response.text()).resolves.toBe(INDEXNOW_KEY)
+ })
+})
diff --git a/apps/landing/src/__tests__/seo-copy-budgets.test.ts b/apps/landing/src/__tests__/seo-copy-budgets.test.ts
new file mode 100644
index 000000000..9f391aa53
--- /dev/null
+++ b/apps/landing/src/__tests__/seo-copy-budgets.test.ts
@@ -0,0 +1,154 @@
+import { describe, expect, it } from 'vitest'
+import { generateMetadata as frameworkMetadata } from '@/app/[framework]/page'
+import { metadata as homeMetadata } from '@/app/page'
+import { FRAMEWORK_IDS, FRAMEWORKS } from '@/lib/frameworks'
+import { siteConfig } from '@/lib/siteConfig'
+import { source } from '@/lib/docs/source'
+
+// Search Console (90 days to 2026-09-08, sc-domain:useupup.com) showed the
+// money pages earning impressions and almost no clicks with titles Google
+// truncated and descriptions two to three times the width a SERP renders.
+// These are the widths that actually display, so a future copy edit that
+// blows past them fails here instead of silently costing clicks.
+const MAX_TITLE = 60
+const MAX_DOCS_TITLE = 65 // frontmatter title + the " | upup docs" template
+const DOCS_TITLE_TEMPLATE_SUFFIX = ' | upup docs'
+const MIN_DESCRIPTION = 120
+const MAX_DESCRIPTION = 160
+
+// A separate PR removes this claim from the marketing copy; nothing may
+// reintroduce it through a title or a description.
+const RETIRED_CLAIM = 'byte-identical'
+
+function textOf(value: unknown): string {
+ return typeof value === 'string' ? value : ''
+}
+
+describe('home page search-result copy', () => {
+ it('renders a title short enough to survive SERP truncation', () => {
+ expect(homeMetadata.title.length).toBeLessThanOrEqual(MAX_TITLE)
+ })
+
+ it('leads the title with the brand, which is the site top query', () => {
+ expect(homeMetadata.title.startsWith('upup')).toBe(true)
+ })
+
+ it('renders a description inside the width Google displays', () => {
+ expect(homeMetadata.description.length).toBeGreaterThanOrEqual(
+ MIN_DESCRIPTION,
+ )
+ expect(homeMetadata.description.length).toBeLessThanOrEqual(
+ MAX_DESCRIPTION,
+ )
+ })
+
+ it('keeps the site-wide fallback title and tagline inside the same budgets', () => {
+ // layout.tsx serves these to every route that declares no metadata of
+ // its own, so they are search-result copy too.
+ expect(siteConfig.title.length).toBeLessThanOrEqual(MAX_TITLE)
+ expect(siteConfig.tagline.length).toBeGreaterThanOrEqual(
+ MIN_DESCRIPTION,
+ )
+ expect(siteConfig.tagline.length).toBeLessThanOrEqual(MAX_DESCRIPTION)
+ })
+})
+
+describe('framework page search-result copy', () => {
+ it.each(FRAMEWORK_IDS)(
+ 'gives /%s/ a title inside the 60-character budget',
+ async id => {
+ const meta = await frameworkMetadata({
+ params: Promise.resolve({ framework: id }),
+ })
+ expect(textOf(meta.title).length).toBeLessThanOrEqual(MAX_TITLE)
+ },
+ )
+
+ it.each(FRAMEWORK_IDS)(
+ 'leads the /%s/ title with the phrase the impressions arrive on',
+ async id => {
+ const meta = await frameworkMetadata({
+ params: Promise.resolve({ framework: id }),
+ })
+ // "vue file uploader", "react uploader", "angular file uploader" —
+ // the query language has to be in front of the brand, not behind it.
+ expect(textOf(meta.title)).toContain(
+ `${FRAMEWORKS[id].name} File Uploader`,
+ )
+ },
+ )
+
+ it.each(FRAMEWORK_IDS)(
+ 'gives /%s/ a description between 120 and 160 characters',
+ async id => {
+ const meta = await frameworkMetadata({
+ params: Promise.resolve({ framework: id }),
+ })
+ const description = textOf(meta.description)
+ expect(description.length).toBeGreaterThanOrEqual(MIN_DESCRIPTION)
+ expect(description.length).toBeLessThanOrEqual(MAX_DESCRIPTION)
+ },
+ )
+
+ it.each(FRAMEWORK_IDS)(
+ 'names the install command for /%s/ in its description',
+ async id => {
+ const meta = await frameworkMetadata({
+ params: Promise.resolve({ framework: id }),
+ })
+ expect(textOf(meta.description)).toContain(
+ `npm install ${FRAMEWORKS[id].pkg}`,
+ )
+ },
+ )
+})
+
+describe('docs frontmatter search-result copy', () => {
+ it('keeps every docs title inside 65 characters once the template is appended', () => {
+ const overBudget = source
+ .getPages()
+ .map(page => ({
+ url: page.url,
+ rendered: `${page.data.title}${DOCS_TITLE_TEMPLATE_SUFFIX}`,
+ }))
+ .filter(entry => entry.rendered.length > MAX_DOCS_TITLE)
+ .map(entry => `${entry.url} (${entry.rendered.length})`)
+ expect(overBudget).toEqual([])
+ })
+
+ it('keeps every docs description that exists inside 160 characters', () => {
+ const overBudget = source
+ .getPages()
+ .map(page => ({
+ url: page.url,
+ description: textOf(page.data.description),
+ }))
+ .filter(entry => entry.description.length > MAX_DESCRIPTION)
+ .map(entry => `${entry.url} (${entry.description.length})`)
+ expect(overBudget).toEqual([])
+ })
+})
+
+describe('retired parity claim', () => {
+ it('is absent from every title and description the site emits', async () => {
+ const copy: string[] = [
+ homeMetadata.title,
+ homeMetadata.description,
+ siteConfig.title,
+ siteConfig.tagline,
+ ]
+ for (const id of FRAMEWORK_IDS) {
+ const meta = await frameworkMetadata({
+ params: Promise.resolve({ framework: id }),
+ })
+ copy.push(textOf(meta.title), textOf(meta.description))
+ }
+ for (const page of source.getPages()) {
+ copy.push(page.data.title, textOf(page.data.description))
+ }
+ const offenders = copy.filter(text =>
+ text.toLowerCase().includes(RETIRED_CLAIM),
+ )
+ expect(offenders).toEqual([])
+ })
+})
diff --git a/apps/landing/src/__tests__/seo-surfaces.test.ts b/apps/landing/src/__tests__/seo-surfaces.test.ts
new file mode 100644
index 000000000..00cbfd0c3
--- /dev/null
+++ b/apps/landing/src/__tests__/seo-surfaces.test.ts
@@ -0,0 +1,222 @@
+import { createElement } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import robots from '@/app/robots'
+import sitemap from '@/app/sitemap'
+import EntityStructuredData from '@/components/StructuredData/EntityStructuredData'
+import StructuredData from '@/components/StructuredData'
+import { AI_CRAWLER_USER_AGENTS } from '@/lib/seo/ai-crawlers'
+
+// The public site's machine-readable surfaces — sitemap, robots, JSON-LD — are
+// the ones nobody looks at until a search engine has already acted on them.
+// Every assertion here pins a decision that was made deliberately and would be
+// silently reversible otherwise (a fake lastmod, a stray /mobile-demo entry, a
+// Review node nobody can substantiate).
+
+const PRODUCTION_ORIGIN = 'https://useupup.com'
+
+/** Every `application/ld+json` payload in a rendered markup string. */
+function parseJsonLdBlocks(markup: string): unknown[] {
+ const blocks = [
+ ...markup.matchAll(
+ /
+ {/* Organization + WebSite JSON-LD. It lives in the ROOT
+ layout on purpose: the entity graph has to be on every
+ page — the 64 docs pages are the bulk of the indexable
+ surface and they mount no page-level . */}
+
{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. */}
-
+
diff --git a/apps/landing/src/app/privacy/page.tsx b/apps/landing/src/app/privacy/page.tsx
index bc8435e6c..1a7892104 100644
--- a/apps/landing/src/app/privacy/page.tsx
+++ b/apps/landing/src/app/privacy/page.tsx
@@ -16,7 +16,7 @@ export default function Privacy() {
Privacy{' '}
@@ -36,8 +36,9 @@ export default function Privacy() {
1. Information We Collect
We may collect personal data such as your name and email
- address when you sign up or contact us. Additionally, we
- gather usage data via cookies and similar technologies.
+ address when you contact us, submit the support form, or
+ request a provider integration. Additionally, we gather
+ usage data via cookies and similar technologies.
2. How We Use Your Information
diff --git a/apps/landing/src/app/robots.ts b/apps/landing/src/app/robots.ts
index 7a3ba2b96..228347cfc 100644
--- a/apps/landing/src/app/robots.ts
+++ b/apps/landing/src/app/robots.ts
@@ -1,4 +1,5 @@
import type { MetadataRoute } from 'next'
+import { AI_CRAWLER_USER_AGENTS } from '@/lib/seo/ai-crawlers'
import { isProductionSite, siteUrl } from '@/lib/site-url'
// Replaces the former static public/robots.txt, which hardcoded the PRODUCTION
@@ -27,12 +28,27 @@ export default function robots(): MetadataRoute.Robots {
}
}
+ // ONE disallow set, shared by both groups. A named AI crawler that got a
+ // laxer list than `*` would be a side door into /api/ and the demo harness;
+ // sharing the constant makes divergence impossible rather than unlikely.
+ const disallow = ['/api/', '/mobile-demo/']
+
return {
rules: [
{
userAgent: '*',
allow: '/',
- disallow: ['/api/', '/mobile-demo/'],
+ disallow,
+ },
+ // The `*` rule above already permits these agents. Naming them is
+ // an explicit, auditable allow: several are opt-out tokens
+ // (Google-Extended, Applebot-Extended, anthropic-ai) whose absence
+ // reads as "undecided" to a reviewer, and the docs corpus + llms.txt
+ // exist precisely so these crawlers can quote us accurately.
+ {
+ userAgent: [...AI_CRAWLER_USER_AGENTS],
+ allow: '/',
+ disallow,
},
],
sitemap,
diff --git a/apps/landing/src/app/sitemap.ts b/apps/landing/src/app/sitemap.ts
index 00675ab0e..6991817fe 100644
--- a/apps/landing/src/app/sitemap.ts
+++ b/apps/landing/src/app/sitemap.ts
@@ -13,32 +13,33 @@ const HIGH_VALUE_DOCS = new Set([
'/docs/quickstarts/next',
])
+// No `lastModified` anywhere below, deliberately. It used to be a single
+// `new Date()` evaluated once per build and stamped on all 73 URLs, which told
+// Google that every page changed on every deploy — Google's documented
+// response to a lastmod it cannot corroborate is to ignore the field for the
+// whole site. There is no real per-page date to use yet (content/docs carries
+// no frontmatter dates and there is no build-time git-mtime map), and an
+// invented one is worse than an absent one. Omitting it is valid sitemap XML.
export default function sitemap(): MetadataRoute.Sitemap {
- const lastModified = new Date()
-
return [
{
url: canonicalUrl(),
- lastModified,
changeFrequency: 'weekly',
priority: 1,
},
// Per-framework landing pages (/react, /vue, …) — high-value entry points.
...FRAMEWORK_IDS.map((id): MetadataRoute.Sitemap[number] => ({
url: canonicalUrl(id),
- lastModified,
changeFrequency: 'weekly',
priority: 0.9,
})),
{
url: canonicalUrl('support'),
- lastModified,
changeFrequency: 'monthly',
priority: 0.4,
},
{
url: canonicalUrl('privacy'),
- lastModified,
changeFrequency: 'yearly',
priority: 0.3,
},
@@ -47,7 +48,6 @@ export default function sitemap(): MetadataRoute.Sitemap {
// to forget. page.url already carries the /docs prefix.
...source.getPages().map((page): MetadataRoute.Sitemap[number] => ({
url: canonicalUrl(page.url),
- lastModified,
changeFrequency: 'monthly',
priority: HIGH_VALUE_DOCS.has(page.url.replace(/\/$/, ''))
? 0.8
diff --git a/apps/landing/src/components/DeferredInteractiveExample.tsx b/apps/landing/src/components/DeferredInteractiveExample.tsx
new file mode 100644
index 000000000..2133a127c
--- /dev/null
+++ b/apps/landing/src/components/DeferredInteractiveExample.tsx
@@ -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: () => },
+)
+
+// Reserves the demo's box so mounting it does not shift the page.
+function DemoPlaceholder() {
+ return
+}
+
+/**
+ * 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(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 (
+
+ {mounted ? (
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/apps/landing/src/components/FeatureShowcase/index.tsx b/apps/landing/src/components/FeatureShowcase/index.tsx
index 6649276b9..0f61a765b 100644
--- a/apps/landing/src/components/FeatureShowcase/index.tsx
+++ b/apps/landing/src/components/FeatureShowcase/index.tsx
@@ -1,7 +1,8 @@
'use client'
-import React, { useRef } from 'react'
+import React, { useEffect, useRef, useState } from 'react'
import { useInView } from 'framer-motion'
+import dynamic from 'next/dynamic'
import {
FaUpload,
FaGlobe,
@@ -19,14 +20,36 @@ import {
import Card from '@/components/ui/Card'
import { H3_HEADING } from '@/components/ui/SectionHeading'
import { ICON_CHIP } from '@/components/ui/recipes'
-import {
- FrameworksScene,
- DriveScene,
- EditorScene,
- ResumeScene,
- PipelineScene,
-} from '@/components/UploaderScene'
-import { ServerModeVignette } from './vignettes'
+
+// The row visuals are decorative (their column is aria-hidden) and expensive:
+// five UploaderScene mocks plus a diagram, each with its own framer timeline,
+// react-icons and tags that React hoists into document-head image
+// preloads. The row TEXT is indexable and stays server-rendered; only the
+// visuals load client-side, and only once their row is near the viewport.
+const FrameworksScene = dynamic(
+ () => import('@/components/UploaderScene/FrameworksScene'),
+ { ssr: false },
+)
+const DriveScene = dynamic(
+ () => import('@/components/UploaderScene/DriveScene'),
+ { ssr: false },
+)
+const EditorScene = dynamic(
+ () => import('@/components/UploaderScene/EditorScene'),
+ { ssr: false },
+)
+const ResumeScene = dynamic(
+ () => import('@/components/UploaderScene/ResumeScene'),
+ { ssr: false },
+)
+const PipelineScene = dynamic(
+ () => import('@/components/UploaderScene/PipelineScene'),
+ { ssr: false },
+)
+const ServerModeVignette = dynamic(
+ () => import('./vignettes').then(m => m.ServerModeVignette),
+ { ssr: false },
+)
interface HeroRow {
icon: React.ReactNode
@@ -46,7 +69,7 @@ const heroRows: HeroRow[] = [
icon: ,
title: 'Six Frameworks, One Uploader',
description:
- 'Native UI for React, Vue, Svelte, Angular, Vanilla JS, and Preact — one uploader that renders byte-identical DOM in every framework, enforced by a cross-framework parity suite.',
+ 'Native UI for React, Vue, Svelte, Angular, Vanilla JS, and Preact — one uploader that renders the same DOM structure, classes, and accessibility tree in every framework, enforced by a cross-framework parity suite.',
Visual: FrameworksScene,
live: true,
},
@@ -130,6 +153,17 @@ function FeatureRow({ row, index }: { row: HeroRow; index: number }) {
// Non-`once` viewport gate so scenes only animate while on-screen (perf,
// not an entrance animation — rows themselves render static).
const active = useInView(ref, { amount: 0.2 })
+ // A SECOND, deliberately earlier observer decides when the visual mounts:
+ // a scene renders taller than the card's reserved min-height, so mounting
+ // it on `active` (20% visible) would resize a card the visitor is already
+ // looking at. At 600px of lead the growth happens off-screen — no visible
+ // shift, and the scene is ready by the time the row scrolls in. `once`
+ // keeps it mounted, so scrolling back never re-downloads the chunk.
+ const near = useInView(ref, { once: true, margin: '600px' })
+ const [visualMounted, setVisualMounted] = useState(false)
+ useEffect(() => {
+ if (near) setVisualMounted(true)
+ }, [near])
const flipped = index % 2 === 1
const { Visual } = row
@@ -166,8 +200,11 @@ function FeatureRow({ row, index }: { row: HeroRow; index: number }) {
aria-hidden="true"
className={`order-1 ${flipped ? 'lg:order-1' : 'lg:order-2'}`}
>
+ {/* min-h keeps the card's box reserved while the visual is
+ still deferred; the early mount above is what keeps the
+ resize itself off-screen. */}
-
+ {visualMounted && }
diff --git a/apps/landing/src/components/Footer/index.tsx b/apps/landing/src/components/Footer/index.tsx
index d8e9db813..665359e6a 100644
--- a/apps/landing/src/components/Footer/index.tsx
+++ b/apps/landing/src/components/Footer/index.tsx
@@ -96,6 +96,18 @@ export default function Footer() {
FAQ
+
diff --git a/apps/landing/src/components/HomepageFeatures/index.tsx b/apps/landing/src/components/HomepageFeatures/index.tsx
index d74778cda..61ebe8742 100644
--- a/apps/landing/src/components/HomepageFeatures/index.tsx
+++ b/apps/landing/src/components/HomepageFeatures/index.tsx
@@ -927,7 +927,7 @@ export default function HomepageFeatures() {
You can connect to upup using any S3 provider. We
- also support some non-S3 connections.
+ also support Azure Blob Storage in client mode.
diff --git a/apps/landing/src/components/HomepageHero/index.tsx b/apps/landing/src/components/HomepageHero/index.tsx
index ad56cb2e7..d7ee41b76 100644
--- a/apps/landing/src/components/HomepageHero/index.tsx
+++ b/apps/landing/src/components/HomepageHero/index.tsx
@@ -12,14 +12,40 @@ import {
ChevronDown,
} from 'lucide-react'
import { motion, AnimatePresence, useInView } from 'framer-motion'
+import dynamic from 'next/dynamic'
import GradientText from '@/components/TextAnimation/GradientText'
-import BlurText from '@/components/TextAnimation/BlurText'
+import RisingWords from '@/components/TextAnimation/RisingWords'
import FrameworkSnippets from '@/components/FrameworkSnippets'
import FrameworkStrip from '@/components/FrameworkStrip'
-import { HeroSession } from '@/components/UploaderScene'
import { FRAMEWORKS, type FrameworkId } from '@/lib/frameworks'
import { useCopyToClipboard } from '@/lib/use-copy-to-clipboard'
+// The hero visual is decorative (aria-hidden) and expensive: the scene engine,
+// a pile of react-icons, and eleven tags that React 19 hoists into
+// `` at the very top of the document, ahead of
+// the CSS the hero COPY needs. None of it is indexable, so it loads client-side
+// only, behind the same viewport gate the scene already used for its timeline.
+// The placeholder reserves the box so nothing shifts when it arrives.
+const HeroSession = dynamic(
+ () => import('@/components/UploaderScene/HeroSession'),
+ {
+ ssr: false,
+ loading: () => ,
+ },
+)
+
+// Matches HeroSession's own root box (mx-auto, max-w-[440px]) and its measured
+// height — 735px at a 412px viewport, 743px at desktop — so mounting the real
+// scene is a zero-shift swap.
+function HeroVisualPlaceholder() {
+ return (
+
+ )
+}
+
export default function HeroSection({
framework,
}: Readonly<{ framework?: FrameworkId }> = {}) {
@@ -36,6 +62,12 @@ export default function HeroSection({
// HeroSession — we just pass the gate down.
const visualRef = useRef(null)
const visualActive = useInView(visualRef, { amount: 0.2 })
+ // …and a one-way latch on top of it, so the scene's chunk is fetched only
+ // once the box has actually been on screen, and stays mounted after that.
+ const [visualMounted, setVisualMounted] = useState(false)
+ useEffect(() => {
+ if (visualActive) setVisualMounted(true)
+ }, [visualActive])
const pkg = fw?.pkg ?? '@useupup/react'
const packageManagers = useMemo(
@@ -92,111 +124,16 @@ export default function HeroSection({
const easeCurve: [number, number, number, number] = [0.25, 0.46, 0.45, 0.94]
- // Animation variants — y/opacity/scale only (no x-slides; the section is
- // overflow-hidden and clips horizontal entrances at narrow viewports).
- const containerVariants = {
- hidden: { opacity: 0 },
- visible: {
- opacity: 1,
- transition: {
- staggerChildren: 0.15,
- delayChildren: 0.2,
- },
- },
- }
-
- const itemVariants = {
- hidden: { opacity: 0, y: 30, scale: 0.95 },
- visible: {
- opacity: 1,
- y: 0,
- scale: 1,
- transition: {
- duration: 0.7,
- ease: easeCurve,
- },
- },
- }
-
- const badgeVariants = {
- hidden: { opacity: 0, y: -20, scale: 0.8 },
- visible: {
- opacity: 1,
- y: 0,
- scale: 1,
- transition: {
- duration: 0.6,
- ease: easeCurve,
- },
- },
- }
-
- const headingVariants = {
- hidden: { opacity: 0, y: 40 },
- visible: {
- opacity: 1,
- y: 0,
- transition: {
- duration: 0.8,
- ease: easeCurve,
- delay: 0.2,
- },
- },
- }
-
- const subtitleVariants = {
- hidden: { opacity: 0, y: 30 },
- visible: {
- opacity: 1,
- y: 0,
- transition: {
- duration: 0.7,
- ease: easeCurve,
- delay: 0.4,
- },
- },
- }
-
- const buttonVariants = {
- hidden: { opacity: 0, y: 20, scale: 0.95 },
- visible: {
- opacity: 1,
- y: 0,
- scale: 1,
- transition: {
- duration: 0.6,
- ease: easeCurve,
- },
- },
- }
-
- const installBoxVariants = {
- hidden: { opacity: 0, y: 30, scale: 0.9 },
- visible: {
- opacity: 1,
- y: 0,
- scale: 1,
- transition: {
- duration: 0.7,
- ease: easeCurve,
- delay: 0.8,
- },
- },
- }
-
- const visualVariants = {
- hidden: { opacity: 0, y: 40, scale: 0.96 },
- visible: {
- opacity: 1,
- y: 0,
- scale: 1,
- transition: {
- duration: 0.9,
- ease: easeCurve,
- delay: 0.3,
- },
- },
- }
+ // The hero copy's entrance is CSS (`.hero-rise` / `.hero-word` in
+ // globals.css), staggered by an inline `--hero-delay`. It used to be a
+ // framer-motion `initial="hidden"` variant tree, which is why the server
+ // HTML shipped the H1, subtitle, CTAs and install box at opacity 0 — on a
+ // throttled phone the LCP text waited for hydration before it painted at
+ // all. Delays stay short and the subtitle (the LCP element) is close to
+ // first paint. Only whileHover/whileTap motion survives here, and only on
+ // elements with no `initial`, so nothing starts invisible again.
+ const rise = (seconds: number) =>
+ ({ '--hero-delay': `${seconds}s` }) as React.CSSProperties
return (
@@ -206,34 +143,34 @@ export default function HeroSection({
right under the fold copy. */}
{/* LEFT — copy, CTAs, install box */}
-
+
{/* Badge — hairline pill (the one border recipe). */}
- Open source · One core, six frameworks
-
+
{/* Main Heading */}
-
-
+
+ {/* No entrance class here: GradientText's root is a
+ flex block with its own infinite gradient
+ animation, and wrapping it in an animated inline
+ span would drop the transform silently. It
+ simply paints with the server HTML. */}
- {fw?.name ?? 'Every Framework'}
+ {fw
+ ? 'One core, every framework'
+ : 'for Every Framework'}
-
+
{/* Subtitle — tightened; every claim carries over verbatim
(drag-and-drop, headless core, native UI, cloud drives,
camera, screen capture, secure S3 server-mode). */}
-
- A drag-and-drop file uploader with a headless core
- and native UI for{' '}
+ A free, open-source drag-and-drop file uploader with
+ a headless core and native UI for{' '}
{fw?.name ??
'React, Vue, Svelte, Angular, Vanilla JS & Preact'}
. Cloud drives, camera, screen capture, and secure
server-mode uploads to any S3-compatible storage.
-
+
{/* CTA Buttons */}
-
+ {/* whileHover/whileTap only — no `initial`, so these
+ never render at opacity 0. */}
@@ -291,10 +233,8 @@ export default function HeroSection({
-
+
+ {/* Framework pages only: the shortest path from
+ this page to working code is that framework's
+ own quickstart doc (/docs/quickstarts//).
+ Plain Link — no motion wrapper — so the CTA row
+ stays trivially mergeable. */}
+ {fw && (
+
+ {fw.name} Quickstart
+
+
+ )}
+
{/* Install Command with Package Manager Select — the page's
ONE install surface. Behaviour is unchanged. */}
-
{/* Flat hairline surface — the page's ONE install
surface. No overflow-hidden so the absolute z-50
@@ -321,27 +276,16 @@ export default function HeroSection({
-
+ {/* Plain nodes: these used to fade in
+ from opacity 0 a full second after
+ hydration, which meant the install
+ command itself was invisible in the
+ server HTML. */}
+
{currentCommand}
-
+
-
+
{/* Copy Button */}
-
+
-
-
+
+
{/* RIGHT — the live-usage animation as the hero's visual
anchor. Decorative, so it carries no copy; the left
- column holds all the info. */}
-
-
-
+ column holds all the info. Client-only and mounted the
+ first time the box reaches the viewport. */}
+
+ {visualMounted ? (
+
+ ) : (
+
+ )}
+
{/* Full-width below the grid: framework strip, then the
diff --git a/apps/landing/src/components/StackBlitzDemoSection/index.tsx b/apps/landing/src/components/StackBlitzDemoSection/index.tsx
index e02832505..5cf80b11b 100644
--- a/apps/landing/src/components/StackBlitzDemoSection/index.tsx
+++ b/apps/landing/src/components/StackBlitzDemoSection/index.tsx
@@ -2,11 +2,13 @@
'use client'
import React, { useCallback, useEffect, useId, useRef, useState } from 'react'
-import { motion, AnimatePresence } from 'framer-motion'
+import { motion, AnimatePresence, useInView } from 'framer-motion'
import { Code, ExternalLink, Maximize2, Minimize2 } from 'lucide-react'
import { FaExclamationTriangle } from 'react-icons/fa'
import { SiStackblitz } from 'react-icons/si'
-import sdk, { type Project } from '@stackblitz/sdk'
+// Type-only: the SDK itself (~4.2 MB of staticblitz client + monaco once the
+// embed runs) is imported dynamically, so it never reaches the initial bundle.
+import { type Project } from '@stackblitz/sdk'
import Link from 'next/link'
import Section from '@/components/ui/Section'
import SectionHeading, { GRADIENT_TEXT } from '@/components/ui/SectionHeading'
@@ -159,9 +161,19 @@ function EditorLoadingOverlay() {
)
}
+// One module-level loader so the SDK is fetched at most once per page, whether
+// the embed effect or the "Open in StackBlitz" button asks for it first.
+const loadStackBlitzSdk = () => import('@stackblitz/sdk').then(m => m.default)
+
export default function StackBlitzDemoSection() {
const containerRef = useRef(null)
+ const gateRef = useRef(null)
const cancelButtonRef = useRef(null)
+ // The embed used to run on mount, on every homepage and framework-page
+ // load, pulling megabytes of third-party JS for a section most visitors
+ // never scroll to. It now waits until the editor card is within ~300px of
+ // the viewport; `once` keeps it embedded from then on.
+ const nearViewport = useInView(gateRef, { once: true, margin: '300px' })
const warningTitleId = useId()
const [isFullscreen, setIsFullscreen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
@@ -170,10 +182,12 @@ export default function StackBlitzDemoSection() {
const [pendingFullscreenState, setPendingFullscreenState] = useState(false)
const openInStackBlitz = () => {
- sdk.openProject(stackblitzProject, {
- openFile: OPEN_FILE,
- newWindow: true,
- })
+ void loadStackBlitzSdk().then(sdk =>
+ sdk.openProject(stackblitzProject, {
+ openFile: OPEN_FILE,
+ newWindow: true,
+ }),
+ )
}
// lock body scroll while full screen is active
@@ -190,11 +204,15 @@ export default function StackBlitzDemoSection() {
}
}, [isFullscreen])
- // embed once, on mount and handle fullscreen transitions
+ // Embed once the section is near the viewport, and re-embed across
+ // fullscreen transitions.
useEffect(() => {
+ if (!nearViewport) return
+
// Fallback timer for the pathological case where embedProject never
// resolves; declared in effect scope so the cleanup below can clear it.
let fallbackTimer: ReturnType | undefined
+ let cancelled = false
// Add a small delay to ensure DOM is ready, especially for fullscreen container
const timeoutId = setTimeout(
@@ -214,63 +232,67 @@ export default function StackBlitzDemoSection() {
setIsLoading(true)
setEmbedFailed(false)
- try {
- // Editor-only view: WebContainer preview needs the page
- // to be cross-origin isolated (COOP/COEP), which we do
- // NOT set globally because it breaks the drive OAuth
- // popups in the live demo. So the embed shows the real
- // (credible) source; "Open in StackBlitz" runs it live
- // on stackblitz.com, which is isolated.
- // The SDK replaces targetContainer with its iframe, so
- // hold the parent to find the frame afterwards.
- const embedParent = targetContainer.parentElement
- sdk.embedProject(targetContainer, stackblitzProject, {
- openFile: OPEN_FILE,
- view: 'editor',
- theme: 'dark',
- hideNavigation: true,
- hideDevTools: true,
+ // Editor-only view: WebContainer preview needs the page
+ // to be cross-origin isolated (COOP/COEP), which we do
+ // NOT set globally because it breaks the drive OAuth
+ // popups in the live demo. So the embed shows the real
+ // (credible) source; "Open in StackBlitz" runs it live
+ // on stackblitz.com, which is isolated.
+ // The SDK replaces targetContainer with its iframe, so
+ // hold the parent to find the frame afterwards.
+ const embedParent = targetContainer.parentElement
+ void loadStackBlitzSdk()
+ .then(sdk => {
+ if (cancelled) return
+ return (
+ sdk
+ .embedProject(
+ targetContainer,
+ stackblitzProject,
+ {
+ openFile: OPEN_FILE,
+ view: 'editor',
+ theme: 'dark',
+ hideNavigation: true,
+ hideDevTools: true,
+ },
+ )
+ // Dismiss the loader when the editor is
+ // actually ready, not on a fixed timer.
+ .then(() => {
+ // The SDK's iframe ships without a title;
+ // screen readers need one.
+ const frame =
+ embedParent?.querySelector(
+ 'iframe:not([title])',
+ )
+ frame?.setAttribute(
+ 'title',
+ 'StackBlitz code editor — upup React example',
+ )
+ setIsLoading(false)
+ })
+ )
})
- // Dismiss the loader when the editor is actually
- // ready, not on a fixed timer.
- .then(() => {
- // The SDK's iframe ships without a title;
- // screen readers need one.
- const frame = embedParent?.querySelector(
- 'iframe:not([title])',
- )
- frame?.setAttribute(
- 'title',
- 'StackBlitz code editor — upup React example',
- )
- setIsLoading(false)
- })
- .catch(error => {
- console.error('StackBlitz embed failed:', error)
- setIsLoading(false)
- setEmbedFailed(true)
- })
-
- // Safety net: never leave the loader up indefinitely.
- fallbackTimer = setTimeout(
- () => setIsLoading(false),
- 15000,
- )
- } catch (error) {
- console.error('StackBlitz embed error:', error)
- setIsLoading(false)
- setEmbedFailed(true)
- }
+ .catch(error => {
+ console.error('StackBlitz embed failed:', error)
+ setIsLoading(false)
+ setEmbedFailed(true)
+ })
+
+ // Safety net: never leave the loader up indefinitely.
+ fallbackTimer = setTimeout(() => setIsLoading(false), 15000)
}
},
isFullscreen ? 100 : 0,
) // Delay for fullscreen to ensure DOM is ready
return () => {
+ cancelled = true
clearTimeout(timeoutId)
if (fallbackTimer) clearTimeout(fallbackTimer)
}
- }, [isFullscreen])
+ }, [isFullscreen, nearViewport])
const toggleFullScreen = () => {
const newState = !isFullscreen
@@ -323,7 +345,10 @@ export default function StackBlitzDemoSection() {
{/* Regular container when not fullscreen */}
{!isFullscreen && (
-
- );
-};
-
-export default BlurText;
diff --git a/apps/landing/src/components/TextAnimation/RisingWords.tsx b/apps/landing/src/components/TextAnimation/RisingWords.tsx
new file mode 100644
index 000000000..376513bc8
--- /dev/null
+++ b/apps/landing/src/components/TextAnimation/RisingWords.tsx
@@ -0,0 +1,54 @@
+import type { CSSProperties } from 'react'
+
+// RisingWords — the hero H1's per-word entrance.
+//
+// This replaces the old framer-motion `BlurText`, which rendered every word as
+// a `motion.span` with an inline `opacity: 0` initial style behind an
+// IntersectionObserver. That put the H1 — server-rendered, indexable text — at
+// zero opacity in the HTML until framer had hydrated and the observer had
+// fired, which on a throttled phone is seconds of pure render delay.
+//
+// Here the words are plain text nodes in the server HTML; a CSS keyframe
+// (`.hero-word` in app/globals.css, with a per-word `--hero-delay`) animates
+// them in. No framer, no observer, no `will-change`, and
+// `prefers-reduced-motion: reduce` turns the animation off in the same
+// stylesheet. The inter-word gap is a non-breaking space inside each word's
+// span, exactly as before: the wrapper is a flex row, so a normal trailing
+// space would collapse away between flex items.
+
+export default function RisingWords({
+ text,
+ className = '',
+ /** Per-word stagger, in milliseconds. */
+ stagger = 60,
+ /** Delay before the first word, in milliseconds. */
+ startDelay = 0,
+}: Readonly<{
+ text: string
+ className?: string
+ stagger?: number
+ startDelay?: number
+}>) {
+ const words = text.split(' ')
+
+ return (
+
+ {words.map((word, index) => (
+
+ {word}
+ {index < words.length - 1 && '\u00A0'}
+
+ ))}
+
+ )
+}
diff --git a/apps/landing/src/components/UploaderScene/DragGhost.tsx b/apps/landing/src/components/UploaderScene/DragGhost.tsx
index 007611b6d..e94d20cf8 100644
--- a/apps/landing/src/components/UploaderScene/DragGhost.tsx
+++ b/apps/landing/src/components/UploaderScene/DragGhost.tsx
@@ -2,6 +2,7 @@
import { AnimatePresence, motion } from 'framer-motion'
import { FaRegFolderOpen } from 'react-icons/fa'
+import { sceneImageSize } from './scene-media'
// ─────────────────────────────────────────────────────────────────────────────
// DragGhost — the "thing being dragged" the timeline glides across the panel: the
@@ -116,6 +117,11 @@ export default function DragGhost({
)}
diff --git a/apps/landing/src/components/UploaderScene/MockDriveBrowser.tsx b/apps/landing/src/components/UploaderScene/MockDriveBrowser.tsx
index 4e052aa16..60c61a7cb 100644
--- a/apps/landing/src/components/UploaderScene/MockDriveBrowser.tsx
+++ b/apps/landing/src/components/UploaderScene/MockDriveBrowser.tsx
@@ -4,7 +4,7 @@ import type { CSSProperties } from 'react'
import { useEffect, useRef } from 'react'
import { motion } from 'framer-motion'
import { FaCheck, FaFolderOpen, FaPlay } from 'react-icons/fa'
-import { SCENE_MEDIA } from './scene-media'
+import { SCENE_MEDIA, sceneImageSize } from './scene-media'
import type { DriveProvider, DriveThumb } from './types'
// ─────────────────────────────────────────────────────────────────────────────
@@ -49,6 +49,9 @@ export default function MockDriveBrowser({
@@ -173,6 +176,9 @@ function ThumbMedia({ thumb, reduce }: { thumb: DriveThumb; reduce: boolean }) {
)
diff --git a/apps/landing/src/components/UploaderScene/MockUploader.tsx b/apps/landing/src/components/UploaderScene/MockUploader.tsx
index b926a6ae5..0a65595b1 100644
--- a/apps/landing/src/components/UploaderScene/MockUploader.tsx
+++ b/apps/landing/src/components/UploaderScene/MockUploader.tsx
@@ -14,7 +14,7 @@ import {
} from 'react-icons/fa'
import { SiGoogledrive, SiDropbox, SiBox } from 'react-icons/si'
import { GrOnedrive } from 'react-icons/gr'
-import { SCENE_MEDIA } from './scene-media'
+import { SCENE_MEDIA, sceneImageSize } from './scene-media'
import type { QueueFile, QueueStage, SourceDef } from './types'
// ─────────────────────────────────────────────────────────────────────────────
@@ -255,6 +255,9 @@ export default function MockUploader({
@@ -262,6 +265,9 @@ export default function MockUploader({
@@ -428,6 +434,9 @@ function FileThumb({ file }: { file: QueueFile }) {
diff --git a/apps/landing/src/components/UploaderScene/scene-media.ts b/apps/landing/src/components/UploaderScene/scene-media.ts
index ef9f25837..73e84c258 100644
--- a/apps/landing/src/components/UploaderScene/scene-media.ts
+++ b/apps/landing/src/components/UploaderScene/scene-media.ts
@@ -35,3 +35,37 @@ export const SCENE_MEDIA = {
devino: '/devino.png',
},
} as const
+
+// Every stock photo in the kit was exported at this size; the two video poster
+// frames are 16:9 and the two logos are their own shapes.
+const DEFAULT_PHOTO_SIZE = { width: 400, height: 300 } as const
+
+const IMAGE_SIZES: Readonly<
+ Record
+> = {
+ [SCENE_MEDIA.videos.beachWaves.poster]: { width: 640, height: 360 },
+ [SCENE_MEDIA.videos.screenShare.poster]: { width: 640, height: 360 },
+ [SCENE_MEDIA.logos.upup]: { width: 3200, height: 679 },
+ [SCENE_MEDIA.logos.devino]: { width: 1905, height: 580 },
+}
+
+/**
+ * Intrinsic pixel dimensions for a scene asset, so every scene `` can
+ * carry `width`/`height`. Two reasons they are not optional:
+ *
+ * - Lighthouse flags width/height-less images as a layout-shift risk on every
+ * page a scene renders on.
+ * - React 19 hoists an eagerly-loaded `` into a `` at the top of the document. Eleven decorative scene photos
+ * were therefore being preloaded ahead of the CSS and fonts the hero COPY
+ * needs; pairing these attributes with `loading="lazy"` stops that.
+ *
+ * The scene images are all `object-cover` inside absolutely-positioned boxes,
+ * so the attributes never change layout — they only describe the file.
+ */
+export function sceneImageSize(src: string | undefined): {
+ readonly width: number
+ readonly height: number
+} {
+ return (src && IMAGE_SIZES[src]) || DEFAULT_PHOTO_SIZE
+}
diff --git a/apps/landing/src/components/docs/DocsHome.tsx b/apps/landing/src/components/docs/DocsHome.tsx
index cabd6d48e..93d867906 100644
--- a/apps/landing/src/components/docs/DocsHome.tsx
+++ b/apps/landing/src/components/docs/DocsHome.tsx
@@ -225,6 +225,22 @@ export function DocsHome() {
))}
+ {/* The machine-readable entry point, as a real anchor. The
+ "AI assistants" card above explains llms.txt; this is the
+ link that actually reaches it — a crawler cannot follow
+ prose, and the corpus was previously unlinked from every
+ HTML page on the site. */}
+
+ Reading these docs with an agent? Start from{' '}
+
+ llms.txt
+
+ .
+
)
diff --git a/apps/landing/src/components/docs/DocsStructuredData.tsx b/apps/landing/src/components/docs/DocsStructuredData.tsx
new file mode 100644
index 000000000..09c1e738d
--- /dev/null
+++ b/apps/landing/src/components/docs/DocsStructuredData.tsx
@@ -0,0 +1,72 @@
+// Server component: the per-docs-page JSON-LD — BreadcrumbList + TechArticle.
+//
+// Built from the SAME `tree`/`url` inputs renders visually, so
+// the markup can never describe a different hierarchy than the page shows. The
+// two nodes point at the site-wide entities by @id (emitted once from the root
+// layout) rather than restating publisher details per page.
+//
+// No `datePublished`/`dateModified`: content/docs carries no frontmatter dates
+// and there is no build-time git-mtime map yet. An invented date is worse than
+// no date — Google treats a date it can't corroborate in the page as a quality
+// signal against the markup, so the field stays out until a real source exists.
+
+import {
+ ORGANIZATION_ID,
+ WEBSITE_ID,
+} from '@/components/StructuredData/EntityStructuredData'
+import { findTrail, type SidebarNode } from '@/lib/docs/sidebar-tree'
+import { canonicalUrl } from '@/lib/site-url'
+
+export function DocsStructuredData({
+ tree,
+ url,
+ title,
+ description,
+}: {
+ tree: SidebarNode[]
+ url: string
+ title: string
+ description?: string
+}) {
+ // Same call the visual breadcrumb makes; the "Docs" root crumb is rendered
+ // unconditionally there, so it leads the list here too.
+ const trail = findTrail(tree, url) ?? []
+ const crumbs: { name: string; url?: string }[] = [
+ { name: 'Docs', url: '/docs' },
+ ...trail.map(node => ({ name: node.name, url: node.url })),
+ ]
+
+ const breadcrumbList = {
+ '@type': 'BreadcrumbList',
+ itemListElement: crumbs.map((crumb, i) => ({
+ '@type': 'ListItem',
+ position: i + 1,
+ name: crumb.name,
+ // A folder with no index page has no URL to point at; schema.org
+ // allows a ListItem to carry only a name, so omit `item` instead
+ // of fabricating a target.
+ ...(crumb.url ? { item: canonicalUrl(crumb.url) } : {}),
+ })),
+ }
+
+ const techArticle = {
+ '@type': 'TechArticle',
+ headline: title,
+ ...(description ? { description } : {}),
+ url: canonicalUrl(url),
+ isPartOf: { '@id': WEBSITE_ID },
+ publisher: { '@id': ORGANIZATION_ID },
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/landing/src/components/providers.tsx b/apps/landing/src/components/providers.tsx
index 6f911fa24..3b0954550 100644
--- a/apps/landing/src/components/providers.tsx
+++ b/apps/landing/src/components/providers.tsx
@@ -10,7 +10,9 @@ export function Providers({ children }: { children: ReactNode }) {
if (!gtag.GA_TRACKING_ID) return
const handleRouteChange = (event: Event) => {
- const url = (event as CustomEvent).detail || window.location.pathname
+ const url =
+ (event as CustomEvent).detail ||
+ window.location.pathname
gtag.pageView(url)
}
@@ -25,11 +27,16 @@ export function Providers({ children }: { children: ReactNode }) {
<>
{gtag.GA_TRACKING_ID && (
<>
+ {/* lazyOnload on BOTH halves — the loader and its config
+ script must share a strategy, or the config can run
+ before gtag.js exists. Analytics is never on the
+ critical path; afterInteractive put ~90 KB of
+ third-party JS in front of hydration on mobile. */}
-