Skip to main content

Mobile PageSpeed Optimization 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: Take the marketing landing (apex odontox.io) from a 49/100 mobile PageSpeed score to 90+ by eliminating dead 11 MB assets, deferring third-party scripts, inlining critical CSS, preloading the right resources, and lazy-loading below-the-fold media. Architecture: The marketing landing is part of a single Vite + React 19 SPA at ui/ that also serves the dashboard and auth subdomains. We will optimise the shared index.html, defer module-level scripts (Sentry, GTM) so they don’t block boot, replace oversized media with appropriately-sized AVIF/WebP, and lazy-mount below-fold video components — all without changing build config (module preload stays disabled — chunk recovery depends on it). Tech Stack: Vite 6, React 19, Tailwind v4, Cloudflare Pages, Cloudflare Workers. Image conversion via sharp (Node, installed on-demand). No new runtime dependencies. Reference report: .superpowers/brainstorm/odontox-mobile-pagespeed-report.md Deployment: After every task, use the odontox-commit-deploy skill (one commit per task, deploy to Cloudflare Pages, force-promote canonical). Each task is independently shippable.

File Map

Edited:
  • ui/index.html — resource hints, inline critical CSS, defer GTM
  • ui/src/main.tsx — defer Sentry init
  • ui/public/_headers — add AVIF Cache-Control
  • ui/public/gtam-loader.js (new) — GTM loader (deferred)
  • ui/src/components/landing/PremiumHero.tsx — replace 1.8 MB ruby.webp icon with 32×32 AVIF
  • ui/src/components/landing/RubySection.tsx — same icon swap
  • ui/src/components/landing/DicomSection.tsx — same icon swap
  • ui/src/components/landing/BridgeSection.tsx — defer Bridge.webm until intersection
  • ui/src/App.tsx — defer GlobalLoadingProvider video sources until first loading state
Created:
  • ui/public/ruby-icon.avif (new, ~3 KB) — 32×32 icon-sized AVIF
  • ui/public/ruby-icon.webp (new, ~2 KB) — 32×32 icon-sized WebP
Deleted:
  • ui/public/ruby.png (11 MB, never referenced in code)

Task 1: Resource hints — preconnect, preload Poppins 500/700, preload critical above-fold assets

Files:
  • Modify: ui/index.html:9-16
  • Step 1: Read current <head> resource-hint block
Run: head -20 /Users/ssh/Documents/Beta-App/odontoX/ui/index.html Confirm lines 9–15 contain the existing preconnect/preload tags.
  • Step 2: Replace the resource-hint block
Edit ui/index.html lines 9–15. Replace:
    <!-- 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 />
with:
    <!-- 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="preconnect" href="https://www.googletagmanager.com" />
    <link rel="dns-prefetch" href="https://www.google.com" />
    <link rel="dns-prefetch" href="https://googleads.g.doubleclick.net" />
    <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-500.woff2" as="font" type="font/woff2" crossorigin />
    <link rel="preload" href="/fonts/poppins-600.woff2" as="font" type="font/woff2" crossorigin />
    <link rel="preload" href="/fonts/poppins-700.woff2" as="font" type="font/woff2" crossorigin />
Rationale: GTM gets full preconnect (the connection is mandatory anyway since the script is in <head>). Google + DoubleClick get dns-prefetch (lighter, fires only if needed). All four Poppins weights get preload so text in any weight renders without re-flow.
  • Step 3: Build to verify no syntax errors
Run: cd /Users/ssh/Documents/Beta-App/odontoX/ui && npm run build 2>&1 | tail -20 Expected: Build succeeds. Confirm dist/index.html contains the new preload tags: grep -c 'poppins-' dist/index.html → should print 4.
  • Step 4: Commit via odontox-commit-deploy
Use the odontox-commit-deploy skill. Commit message:
perf(landing): preload poppins 500/700, preconnect to GTM, dns-prefetch Google/DoubleClick

Task 2: Inline critical.css into <head> so first paint never waits for JS

Files:
  • Modify: ui/index.html (add <style> block)
  • Modify: ui/src/main.tsx:4 (remove the import './critical.css' line)
  • Reference: ui/src/critical.css (read full contents, inline into HTML)
