Skip to main content

Public Pages Performance Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Make the public pages of odontox.io (landing + legal + referral + transition pages) load and paint dramatically faster, without breaking authenticated routes. Architecture: Four independent, revertible phases shipped in ascending risk order: (1) network hints + AVIF/WebP images + short-max-age HSTS, (2) JS bundle trim via guarded manualChunks splits, (3) Puppeteer-driven snapshot prerender (Playwright, already a dep) that visits each public route on a local vite preview and writes static HTML, (4) HSTS preload finalize. Tech Stack: Vite, React 19, Tailwind v4, Cloudflare Pages, Playwright (for prerender + measurement), Lighthouse (via Playwright + chrome-launcher). Spec: docs/superpowers/specs/2026-05-12-public-pages-perf-design.md

File Map

FileActionResponsibility
ui/index.htmlModifyAdd preload/preconnect/dns-prefetch hints
ui/public/_headersModifyHSTS (short, then long+preload), confirm cache headers
ui/public/_redirectsModifyMap nested public route HTML files (Phase 3)
ui/vite.config.tsModifyAdd guarded manualChunks for dashboard-only deps
ui/src/main.tsxModifyEmit window.__PRERENDER_READY__ after mount (Phase 3)
ui/scripts/audit-public-deps.mjsCreateStatic-import graph audit per public route
ui/scripts/prerender.mjsCreateSnapshot prerender using Playwright
ui/scripts/prerender-routes.jsonCreateRoute list + per-route wait conditions
ui/scripts/perf-measure.mjsCreateLighthouse run via Playwright
ui/package.jsonModifyNew build chain, prerender, audit:public, perf:measure scripts
ui/postbuild.jsModifyApply data-cfasync injection to all prerendered HTML, not just index.html
ui/src/components/landing/PremiumHero.tsxModify (if needed)Gate any non-deterministic render reads (Phase 3 hydration safety)
docs/perf/README.mdCreatePerf tracking log
docs/perf/baseline-2026-05-12.jsonCreatePre-change Lighthouse + asset sizes
docs/perf/post-phase-N-<date>.jsonCreate (per phase)Post-deploy measurements

Task 1: Capture baseline measurements

Why: every later task is judged against this. Without numbers we can’t tell if we made things worse. Files:
  • Create: ui/scripts/perf-measure.mjs
  • Modify: ui/package.json
  • Create: docs/perf/README.md
  • Create: docs/perf/baseline-2026-05-12.json
  • Step 1: Write the measurement script
Create ui/scripts/perf-measure.mjs:
#!/usr/bin/env node
// Run Lighthouse against a URL and save results.
// Usage: node scripts/perf-measure.mjs <url> <outfile> [route...]
//   node scripts/perf-measure.mjs https://odontox.io ../docs/perf/baseline-2026-05-12.json / /legal /the-recall-effect

import { chromium } from 'playwright';
import lighthouse from 'lighthouse';
import { writeFileSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';

const [,, baseUrl, outFile, ...routes] = process.argv;
if (!baseUrl || !outFile || routes.length === 0) {
  console.error('usage: perf-measure.mjs <baseUrl> <outFile> <route> [route...]');
  process.exit(1);
}

const browser = await chromium.launch({
  args: ['--remote-debugging-port=9222', '--no-sandbox'],
});

const results = { capturedAt: new Date().toISOString(), baseUrl, runs: [] };

for (const route of routes) {
  const url = new URL(route, baseUrl).toString();
  console.log(`[perf] measuring ${url}`);
  const { lhr } = await lighthouse(url, {
    port: 9222,
    output: 'json',
    onlyCategories: ['performance'],
    formFactor: 'mobile',
    screenEmulation: { mobile: true, width: 412, height: 823, deviceScaleFactor: 1.75, disabled: false },
    throttling: {
      rttMs: 150, throughputKbps: 1638.4, cpuSlowdownMultiplier: 4,
      requestLatencyMs: 562.5, downloadThroughputKbps: 1474.56, uploadThroughputKbps: 675,
    },
    logLevel: 'error',
  });

  const audits = lhr.audits;
  results.runs.push({
    route,
    url,
    performanceScore: Math.round((lhr.categories.performance.score ?? 0) * 100),
    fcp: audits['first-contentful-paint']?.numericValue,
    lcp: audits['largest-contentful-paint']?.numericValue,
    tti: audits['interactive']?.numericValue,
    tbt: audits['total-blocking-time']?.numericValue,
    cls: audits['cumulative-layout-shift']?.numericValue,
    speedIndex: audits['speed-index']?.numericValue,
    totalByteWeight: audits['total-byte-weight']?.numericValue,
    domSize: audits['dom-size']?.numericValue,
  });
}

await browser.close();

const outPath = resolve(outFile);
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, JSON.stringify(results, null, 2));
console.log(`[perf] wrote ${outPath}`);
  • Step 2: Install the one missing dep
Run from ui/:
npm install --save-dev lighthouse
Expected: lighthouse added to devDependencies. playwright is already present.
  • Step 3: Add npm script
In ui/package.json scripts block, add:
"perf:measure": "node scripts/perf-measure.mjs"
  • Step 4: Run the baseline
From ui/:
npm run perf:measure -- https://odontox.io ../docs/perf/baseline-2026-05-12.json / /legal /the-recall-effect /refer /refer-payout-form
Expected: writes docs/perf/baseline-2026-05-12.json with one entry per route. The Performance score for / should be roughly in the 50–70 range based on the HAR.
  • Step 5: Write the trend log
Create docs/perf/README.md:
# Public Pages Performance Tracking

Each phase deploy gets a snapshot file `post-phase-<n>-<YYYY-MM-DD>.json` captured
via `npm run perf:measure` (in `ui/`) against `https://odontox.io`.

Routes measured:
- `/` (Landing)
- `/legal`
- `/the-recall-effect`
- `/refer`
- `/refer-payout-form`

Targets (mobile, Slow 4G profile):
- Performance ≥ 90 on `/`
- FCP < 1.2s
- LCP < 2.0s
- TBT < 200ms

| Phase | File | Performance / | FCP | LCP | TBT |
|---|---|---|---|---|---|
| Baseline | `baseline-2026-05-12.json` | _fill in_ | | | |
  • Step 6: Commit
git add ui/scripts/perf-measure.mjs ui/package.json ui/package-lock.json docs/perf/
git commit -m "perf: add Lighthouse baseline measurement script + 2026-05-12 baseline"

