Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
47 changes: 47 additions & 0 deletions docs/performance/startup-navigation-2026-09-19/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
2 changes: 1 addition & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 2 additions & 4 deletions e2e/animation-polish.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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 });
Expand Down
9 changes: 9 additions & 0 deletions e2e/helpers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] }),
}));
}

/**
Expand Down
27 changes: 5 additions & 22 deletions e2e/home-navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>(
`[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();
Expand Down
98 changes: 98 additions & 0 deletions e2e/loading-performance.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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');
});
5 changes: 4 additions & 1 deletion e2e/login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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',
Expand All @@ -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');
Expand Down
8 changes: 4 additions & 4 deletions e2e/startup-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand All @@ -68,14 +68,14 @@ 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));
await route.continue();
});

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);
});
Loading
Loading