The CSS is currently imported by main.tsx so it ships inside the JS bundle. Inlining it eliminates the JS dependency for first paint and lets the browser paint the body background + skeleton hero before the 370 KB bundle finishes parsing.
  • Step 1: Read critical.css contents
Run: cat /Users/ssh/Documents/Beta-App/odontoX/ui/src/critical.css Capture the full contents (currently 103 lines; lines 1–102 are the @font-face declarations, :root vars, body styles, and hero skeleton).
  • Step 2: Inline the CSS into index.html
Edit ui/index.html. Just before </head> (currently line 71), insert:
    <style>
      /* Inlined from ui/src/critical.css — first-paint critical styles */
      /* DO NOT EDIT HERE — edit ui/src/critical.css and re-inline */
      [PASTE THE FULL CONTENTS OF critical.css HERE]
    </style>
Replace [PASTE …] with the actual contents of critical.css (all 103 lines, verbatim).
  • Step 3: Remove the JS import
Edit ui/src/main.tsx. Remove line 4: import './critical.css'.
  • Step 4: Build and verify
Run: cd /Users/ssh/Documents/Beta-App/odontoX/ui && npm run build 2>&1 | tail -20 Expected: Build succeeds. Confirm critical.css is no longer imported in main bundle: grep -l "poppins-400.woff2" dist/index.html should match (the inlined block contains it).
  • Step 5: Smoke-test in dev mode
Run: cd /Users/ssh/Documents/Beta-App/odontoX/ui && npm run dev in the background. Open http://localhost:5173 in a browser. Visually confirm:
  • Body background renders immediately (not white-flash)
  • Poppins font applies on first paint (no FOUC swap from system to Poppins)
  • No console errors about missing CSS
Stop the dev server before continuing.
  • Step 6: Commit via odontox-commit-deploy
Commit message:
perf(landing): inline critical.css into index.html head to eliminate JS-blocking first paint

Task 3: Defer Google Tag Manager until after page is interactive

Files:
  • Modify: ui/index.html:64-66 (remove early GTM tags)
  • Create: ui/public/gtm-loader.js
  • Modify: ui/index.html (add deferred loader script tag near </body>)
  • Modify: ui/public/gtag-init.js (move into loader)
GTM currently loads in <head> as async. async does not block parsing but it does compete for bandwidth and CPU during the critical render window. We delay loading until 2 seconds after window.load (or first user interaction, whichever is sooner) — long enough that LCP and TTI have both fired. Conversion-tracking note: this delays Ads conversion firing by ~2s on every visit. Anyone landing and immediately bouncing in under 2s will not be attributed. For a B2B SaaS landing this is acceptable; if the user disputes, they can lower the delay to 500ms.
  • Step 1: Create ui/public/gtm-loader.js
Write to /Users/ssh/Documents/Beta-App/odontoX/ui/public/gtm-loader.js:
// Deferred Google Tag Manager loader.
// Loads GTM 2s after `load` event OR on first user interaction (whichever is first).
// Cuts ~136 KB of render-blocking script weight off the critical path.
(function () {
  var loaded = false;
  function loadGTM() {
    if (loaded) return;
    loaded = true;
    window.dataLayer = window.dataLayer || [];
    function gtag(){ dataLayer.push(arguments); }
    gtag('js', new Date());
    gtag('config', 'AW-18106759828');
    var s = document.createElement('script');
    s.async = true;
    s.src = 'https://www.googletagmanager.com/gtag/js?id=AW-18106759828';
    document.head.appendChild(s);
  }
  var triggers = ['scroll', 'keydown', 'mousedown', 'touchstart', 'pointerdown'];
  function onInteraction() {
    triggers.forEach(function (t) { window.removeEventListener(t, onInteraction, { passive: true }); });
    loadGTM();
  }
  triggers.forEach(function (t) { window.addEventListener(t, onInteraction, { passive: true }); });
  if (document.readyState === 'complete') {
    setTimeout(loadGTM, 2000);
  } else {
    window.addEventListener('load', function () { setTimeout(loadGTM, 2000); }, { once: true });
  }
})();
  • Step 2: Remove the eager GTM tags from <head>
Edit ui/index.html. Remove lines 64–66:
    <!-- Google Ads -->
    <script data-cfasync="false" async src="https://www.googletagmanager.com/gtag/js?id=AW-18106759828"></script>
    <script data-cfasync="false" src="/gtag-init.js"></script>
  • Step 3: Add the deferred loader at the end of <body>