Task 2: Phase 1 — Resource hints in index.html

Why: preload critical fonts and warm the API/asset connections before the JS bundle even parses. Cheapest possible win. Files:
  • Modify: ui/index.html
  • Step 1: Add preload + preconnect block
In ui/index.html, immediately after the <meta name="theme-color" ...> line and BEFORE the <title> (so hints are discovered as early as possible by the preload scanner), insert:
    <!-- Resource hints: discover critical assets before the JS bundle parses -->
    <link rel="preconnect" href="https://api.odontox.io" crossorigin />
    <link rel="preconnect" href="https://assets.odontox.io" crossorigin />
    <link rel="dns-prefetch" href="https://www.googletagmanager.com" />
    <link rel="dns-prefetch" href="https://o4511296397901824.ingest.sentry.io" />
    <link rel="preload" href="/fonts/poppins-400.woff2" as="font" type="font/woff2" crossorigin />
    <link rel="preload" href="/fonts/poppins-600.woff2" as="font" type="font/woff2" crossorigin />
Rules:
  • preconnect is reserved for origins we hit on first paint (api.odontox.io for auth check on hydration, assets.odontox.io for the og-image and any hero asset)
  • dns-prefetch for origins we probably hit but don’t want to pay full TCP+TLS for
  • Only 400 and 600 Poppins are preloaded — they cover the body + h1/h2 above the fold. 500 / 700 load on demand
  • crossorigin is required on font preload (woff2 fonts are CORS-cross-origin by spec even when same-origin)
  • Step 2: Verify the font filenames exist
ls ui/public/fonts/poppins-400.woff2 ui/public/fonts/poppins-600.woff2
Expected: both files exist (already confirmed in scoping).
  • Step 3: Build and visually check
cd ui && npm run build
Then open ui/dist/index.html and confirm the new <link> lines are present. Confirm the postbuild script still ran (look for data-cfasync="false" on script tags).
  • Step 4: Commit
git add ui/index.html
git commit -m "perf(ui): preload Poppins 400/600 + preconnect api.odontox.io and assets.odontox.io"

Task 3: Phase 1 — HSTS short max-age

Why: the 146ms HTTP→HTTPS redirect costs every first-time visitor. HSTS tells the browser “always use HTTPS for this origin.” We start short so it’s easy to revert if some unknown HTTP-only path exists; we escalate later in Phase 4. Files:
  • Modify: ui/public/_headers
  • Step 1: Add HSTS to the global block
