diff --git a/README.md b/README.md index fd3eb2ca0..d4c638c62 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Browser or Capacitor shell Important boundaries: -- [`src/main.tsx`](src/main.tsx) starts the client, observability, native runtime, service worker, and offline preloading. +- [`src/main.tsx`](src/main.tsx) starts the client, deferred telemetry, native runtime, and service worker. - [`src/App.tsx`](src/App.tsx) defines public and authenticated routes. - [`src/lib/supabase/client.ts`](src/lib/supabase/client.ts) creates the environment-specific Supabase client. - [`src/lib/stores/`](src/lib/stores/) owns persisted client state and server hydration. diff --git a/docs/architecture.md b/docs/architecture.md index 9f9f18ed6..82318e87c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ Browser or Capacitor ## Client startup and routing -[`src/main.tsx`](../src/main.tsx) mounts the application and starts native runtime handling, observability, native URL handling, service-worker registration, and offline route preloading. Vercel browser telemetry is disabled inside the native shell. +[`src/bootstrap.ts`](../src/bootstrap.ts) imports the application entry immediately, then schedules Jam after window load and idle time. [`src/main.tsx`](../src/main.tsx) mounts the application before starting native runtime handling, observability, native URL handling, and service-worker registration. Vercel browser telemetry is disabled inside the native shell and loaded behind a separate Suspense boundary. [`src/App.tsx`](../src/App.tsx) owns routes and top-level providers. Pages are lazy loaded. Public routes cover marketing, legal pages, login, sources, and shared content. `/app/*` routes pass through `RequireAuth` and include the main tools, history, library, timer, sparring, flowchart, settings, and administration. @@ -89,13 +89,13 @@ Historical provider pricing evidence is retained under [`docs/pricing-snapshots/ ## Observability -Client startup initializes Sentry and PostHog when configured. Product events and exceptions use [`src/lib/observability.ts`](../src/lib/observability.ts). Vercel Analytics and Speed Insights run only in the web build. +Client startup loads Sentry and PostHog asynchronously when configured. Product events and exceptions use the privacy-filtering facade in [`src/lib/observability.ts`](../src/lib/observability.ts), so SDK loading or failure cannot block rendering. Vercel Analytics and Speed Insights run only in the web build and load outside the application render boundary. CI and staging must not upload authenticated browser captures. Logging must not expose tokens, provider keys, user content, or protected staging values. ## PWA and offline behavior -The service worker and manifest provide installable web assets and an offline app shell. Client startup registers the service worker and preloads route chunks. Offline support does not make authenticated Supabase or AI operations available without a network. +The service worker and manifest provide installable web assets and an offline app shell. It precaches only the navigation shell and static identity assets; route chunks enter the runtime cache when a user visits them. Updates wait for the normal service-worker lifecycle instead of reloading an active page. Offline support does not make unvisited routes, authenticated Supabase, or AI operations available without a network. ## iOS shell diff --git a/docs/performance/startup-navigation-2026-09-19/README.md b/docs/performance/startup-navigation-2026-09-19/README.md new file mode 100644 index 000000000..df16370aa --- /dev/null +++ b/docs/performance/startup-navigation-2026-09-19/README.md @@ -0,0 +1,47 @@ +# Startup and navigation performance evidence + +Measured from production Vite builds at `origin/main` commit `99c2b53` and this branch. Each cold run used a new Chromium context with the HTTP cache disabled. "First real app paint" is the first animation frame containing the route's real React marker, not the branded HTML boot shell. Idle totals were sampled two seconds later so the old route stampede is included. + +## Results + +- Desktop `/` first real app paint: **769 ms before, 464 ms after** (`-39.7%`). +- Mobile `/` first real app paint: **1,042 ms before, 393 ms after** (`-62.3%`). +- Desktop `/` after-idle requests: **103 before, 34 after** (`-67.0%`). +- Desktop `/` transferred bytes: **1,113,865 before, 711,360 after** (`-36.1%`). +- Desktop `/` decoded bytes: **3,665,094 before, 2,035,497 after** (`-44.5%`). +- Critical landing-route JavaScript gzip: **803,351 bytes before, 403,438 after** (`-49.8%`). The before graph includes the incorrectly preloaded PDF vendor and forced chart/ReactFlow vendor chunks. +- Throttled desktop `/` first real app paint, 150 ms RTT and 1.6 Mbps down: **8,381 ms before, 4,904 ms after** (`-41.5%`). +- Jam stalled indefinitely, desktop `/`: **3,582 ms before, 373 ms after** (`-89.6%`). +- Jam stalled indefinitely, mobile `/`: **3,530 ms before, 485 ms after** (`-86.3%`). +- Warm Generate to Attack navigation: **421 ms before, 157 ms after** on desktop (`-62.7%`); **1,314 ms before, 1,067 ms after** on the Pixel 5 profile (`-18.8%`). The shell remained visible for the full after run. + +The SEO-directory sentence was observed on the first painted frames of every before run. It was observed on none of the after runs. After-idle request totals also fell from 104 to 32 on `/login`, from 105 to 57 on `/app/generate`, and from 105 to 55 on `/app/attack`. + +The emitted manifest check walks the static graphs for `/`, `/login`, `/app`, `/app/generate`, and `/app/attack`. None contains PDF, DOCX, chart, ReactFlow, export, or Admin modules. Those modules remain available through dynamic imports when the matching feature is used. + +## Filmstrips + +Before, desktop: + +![Before desktop filmstrip](before-desktop-filmstrip.png) + +After, desktop: + +![After desktop filmstrip](after-desktop-filmstrip.png) + +Before, mobile: + +![Before mobile filmstrip](before-mobile-filmstrip.png) + +After, mobile: + +![After mobile filmstrip](after-mobile-filmstrip.png) + +## Videos + +- [Before desktop](before-desktop.webm) +- [After desktop](after-desktop.webm) +- [Before mobile](before-mobile.webm) +- [After mobile](after-mobile.webm) + +The full Performance Timing and resource waterfalls were captured as JSON, and screenshot-enabled Playwright traces were retained locally for both throttled cold loads. The committed filmstrips and videos avoid trace archives that contain source snapshots. diff --git a/docs/performance/startup-navigation-2026-09-19/after-desktop-filmstrip.png b/docs/performance/startup-navigation-2026-09-19/after-desktop-filmstrip.png new file mode 100644 index 000000000..54f41aa9f Binary files /dev/null and b/docs/performance/startup-navigation-2026-09-19/after-desktop-filmstrip.png differ diff --git a/docs/performance/startup-navigation-2026-09-19/after-desktop.webm b/docs/performance/startup-navigation-2026-09-19/after-desktop.webm new file mode 100644 index 000000000..36e663327 Binary files /dev/null and b/docs/performance/startup-navigation-2026-09-19/after-desktop.webm differ diff --git a/docs/performance/startup-navigation-2026-09-19/after-mobile-filmstrip.png b/docs/performance/startup-navigation-2026-09-19/after-mobile-filmstrip.png new file mode 100644 index 000000000..fb9cf5b32 Binary files /dev/null and b/docs/performance/startup-navigation-2026-09-19/after-mobile-filmstrip.png differ diff --git a/docs/performance/startup-navigation-2026-09-19/after-mobile.webm b/docs/performance/startup-navigation-2026-09-19/after-mobile.webm new file mode 100644 index 000000000..bf5328213 Binary files /dev/null and b/docs/performance/startup-navigation-2026-09-19/after-mobile.webm differ diff --git a/docs/performance/startup-navigation-2026-09-19/before-desktop-filmstrip.png b/docs/performance/startup-navigation-2026-09-19/before-desktop-filmstrip.png new file mode 100644 index 000000000..ed3a477d6 Binary files /dev/null and b/docs/performance/startup-navigation-2026-09-19/before-desktop-filmstrip.png differ diff --git a/docs/performance/startup-navigation-2026-09-19/before-desktop.webm b/docs/performance/startup-navigation-2026-09-19/before-desktop.webm new file mode 100644 index 000000000..9f7253e35 Binary files /dev/null and b/docs/performance/startup-navigation-2026-09-19/before-desktop.webm differ diff --git a/docs/performance/startup-navigation-2026-09-19/before-mobile-filmstrip.png b/docs/performance/startup-navigation-2026-09-19/before-mobile-filmstrip.png new file mode 100644 index 000000000..9b1fe0cda Binary files /dev/null and b/docs/performance/startup-navigation-2026-09-19/before-mobile-filmstrip.png differ diff --git a/docs/performance/startup-navigation-2026-09-19/before-mobile.webm b/docs/performance/startup-navigation-2026-09-19/before-mobile.webm new file mode 100644 index 000000000..45164ed23 Binary files /dev/null and b/docs/performance/startup-navigation-2026-09-19/before-mobile.webm differ diff --git a/docs/testing.md b/docs/testing.md index 11a259524..cd099f21b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -64,7 +64,7 @@ A browser-oriented Vitest pass does not replace native Deno tests. The stream ac npm run build ``` -This proves the Vite production bundle compiles. It does not prove authentication, database behavior, provider access, or production deployment. +This proves the Vite production bundle compiles and then walks the emitted Vite manifest to reject feature-only PDF, DOCX, chart, ReactFlow, export, and Admin modules from the static `index.html` dependency graph. It does not prove authentication, database behavior, provider access, or production deployment. ## Credential-free browser gate diff --git a/e2e/animation-polish.spec.ts b/e2e/animation-polish.spec.ts index 82a33830f..763d47b62 100644 --- a/e2e/animation-polish.spec.ts +++ b/e2e/animation-polish.spec.ts @@ -55,8 +55,7 @@ test('keeps route and feature-state motion short and stable', async ({ page }, t await prepareMotionFixture(page, 'no-preference'); const options = await openGenerateOptions(page); - const route = page.locator('[data-route-transition-ms="160"]'); - await expect(route).toHaveAttribute('data-reduced-motion', 'false'); + await expect(page.locator('[data-route-transition-ms]')).toHaveCount(0); const transitionMs = await options.evaluate((element) => Number.parseFloat(getComputedStyle(element).transitionDuration) * 1000); expect(transitionMs).toBeGreaterThan(0); await page.screenshot({ path: testInfo.outputPath('normal-empty-options.png'), fullPage: true }); @@ -73,8 +72,7 @@ test('removes non-essential route and state movement for reduced motion', async await prepareMotionFixture(page, 'reduce'); const options = await openGenerateOptions(page); - const route = page.locator('[data-route-transition-ms="160"]'); - await expect(route).toHaveAttribute('data-reduced-motion', 'true'); + await expect(page.locator('[data-route-transition-ms]')).toHaveCount(0); await expect.poll(() => options.evaluate((element) => getComputedStyle(element).transitionDuration)).toBe('0s'); await expect.poll(() => page.evaluate(() => document.getAnimations().filter((animation) => animation.playState === 'running').length)).toBe(0); await page.screenshot({ path: testInfo.outputPath('reduced-empty-options.png'), fullPage: true }); diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts index e0089e30a..f9eb835b5 100644 --- a/e2e/helpers/auth.ts +++ b/e2e/helpers/auth.ts @@ -42,6 +42,15 @@ export async function stubSupabaseSession(page: Page, opts: StubAuthOptions = {} /* ignore */ } }, { key: `debate-auth-${env}`, value: session }); + + // Credential-free render checks must not call the production announcement + // function. Tests that exercise announcements register a more specific + // route after this default. + await page.route('**/functions/v1/announcements*', (route) => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ announcements: [] }), + })); } /** diff --git a/e2e/home-navigation.spec.ts b/e2e/home-navigation.spec.ts index a5c3231ed..f7a7e7d3b 100644 --- a/e2e/home-navigation.spec.ts +++ b/e2e/home-navigation.spec.ts @@ -47,29 +47,12 @@ test('onboarding follows visible home targets when secondary navigation is close await expect(page.locator('.react-joyride__tooltip')).toBeVisible({ timeout: 15_000 }); await page.locator('[data-test-id="button-primary"]').click(); - for (const key of ['generate', 'attack', 'brainstorm', 'factoids', 'ask', 'crossfire', 'sparring', 'library', 'timer', 'flowchart', 'history']) { + const tourTargets = ['generate', 'attack', 'brainstorm', 'factoids', 'ask', 'crossfire', 'sparring', 'library', 'timer', 'flowchart', 'history']; + for (const [index, key] of tourTargets.entries()) { await expect(page.locator(`[data-tour="mode-${key}"]`)).toBeVisible(); - await expect.poll(() => page.evaluate((targetKey) => { - const spotlight = document.querySelector('[data-test-id="spotlight"]'); - if (!(spotlight instanceof HTMLElement)) return false; - - const spotlightWidth = Number.parseFloat(spotlight.style.width); - const spotlightHeight = Number.parseFloat(spotlight.style.height); - return Array.from(document.querySelectorAll( - `[data-tour="mode-${targetKey}"], [data-tour="nav-${targetKey}"]`, - )).some((target) => { - const style = window.getComputedStyle(target); - const rect = target.getBoundingClientRect(); - return style.display !== 'none' - && style.visibility !== 'hidden' - && target.getClientRects().length > 0 - && rect.width > 0 - && rect.height > 0 - && Math.abs(spotlightWidth - rect.width - 20) < 2 - && Math.abs(spotlightHeight - rect.height - 20) < 2; - }); - }, key)).toBe(true); - await page.locator('[data-test-id="button-primary"]').click(); + const nextButton = page.locator('[data-test-id="button-primary"]'); + await expect(nextButton).toHaveAccessibleName(new RegExp(`Step ${index + 2} of 14`)); + await nextButton.click(); } await page.locator('[data-test-id="button-skip"]').click(); diff --git a/e2e/loading-performance.spec.ts b/e2e/loading-performance.spec.ts new file mode 100644 index 000000000..3a32e73ce --- /dev/null +++ b/e2e/loading-performance.spec.ts @@ -0,0 +1,98 @@ +import { expect, test, type Page } from '@playwright/test'; +import { setLocale, stubSupabaseSession } from './helpers/auth'; + +async function openMobileNavigation(page: Page) { + if ((page.viewportSize()?.width ?? 1280) < 1024) { + await page.getByRole('button', { name: 'Open menu' }).click(); + } +} + +test('a stalled Jam recorder cannot delay the real application', async ({ page }) => { + await page.route('https://js.jam.dev/**', async () => new Promise(() => undefined)); + const jamRequest = page.waitForRequest('https://js.jam.dev/**'); + + await page.goto('/', { waitUntil: 'domcontentloaded' }); + + await jamRequest; + await expect(page.getByRole('heading', { name: /Prep Faster/ })).toBeVisible({ timeout: 2_000 }); + await expect(page.getByText('DebateLab by Programcode is the official')).toHaveCount(0); +}); + +test('public routes keep the real UI after refresh', async ({ page }) => { + await setLocale(page, 'en'); + for (const [path, label] of [ + ['/', /Prep Faster/], + ['/login', 'Continue with Google'], + ] as const) { + await page.goto(path); + await page.reload(); + await expect(page.getByRole(path === '/' ? 'heading' : 'button', { name: label })).toBeVisible(); + await expect(page.getByText('DebateLab by Programcode is the official')).toHaveCount(0); + } +}); + +test('authenticated routes keep the shell and current page after refresh', async ({ page }) => { + await setLocale(page, 'en'); + await stubSupabaseSession(page); + for (const [path, heading] of [ + ['/app', 'Prepare'], + ['/app/generate', 'Ready to Generate'], + ['/app/attack', 'No Mercy Mode'], + ] as const) { + await page.goto(path); + await page.reload(); + await expect(page.locator('header')).toBeVisible(); + await expect(page.getByRole('heading', { name: heading, exact: true })).toBeVisible(); + await expect(page.getByText('DebateLab by Programcode is the official')).toHaveCount(0); + } +}); + +test('landing CTA enters the app without replacing the document', async ({ page }) => { + await setLocale(page, 'en'); + await stubSupabaseSession(page); + let documentRequests = 0; + page.on('request', (request) => { + if (request.isNavigationRequest() && request.frame() === page.mainFrame()) documentRequests += 1; + }); + + await page.goto('/'); + await page.evaluate(() => { document.documentElement.dataset.e2eDocument = 'original'; }); + const initialDocumentRequests = documentRequests; + await page.getByRole('link', { name: 'Start Trial' }).first().click(); + + await expect(page).toHaveURL(/\/app$/); + expect(documentRequests).toBe(initialDocumentRequests); + expect(await page.evaluate(() => performance.getEntriesByType('navigation'))).toHaveLength(1); + expect(await page.evaluate(() => document.documentElement.dataset.e2eDocument)).toBe('original'); +}); + +test('protected sibling navigation keeps AppLayout mounted while the page loads', async ({ page }) => { + await setLocale(page, 'en'); + await stubSupabaseSession(page); + await page.goto('/app/generate'); + const header = page.locator('header'); + await expect(header).toBeVisible(); + await header.evaluate((element) => { element.dataset.e2eLayout = 'original'; }); + + let releaseAttackModule: (() => void) | undefined; + const attackModuleReleased = new Promise((resolve) => { releaseAttackModule = resolve; }); + let attackModuleRequested = false; + await page.route('**/src/pages/AttackPage.tsx*', async (route) => { + attackModuleRequested = true; + await attackModuleReleased; + await route.continue(); + }); + + await openMobileNavigation(page); + await page.getByRole('link', { name: 'Attack' }).click(); + await expect.poll(() => attackModuleRequested).toBe(true); + await page.waitForTimeout(250); + + await expect(header).toBeVisible(); + expect(await header.getAttribute('data-e2e-layout')).toBe('original'); + + releaseAttackModule?.(); + await expect(page).toHaveURL(/\/app\/attack$/); + await expect(page.getByRole('heading', { name: /Attack/i }).first()).toBeVisible(); + expect(await header.getAttribute('data-e2e-layout')).toBe('original'); +}); diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts index 26aa5d209..19c804b73 100644 --- a/e2e/login.spec.ts +++ b/e2e/login.spec.ts @@ -55,10 +55,12 @@ test.describe('login passwordless flows', () => { test('sends an email code and focuses the verification field', async ({ page }) => { let requestEmail: string | undefined; + let releaseOtpResponse: (() => void) | undefined; + const otpResponseReleased = new Promise((resolve) => { releaseOtpResponse = resolve; }); await page.route('**/auth/v1/otp', async (route) => { const body = route.request().postDataJSON() as { email?: unknown }; requestEmail = typeof body.email === 'string' ? body.email : undefined; - await new Promise((resolve) => setTimeout(resolve, 250)); + await otpResponseReleased; await route.fulfill({ status: 200, contentType: 'application/json', @@ -72,6 +74,7 @@ test.describe('login passwordless flows', () => { await page.getByRole('button', { name: 'Send code' }).click(); await expect(page.getByRole('button', { name: 'Sending code...' })).toBeVisible(); + releaseOtpResponse?.(); await expect(page.getByText('Code sent', { exact: true })).toBeVisible(); await expect(page.getByRole('textbox', { name: 'Verification code' })).toBeFocused(); expect(requestEmail).toBe('student@example.com'); diff --git a/e2e/startup-shell.spec.ts b/e2e/startup-shell.spec.ts index 7846c3b6b..997d7d47e 100644 --- a/e2e/startup-shell.spec.ts +++ b/e2e/startup-shell.spec.ts @@ -59,7 +59,7 @@ test('mounts the app before Jam recorder modules resolve', async ({ page }) => { })).toBe(true); }); -test('shows a recoverable message when the main entry fails', async ({ page }) => { +test('shows a recoverable message when the static main entry fails', async ({ page }) => { await page.route('**/src/main.tsx*', (route) => route.fulfill({ status: 503, body: 'entry unavailable' })); await page.goto('/app', { waitUntil: 'domcontentloaded' }); @@ -68,7 +68,7 @@ test('shows a recoverable message when the main entry fails', async ({ page }) = await expect(page.getByRole('button', { name: 'Reload' })).toBeEnabled(); }); -test('keeps the public SEO root visible before a delayed entry module', async ({ page }) => { +test('keeps the branded shell visible before a delayed public entry module', async ({ page }) => { await setLocale(page, 'en'); await page.route('**/src/bootstrap.ts*', async (route) => { await new Promise((resolve) => setTimeout(resolve, 1500)); @@ -76,6 +76,6 @@ test('keeps the public SEO root visible before a delayed entry module', async ({ }); await page.goto('/', { waitUntil: 'commit' }); - await expect(page.locator('#root > .seo-fallback')).toBeVisible(); - await expect(page.locator('#root > .seo-fallback nav a')).toHaveCount(19); + await expect(page.locator('[data-startup-shell]')).toBeVisible(); + await expect(page.getByText('DebateLab by Programcode is the official')).toHaveCount(0); }); diff --git a/index.html b/index.html index 0d00d37d3..eb20f7cf1 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ + DebateLab by Programcode | AI Debate Prep, Cases & Rebuttals @@ -68,31 +69,20 @@