Edit ui/index.html. Just after the main script tag (<script data-cfasync="false" type="module" src="/src/main.tsx"></script> on line 74), insert:
    <script data-cfasync="false" defer src="/gtm-loader.js"></script>
  • Step 4: Delete the now-redundant gtag-init.js
Run: rm /Users/ssh/Documents/Beta-App/odontoX/ui/public/gtag-init.js (All initialisation logic is now inside gtm-loader.js.)
  • Step 5: Build and verify
Run: cd /Users/ssh/Documents/Beta-App/odontoX/ui && npm run build 2>&1 | tail -5 Expected: Build succeeds. Run: grep -c 'gtm-loader' dist/index.html1. Run: grep -c 'gtag/js' dist/index.html0 (no eager GTM in HTML anymore).
  • Step 6: Smoke-test in dev mode
Run: cd /Users/ssh/Documents/Beta-App/odontoX/ui && npm run dev in the background. Open browser DevTools → Network tab → filter “gtag”. Reload page. Confirm:
  • gtag/js?id=AW-... request appears ~2s after load event, NOT during initial render
  • Scroll the page within first 2s → GTM loads immediately on first scroll
  • window.dataLayer exists in console after GTM loads
Stop the dev server.
  • Step 7: Commit via odontox-commit-deploy
Commit message:
perf(landing): defer GTM/Google Ads load until 2s after page interactive

Task 4: Defer Sentry initialisation until after first paint

Files:
  • Modify: ui/src/main.tsx:1-45
Sentry currently inits synchronously at the top of main.tsx before React renders. The @sentry/react package + browser tracing + replay integration adds significant parse+execute cost on the critical path. Sentry just needs to be initialised before any user-triggered code path, not before first paint. Risk: any error thrown during boot (between <script> parse and Sentry.init) won’t be captured. We mitigate with a tiny window.addEventListener('error', …) pre-handler that queues errors for Sentry once it loads.
  • Step 1: Read current main.tsx Sentry block
Run: sed -n '1,50p' /Users/ssh/Documents/Beta-App/odontoX/ui/src/main.tsx Confirm lines 1–45 match what’s documented in the file map.
  • Step 2: Replace the Sentry init block with a deferred version
Edit ui/src/main.tsx. Replace lines 1–57 (from import * as Sentry through the end of the sentry-test block). Replace this:
import * as Sentry from '@sentry/react';
import { createRoot } from 'react-dom/client'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import './critical.css'
import './index.css'
import App from './App.tsx'
import { getSubdomain, hasAuthCookie, getAuthUrl, getDashboardUrl, SUBDOMAINS } from '@/lib/subdomain-utils';
import { removeAuthToken, signOut } from '@/lib/serverComm';
import { queryClient, persistOptions, pruneStaleClinicCaches } from '@/lib/queryClient';
import { initPostHog } from './lib/posthog';

// Sentry must init before anything else so it catches boot-time errors
Sentry.init({
  dsn: 'https://65423903290a68ed4ea10a0752584087@o4511296397901824.ingest.us.sentry.io/4511296402554880',
  environment: import.meta.env.MODE,
  integrations: [
    Sentry.browserTracingIntegration(),
    Sentry.replayIntegration({
      maskAllText: false,
      blockAllMedia: false,
    }),
  ],
  tracesSampleRate: 0.2,
  replaysOnErrorSampleRate: 1.0,
  // 100% during verification — lower to 0.1 before production launch
  replaysSessionSampleRate: 1.0,
  // Keep false: sendDefaultPii:true would auto-collect IP addresses (HIPAA concern)
  sendDefaultPii: false,
  // Drop all events from UAT, staging, or dev — production alerts only.
  beforeSend: (event) => {
    return import.meta.env.MODE === 'production' ? event : null;
  },
  // Ignore noisy non-actionable errors
  ignoreErrors: [
    'ResizeObserver loop limit exceeded',
    'ResizeObserver loop completed with undelivered notifications',
    'Non-Error promise rejection captured',
    /^Loading chunk \d+ failed/,
    /^Loading CSS chunk \d+ failed/,
    // Stale-deployment chunk failures: auto-reloaded by ErrorBoundary/chunk-recovery
    'Importing a module script failed',
    'Failed to fetch dynamically imported module',
    'Failed to load module script',
  ],
});