In ui/public/_headers, inside the existing /* global block (line 5 area, where Content-Security-Policy: etc. live), add a new line at the end of that block:
  Strict-Transport-Security: max-age=604800; includeSubDomains
Final block looks like:
/*
  Content-Security-Policy: <existing CSP unchanged>
  X-Content-Type-Options: nosniff
  X-Frame-Options: SAMEORIGIN
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(self), geolocation=()
  Strict-Transport-Security: max-age=604800; includeSubDomains
Why 604800 (1 week): if anything breaks on a subdomain we missed (e.g., a debug subdomain on plain HTTP), revert is cheap — browsers forget the policy after a week. We escalate to 2 years + preload in Phase 4 after observing one clean week. Why no preload directive yet: that’s a one-way commitment (registry submission). Phase 4.
  • Step 2: Confirm no subdomain runs on plain HTTP
Manually check production-served subdomains:
  • odontox.io, www.odontox.io — Cloudflare-fronted, HTTPS-only ✓
  • id.odontox.io, go.odontox.io, portal.odontox.io — Cloudflare Pages, HTTPS-only ✓
  • api.odontox.io, ph.odontox.io, tenants.odontox.io — Cloudflare Workers, HTTPS-only ✓
  • assets.odontox.io — Cloudflare R2/CDN, HTTPS-only ✓
  • stg.sshssn.workers.dev (staging fallback) — HTTPS-only ✓
If any subdomain shows plain HTTP, stop and remove includeSubDomains before continuing.
  • Step 3: Commit
git add ui/public/_headers
git commit -m "perf(headers): add HSTS max-age=1w with includeSubDomains"

Task 4: Phase 1 — Hero/og-image AVIF + WebP

Why: the og-image is a 1200×630 PNG served from assets.odontox.io/odontox-prod-asset/og-image.png. PNG → AVIF typically drops 70–80% of bytes; AVIF + WebP fallback covers 100% of modern browsers. Files:
  • Investigate first, then potentially modify components that render hero/og imagery
  • Step 1: Locate the source files
find /Users/ssh/Documents/Beta-App/odontoX -name "og-image*" -not -path "*/node_modules/*" 2>/dev/null
find /Users/ssh/Documents/Beta-App/odontoX -name "hero*" -path "*/ui/*" -not -path "*/node_modules/*" 2>/dev/null | head
If the source file lives in this repo (e.g., ui/public/og-image.png), convert it locally with sharp or cwebp/avifenc:
# from ui/public/ (or wherever the source lives)
npx sharp-cli og-image.png -o og-image.avif --avif-quality 60
npx sharp-cli og-image.png -o og-image.webp --webp-quality 80
If the source lives only in R2 (the URL is https://assets.odontox.io/odontox-prod-asset/og-image.png), download → convert → re-upload via the existing R2 tooling (look for an r2 upload helper in scripts/ or server/).
  • Step 2: Pause for user confirmation before touching R2
R2 / assets.odontox.io is a shared production bucket. Stop and ask the user before uploading anything new. Show them the local AVIF/WebP file sizes vs the original PNG and ask for go-ahead.
  • Step 3: After confirmation — upload AVIF + WebP alongside the PNG
Keep the PNG (don’t delete; old shares + social cards may reference it). Upload as siblings:
  • og-image.avif
  • og-image.webp
Verify via curl -I https://assets.odontox.io/odontox-prod-asset/og-image.avif returns 200.
  • Step 4: Update <meta property="og:image"> if a separate hero image is in play
The og:image meta in index.html is used by social crawlers, not by the rendered page. Crawlers like Facebook/X don’t reliably handle AVIF — leave og:image pointing at the PNG. No change to index.html. For the actual rendered hero / above-the-fold image in the landing page itself, locate the <img> (likely in PremiumHero.tsx or Landing.tsx) and replace it with a <picture>:
<picture>
  <source type="image/avif" srcSet="https://assets.odontox.io/odontox-prod-asset/og-image.avif" />
  <source type="image/webp" srcSet="https://assets.odontox.io/odontox-prod-asset/og-image.webp" />
  <img
    src="https://assets.odontox.io/odontox-prod-asset/og-image.png"
    alt="OdontoX dashboard"
    width="1200"
    height="630"
    fetchpriority="high"
    decoding="async"
    loading="eager"
  />
</picture>
The width/height attributes prevent CLS. fetchpriority="high" and loading="eager" ensure the LCP element is prioritized (the existing applyImageLoadingPolicy in main.tsx won’t override loading="eager" because it explicitly checks getAttribute('loading') !== 'eager').
  • Step 5: If no obvious hero image exists in the landing components
Skip Step 4 and only do the AVIF upload for the social-card use case. Note this in the commit message.
  • Step 6: Build and visually verify
cd ui && npm run build && npm run preview
Open the preview URL. Inspect the hero <picture> in DevTools — Network panel should show the AVIF request, not the PNG.
  • Step 7: Commit
git add ui/src/components/landing/
git commit -m "perf(images): serve hero as AVIF+WebP via <picture>, fallback to PNG"

Task 5: Phase 1 deploy

  • Step 1: Verify type check + lint pass
From ui/:
npx tsc --noEmit
npm run lint
Expected: both pass.
  • Step 2: Run measurement against local preview to sanity-check
cd ui && npm run build && npm run preview &
sleep 3
npm run perf:measure -- http://localhost:4173 /tmp/phase1-local.json / /legal /the-recall-effect
kill %1
Expected: FCP and TBT should improve relative to local-baseline. (Local won’t show the HSTS or production-edge wins — those need production deploy.)
  • Step 3: Deploy via the odontox-commit-deploy skill
Invoke the odontox-commit-deploy skill. Use commit message:
perf(public): phase 1 — preload fonts, preconnect, HSTS short, AVIF/WebP hero
  • Step 4: Post-deploy verification
After Cloudflare Pages publish completes:
# Confirm HSTS is live
curl -sI https://odontox.io/ | grep -i strict-transport-security
# Expected: Strict-Transport-Security: max-age=604800; includeSubDomains

# Confirm preload hints reach the browser
curl -s https://odontox.io/ | grep -E 'preload|preconnect' | head

# Confirm site still loads and Landing renders
# (open https://odontox.io/ in an incognito window, scan for visual regressions)
  • Step 5: Capture Phase 1 measurement
cd ui && npm run perf:measure -- https://odontox.io ../docs/perf/post-phase-1-$(date +%Y-%m-%d).json / /legal /the-recall-effect /refer /refer-payout-form
Append the result row to docs/perf/README.md.
  • Step 6: Observe 24h
Watch Sentry for new client-side errors. If any flat-line of 5xx or new error class appears, revert HSTS first (it’s the least-tested change).

Task 6: Phase 2 — Static-import audit script

Why: the spec assumed Radix/recharts/framer-motion/gsap could all be split out of the public entry. Reality check: framer-motion is imported by 14+ public components (TheRecallEffectPage, ReferEarn, LoginSuccess, LogoutSuccess, DetailedFeatures, BridgeSection, BeforeAfter, BentoFeatures, RubySection, MobileAppCTA, DicomSection, CTA, FAQ, CookieBanner, ScrollToTop). It cannot be split out. We need to know per-dep whether it’s actually reachable from the public route tree before moving anything. Files:
  • Create: ui/scripts/audit-public-deps.mjs
  • Modify: ui/package.json
  • Step 1: Write the audit script
Create ui/scripts/audit-public-deps.mjs:
#!/usr/bin/env node
// Walk the import graph from each public route component and report which
// node_modules deps are reachable. Used to decide what's safe to chunk-split.
//
// Run from ui/:  node scripts/audit-public-deps.mjs

import { readFileSync, existsSync } from 'node:fs';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const SRC = resolve(ROOT, 'src');

// Entry components for every public route in App.tsx
const PUBLIC_ENTRY_FILES = [
  'src/pages/Landing.tsx',
  'src/pages/LegalPage.tsx',
  'src/pages/TheRecallEffectPage.tsx',
  'src/pages/ReferEarn.tsx',
  'src/pages/ReferPayoutForm.tsx',
  'src/pages/LoginSuccess.tsx',
  'src/pages/LogoutSuccess.tsx',
  // Plus the always-mounted provider tree
  'src/App.tsx',
  'src/main.tsx',
];

const DEPS_OF_INTEREST = [
  'framer-motion',
  'recharts',
  'gsap',
  '@gsap/react',
  '@radix-ui/react-accordion',
  '@radix-ui/react-alert-dialog',
  '@radix-ui/react-dialog',
  '@radix-ui/react-popover',
  '@radix-ui/react-tooltip',
  '@radix-ui/react-dropdown-menu',
  '@radix-ui/react-tabs',
  '@radix-ui/react-select',
  '@radix-ui/react-slider',
];

const visited = new Set();
const found = new Map(); // dep -> Set<entryFile that reaches it>

function resolveImport(specifier, fromFile) {
  if (specifier.startsWith('.')) {
    const base = resolve(dirname(fromFile), specifier);
    for (const ext of ['', '.ts', '.tsx', '/index.ts', '/index.tsx']) {
      const candidate = base + ext;
      if (existsSync(candidate)) return candidate;
    }
    return null;
  }
  if (specifier.startsWith('@/')) {
    const base = resolve(SRC, specifier.slice(2));
    for (const ext of ['', '.ts', '.tsx', '/index.ts', '/index.tsx']) {
      const candidate = base + ext;
      if (existsSync(candidate)) return candidate;
    }
    return null;
  }
  // node_modules dep
  return { dep: specifier };
}

function walk(file, entryFile) {
  if (visited.has(file)) return;
  visited.add(file);

  let src;
  try { src = readFileSync(file, 'utf8'); } catch { return; }

  // Match static imports only — dynamic import() is the whole point of the split.
  // Capture: import ... from "X"; import "X"; export ... from "X";
  const re = /(?:^|\n)\s*(?:import|export)\s+(?:[^'"\n;]+?from\s+)?['"]([^'"]+)['"]/g;
  let m;
  while ((m = re.exec(src))) {
    const spec = m[1];
    const resolved = resolveImport(spec, file);
    if (!resolved) continue;
    if (typeof resolved === 'string') {
      walk(resolved, entryFile);
    } else {
      const dep = resolved.dep;
      // Match against deps of interest
      for (const target of DEPS_OF_INTEREST) {
        if (dep === target || dep.startsWith(target + '/')) {
          if (!found.has(target)) found.set(target, new Set());
          found.get(target).add(entryFile);
        }
      }
    }
  }
}

for (const entry of PUBLIC_ENTRY_FILES) {
  visited.clear();
  walk(resolve(ROOT, entry), entry);
}

console.log('Public-route reachability for dashboard-suspected deps:\n');
for (const dep of DEPS_OF_INTEREST) {
  const reachers = found.get(dep);
  if (!reachers) {
    console.log(`  ✅ ${dep}  — NOT reachable from any public entry — SAFE TO SPLIT`);
  } else {
    console.log(`  ❌ ${dep}  — reachable from: ${[...reachers].join(', ')}`);
  }
}
Notes:
  • Captures only static import/export ... from syntax. Dynamic import() is intentionally ignored — that’s what we want to encourage.
  • Treats @/ as the alias for src/ (matches ui/vite.config.ts resolve.alias).
  • Walks transitively until it hits node_modules deps, then records them.
  • Doesn’t actually trace into node_modules — we only care about whether our code statically reaches the dep.
  • Step 2: Add npm script
In ui/package.json scripts:
"audit:public": "node scripts/audit-public-deps.mjs"
  • Step 3: Run the audit and save its output
cd ui && npm run audit:public | tee ../docs/perf/dep-audit-2026-05-12.txt
Expected output: framer-motion will show ❌ (reachable). At minimum recharts should show ✅. Some Radix components likely ✅, others ❌.
  • Step 4: Commit
git add ui/scripts/audit-public-deps.mjs ui/package.json docs/perf/dep-audit-2026-05-12.txt
git commit -m "perf: add public-entry static import audit script + initial run"

Task 7: Phase 2 — Add manualChunks for confirmed-safe deps only

Why: the audit from Task 6 tells us exactly which deps can be safely chunked out. Any dep that ❌‘d in the audit stays in the public entry because moving it would either break a public route or force a lazy-load that hurts UX. Files:
  • Modify: ui/vite.config.ts
  • Step 1: Read the audit output
Open docs/perf/dep-audit-2026-05-12.txt. For each ✅ entry, that dep is a candidate. For each ❌, skip it.
  • Step 2: Extend the manualChunks function
In ui/vite.config.ts, inside the existing manualChunks(id) function (line ~70), add new conditional blocks BEFORE the return undefined; line. Only add a block for deps marked ✅ in the audit. Example for recharts (almost certainly safe) and any safe Radix components:
manualChunks(id) {
  if (!id.includes('node_modules')) return undefined;
  if (id.includes('/dwv/')) return 'vendor-dicom';
  if (id.includes('/three/') || id.includes('/troika-') || id.includes('/webgl-sdf-generator/') || id.includes('/bidi-js/')) return 'vendor-three';
  if (id.includes('/react-tiff/') || id.includes('/utif/') || id.includes('/react-i18next/') || id.includes('/i18next/')) return 'vendor-tiff';
  if (id.includes('/@react-pdf/') || id.includes('/react-pdf/') || id.includes('/jspdf') || id.includes('/pdfkit/')) return 'vendor-pdf';
  if (id.includes('/html2canvas/')) return 'vendor-html2canvas';
  if (id.includes('/mammoth/') || id.includes('/docx-preview/')) return 'vendor-docx';

  // NEW: dashboard-only deps confirmed by audit-public-deps.mjs
  if (id.includes('/recharts/') || id.includes('/d3-')) return 'vendor-charts';

  // NEW: Add one of these blocks PER ✅ Radix dep from the audit.
  // Example — only add if `npm run audit:public` showed ✅ for these:
  // if (id.includes('/@radix-ui/react-context-menu/')) return 'vendor-radix-dashboard';
  // if (id.includes('/@radix-ui/react-menubar/')) return 'vendor-radix-dashboard';

  return undefined;
}
Rules:
  • One new vendor-* chunk per category of safe deps (don’t make 20 tiny chunks)
  • d3-* rides with recharts because recharts depends on a bunch of d3 sub-packages
  • Comment each new block with // confirmed safe by audit YYYY-MM-DD
  • Step 3: Build and inspect chunk sizes
cd ui && npm run build
Read the build output. Note:
  • The size of the new vendor-charts (etc.) chunk(s)
  • The size of the main entry chunk before/after — there’s no “before” in this build, so compare against the HAR baseline (432KB brotli)
  • Watch for new circular-import / TDZ warnings in the build log. If you see “Cannot set properties of undefined” or “ReferenceError: … is not defined” warnings, back out the chunk that introduced it — that’s the same class of bug the existing comments in vite.config.ts warn about.
  • Step 4: Smoke-test the preview build
npm run preview
Open http://localhost:4173/ and click through:
  • / (landing)
  • /legal, /the-recall-effect, /refer, /refer-payout-form
  • Log in via http://id.localhost:4173/auth/login if local subdomain routing is set up (skip if not)
  • Navigate to /dashboard (you’ll need a real session — alternatively just confirm the route loads its chunks)
Watch the browser console for any chunk-load errors, hydration warnings, or runtime exceptions.
  • Step 5: Commit
git add ui/vite.config.ts
git commit -m "perf(build): split recharts + d3 + audited Radix into vendor-charts chunk"

Task 8: Phase 2 — Audit verifies dashboard chunks didn’t grow

Why: the spec’s non-regression criterion says “build artifact diff: chunks named under vendor-* and the dashboard route chunks must be the same size ±2% before and after Layer 2.” We need to confirm this. Files: none
  • Step 1: Capture chunk inventory pre-change
Already captured implicitly — the Phase 1 build (Task 5) is the pre-change snapshot. From that build’s terminal output, copy the chunk-size table into docs/perf/chunks-pre-phase-2.txt. If you didn’t save it, rebuild from the commit before this task and capture:
git stash
cd ui && npm run build 2>&1 | grep -E "^dist/assets/" > ../docs/perf/chunks-pre-phase-2.txt
git stash pop
  • Step 2: Capture chunk inventory post-change
cd ui && npm run build 2>&1 | grep -E "^dist/assets/" > ../docs/perf/chunks-post-phase-2.txt
  • Step 3: Diff and verify
diff ../docs/perf/chunks-pre-phase-2.txt ../docs/perf/chunks-post-phase-2.txt | head -60
Expected:
  • Main entry chunk should be smaller (we moved deps out)
  • A new vendor-charts (etc.) chunk should appear
  • Existing vendor-dicom, vendor-three, vendor-pdf, etc. should be unchanged within ±2%
  • Dashboard component chunks (likely named by hash) should be unchanged within ±2%
If any existing chunk grew by more than 2%, you’ve accidentally pulled a public-route dep into a dashboard chunk. Stop, revert the offending manualChunks block, rerun audit.
  • Step 4: Commit the diff artifacts
git add docs/perf/chunks-pre-phase-2.txt docs/perf/chunks-post-phase-2.txt
git commit -m "perf: capture chunk-size diff for phase 2 (non-regression evidence)"

Task 9: Phase 2 deploy

  • Step 1: Final pre-deploy checks
cd ui && npx tsc --noEmit && npm run lint && npm run build
All three must pass.
  • Step 2: Deploy via the odontox-commit-deploy skill
Commit message for the deploy:
perf(public): phase 2 — split confirmed-safe dashboard deps to vendor-charts chunk
  • Step 3: Post-deploy verification
After publish completes:
# Confirm the new chunk appears in the deployed asset manifest
curl -s https://odontox.io/ | grep -oE 'assets/[a-zA-Z0-9_-]+\.js' | sort -u | head -20
Manually:
  • Open https://odontox.io/ in incognito — landing renders, no console errors
  • Open https://go.odontox.io/auth/login — auth flow works
  • Log in and navigate to the dashboard — charts module loads (this exercises the new vendor-charts chunk lazy-load)
  • Step 4: Capture Phase 2 measurement
cd ui && npm run perf:measure -- https://odontox.io ../docs/perf/post-phase-2-$(date +%Y-%m-%d).json / /legal /the-recall-effect /refer /refer-payout-form
Append row to docs/perf/README.md.
  • Step 5: Observe 48h
Watch Sentry for new errors of any kind. Particularly watch for “Failed to fetch dynamically imported module” — that’s the canary for a broken chunk split. If errors appear, revert by reverting the Phase 2 commit and redeploying.

Task 10: Phase 3 — Prepare for prerender (hydration safety)

Why: snapshot prerender via Playwright runs the app in a real Chromium and captures the DOM after first paint. Hydration on the real user’s browser then has to produce the same DOM. If any landing component reads time/random/agent during render, hydration will mismatch and React 19 will produce a console warning and re-render the subtree. We need to find and gate any such reads. Files:
  • Audit: src/pages/Landing.tsx, src/pages/LegalPage.tsx, src/pages/TheRecallEffectPage.tsx, src/pages/ReferEarn.tsx, src/pages/ReferPayoutForm.tsx, src/pages/LoginSuccess.tsx, src/pages/LogoutSuccess.tsx, and the components they import from src/components/landing/
  • Step 1: Grep for non-deterministic render reads
From ui/:
grep -rE "Date\.now\(\)|Math\.random\(\)|navigator\.|window\.(?!location\.replace|location\.href|location\.reload|history|addEventListener)" src/pages/Landing.tsx src/pages/LegalPage.tsx src/pages/TheRecallEffectPage.tsx src/pages/ReferEarn.tsx src/pages/ReferPayoutForm.tsx src/pages/LoginSuccess.tsx src/pages/LogoutSuccess.tsx src/components/landing/ src/components/shared/ScrollToTop.tsx 2>/dev/null
For each hit, classify:
  • Inside useEffect / event handler → safe, skip
  • Top-level component body → unsafe, must fix
  • Inside useState initializer → unsafe, must fix (state init runs at first render)
  • Inside useMemo / useCallback with no deps → effectively run-once-at-render — unsafe
  • Step 2: For each unsafe hit, gate it
Pattern A — replace useState(() => readWindow()) with useState(null) + useEffect:
// BEFORE
const [theme, setTheme] = useState(() => localStorage.getItem('theme') ?? 'light');

// AFTER
const [theme, setTheme] = useState<string | null>(null);
useEffect(() => {
  setTheme(localStorage.getItem('theme') ?? 'light');
}, []);
Pattern B — replace top-of-body access with useSyncExternalStore:
// BEFORE
const isMobile = window.matchMedia('(max-width: 640px)').matches;

// AFTER
const isMobile = useSyncExternalStore(
  (cb) => {
    const mq = window.matchMedia('(max-width: 640px)');
    mq.addEventListener('change', cb);
    return () => mq.removeEventListener('change', cb);
  },
  () => window.matchMedia('(max-width: 640px)').matches,
  () => false, // server snapshot — assume desktop
);
If no unsafe hits exist, skip Step 2 entirely.
  • Step 3: Commit any gating changes
git add src/pages/ src/components/landing/
git commit -m "perf(landing): gate non-deterministic reads behind useEffect for prerender hydration safety"
If nothing changed, skip the commit. Note in docs/perf/README.md: “Hydration audit clean — no gating required.”

Task 11: Phase 3 — Emit prerender-ready signal

Why: Playwright needs to know when the page is “done” enough to snapshot. networkidle is unreliable (Sentry/PostHog keep tiny pings open). A custom signal is deterministic. Files:
  • Modify: ui/src/main.tsx
  • Step 1: Add the signal
In ui/src/main.tsx, at the very end of the file (after the createRoot(...).render(...) call), append:
// Prerender signal — Playwright watches for this when snapshotting public routes.
// Safe to leave in production: setting a flag costs nothing.
if (typeof window !== 'undefined') {
  // Defer one microtask so React has flushed its initial render.
  queueMicrotask(() => {
    (window as unknown as { __PRERENDER_READY__?: boolean }).__PRERENDER_READY__ = true;
  });
}
Place it inside the existing if (!didRedirect) { ... createRoot ... } block, after the .render(...) call. If the user is being redirected, we don’t want to signal “ready” — the redirect should resolve first.
  • Step 2: Build and verify in browser
cd ui && npm run build && npm run preview
Open http://localhost:4173/ in a browser. Open DevTools console and type:
window.__PRERENDER_READY__
Expected: true
  • Step 3: Commit
git add ui/src/main.tsx
git commit -m "perf(boot): emit window.__PRERENDER_READY__ signal for Playwright snapshotting"

Task 12: Phase 3 — Prerender route list

Files:
  • Create: ui/scripts/prerender-routes.json
  • Step 1: Create the route list
ui/scripts/prerender-routes.json:
{
  "routes": [
    { "path": "/", "outFile": "index.html", "title": "Landing" },
    { "path": "/legal", "outFile": "legal/index.html", "title": "Legal" },
    { "path": "/the-recall-effect", "outFile": "the-recall-effect/index.html", "title": "Recall Effect" },
    { "path": "/refer", "outFile": "refer/index.html", "title": "Refer & Earn" },
    { "path": "/refer-payout-form", "outFile": "refer-payout-form/index.html", "title": "Refer Payout Form" },
    { "path": "/logout-success", "outFile": "logout-success/index.html", "title": "Logout Success" },
    { "path": "/login-success", "outFile": "login-success/index.html", "title": "Login Success" }
  ],
  "waitForSignal": "__PRERENDER_READY__",
  "waitTimeoutMs": 15000,
  "stripScripts": [
    "googletagmanager.com",
    "sentry.io",
    "posthog",
    "cloudflareinsights.com"
  ]
}
Why these routes:
  • /, /the-recall-effect, /refer, /refer-payout-form — pure marketing, biggest first-paint win
  • /legal — public, terms/privacy/book all redirect into it
  • /login-success, /logout-success — auth transition pages with motion, prerender stabilizes them
  • NOT /privacy, /terms, /book — these are <Navigate> redirects; prerendering them captures a flash of redirect screen
  • NOT /auth/* — auth subdomain, has dynamic state
  • NOT /dashboard/*, /share/*, /appointment/*, /invitation/*, /onboard/*, /upgrade, /account-status, /session-timeout, /files/tiff-viewer — all stateful
stripScripts: at hydration time these third-party scripts will be loaded by the hydrated React tree. We also don’t want them to fire during the prerender snapshot (double-execution = double telemetry). The prerender script removes any <script src="...domain..."> matching these substrings from the snapshotted HTML.
  • Step 2: Commit
git add ui/scripts/prerender-routes.json
git commit -m "perf(prerender): define public-route list for snapshot prerender"

Task 13: Phase 3 — Prerender script

Files:
  • Create: ui/scripts/prerender.mjs
  • Step 1: Write the script
ui/scripts/prerender.mjs:
#!/usr/bin/env node
// Snapshot prerender for public routes.
//
// 1. Spins up `vite preview` on a free port
// 2. For each route in prerender-routes.json:
//    a. Visits the route with Chromium
//    b. Waits for window.__PRERENDER_READY__ === true
//    c. Captures document.documentElement.outerHTML
//    d. Strips third-party telemetry scripts
//    e. Writes the snapshot to dist/<outFile>
// 3. Tears everything down
//
// Run from ui/:  node scripts/prerender.mjs

import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createServer } from 'node:net';

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const DIST = join(ROOT, 'dist');
const ROUTES_FILE = join(ROOT, 'scripts', 'prerender-routes.json');

if (!existsSync(DIST)) {
  console.error('[prerender] dist/ does not exist. Run `vite build` first.');
  process.exit(1);
}

const config = JSON.parse(readFileSync(ROUTES_FILE, 'utf8'));

// Find a free port
const findFreePort = () => new Promise((resolveFn) => {
  const srv = createServer();
  srv.listen(0, () => {
    const port = srv.address().port;
    srv.close(() => resolveFn(port));
  });
});

const port = await findFreePort();
console.log(`[prerender] starting vite preview on :${port}`);

const preview = spawn('npx', ['vite', 'preview', '--port', String(port), '--strictPort'], {
  cwd: ROOT,
  stdio: ['ignore', 'pipe', 'pipe'],
});

// Wait for "Local:" line in preview output (signals server is ready)
await new Promise((resolveFn, rejectFn) => {
  const timer = setTimeout(() => rejectFn(new Error('vite preview did not start within 15s')), 15_000);
  preview.stdout.on('data', (d) => {
    const s = d.toString();
    if (s.includes('Local:')) { clearTimeout(timer); resolveFn(); }
  });
  preview.stderr.on('data', (d) => process.stderr.write(`[vite-preview] ${d}`));
  preview.on('exit', (code) => rejectFn(new Error(`vite preview exited early with code ${code}`)));
});

const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1366, height: 900 } });

let failed = 0;
for (const route of config.routes) {
  const url = `http://localhost:${port}${route.path}`;
  console.log(`[prerender] snapshotting ${url}`);
  const page = await ctx.newPage();
  try {
    await page.goto(url, { waitUntil: 'load', timeout: 30_000 });
    await page.waitForFunction(
      (signal) => Boolean(window[signal]),
      config.waitForSignal,
      { timeout: config.waitTimeoutMs },
    );
    // Give one extra frame for any final layout effects to flush
    await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => r())));

    let html = await page.evaluate(() => {
      // Remove any framework-injected style-loaders or duplicate hydration markers
      // before serialization
      return '<!doctype html>\n' + document.documentElement.outerHTML;
    });

    // Strip third-party scripts that should only run client-side on hydration
    const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    for (const needle of config.stripScripts) {
      // Match: <script ... src="...needle..." ...></script>
      const pattern = new RegExp(
        '<script\\b[^>]*\\bsrc=["\'][^"\']*' + escapeRe(needle) + '[^"\']*["\'][^>]*>\\s*</script>',
        'gi'
      );
      html = html.replace(pattern, '');
    }

    // Add a marker so we can tell prerendered pages apart from SPA-served at runtime
    html = html.replace('<head>', '<head>\n    <meta name="x-odx-prerendered" content="' + new Date().toISOString() + '" />');

    const outPath = join(DIST, route.outFile);
    mkdirSync(dirname(outPath), { recursive: true });
    writeFileSync(outPath, html);
    console.log(`[prerender]   → ${route.outFile} (${html.length} chars)`);
  } catch (err) {
    failed += 1;
    console.error(`[prerender]   ✗ ${route.path}: ${err.message}`);
  } finally {
    await page.close();
  }
}

await ctx.close();
await browser.close();
preview.kill('SIGTERM');

if (failed > 0) {
  console.error(`[prerender] ${failed} route(s) failed`);
  process.exit(1);
}
console.log(`[prerender] done — ${config.routes.length} route(s) snapshotted`);
Notes:
  • Uses vite preview to serve the freshly built dist/. This means prerender runs against the actual production-mode build, not dev.
  • Waits for the __PRERENDER_READY__ signal from Task 11. If the signal never fires (page errored), the route fails and the script exits non-zero — that fails the build, which is what we want.
  • Strips third-party telemetry scripts so they don’t double-fire.
  • Adds an x-odx-prerendered meta tag to the HTML — useful for debugging “is this page coming from a snapshot or live SPA?”
  • Per-route failures don’t abort the loop — we collect failures and exit non-zero at the end so the build fails after we see all errors.
  • Step 2: Add npm script
In ui/package.json scripts, replace the build line and add a new prerender line:
"build": "vite build && node postbuild.js && node scripts/prerender.mjs",
"prerender": "node scripts/prerender.mjs"
Order: vite buildpostbuild.js (existing data-cfasync injection on index.html) → prerender.mjs (snapshots routes). ⚠️ Important: postbuild.js currently only operates on dist/index.html. The prerender step re-writes dist/index.html afterward. We need postbuild.js to also apply to the new prerendered files. See Task 14.
  • Step 3: Commit
git add ui/scripts/prerender.mjs ui/package.json
git commit -m "perf(build): add Playwright snapshot prerender for public routes"

Task 14: Phase 3 — Update postbuild to handle all prerendered HTML

Why: the existing postbuild.js adds data-cfasync="false" only to dist/index.html. We now have dist/legal/index.html, dist/refer/index.html, etc. — they all need the same treatment. The prerender script wrote the HTML after postbuild ran for the originals — but the original index.html is now overwritten by the snapshot, so its data-cfasync attributes are gone. So postbuild needs to run after prerender, on all HTML files. Files:
  • Modify: ui/postbuild.js
  • Modify: ui/package.json (build script order)
  • Step 1: Generalize postbuild.js
Replace ui/postbuild.js contents:
import fs from 'fs';
import path from 'path';

const DIST = path.join(process.cwd(), 'dist');

function walk(dir) {
  const out = [];
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) out.push(...walk(full));
    else if (entry.name.endsWith('.html')) out.push(full);
  }
  return out;
}

if (fs.existsSync(DIST)) {
  const files = walk(DIST);
  let touched = 0;
  for (const file of files) {
    let html = fs.readFileSync(file, 'utf8');
    const before = html;
    // Inject data-cfasync="false" into any <script> that doesn't already have it.
    html = html.replace(/<script\b(?![^>]*data-cfasync)/g, '<script data-cfasync="false"');
    if (html !== before) {
      fs.writeFileSync(file, html);
      touched += 1;
    }
  }
  console.log(`[postbuild] data-cfasync injected into ${touched} HTML file(s)`);
}
  • Step 2: Reorder the build script
In ui/package.json:
"build": "vite build && node scripts/prerender.mjs && node postbuild.js"
Order:
  1. vite build → writes dist/index.html + all hashed assets
  2. scripts/prerender.mjs → writes dist/<route>/index.html for each public route (overwrites dist/index.html with the snapshot version)
  3. node postbuild.js → applies data-cfasync to every .html under dist/
  • Step 3: Full build and verify
cd ui && npm run build
Then:
ls dist/index.html dist/legal/index.html dist/the-recall-effect/index.html dist/refer/index.html
grep -c "data-cfasync" dist/index.html  # expect >= 3 (gtag + theme-init + chunk-recovery + main)
grep -c "x-odx-prerendered" dist/index.html  # expect 1
Open dist/the-recall-effect/index.html in a browser locally (or cat | head -100). Confirm:
  • The <head> has all the meta tags from index.html
  • The <body> has actual rendered React content (headings, paragraphs visible in source)
  • No <script src="https://www.googletagmanager.com/..."> remains (stripped)
  • Step 4: Commit
git add ui/postbuild.js ui/package.json
git commit -m "perf(build): postbuild walks all prerendered HTML for data-cfasync injection"

Task 15: Phase 3 — Verify SPA fallback still works for unprerendered routes

Why: Cloudflare Pages will serve dist/legal/index.html for /legal, but for any route NOT in the prerender list (e.g., /dashboard, /auth/login, /share/abc123), it must still fall through to dist/index.html so React Router handles the route. The existing _redirects already has /* /index.html 200. We need to confirm prerendered routes intercept that. Files:
  • Modify (if needed): ui/public/_redirects
  • Step 1: Inspect existing _redirects
cat ui/public/_redirects
Confirm there’s a /* /index.html 200 line at the end. Cloudflare Pages matches files first, then redirects — so /legal/ is served as dist/legal/index.html automatically, no extra rule needed. Same for /legal/index.html and /legal (Pages normalizes trailing slash).
  • Step 2: Test trailing-slash handling locally
cd ui && npm run preview &
sleep 2
curl -s http://localhost:4173/legal | head -5    # expect prerendered legal page
curl -s http://localhost:4173/legal/ | head -5   # expect same
curl -s http://localhost:4173/dashboard | head -5  # expect SPA shell (index.html)
curl -s http://localhost:4173/some-bogus-route | head -5  # expect SPA shell
kill %1
If /legal/ returns 404 or wrong content, the preview server isn’t doing the same lookups as Cloudflare Pages. In that case, add to _redirects immediately before the /* fallback:
/legal /legal/index.html 200
/the-recall-effect /the-recall-effect/index.html 200
/refer /refer/index.html 200
/refer-payout-form /refer-payout-form/index.html 200
/logout-success /logout-success/index.html 200
/login-success /login-success/index.html 200
  • Step 3: Commit if _redirects changed
git add ui/public/_redirects
git commit -m "perf(redirects): map prerendered route paths to their HTML files"
If _redirects didn’t change, skip this commit.

Task 16: Phase 3 — Test hydration cleanliness

Why: the whole prerender strategy collapses if hydration mismatches throw warnings. We need to look at the browser console. Files: none
  • Step 1: Run the preview and inspect
cd ui && npm run preview &
sleep 2
For each prerendered route, launch a Playwright session that:
  1. Opens the route
  2. Records all console messages
  3. Reports any warning or error with “hydration” or “did not match” in it
Write ui/scripts/check-hydration.mjs:
import { chromium } from 'playwright';

const ROUTES = ['/', '/legal', '/the-recall-effect', '/refer', '/refer-payout-form', '/login-success', '/logout-success'];
const browser = await chromium.launch();
const ctx = await browser.newContext();

let issues = 0;
for (const path of ROUTES) {
  const page = await ctx.newPage();
  const messages = [];
  page.on('console', (msg) => messages.push({ type: msg.type(), text: msg.text() }));
  page.on('pageerror', (err) => messages.push({ type: 'pageerror', text: err.message }));
  await page.goto(`http://localhost:4173${path}`, { waitUntil: 'load' });
  await page.waitForFunction(() => window.__PRERENDER_READY__ === true, { timeout: 10_000 });
  await page.evaluate(() => new Promise((r) => setTimeout(r, 500))); // settle

  const hydrationIssues = messages.filter((m) =>
    /hydrat|did not match|mismatch|Text content does not match/i.test(m.text)
  );
  if (hydrationIssues.length > 0) {
    console.log(`✗ ${path}`);
    hydrationIssues.forEach((m) => console.log(`    [${m.type}] ${m.text.slice(0, 200)}`));
    issues += hydrationIssues.length;
  } else {
    console.log(`✓ ${path}`);
  }
  await page.close();
}

await ctx.close();
await browser.close();
process.exit(issues > 0 ? 1 : 0);
Add to ui/package.json:
"check:hydration": "node scripts/check-hydration.mjs"
Run:
npm run check:hydration
Expected: all routes show ✓. If any show ✗, identify the offending component, go back to Task 10 Step 2 to gate the read, then rerun.
kill %1   # stop preview
  • Step 2: Commit
git add ui/scripts/check-hydration.mjs ui/package.json
git commit -m "perf(prerender): add hydration-mismatch check script"

Task 17: Phase 3 deploy

  • Step 1: Final pre-deploy checks
cd ui && npx tsc --noEmit && npm run lint && npm run build && npm run check:hydration
All four must pass.
  • Step 2: Deploy via the odontox-commit-deploy skill
Commit message:
perf(public): phase 3 — Playwright snapshot prerender for public routes
  • Step 3: Post-deploy verification
After publish:
# Public routes should now have the prerender marker
curl -sI https://odontox.io/ | grep -i x-odx-fn  # confirm Cloudflare Pages is serving
curl -s https://odontox.io/ | grep "x-odx-prerendered" | head -1  # confirm meta tag is present
curl -s https://odontox.io/legal | grep "x-odx-prerendered" | head -1

# Dashboard routes should NOT have the marker
curl -s https://go.odontox.io/auth/login | grep "x-odx-prerendered"  # expect empty
Manually:
  • Incognito → https://odontox.io/ — landing renders instantly with content (no blank flash)
  • Network tab — first response is HTML with body content, not a blank shell
  • View source — <h1> and headline content visible directly in the HTML
  • Step 4: Capture Phase 3 measurement
cd ui && npm run perf:measure -- https://odontox.io ../docs/perf/post-phase-3-$(date +%Y-%m-%d).json / /legal /the-recall-effect /refer /refer-payout-form
Append row to docs/perf/README.md. FCP and LCP should drop substantially (the entire React hydration round-trip is gone from the critical path).
  • Step 5: Observe 48h
Sentry watch. Particularly:
  • Any new “Hydration failed” / “Text content does not match” / “Did not match” warnings → revert
  • Any spike in client-side errors → revert
  • Manually re-check public routes once a day in incognito to confirm visual parity

Task 18: Phase 4 — HSTS preload finalize

Why: after a week of clean traffic at max-age=604800, we promote to max-age=63072000; includeSubDomains; preload and submit to https://hstspreload.org. This is the final ~146ms saving — browsers on the preload list never even try HTTP for odontox.io. Prerequisite: at least 7 days have passed since Task 5’s deploy AND no production HTTP-only subdomain has been discovered. Files:
  • Modify: ui/public/_headers
  • Step 1: Update the HSTS line
In ui/public/_headers, in the /* block, change the HSTS line:
  Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
  • Step 2: Deploy via odontox-commit-deploy
Commit message:
perf(headers): phase 4 — HSTS preload (2 years, includeSubDomains)
  • Step 3: Confirm header is live
curl -sI https://odontox.io/ | grep -i strict-transport-security
# Expected: Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
  • Step 4: Submit to hstspreload.org
Manual step (cannot be automated): the user opens https://hstspreload.org, enters odontox.io, and submits. Acceptance takes anywhere from a few days to several weeks. Stop and hand off to user at this point with a one-line message:
“Phase 4 deployed. Please submit odontox.io to https://hstspreload.org/ to finalize HSTS preload. The header is already live; this is just the registry submission.”

Task 19: Final measurement + writeup

Files:
  • Modify: docs/perf/README.md
  • Step 1: Capture final numbers
cd ui && npm run perf:measure -- https://odontox.io ../docs/perf/final-$(date +%Y-%m-%d).json / /legal /the-recall-effect /refer /refer-payout-form
  • Step 2: Update the README with the trend
Append to docs/perf/README.md a summary block:
## Outcome (2026-05-12 → 2026-XX-XX)

| Metric | Baseline | After Phase 1 | After Phase 2 | After Phase 3 | Target |
|---|---|---|---|---|---|
| Performance / | ?? | ?? | ?? | ?? | ≥ 90 |
| FCP (ms) | ?? | ?? | ?? | ?? | < 1200 |
| LCP (ms) | ?? | ?? | ?? | ?? | < 2000 |
| TBT (ms) | ?? | ?? | ?? | ?? | < 200 |
| Total bytes | ?? | ?? | ?? | ?? | — |
Fill in actual numbers from the captured JSON files. Note which targets were hit and which weren’t.
  • Step 3: Commit
git add docs/perf/
git commit -m "perf: capture final measurements after all 4 phases shipped"

Rollback notes

Each phase is independently revertible:
  • Phase 1: revert the commit that added HSTS / preload tags. Cache-Control already prevents the immutable assets from being stuck.
  • Phase 2: revert the manualChunks change in vite.config.ts. Existing chunk hashes will differ → the existing chunk-recovery script in public/chunk-recovery.js handles the stale-hash 404 case for in-flight users.
  • Phase 3: revert the build script change to remove scripts/prerender.mjs from the chain. The prerendered HTML files won’t be regenerated next build; Cloudflare Pages will fall back to serving dist/index.html for all routes (the existing SPA behavior). React Router takes over.
  • Phase 4: this one is genuinely hard to revert (browser-cached HSTS preload). Don’t do Phase 4 until Phase 1–3 are stable.
Each revert is its own deploy via the odontox-commit-deploy skill.

Out of scope (do not implement in this plan)

  • Splitting the public site into a separate Astro/Vite app
  • Service worker / offline support
  • HTTP/3 tuning beyond what Cloudflare already does
  • Image CDN switch
  • Tailwind v4 config tuning
  • Replacing framer-motion (would be a separate large refactor)