// Initialize Analytics immediately
if (typeof window !== 'undefined') {
  initPostHog();
}

// Sentry connectivity test — visit any page with ?sentry-test=1 to fire a test event
if (new URLSearchParams(window.location.search).get('sentry-test') === '1') {
  const msgId = Sentry.captureMessage('OdontoX UI Sentry Integration Test', 'info');
  Sentry.captureException(new Error('OdontoX UI Sentry test exception (not a real error)'));
  console.info('[Sentry Test] Event fired. Message ID:', msgId);
}
with:
import { createRoot } from 'react-dom/client'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import './index.css'
import App from './App.tsx'
import { getSubdomain, hasAuthCookie, getAuthUrl, getDashboardUrl, SUBDOMAINS } from '@/lib/subdomain-utils';
import { removeAuthToken, signOut } from '@/lib/serverComm';
import { queryClient, persistOptions, pruneStaleClinicCaches } from '@/lib/queryClient';
import { initPostHog } from './lib/posthog';

// Deferred Sentry init.
// Sentry's bundle is large (~50 KB gz with replay+tracing). Loading it eagerly blocks
// React render. We queue any boot-time errors and flush them once Sentry has loaded.
type QueuedError = { kind: 'error'; arg: unknown } | { kind: 'rejection'; arg: unknown };
const sentryQueue: QueuedError[] = [];
const queueError = (e: ErrorEvent) => sentryQueue.push({ kind: 'error', arg: e.error ?? e.message });
const queueRejection = (e: PromiseRejectionEvent) => sentryQueue.push({ kind: 'rejection', arg: e.reason });
window.addEventListener('error', queueError);
window.addEventListener('unhandledrejection', queueRejection);

const loadSentry = async () => {
  const Sentry = await import('@sentry/react');
  Sentry.init({
    dsn: 'https://65423903290a68ed4ea10a0752584087@o4511296397901824.ingest.us.sentry.io/4511296402554880',
    environment: import.meta.env.MODE,
    integrations: [
      Sentry.browserTracingIntegration(),
      Sentry.replayIntegration({
        maskAllText: false,
        blockAllMedia: false,
      }),
    ],
    tracesSampleRate: 0.2,
    replaysOnErrorSampleRate: 1.0,
    replaysSessionSampleRate: 1.0,
    sendDefaultPii: false,
    beforeSend: (event) => {
      return import.meta.env.MODE === 'production' ? event : null;
    },
    ignoreErrors: [
      'ResizeObserver loop limit exceeded',
      'ResizeObserver loop completed with undelivered notifications',
      'Non-Error promise rejection captured',
      /^Loading chunk \d+ failed/,
      /^Loading CSS chunk \d+ failed/,
      'Importing a module script failed',
      'Failed to fetch dynamically imported module',
      'Failed to load module script',
    ],
  });
  // Flush queued boot-time errors and stop intercepting.
  window.removeEventListener('error', queueError);
  window.removeEventListener('unhandledrejection', queueRejection);
  for (const item of sentryQueue) {
    if (item.kind === 'error') Sentry.captureException(item.arg);
    else Sentry.captureException(item.arg);
  }
  sentryQueue.length = 0;

  // Sentry connectivity test — visit any page with ?sentry-test=1 to fire a test event
  if (new URLSearchParams(window.location.search).get('sentry-test') === '1') {
    const msgId = Sentry.captureMessage('OdontoX UI Sentry Integration Test', 'info');
    Sentry.captureException(new Error('OdontoX UI Sentry test exception (not a real error)'));
    console.info('[Sentry Test] Event fired. Message ID:', msgId);
  }
};

// Load Sentry after `load` (give first paint + LCP a clear runway).
// requestIdleCallback if available, otherwise setTimeout fallback.
const scheduleSentry = () => {
  if ('requestIdleCallback' in window) {
    (window as Window & typeof globalThis).requestIdleCallback(loadSentry, { timeout: 4000 });
  } else {
    setTimeout(loadSentry, 2000);
  }
};
if (document.readyState === 'complete') {
  scheduleSentry();
} else {
  window.addEventListener('load', scheduleSentry, { once: true });
}

// Initialize Analytics immediately
if (typeof window !== 'undefined') {
  initPostHog();
}
Leave the rest of main.tsx (chunk-reload logic, image-loading policy, redirects, etc.) untouched.
  • Step 3: Type-check
Run: cd /Users/ssh/Documents/Beta-App/odontoX/ui && npx tsc --noEmit 2>&1 | tail -20 Expected: no type errors. If requestIdleCallback typing fails, narrow the cast:
(window as unknown as { requestIdleCallback: (cb: () => void, opts?: { timeout?: number }) => void })
  .requestIdleCallback(loadSentry, { timeout: 4000 });
  • Step 4: Build
Run: cd /Users/ssh/Documents/Beta-App/odontoX/ui && npm run build 2>&1 | tail -5 Expected: Build succeeds. Sentry will be code-split into its own chunk (since it’s now a dynamic import).
  • Step 5: Smoke-test in dev
Run dev server. Open landing page. In DevTools Network panel, confirm:
  • No @sentry chunk loads during initial render
  • A Sentry chunk loads ~2s after load event
  • ?sentry-test=1 URL still fires test events after the deferred load
Stop the dev server.
  • Step 6: Commit via odontox-commit-deploy
Commit message:
perf(landing): defer Sentry init via dynamic import + boot-time error queue

Task 5: Delete dead ruby.png (11 MB orphan) and resize ruby.webp icon

Files:
  • Delete: ui/public/ruby.png (11 MB, zero references in code)
  • Create: ui/public/ruby-icon.avif (~3 KB, 64×64 px)
  • Create: ui/public/ruby-icon.webp (~2 KB, 64×64 px)
  • Modify: all 8 files that reference ruby.webp as a small icon
The current ruby.webp is 1.8 MB at full resolution but is displayed as a 16×16 to 40×40 icon on the landing page (h-3.5 w-3.5, h-4 w-4, h-5 w-5, etc.). We create a 64×64 (2x retina) icon-sized version and swap all small-icon usages to it. The 1.8 MB original stays for the few places that actually need full resolution (PdfReports, etc.) — we’ll audit those.
  • Step 1: Confirm ruby.png is unreferenced
Run: cd /Users/ssh/Documents/Beta-App/odontoX && grep -rn "ruby\.png" ui/src ui/index.html ui/public 2>/dev/null Expected: zero matches. If anything matches, stop and report — do not delete.
  • Step 2: Delete ruby.png
Run: rm /Users/ssh/Documents/Beta-App/odontoX/ui/public/ruby.png
  • Step 3: Generate icon-sized AVIF + WebP from the existing webp
Install sharp ephemerally and run a one-off conversion:
cd /tmp && npm init -y >/dev/null 2>&1 && npm install --no-save sharp >/dev/null 2>&1
node -e "
const sharp = require('sharp');
const src = '/Users/ssh/Documents/Beta-App/odontoX/ui/public/ruby.webp';
const out = '/Users/ssh/Documents/Beta-App/odontoX/ui/public';
(async () => {
  await sharp(src).resize(64, 64, { fit: 'cover' }).avif({ quality: 70 }).toFile(out + '/ruby-icon.avif');
  await sharp(src).resize(64, 64, { fit: 'cover' }).webp({ quality: 85 }).toFile(out + '/ruby-icon.webp');
  console.log('done');
})();
"
Verify:
ls -lh /Users/ssh/Documents/Beta-App/odontoX/ui/public/ruby-icon.*
Expected: each file under 8 KB.
  • Step 4: Identify icon usages
Run: cd /Users/ssh/Documents/Beta-App/odontoX/ui && grep -rn 'ruby\.webp' src --include="*.tsx" --include="*.ts" 2>/dev/null The icon usages (small display size) are in:
  • src/components/landing/PremiumHero.tsx lines 180, 240, 274, 304
  • src/components/landing/RubySection.tsx line 28
  • src/components/landing/DicomSection.tsx line 127
  • src/components/icons/OdontoXAIIcon.tsx line 6
  • src/components/files/DicomViewer.tsx line 810
  • src/components/files/XRayWorkstation.tsx line 716
  • src/components/files/XRayRubyPanel.tsx line 322
  • src/pages/UpgradeRequest.tsx line 368
  • src/lib/dicom-utils.ts line 266 (used in 28×28 PDF embed — keep this one on the high-res webp, PDFs need quality)
Keep ruby.webp (NOT swap to icon) in:
  • src/components/landing/RubySection.tsx line 171 (large hero-area image — verify usage context first)
  • src/lib/dicom-utils.ts line 266 (PDF embed)
  • Step 5: Swap icon usages to ruby-icon.webp with AVIF fallback via <picture>
For each file in the icon-usage list above, replace:
<img src="/ruby.webp" className="..." alt="Ruby" />
with:
<picture>
  <source srcSet="/ruby-icon.avif" type="image/avif" />
  <source srcSet="/ruby-icon.webp" type="image/webp" />
  <img src="/ruby-icon.webp" className="..." alt="Ruby" />
</picture>
For dicom-utils.ts:266 (a string template), leave it as ruby.webp (PDF rendering doesn’t support <picture>). For RubySection.tsx:171, first read context (10 lines around line 171). If the image is rendered larger than ~96×96 px, keep on /ruby.webp (full-resolution). If it’s small, swap to icon.
  • Step 6: Verify ruby.webp (full-res) is still used somewhere
Run: cd /Users/ssh/Documents/Beta-App/odontoX/ui && grep -rn 'ruby\.webp' src 2>/dev/null | wc -l Expected: at least 1 match (PDF embed). If zero, delete the full-res ruby.webp too.
  • Step 7: Build and visual smoke-test
Run: cd /Users/ssh/Documents/Beta-App/odontoX/ui && npm run build 2>&1 | tail -5 Expected: succeeds. Run dev server. Visually confirm Ruby icons render at the same size as before on the landing page (Hero, RubySection, DicomSection). Stop dev server.
  • Step 8: Commit via odontox-commit-deploy
Commit message:
perf(landing): kill dead 11MB ruby.png, swap icon usages to 4KB AVIF/WebP

Task 6: Lazy-mount BridgeSection video (3.2 MB) until it scrolls into view

Files:
  • Modify: ui/src/components/landing/BridgeSection.tsx:29 (and the surrounding video tag)
Bridge.webm is 3.2 MB and lives below the fold on the landing. It should only download when the section is near the viewport.
  • Step 1: Read the current Bridge video usage
Run: grep -n -B2 -A20 'Bridge\.webm\|BRIDGE_VIDEO_URL' /Users/ssh/Documents/Beta-App/odontoX/ui/src/components/landing/BridgeSection.tsx Identify the <video> tag that uses BRIDGE_VIDEO_URL.
  • Step 2: Gate the video behind an IntersectionObserver
At the top of BridgeSection.tsx, add the React import if not present:
import { useState, useEffect, useRef } from 'react';
Inside the component (just before the return), add:
const videoContainerRef = useRef<HTMLDivElement>(null);
const [shouldLoadVideo, setShouldLoadVideo] = useState(false);

useEffect(() => {
  if (shouldLoadVideo) return;
  const el = videoContainerRef.current;
  if (!el) return;
  const observer = new IntersectionObserver(
    (entries) => {
      if (entries.some((e) => e.isIntersecting)) {
        setShouldLoadVideo(true);
        observer.disconnect();
      }
    },
    { rootMargin: '300px' } // start loading 300px before entering viewport
  );
  observer.observe(el);
  return () => observer.disconnect();
}, [shouldLoadVideo]);
Wrap the existing <video src={BRIDGE_VIDEO_URL} ...> in:
<div ref={videoContainerRef}>
  {shouldLoadVideo && (
    <video src={BRIDGE_VIDEO_URL} {...originalProps}>...</video>
  )}
  {!shouldLoadVideo && (
    <div className="[same dimensions/aspect as the video]" aria-hidden="true" />
  )}
</div>
(The agent should preserve the original video’s surrounding container and styling. Apply the same dimensions to the placeholder <div> so layout doesn’t shift — verify CLS stays 0.)
  • Step 3: Type-check and build
Run: cd /Users/ssh/Documents/Beta-App/odontoX/ui && npx tsc --noEmit 2>&1 | grep BridgeSection Expected: no errors. Run: npm run build 2>&1 | tail -5 Expected: succeeds.
  • Step 4: Smoke-test in dev
Run dev server. Open DevTools → Network → filter “Bridge”. Reload page. Confirm:
  • Bridge.webm does not load on initial page load
  • Scroll down toward the BridgeSection — Bridge.webm loads when section is within 300 px of viewport
  • Video plays normally once loaded
  • No visible layout shift when video mounts (CLS check)
Stop dev server.
  • Step 5: Commit via odontox-commit-deploy
Commit message:
perf(landing): lazy-mount Bridge.webm (3.2MB) via IntersectionObserver

Task 7: Defer GlobalLoadingProvider video sources until first loading state

Files:
  • Modify: ui/src/App.tsx:985
  • Read first: ui/src/components/ui/GlobalLoadingProvider.tsx (or wherever the provider lives — find with grep)
The GlobalLoadingProvider currently receives defaultVideoSources={['/loader.webm', '/loader.mp4']} as an eager prop. The provider likely either preloads or attaches <video> tags with these sources at mount, pulling in 1.3 MB + 2.3 MB = 3.6 MB even when no loading is happening. On the landing page, the global loader never shows, so this is pure waste.
  • Step 1: Find the provider source
Run: grep -rn 'GlobalLoadingProvider' /Users/ssh/Documents/Beta-App/odontoX/ui/src --include="*.tsx" --include="*.ts" -l 2>/dev/null Read the provider component. Identify how defaultVideoSources is used.
  • Step 2: Determine the right fix
Decision tree:
  • Case A: Provider attaches <video> tags at mount and they auto-load. Add preload="none" to the <video> tags so the browser does not fetch the bytes until .play() is called.
  • Case B: Provider explicitly fetches/preloads the video bytes at mount. Defer the fetch to the first call to showLoading().
  • Case C: Provider only uses the sources when showLoading() is called (so no eager load happens). No fix needed; the bandwidth in the HAR was a false alarm. Confirm via DevTools Network panel.
Apply the appropriate fix. In all cases, the goal is: loader.webm and loader.mp4 should NOT appear in the network waterfall of an initial landing-page load.
  • Step 3: Build and verify
Run: cd /Users/ssh/Documents/Beta-App/odontoX/ui && npm run build 2>&1 | tail -5 Expected: succeeds. Run dev server. Open landing page. DevTools Network → filter “loader”. Reload. Confirm loader.webm and loader.mp4 do NOT appear in the request waterfall during initial load.
  • Step 4: Smoke-test the loader still works elsewhere
Trigger a global loading state (e.g. log in or navigate to a route that uses the loader). Confirm the loading video appears as expected when actually needed. Stop dev server.
  • Step 5: Commit via odontox-commit-deploy
Commit message:
perf: defer loader.webm/loader.mp4 (3.6MB) until first loading state

Task 8: Add Cache-Control entries for .avif and tighten *.png in _headers

Files:
  • Modify: ui/public/_headers:43-44
The _headers file currently does not specify a Cache-Control rule for .avif files. Cloudflare Pages will fall back to a default short cache. Hashed bundle assets are already covered by /assets/* immutable, but root-level images like /ruby-icon.avif need their own rule. The current /*.png rule (max-age=86400) is loose — pin it to images we expect to remain stable.
  • Step 1: Edit ui/public/_headers
Replace lines 43–53 (the static-files block) with:
# Static files — long cache for image formats with predictable URLs
/*.png
  Cache-Control: public, max-age=86400, stale-while-revalidate=604800
/*.ico
  Cache-Control: public, max-age=86400
/*.svg
  Cache-Control: public, max-age=86400
/*.webp
  Cache-Control: public, max-age=31536000, immutable
/*.avif
  Cache-Control: public, max-age=31536000, immutable
/*.woff2
  Cache-Control: public, max-age=31536000, immutable
/*.webm
  Cache-Control: public, max-age=31536000, immutable
/*.mp4
  Cache-Control: public, max-age=31536000, immutable
(Adds .avif, .webm, .mp4, and SWR on .png.)
  • Step 2: Build and verify headers file is copied
Run: cd /Users/ssh/Documents/Beta-App/odontoX/ui && npm run build && grep -c '.avif' dist/_headers Expected: 1.
  • Step 3: Commit via odontox-commit-deploy
Commit message:
perf(headers): add Cache-Control for AVIF/WEBM/MP4, add SWR for PNG

Task 9: Re-test PageSpeed + Lighthouse, record before/after

Files:
  • Create: docs/qa/2026-05-14-mobile-pagespeed-results.md
After all prior tasks ship to production, re-test on the same Uptrends free tool used in the baseline report (or run Lighthouse via Chrome DevTools on mobile preset against https://odontox.io/).
  • Step 1: Confirm latest changes are live
Run: curl -s -o /dev/null -w '%{http_code} %{size_download}\n' https://odontox.io/ Expected: 200, and the size should be small (the HTML doc only, ~5–10 KB). Run: curl -s https://odontox.io/ | grep -c 'gtm-loader\|preload.*poppins-500' Expected: at least 2 (confirms our deploy is live).
  • Step 2: Run Lighthouse from the command line (or open Chrome DevTools)
Option A (CLI):
npx -y lighthouse https://odontox.io/ --preset=mobile --form-factor=mobile --throttling-method=simulate --output=json --output-path=/tmp/odontox-after.json --quiet --chrome-flags="--headless"
Extract the score:
node -e "const r=require('/tmp/odontox-after.json'); console.log('Performance:', Math.round(r.categories.performance.score*100), 'LCP:', r.audits['largest-contentful-paint'].displayValue, 'TBT:', r.audits['total-blocking-time'].displayValue, 'CLS:', r.audits['cumulative-layout-shift'].displayValue);"
Option B: open Chrome DevTools → Lighthouse panel → Mobile preset → Run.
  • Step 3: Document results
Create docs/qa/2026-05-14-mobile-pagespeed-results.md with the format:
# Mobile PageSpeed Optimisation Results — 2026-05-14

## Before (baseline from .superpowers/brainstorm/odontox-mobile-pagespeed-report.md)
- Performance: 49 / 100
- LCP: 3,853 ms
- TBT: 198 ms
- CLS: 0
- Page size: ~18 MB
- Total requests: 78

## After (post-implementation)
- Performance: [X] / 100
- LCP: [X] ms
- TBT: [X] ms
- CLS: [X]
- Page size: [X] MB
- Total requests: [X]

## Delta
- Performance: +[X] pts
- LCP: −[X]%
- Page size: −[X] MB ([X]% reduction)
- Requests: −[X]

## Tasks shipped
1. Resource hints (preload Poppins 500/700, preconnect GTM, dns-prefetch Google/DoubleClick)
2. Inlined critical.css into <head>
3. Deferred GTM until 2s after load or first interaction
4. Deferred Sentry via dynamic import
5. Killed dead 11 MB ruby.png + swapped icons to 4 KB AVIF
6. Lazy-mounted Bridge.webm (3.2 MB) on intersection
7. Deferred loader.webm/loader.mp4 (3.6 MB) until needed
8. Added AVIF/WEBM/MP4 Cache-Control headers

## Remaining gaps (if score < 90)
- [list anything still flagged by Lighthouse, e.g. main bundle size, third-party script weight]
  • Step 4: Commit the results doc
Commit message:
docs: record mobile PageSpeed results after optimisation pass

Self-Review Notes

  • Spec coverage: Plan covers Priority 1 (JS deferral via Sentry/GTM), Priority 2 (page weight via media kill + lazy load), Priority 3 (third-party deferral), Priority 4 (LCP — font preload + critical CSS inline), Priority 5 (font-display swap is already in critical.css; we add missing preloads), Priority 7 (critical CSS inline). Priority 6 (TTFB/Brotli) is handled by Cloudflare Pages defaults — no code change needed. Priority 8 (HTTP/3) is already active.
  • JS code splitting (Priority 1): Sentry is now dynamic-imported. Further marketing-bundle splitting (route-level lazy boundaries) is a larger refactor — out of scope for this plan; flag in results doc if Lighthouse still complains.
  • Image LCP (Priority 4): The current LCP element is text (the h1 headline), not an image — confirmed by reading PremiumHero.tsx. Font preload + critical CSS inline is the right fix. No image preload tag added because there is no above-the-fold image.

Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-05-14-mobile-pagespeed-optimization.md. Two execution options: 1. Subagent-Driven (recommended) — Dispatch a fresh subagent per task with two-stage review. Best for shipping incrementally. 2. Inline Execution — Execute tasks sequentially in this session with checkpoints. Faster if you want everything in one shot. Which approach?