diff --git a/docs/performance/smooth-navigation-baseline/README.md b/docs/performance/smooth-navigation-baseline/README.md index cecf086ef..27d9115ff 100644 --- a/docs/performance/smooth-navigation-baseline/README.md +++ b/docs/performance/smooth-navigation-baseline/README.md @@ -12,6 +12,12 @@ requests, so they prove rendering and browser timing only. ## Reproduce +The harness `scripts/measure-navigation-performance.mjs` was a single-use +baseline tool and was removed once this evidence was captured. Recover it with +`git show 9127589:scripts/measure-navigation-performance.mjs` if the matrix ever +needs re-running. The commands below are the ones that produced `run-1.json` +and `run-2.json`. + ```text export PATH=/home/codex/.nvm/versions/node/v22.23.2/bin:$PATH node --version # v22.23.2 diff --git a/e2e/animation-polish.spec.ts b/e2e/animation-polish.spec.ts index 6a3a9505e..17886e968 100644 --- a/e2e/animation-polish.spec.ts +++ b/e2e/animation-polish.spec.ts @@ -1,12 +1,14 @@ import { expect, test, type Page } from '@playwright/test'; import { stubSupabaseSession } from './helpers/auth'; +import { stubAIResponses } from './fixtures/ai-responses'; -const featureConfig = { provider: 'mock', model: 'local', temperature: 0.7 }; +const featureConfig = { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7 }; const STATE_TIMEOUT_MS = 15_000; async function prepareMotionFixture(page: Page, reducedMotion: 'no-preference' | 'reduce') { await page.emulateMedia({ reducedMotion }); await stubSupabaseSession(page); + await stubAIResponses(page); await page.addInitScript((config) => { window.localStorage.setItem('debate-trainer-settings', JSON.stringify({ state: { @@ -47,10 +49,27 @@ async function openGenerateOptions(page: Page) { async function generateMockCase(page: Page) { await page.getByLabel('Motion / Topic').fill('This House would make public transport free.'); await page.getByRole('button', { name: 'Generate Debate Case' }).click(); + await page.mouse.move(0, 0); await expect(page.getByText('Reading the motion...')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await expect(page.getByText(/We support the motion/)).toBeVisible({ timeout: STATE_TIMEOUT_MS }); } +async function openFeatureRoute(page: Page, route: 'brainstorm' | 'factoids' | 'ask') { + const desktopLink = page.locator(`[data-tour="nav-${route}"]`).first(); + + if ((page.viewportSize()?.width ?? 1280) < 1024) { + await page.getByRole('button', { name: 'Open menu' }).click(); + await page.getByRole('navigation', { name: 'Mobile navigation' }) + .locator(`a[href="/app/${route}"]`) + .click(); + } else { + if (!await desktopLink.isVisible()) await page.locator('summary[data-tour="nav-more"]').click(); + await desktopLink.click(); + } + + await expect(page).toHaveURL(new RegExp(`/app/${route}$`)); +} + test('keeps route and feature-state motion short and stable', async ({ page }, testInfo) => { await prepareMotionFixture(page, 'no-preference'); const options = await openGenerateOptions(page); @@ -139,21 +158,21 @@ test('audits applicable empty, loading, result, and option states across feature await expect(page.getByRole('heading', { name: 'Overall Assessment' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await page.screenshot({ path: testInfo.outputPath('attack-result.png'), fullPage: true }); - await page.goto('/app/brainstorm', { waitUntil: 'domcontentloaded' }); + await openFeatureRoute(page, 'brainstorm'); await expect(page.getByText('Unlock Creative Angles')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await page.getByLabel('Motion / Topic').fill('This House would make public transport free.'); await page.getByRole('button', { name: 'Start Brainstorming' }).click(); await expect(page.getByText('Exploring angles...')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await expect(page.getByRole('heading', { name: 'Top 5 Argument Angles' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); - await page.goto('/app/factoids', { waitUntil: 'domcontentloaded' }); + await openFeatureRoute(page, 'factoids'); await expect(page.getByText('Evidence That Wins')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await page.getByLabel('Statement').fill('Free public transport reduces urban congestion.'); await page.getByRole('button', { name: 'Find Factoids' }).click(); await expect(page.getByText('Searching databases...')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await expect(page.getByRole('heading', { name: 'Factoids' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); - await page.goto('/app/ask', { waitUntil: 'domcontentloaded' }); + await openFeatureRoute(page, 'ask'); await expect(page.getByText('Start a conversation')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await page.getByPlaceholder('Ask your debate coach...').fill('How should I weigh access against cost?'); await page.getByRole('button', { name: 'Send message' }).click(); diff --git a/e2e/fixtures/ai-responses.ts b/e2e/fixtures/ai-responses.ts new file mode 100644 index 000000000..030ebf660 --- /dev/null +++ b/e2e/fixtures/ai-responses.ts @@ -0,0 +1,184 @@ +/** + * Canned edge-function responses for specs that exercise UI states rather than + * AI quality. Intercepting the network keeps the app's real request path — and + * its schema validation — under test, unlike the deleted in-app mock provider + * which bypassed both. + */ +import type { Page } from '@playwright/test'; + +const CORS_HEADERS = { + 'access-control-allow-origin': '*', + 'access-control-allow-headers': 'authorization, x-client-info, apikey, content-type', + 'access-control-allow-methods': 'POST, OPTIONS', + 'content-type': 'application/json', +}; + +// The client rejects a generate response whose contention count does not match +// the requested length (see expectedContentionCount in src/lib/ai/client.ts). +const CONTENTION_COUNT: Record = { brief: 2, standard: 3, comprehensive: 4 }; + +const CONTENTIONS = [ + { + id: '1', + claim: 'Access improves for the people who need it most', + reasoning: 'Fares are a regressive barrier concentrated on low-income riders.', + evidence: 'Comparable jurisdictions report ridership gains after fare removal.', + needsSource: false, + impact: 'More people reach work, school, and healthcare.', + }, + { + id: '2', + claim: 'Congestion and emissions fall as mode share shifts', + reasoning: 'Removing the marginal cost of a trip shifts journeys off the road.', + evidence: 'Mode-share studies show measurable shifts at zero fare.', + needsSource: false, + impact: 'Shorter journeys and lower urban emissions.', + }, + { + id: '3', + claim: 'Fare collection overhead outweighs much of the revenue it protects', + reasoning: 'Gates, inspectors, and ticketing systems consume a large share of receipts.', + evidence: 'Operators report collection costs well above headline estimates.', + needsSource: false, + impact: 'The net funding gap is far smaller than opponents claim.', + }, + { + id: '4', + claim: 'Civic participation rises when mobility is not rationed by price', + reasoning: 'Unpriced trips are taken for non-essential civic and social reasons too.', + evidence: 'Ridership growth after fare removal skews to off-peak trips.', + needsSource: false, + impact: 'Denser civic life, not just cheaper commutes.', + }, +]; + +const generate = (motion: string, length: string) => ({ + caseStructure: { + thesis: `We support the motion that ${motion}`, + framework: 'Net societal benefit, weighed on access and cost.', + keyDefinitions: ['Public transport: publicly funded scheduled transit.'], + burdenOfProof: 'We must show the policy produces a net gain in practice.', + }, + contentions: CONTENTIONS.slice(0, CONTENTION_COUNT[length] ?? 2), + predictedOpponentCase: { + likelyArguments: ['The lost farebox revenue has to come from somewhere.'], + strategicWeaknesses: ['Assumes fare revenue is the only funding model.'], + keyClashes: ['Who actually bears the cost.'], + }, + counters: [ + { + targetArgument: 'Funding gap', + counterpoint: 'Fare collection itself carries substantial overhead.', + rebuttal: 'Net cost is far below headline farebox revenue.', + }, + ], + metadata: { side: 'pro', format: 'bp', generatedAt: new Date().toISOString() }, +}); + +const attack = { + overallAssessment: { + score: 62, + summary: 'The case is coherent but leaves its central warrant underdefended.', + strengthAreas: ['Clear thesis', 'Explicit framework'], + }, + weaknesses: [ + { + id: 'w1', + severity: 'major', + type: 'evidential', + description: 'The congestion claim has no comparative evidence.', + whyWeak: 'Correlation between fare policy and congestion is asserted, not shown.', + howOpponentAttacks: 'Name a city where ridership rose and congestion did not fall.', + improvedVersion: 'Cite a before/after study with a control corridor.', + }, + ], + fallaciesDetected: [ + { name: 'Hasty generalization', location: 'Second contention', explanation: 'One city generalized to all.' }, + ], + crossExQuestions: [ + { question: 'Which funding line replaces the farebox?', purpose: 'Force a concrete mechanism.', expectedTrap: 'A vague appeal to general taxation.' }, + ], + weighingAnalysis: { + currentWeighing: 'Access is asserted to outweigh cost without comparison.', + missingComparisons: ['Cost per additional rider versus targeted subsidy.'], + improvementSuggestions: ['Weigh against the strongest alternative, not the status quo.'], + }, +}; + +const idea = { + id: 'i1', + title: 'Access as a precondition for participation', + description: 'Mobility gates work, education, and civic life.', + side: 'pro', + strength: 'strong', + tradeoffs: ['Requires a durable funding source.'], + relatedTo: [], +}; + +const brainstorm = { + needsMoreInfo: false, + topFive: [{ rank: 1, idea, whyTop: 'It reaches the widest impact with the clearest warrant.' }], + nextSteps: ['Find a before/after ridership study.'], + warningsAndTradeoffs: ['Do not overclaim the congestion effect.'], +}; + +const factoids = { + factoids: [ + { + id: 'f1', + shortClaim: 'Fare removal raised ridership in Tallinn', + keyFigure: '+14% ridership', + context: 'Measured over the first year of the free-fare scheme.', + limitations: 'A single city with unusual baseline car ownership.', + citation: { title: 'Free public transport evaluation', publisher: 'Municipal transport authority', verified: 'verified' }, + howToUse: 'Use as an existence proof, not as a universal rate.', + relevanceScore: 80, + }, + ], + searchSuggestions: ['fare-free transit ridership study'], + warnings: [], + verificationStatus: 'complete', +}; + +const ask = (message: string) => ({ + response: `Great question about ${message.slice(0, 60)} — weigh access against cost by naming who bears each.`, + followUpQuestions: ['What is the strongest alternative policy?'], + uncertainties: ['The congestion effect size varies widely by city.'], +}); + +type AiRequest = { feature?: string; input?: Record }; + +// The deleted mock provider delayed 1-2s per feature, which is what made the +// loading states observable. Specs assert on those states, so keep the latency. +const RESPONSE_DELAY_MS = 1_200; + +/** + * Serves canned output for every `ai` edge-function call. Specs that need a + * specific payload should add their own narrower `page.route` first. + */ +export async function stubAIResponses(page: Page) { + await page.route('**/functions/v1/ai**', async (route) => { + if (route.request().method() === 'OPTIONS') { + await route.fulfill({ status: 200, headers: CORS_HEADERS, body: 'ok' }); + return; + } + const body = (route.request().postDataJSON() ?? {}) as AiRequest; + const motion = typeof body.input?.motion === 'string' ? body.input.motion : 'the motion'; + const message = typeof body.input?.message === 'string' ? body.input.message : 'this'; + const length = typeof body.input?.length === 'string' ? body.input.length : 'brief'; + const output = { + generate: () => generate(motion, length), + attack: () => attack, + brainstorm: () => brainstorm, + factoids: () => factoids, + ask: () => ask(message), + }[body.feature ?? '']?.(); + + if (!output) { + await route.fulfill({ status: 400, headers: CORS_HEADERS, body: JSON.stringify({ error: `No fixture for feature ${body.feature}` }) }); + return; + } + await new Promise((resolve) => setTimeout(resolve, RESPONSE_DELAY_MS)); + await route.fulfill({ status: 200, headers: CORS_HEADERS, body: JSON.stringify({ output, discoveredSources: [] }) }); + }); +} diff --git a/e2e/home-navigation.spec.ts b/e2e/home-navigation.spec.ts index cabf6a63e..7fc83f153 100644 --- a/e2e/home-navigation.spec.ts +++ b/e2e/home-navigation.spec.ts @@ -60,19 +60,6 @@ test('onboarding follows visible home targets when secondary navigation is close test('onboarding keeps its original start schedule after a delayed lazy chunk', async ({ page }) => { await page.addInitScript(() => { - const originalSetItem = Storage.prototype.setItem; - Storage.prototype.setItem = function setItem(key, value) { - if (key === 'debate-trainer-onboarding') { - try { - if (JSON.parse(value).state?.isTourRunning === true) { - originalSetItem.call(this, '__onboarding-tour-started-at', String(Date.now())); - } - } catch { - // Ignore malformed test-only storage writes. - } - } - originalSetItem.call(this, key, value); - }; window.localStorage.setItem('debate-trainer-onboarding', JSON.stringify({ state: { hasCompletedOnboarding: false, isTourRunning: false }, version: 0, @@ -80,7 +67,12 @@ test('onboarding keeps its original start schedule after a delayed lazy chunk', }); await page.route('**/src/components/features/onboarding/OnboardingTour.tsx*', async (route) => { const response = await route.fetch(); - const body = `${await response.text()}\nwindow.localStorage.setItem('__onboarding-module-evaluated-at', String(Date.now()));`; + const source = await response.text(); + const body = source.replace( + /const\s+timer\s*=\s*setTimeout\(\(\)\s*=>\s*\{/, + 'window.localStorage.setItem("__onboarding-auto-start-delay", String(autoStartDelay));\n const timer = setTimeout(() => {', + ); + if (body === source) throw new Error('Unable to instrument the onboarding auto-start delay'); await new Promise((resolve) => setTimeout(resolve, 750)); await route.fulfill({ response, body }); }); @@ -92,11 +84,10 @@ test('onboarding keeps its original start schedule after a delayed lazy chunk', await page.goto('/app', { waitUntil: 'domcontentloaded' }); - await expect.poll(async () => Number(await page.evaluate(() => window.localStorage.getItem('__onboarding-module-evaluated-at') ?? 0)), { timeout: 15_000 }).toBeGreaterThan(0); - await expect.poll(async () => Number(await page.evaluate(() => window.localStorage.getItem('__onboarding-tour-started-at') ?? 0)), { timeout: 15_000 }).toBeGreaterThan(0); - const moduleEvaluatedAt = await page.evaluate(() => Number(window.localStorage.getItem('__onboarding-module-evaluated-at'))); - const startedAt = await page.evaluate(() => Number(window.localStorage.getItem('__onboarding-tour-started-at'))); - expect(startedAt - moduleEvaluatedAt).toBeLessThan(450); + await expect.poll( + () => page.evaluate(() => Number(window.localStorage.getItem('__onboarding-auto-start-delay') ?? -1)), + { timeout: 15_000 }, + ).toBe(0); await expect(page.locator('.react-joyride__tooltip')).toBeVisible({ timeout: 15_000 }); }); diff --git a/e2e/hungarian-parity.spec.ts b/e2e/hungarian-parity.spec.ts index 66597913d..0043fd241 100644 --- a/e2e/hungarian-parity.spec.ts +++ b/e2e/hungarian-parity.spec.ts @@ -140,7 +140,7 @@ test.describe('Hungarian parity', () => { await page.getByRole('button', { name: 'Forrás Hozzáadása' }).click(); await expect(page.getByText('Importálás URL-ből')).toBeVisible(); await page.getByRole('button', { name: 'Mentés a Könyvtárba' }).click(); - await expect(page.locator('[toast-close]')).toHaveAttribute('aria-label', 'Bezárás'); + await expect(page.locator('[data-sonner-toast] [data-close-button]')).toHaveAttribute('aria-label', 'Bezárás'); await page.goto('/app/flowchart', { waitUntil: 'domcontentloaded' }); await page.getByRole('button', { name: 'Betöltés' }).click(); diff --git a/e2e/navigation-performance.spec.ts b/e2e/navigation-performance.spec.ts index 718eac900..463874066 100644 --- a/e2e/navigation-performance.spec.ts +++ b/e2e/navigation-performance.spec.ts @@ -58,6 +58,7 @@ async function openAppNav(page: Page, label: string) { } test('cold routes expose real content on both browser profiles', async ({ page }) => { + test.setTimeout(60_000); await setLocale(page, 'en'); for (const [path, selector] of publicRoutes) { await page.goto(path, { waitUntil: 'domcontentloaded' }); @@ -79,6 +80,17 @@ test('cold routes expose real content on both browser profiles', async ({ page } await appPage.close(); }); +test('workspaces stay unavailable without a collaboration endpoint', async ({ page }) => { + await setLocale(page, 'en'); + await stubSupabaseSession(page); + await page.goto('/app', { waitUntil: 'domcontentloaded' }); + await waitForContent(page, { selector: 'main h1', text: 'DebateLab' }); + + await expect(page.locator('a[href="/app/workspaces"]')).toHaveCount(0); + await page.goto('/app/workspaces', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: '404' })).toBeVisible(); +}); + test('a delayed public route keeps the main landmark', async ({ page }) => { await setLocale(page, 'en'); let releaseTerms: (() => void) | undefined; @@ -305,9 +317,13 @@ test('hover intent is limited to fine pointers and click stays in-document', asy expect(requestCount()).toBe(0); await link.hover(); - await page.waitForTimeout(INTENT_OBSERVATION_MS); const supportsHover = await page.evaluate(() => window.matchMedia('(hover: hover) and (pointer: fine)').matches); - expect(requestCount()).toBe(supportsHover ? 1 : 0); + if (supportsHover) { + await expect.poll(requestCount).toBe(1); + } else { + await page.waitForTimeout(INTENT_OBSERVATION_MS); + expect(requestCount()).toBe(0); + } const navigationEntriesBeforeClick = await page.evaluate(() => performance.getEntriesByType('navigation').length); await startGenericLoaderObservation(page); @@ -327,8 +343,7 @@ test('keyboard focus fetches one destination chunk before click', async ({ page await link.focus(); await expect(link).toBeFocused(); - await page.waitForTimeout(INTENT_OBSERVATION_MS); - expect(requestCount()).toBe(1); + await expect.poll(requestCount).toBe(1); await startGenericLoaderObservation(page); await link.click(); @@ -345,8 +360,7 @@ test('touch intent fetches one destination chunk before click', async ({ page }) expect(requestCount()).toBe(0); await link.dispatchEvent('pointerdown', { pointerType: 'touch' }); - await page.waitForTimeout(INTENT_OBSERVATION_MS); - expect(requestCount()).toBe(1); + await expect.poll(requestCount).toBe(1); await startGenericLoaderObservation(page); await link.click(); diff --git a/package-lock.json b/package-lock.json index 09f7c18ba..8095eea51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "@hocuspocus/provider": "4.7.0", "@hocuspocus/server": "4.7.0", "@hookform/resolvers": "^3.10.0", - "@jam.dev/sdk": "^1.0.1", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-checkbox": "^1.3.2", @@ -34,7 +33,6 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", - "@radix-ui/react-toast": "^1.2.14", "@radix-ui/react-tooltip": "^1.2.7", "@sentry/react": "^10.65.0", "@supabase/supabase-js": "^2.49.1", @@ -54,7 +52,6 @@ "clsx": "^2.1.1", "date-fns": "^3.6.0", "docx": "^9.5.1", - "file-saver": "^2.0.5", "framer-motion": "^12.31.0", "html-to-image": "^1.11.13", "i18next": "^23.16.8", @@ -63,7 +60,6 @@ "lib0": "0.2.117", "lucide-react": "^0.462.0", "mammoth": "^1.12.0", - "next-themes": "^0.3.0", "pdfjs-dist": "^6.3.289", "pg": "8.23.0", "posthog-js": "^1.399.2", @@ -74,10 +70,9 @@ "react-joyride": "^2.9.3", "react-markdown": "^9.0.1", "react-router-dom": "^7.18.3", - "react-window": "^2.2.5", "reactflow": "^11.11.4", "recharts": "^2.15.4", - "sonner": "^1.7.4", + "sonner": "^2.0.8", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "y-protocols": "1.0.7", @@ -91,7 +86,6 @@ "@tailwindcss/typography": "^0.5.16", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.0.0", - "@types/file-saver": "^2.0.7", "@types/node": "^22.16.5", "@types/pg": "8.23.1", "@types/react": "^18.3.23", @@ -1229,12 +1223,6 @@ "node": ">=18.0.0" } }, - "node_modules/@jam.dev/sdk": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@jam.dev/sdk/-/sdk-1.0.1.tgz", - "integrity": "sha512-M7KZt079P76BnevBq/z+OaFZGPfA8KVbDSmc9+ARJhPz1t9IeTjrM0cLzbf+07H9IcVizovO65bhdMUWm9EFRQ==", - "license": "Apache-2.0" - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2408,40 +2396,6 @@ } } }, - "node_modules/@radix-ui/react-toast": { - "version": "1.2.23", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.23.tgz", - "integrity": "sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-collection": "1.1.15", - "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-dismissable-layer": "1.1.19", - "@radix-ui/react-portal": "1.1.17", - "@radix-ui/react-presence": "1.1.10", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-callback-ref": "1.1.4", - "@radix-ui/react-use-controllable-state": "1.2.6", - "@radix-ui/react-use-layout-effect": "1.1.4", - "@radix-ui/react-visually-hidden": "1.2.11" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.16", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", @@ -4714,13 +4668,6 @@ "@types/estree": "*" } }, - "node_modules/@types/file-saver": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", - "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/fs-extra": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", @@ -7202,12 +7149,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-saver": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", - "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", - "license": "MIT" - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -9249,16 +9190,6 @@ "dev": true, "license": "MIT" }, - "node_modules/next-themes": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.3.0.tgz", - "integrity": "sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8 || ^17 || ^18", - "react-dom": "^16.8 || ^17 || ^18" - } - }, "node_modules/node-releases": { "version": "2.0.54", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", @@ -10641,16 +10572,6 @@ "react-dom": ">=16.6.0" } }, - "node_modules/react-window": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/react-window/-/react-window-2.3.0.tgz", - "integrity": "sha512-FW6TIpaOH646k51X7yE+LSCWGkt5Pfsnc1fVyq/sCI9h0pTqmMiBXM04pzFKg3Bt7NGkeV6kqbU8d/QjmFS7Ug==", - "license": "MIT", - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, "node_modules/reactflow": { "version": "11.11.4", "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", @@ -11115,13 +11036,19 @@ } }, "node_modules/sonner": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", - "integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", + "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", "license": "MIT", "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/source-map": { diff --git a/package.json b/package.json index bdda8f8ed..4c8a8fd2c 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "@hocuspocus/provider": "4.7.0", "@hocuspocus/server": "4.7.0", "@hookform/resolvers": "^3.10.0", - "@jam.dev/sdk": "^1.0.1", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-checkbox": "^1.3.2", @@ -56,7 +55,6 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", - "@radix-ui/react-toast": "^1.2.14", "@radix-ui/react-tooltip": "^1.2.7", "@sentry/react": "^10.65.0", "@supabase/supabase-js": "^2.49.1", @@ -76,7 +74,6 @@ "clsx": "^2.1.1", "date-fns": "^3.6.0", "docx": "^9.5.1", - "file-saver": "^2.0.5", "framer-motion": "^12.31.0", "html-to-image": "^1.11.13", "i18next": "^23.16.8", @@ -85,7 +82,6 @@ "lib0": "0.2.117", "lucide-react": "^0.462.0", "mammoth": "^1.12.0", - "next-themes": "^0.3.0", "pdfjs-dist": "^6.3.289", "pg": "8.23.0", "posthog-js": "^1.399.2", @@ -96,10 +92,9 @@ "react-joyride": "^2.9.3", "react-markdown": "^9.0.1", "react-router-dom": "^7.18.3", - "react-window": "^2.2.5", "reactflow": "^11.11.4", "recharts": "^2.15.4", - "sonner": "^1.7.4", + "sonner": "^2.0.8", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "y-protocols": "1.0.7", @@ -113,7 +108,6 @@ "@tailwindcss/typography": "^0.5.16", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.0.0", - "@types/file-saver": "^2.0.7", "@types/node": "^22.16.5", "@types/pg": "8.23.1", "@types/react": "^18.3.23", diff --git a/plans/model-cost-accounting.md b/plans/model-cost-accounting.md index 0233040fd..bd2034866 100644 --- a/plans/model-cost-accounting.md +++ b/plans/model-cost-accounting.md @@ -6,6 +6,15 @@ **Goal:** Finish the operational accounting cleanup so stored model costs stay auditable, incomplete provider work is not billed as complete, and admin totals distinguish unknown cost from non-AI activity. +> **Superseded in part (2026-09-21).** The app routes every request through +> OpenRouter, which reports the authoritative account charge per response, so +> the direct-provider pricing table (`_shared/model-pricing.ts`), its refresh +> script (`scripts/update-model-pricing.ts`), the `tsx` dev dependency, and the +> per-provider usage normalizers were unreachable and have been removed. +> `docs/pricing-snapshots/` is kept as historical evidence. The remaining goals +> below — incomplete work staying unpriced, and admin totals distinguishing +> unknown cost from non-AI activity — are unaffected. + ## Preconditions Work in a new isolated T3 Code worktree with a dedicated feature branch owned by this plan. Before reading for implementation or editing, run `pwd`, `git branch --show-current`, `git status --short --branch`, and `git worktree list --porcelain`. Stop if the current directory is the main checkout, the branch is `main`, the worktree is dirty with another task's changes, or an existing plan worktree is being reused. Keep this plan's branch and PR independent of every other plan. diff --git a/public/favicon copy.ico b/public/favicon copy.ico deleted file mode 100644 index 984154c14..000000000 Binary files a/public/favicon copy.ico and /dev/null differ diff --git a/public/favicon copy.jpeg b/public/favicon copy.jpeg deleted file mode 100644 index 322e4c02a..000000000 Binary files a/public/favicon copy.jpeg and /dev/null differ diff --git a/public/favicon copy.png b/public/favicon copy.png deleted file mode 100644 index 79e228836..000000000 Binary files a/public/favicon copy.png and /dev/null differ diff --git a/scripts/measure-navigation-performance.mjs b/scripts/measure-navigation-performance.mjs deleted file mode 100644 index f88a4e283..000000000 --- a/scripts/measure-navigation-performance.mjs +++ /dev/null @@ -1,866 +0,0 @@ -import { createRequire } from 'node:module'; -import { execFileSync, spawn, spawnSync } from 'node:child_process'; -import { createWriteStream } from 'node:fs'; -import { createServer } from 'node:net'; -import { gzipSync } from 'node:zlib'; -import { - copyFile, - mkdtemp, - mkdir, - readFile, - readdir, - rm, - stat, - writeFile, -} from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, relative, resolve } from 'node:path'; -import { chromium, devices } from '@playwright/test'; - -const require = createRequire(import.meta.url); -const ROOT = process.cwd(); -const OUTPUT_DIR = resolve(ROOT, process.env.PERFORMANCE_OUTPUT_DIR || 'docs/performance/smooth-navigation-baseline'); -const DIST_DIR = resolve(ROOT, 'dist'); -const RUN_LABEL = process.env.BASELINE_RUN || `run-${new Date().toISOString().replaceAll(':', '-')}`; -const DELAY_MS = 750; -const SERVER_TIMEOUT_MS = 45_000; -const CONTENT_TIMEOUT_MS = Number(process.env.PERFORMANCE_CONTENT_TIMEOUT_MS || 90_000); - -const ROUTES = [ - { path: '/', marker: { selector: 'h1', text: 'Prep Faster' }, module: 'LandingPage' }, - { path: '/login', marker: { selector: 'button', text: 'Continue with Google' }, module: 'LoginPage' }, - { path: '/app', marker: { selector: 'main h1', text: 'DebateLab' }, module: 'HomePage', authenticated: true }, - { path: '/app/generate', marker: { selector: 'main h1', text: 'Generate' }, module: 'GeneratePage', authenticated: true }, - { path: '/app/attack', marker: { selector: 'main h1', text: 'Attack' }, module: 'AttackPage', authenticated: true }, - { path: '/app/flowchart', marker: { selector: 'main h3', text: 'Argument Flowchart' }, module: 'FlowchartPage', authenticated: true }, - { path: '/app/history', marker: { selector: 'main h1', text: 'History' }, module: 'HistoryPage', authenticated: true }, - { path: '/app/admin', marker: { selector: 'main h1, main h2, main h3', text: 'Admin Dashboard|Access Denied' }, module: 'AdminPage', authenticated: true }, -]; - -const TRANSITIONS = [ - { from: '/', to: '/app', label: 'Landing CTA -> /app', module: 'HomePage', authenticated: true }, - { from: '/app', to: '/app/generate', label: 'Home -> Generate', module: 'GeneratePage', authenticated: true }, - { from: '/app/generate', to: '/app/attack', label: 'Generate -> Attack', module: 'AttackPage', authenticated: true }, - { from: '/app', to: '/app/flowchart', label: 'Home -> Flowchart', module: 'FlowchartPage', authenticated: true }, - { from: '/app', to: '/app/history', label: 'Home -> History', module: 'HistoryPage', authenticated: true }, -]; - -const PROFILES = [ - { - name: 'desktop', - viewport: { width: 1280, height: 800 }, - device: { viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 }, - networkProfile: 'unthrottled loopback', - cpuProfile: 'Chromium default CPU, no emulation', - }, - { - name: 'pixel-5', - device: devices['Pixel 5'], - networkProfile: 'unthrottled loopback', - cpuProfile: 'Chromium default CPU, no emulation', - }, -]; - -function git(command) { - return execFileSync('git', command, { cwd: ROOT, encoding: 'utf8' }).trim(); -} - -function sleep(ms) { - return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); -} - -async function freePort() { - const server = createServer(); - await new Promise((resolvePromise, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolvePromise); - }); - const address = server.address(); - const port = typeof address === 'object' && address ? address.port : 0; - await new Promise((resolvePromise) => server.close(resolvePromise)); - if (!port) throw new Error('Could not allocate a free loopback port.'); - return port; -} - -async function waitForServer(url) { - const started = Date.now(); - while (Date.now() - started < SERVER_TIMEOUT_MS) { - try { - const response = await fetch(url, { signal: AbortSignal.timeout(2_000) }); - if (response.ok) return; - } catch { - // The Vite process is still starting. - } - await sleep(150); - } - throw new Error(`Vite did not answer ${url} within ${SERVER_TIMEOUT_MS}ms.`); -} - -async function startServer(port) { - const logDir = await mkdtemp(join(tmpdir(), 'debatelab-vite-')); - const stdoutPath = join(logDir, 'stdout.log'); - const stderrPath = join(logDir, 'stderr.log'); - const child = spawn('npm', ['run', 'dev', '--', '--host', '127.0.0.1', '--port', String(port)], { - cwd: ROOT, - env: { ...process.env, FORCE_COLOR: '0', PORT: String(port) }, - detached: true, - stdio: ['ignore', 'pipe', 'pipe'], - }); - child.stdout.pipe(createWriteStream(stdoutPath)); - child.stderr.pipe(createWriteStream(stderrPath)); - const baseURL = `http://127.0.0.1:${port}`; - try { - await waitForServer(baseURL); - } catch (error) { - await stopServer(child); - throw new Error(`${error.message}\n${await readFile(stderrPath, 'utf8')}`); - } - return { child, baseURL, logDir }; -} - -async function stopServer(child) { - const signalGroup = (signal) => { - if (!child.pid) return; - try { - process.kill(-child.pid, signal); - } catch (error) { - if (error.code !== 'ESRCH') throw error; - } - }; - if (child.exitCode !== null) { - signalGroup('SIGTERM'); - return; - } - signalGroup('SIGTERM'); - await Promise.race([ - new Promise((resolvePromise) => child.once('exit', resolvePromise)), - sleep(5_000).then(() => signalGroup('SIGKILL')), - ]); - signalGroup('SIGKILL'); -} - -async function cleanupResources({ browser, server, stopServerFn = stopServer, removeLogDirectory = rm }) { - const errors = []; - if (browser) { - try { - await browser.close(); - } catch (error) { - errors.push(new Error(`browser.close failed: ${error instanceof Error ? error.message : String(error)}`)); - } - } - if (server?.child) { - try { - await stopServerFn(server.child); - } catch (error) { - errors.push(new Error(`Vite cleanup failed: ${error instanceof Error ? error.message : String(error)}`)); - } - } - if (server?.logDir) { - try { - await removeLogDirectory(server.logDir, { recursive: true, force: true }); - } catch (error) { - errors.push(new Error(`Vite log cleanup failed: ${error instanceof Error ? error.message : String(error)}`)); - } - } - return errors.length ? new AggregateError(errors, 'Resource cleanup failed.') : null; -} - -function authSession() { - const expiresAt = Math.floor(Date.now() / 1000) + 3_600; - return { - access_token: 'stub-access', - refresh_token: 'stub-refresh', - expires_in: 3_600, - expires_at: expiresAt, - token_type: 'bearer', - user: { - id: '00000000-0000-0000-0000-000000000001', - aud: 'authenticated', - role: 'authenticated', - email: 'e2e@example.com', - app_metadata: { provider: 'google' }, - user_metadata: { full_name: 'E2E Tester' }, - created_at: new Date().toISOString(), - }, - }; -} - -const INIT_INSTRUMENTATION = ({ markers }) => { - const baseline = { - initial: { - target: window.location.pathname, - firstSkeletonFrame: null, - firstRealContentFrame: null, - }, - transition: null, - longTasks: [], - layoutShifts: [], - eventTimings: [], - }; - - const visible = (element) => { - if (!element) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; - }; - - const realContent = (path) => { - const marker = markers[path] || { selector: 'main h1, main h2, main h3', text: '' }; - const element = document.querySelector(marker.selector); - return visible(element) && (!marker.text || marker.text.split('|').some((text) => element.textContent?.includes(text))); - }; - const skeleton = () => visible(document.querySelector('[data-route-skeleton]')); - - const normalizePath = (value) => { - const path = value.replace(/\/+$/, ''); - return path || '/'; - }; - - document.addEventListener('click', (event) => { - if (!baseline.transition || baseline.transition.clickTime !== null) return; - const element = event.target instanceof Element ? event.target.closest('a[href]') : null; - if (!element) return; - const destination = new URL(element.href, window.location.href); - if (normalizePath(destination.pathname) !== normalizePath(baseline.transition.target)) return; - const clickTime = performance.now(); - baseline.transition.clickTime = clickTime; - }, true); - - const sample = () => { - const now = performance.now(); - const path = window.location.pathname; - if (path === baseline.initial.target) { - if (baseline.initial.firstSkeletonFrame === null && skeleton()) baseline.initial.firstSkeletonFrame = now; - if (baseline.initial.firstRealContentFrame === null && realContent(path)) baseline.initial.firstRealContentFrame = now; - } - if (baseline.transition?.target === path) { - if (baseline.transition.firstSkeletonFrame === null && skeleton()) baseline.transition.firstSkeletonFrame = now; - if (baseline.transition.firstRealContentFrame === null && realContent(path)) baseline.transition.firstRealContentFrame = now; - } - window.requestAnimationFrame(sample); - }; - - if (PerformanceObserver.supportedEntryTypes.includes('longtask')) { - new PerformanceObserver((list) => { - for (const entry of list.getEntries()) baseline.longTasks.push({ startTime: entry.startTime, duration: entry.duration }); - }).observe({ type: 'longtask', buffered: true }); - } - if (PerformanceObserver.supportedEntryTypes.includes('layout-shift')) { - new PerformanceObserver((list) => { - for (const entry of list.getEntries()) { - if (!entry.hadRecentInput) baseline.layoutShifts.push({ startTime: entry.startTime, value: entry.value }); - } - }).observe({ type: 'layout-shift', buffered: true }); - } - if (PerformanceObserver.supportedEntryTypes.includes('event')) { - new PerformanceObserver((list) => { - for (const entry of list.getEntries()) { - baseline.eventTimings.push({ - name: entry.name, - startTime: entry.startTime, - duration: entry.duration, - interactionId: entry.interactionId, - }); - } - }).observe({ type: 'event', buffered: true, durationThreshold: 16 }); - } - - window.__navigationBaseline = { - startTransition(target) { - const resourceBoundary = performance.now(); - baseline.transition = { - target, - clickTime: null, - resourceBoundary, - firstSkeletonFrame: null, - firstRealContentFrame: null, - shellBefore: document.querySelector('header'), - }; - }, - snapshot() { - return { - initial: baseline.initial, - transition: baseline.transition ? { - target: baseline.transition.target, - clickTime: baseline.transition.clickTime, - resourceBoundary: baseline.transition.resourceBoundary, - firstSkeletonFrame: baseline.transition.firstSkeletonFrame, - firstRealContentFrame: baseline.transition.firstRealContentFrame, - shellDomIdentitySurvived: baseline.transition.shellBefore - ? document.querySelector('header') === baseline.transition.shellBefore - : null, - } : null, - longTasks: baseline.longTasks, - layoutShifts: baseline.layoutShifts, - eventTimings: baseline.eventTimings, - }; - }, - }; - sample(); -}; - -async function createContext(browser, profile, { authenticated = false, cacheDisabled = false, videoDir, baseURL } = {}) { - const context = await browser.newContext({ - ...profile.device, - serviceWorkers: 'block', - ...(videoDir ? { recordVideo: { dir: videoDir, size: profile.device.viewport } } : {}), - }); - const markerMap = Object.fromEntries(ROUTES.map(({ path, marker }) => [path, marker])); - await context.addInitScript(INIT_INSTRUMENTATION, { markers: markerMap }); - await context.addInitScript((locale) => { - window.localStorage.setItem('debate-trainer-settings', JSON.stringify({ - state: { language: locale, outputLanguage: locale, theme: 'system' }, - version: 0, - })); - }, 'en'); - if (authenticated) { - await context.addInitScript((session) => { - window.localStorage.setItem('debate-auth-production', JSON.stringify(session)); - window.localStorage.setItem('debate-trainer-onboarding', JSON.stringify({ - state: { hasCompletedOnboarding: true, isTourRunning: false }, - version: 0, - })); - }, authSession()); - } - const page = await context.newPage(); - const origin = new URL(baseURL).origin; - await page.route('**/*', (route) => { - const url = new URL(route.request().url()); - if (url.origin !== origin && url.protocol !== 'data:') return route.abort(); - return route.continue(); - }); - const client = await context.newCDPSession(page); - await client.send('Performance.enable'); - if (cacheDisabled) { - await client.send('Network.enable'); - await client.send('Network.setCacheDisabled', { cacheDisabled: true }); - } - return { context, page, client }; -} - -async function readMainThreadMetrics(client) { - const { metrics } = await client.send('Performance.getMetrics'); - const values = Object.fromEntries(metrics.map(({ name, value }) => [name, value * 1_000])); - return { - scriptDurationMs: values.ScriptDuration ?? 0, - taskDurationMs: values.TaskDuration ?? 0, - layoutDurationMs: values.LayoutDuration ?? 0, - recalcStyleDurationMs: values.RecalcStyleDuration ?? 0, - }; -} - -function metricDelta(after, before) { - return Object.fromEntries(Object.keys(after).map((key) => [key, Math.max(0, after[key] - before[key])])); -} - -async function waitForContent(page, path) { - const route = ROUTES.find((item) => item.path === path); - if (!route) throw new Error(`No route definition for ${path}.`); - await page.waitForFunction((marker) => { - const element = document.querySelector(marker.selector); - if (!element) return false; - const rect = element.getBoundingClientRect(); - const style = getComputedStyle(element); - return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none' - && marker.text.split('|').some((text) => element.textContent?.includes(text)); - }, route.marker, { timeout: CONTENT_TIMEOUT_MS }); -} - -function routeChunkPattern(module) { - return new RegExp(`/src/pages/${module}\\.tsx(?:[?#]|$)`, 'i'); -} - -async function collectMetrics(page, { path, kind, profile, cacheState, routeModule, label }) { - const metricsHandle = await page.waitForFunction(({ target, chunkPattern }) => { - const resources = performance.getEntriesByType('resource').map((entry) => ({ - name: entry.name, - sameOrigin: new URL(entry.name, location.href).origin === location.origin, - startTime: entry.startTime, - duration: entry.duration, - responseEnd: entry.responseEnd, - transferSize: entry.transferSize, - encodedBodySize: entry.encodedBodySize, - decodedBodySize: entry.decodedBodySize, - })); - const navigation = performance.getEntriesByType('navigation')[0]; - const state = window.__navigationBaseline?.snapshot() || { - initial: { firstSkeletonFrame: null, firstRealContentFrame: null }, - transition: null, - longTasks: [], - layoutShifts: [], - eventTimings: [], - }; - const transition = state.transition; - const boundary = transition?.resourceBoundary ?? null; - const measuredResources = boundary === null - ? resources - : resources.filter((entry) => entry.startTime >= boundary); - const sameOriginResources = measuredResources.filter((entry) => entry.sameOrigin); - const backgroundResources = boundary === null ? [] : sameOriginResources.filter((entry) => - entry.name.includes('/node_modules/.vite/deps/@vercel_analytics_react.js') - || entry.name.includes('/node_modules/.vite/deps/@vercel_speed-insights_react.js') - ); - const appResources = sameOriginResources.filter((entry) => !backgroundResources.includes(entry)); - const measuredLongTasks = boundary === null - ? state.longTasks - : state.longTasks.filter((entry) => entry.startTime >= boundary); - const measuredLayoutShifts = boundary === null - ? state.layoutShifts - : state.layoutShifts.filter((entry) => entry.startTime >= boundary); - const measuredEventTimings = boundary === null - ? state.eventTimings - : state.eventTimings.filter((entry) => entry.startTime >= boundary); - const routeChunks = appResources.filter((entry) => new RegExp(chunkPattern, 'i').test(entry.name)); - const routeCodeReadyAt = routeChunks.length ? Math.max(...routeChunks.map((entry) => entry.responseEnd)) : null; - const realContentAt = transition?.firstRealContentFrame ?? state.initial.firstRealContentFrame; - const relative = (value, start) => value === null || start === null ? null : Math.max(0, value - start); - return { - path: target, - clickTime: transition?.clickTime ?? null, - measurementBoundary: boundary, - firstSkeletonFrameMs: transition - ? relative(transition.firstSkeletonFrame, transition.clickTime) - : state.initial.firstSkeletonFrame, - firstRealContentFrameMs: transition - ? relative(transition.firstRealContentFrame, transition.clickTime) - : state.initial.firstRealContentFrame, - routeChunkRequests: routeChunks.map((entry) => ({ - url: entry.name, - startTime: entry.startTime, - endTime: entry.responseEnd, - duration: entry.duration, - transferSize: entry.transferSize, - decodedBodySize: entry.decodedBodySize, - })), - postChunkRenderMs: routeCodeReadyAt === null || realContentAt === null - ? null - : Math.max(0, realContentAt - routeCodeReadyAt), - longTasks: measuredLongTasks, - postChunkLongTasks: routeCodeReadyAt === null - ? [] - : measuredLongTasks.filter((entry) => entry.startTime >= routeCodeReadyAt), - controlledInteractionDurationMs: measuredEventTimings - .filter((entry) => entry.interactionId > 0) - .reduce((maximum, entry) => Math.max(maximum, entry.duration), 0), - eventTimings: measuredEventTimings, - cls: measuredLayoutShifts.reduce((sum, entry) => sum + entry.value, 0), - layoutShifts: measuredLayoutShifts, - requestCount: appResources.length, - browserRequestCount: measuredResources.length, - externalRequestCount: measuredResources.length - sameOriginResources.length, - backgroundRequestCount: backgroundResources.length, - backgroundResources, - transferredBytes: appResources.reduce((sum, entry) => sum + (entry.transferSize || 0), 0), - encodedBytes: appResources.reduce((sum, entry) => sum + (entry.encodedBodySize || 0), 0), - decodedBytes: appResources.reduce((sum, entry) => sum + (entry.decodedBodySize || 0), 0), - resources: appResources, - sourceWorkExcluded: boundary === null ? null : { - requestCount: resources.filter((entry) => entry.sameOrigin && entry.startTime < boundary).length, - longTaskCount: state.longTasks.length - measuredLongTasks.length, - layoutShiftCount: state.layoutShifts.length - measuredLayoutShifts.length, - }, - navigationTiming: navigation ? { - domContentLoaded: navigation.domContentLoadedEventEnd, - loadEventEnd: navigation.loadEventEnd, - responseEnd: navigation.responseEnd, - } : null, - shellDomIdentitySurvived: transition?.shellDomIdentitySurvived ?? null, - }; - }, { target: path, chunkPattern: routeChunkPattern(routeModule).source }, { timeout: CONTENT_TIMEOUT_MS, polling: 'raf' }); - const metrics = await metricsHandle.jsonValue(); - return { - ...metrics, - kind, - label, - profile, - cacheState, - routeModule, - }; -} - -async function gotoAndMeasure(page, path, options) { - await page.goto(`${options.baseURL}${path}`, { waitUntil: 'commit', timeout: CONTENT_TIMEOUT_MS }); - await waitForContent(page, path); - await sleep(150); - return collectMetrics(page, { ...options, path }); -} - -async function measureColdRoute(browser, profile, route, baseURL) { - const { context, page, client } = await createContext(browser, profile, { - authenticated: route.authenticated, - cacheDisabled: true, - baseURL, - }); - try { - const before = await readMainThreadMetrics(client); - const metrics = await gotoAndMeasure(page, route.path, { - baseURL, - kind: 'cold-refresh', - profile: profile.name, - cacheState: 'new context, HTTP cache disabled, service workers blocked', - routeModule: route.module, - label: `Cold ${route.path}`, - }); - return { ...metrics, mainThread: metricDelta(await readMainThreadMetrics(client), before) }; - } finally { - await context.close(); - } -} - -async function clickRoute(page, from, to) { - if (from === '/' && to === '/app') { - await page.getByRole('link', { name: 'Start Trial' }).first().click(); - return; - } - if (to === '/app') { - await page.locator('header a[href="/app"]').first().click(); - return; - } - if (from === '/app') { - const homeLink = page.locator(`main a[href="${to}"]`).first(); - if (await homeLink.count()) { - await homeLink.click(); - return; - } - } - const label = to.split('/').pop().replace(/^[a-z]/, (letter) => letter.toUpperCase()); - if ((page.viewportSize()?.width ?? 1280) < 1024) { - await page.getByRole('button', { name: 'Open menu' }).click(); - await page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: new RegExp(`^${label}`) }).click(); - return; - } - if (['Flowchart', 'History'].includes(label)) { - await page.locator('summary[aria-label="More"]').click(); - await page.locator('details').getByRole('link', { name: label }).click(); - return; - } - await page.getByRole('navigation', { name: 'Main navigation' }).getByRole('link', { name: label }).click(); -} - -async function clickDestination(page, transition, { measure = true } = {}) { - if (measure) await page.evaluate((target) => window.__navigationBaseline?.startTransition(target), transition.to); - await clickRoute(page, transition.from, transition.to); - await page.waitForFunction((target) => window.location.pathname === target, transition.to, { timeout: CONTENT_TIMEOUT_MS }); - await waitForContent(page, transition.to); - if (measure) { - await page.waitForFunction(() => window.__navigationBaseline?.snapshot().clickTime !== null, undefined, { timeout: CONTENT_TIMEOUT_MS }); - } - await page.waitForLoadState('domcontentloaded', { timeout: CONTENT_TIMEOUT_MS }).catch(() => undefined); - await sleep(150); -} - -async function measureTransition(browser, profile, transition, baseURL, warm) { - const { context, page, client } = await createContext(browser, profile, { authenticated: transition.authenticated, baseURL }); - try { - await page.goto(`${baseURL}${transition.from}`, { waitUntil: 'commit', timeout: CONTENT_TIMEOUT_MS }); - await waitForContent(page, transition.from); - let warmCacheProof = null; - if (warm) { - await clickDestination(page, transition, { measure: false }); - warmCacheProof = await page.evaluate((module) => { - const entry = performance.getEntriesByType('resource') - .filter((resource) => new RegExp(`/src/pages/${module}\\.tsx(?:[?#]|$)`, 'i').test(resource.name)) - .at(-1); - return entry ? { - url: entry.name, - startTime: entry.startTime, - responseEnd: entry.responseEnd, - transferSize: entry.transferSize, - encodedBodySize: entry.encodedBodySize, - decodedBodySize: entry.decodedBodySize, - } : null; - }, transition.module); - if (transition.from === '/') { - await page.goBack({ waitUntil: 'commit', timeout: CONTENT_TIMEOUT_MS }).catch(() => undefined); - } else { - await clickRoute(page, transition.to, transition.from); - } - await page.waitForFunction((target) => window.location.pathname === target, transition.from, { timeout: CONTENT_TIMEOUT_MS }); - await waitForContent(page, transition.from); - } - const before = await readMainThreadMetrics(client); - await clickDestination(page, transition); - const metrics = await collectMetrics(page, { - path: transition.to, - kind: warm ? 'warm-click' : 'first-visit-click', - profile: profile.name, - cacheState: warm ? 'same-document destination visit, same-document return, module cache warm' : 'new context, browser cache empty', - routeModule: transition.module, - label: transition.label, - }); - const measured = { ...metrics, mainThread: metricDelta(await readMainThreadMetrics(client), before) }; - return warm ? { - ...measured, - warmCacheProof: { - sameDocumentNavigation: true, - destinationWarmRequest: warmCacheProof, - measuredDestinationRequestCount: measured.routeChunkRequests.length, - semantics: measured.routeChunkRequests.length === 0 - ? 'destination module remained resolved across same-document return; measured click issued no destination request' - : 'destination request remained observable after same-document warm visit', - }, - } : measured; - } finally { - await context.close(); - } -} - -async function captureDelayedTransition(browser, profile, baseURL, outputDir) { - const tempRoot = await mkdtemp(join(tmpdir(), `debatelab-delay-${profile.name}-`)); - const frameDir = join(tempRoot, 'frames'); - const videoDir = join(tempRoot, 'video'); - await mkdir(frameDir, { recursive: true }); - await mkdir(videoDir, { recursive: true }); - const { context, page, client } = await createContext(browser, profile, { - authenticated: true, - cacheDisabled: true, - videoDir, - baseURL, - }); - let requestedAt = null; - await page.route('**/src/pages/AttackPage.tsx*', async (route) => { - requestedAt = Date.now(); - await sleep(DELAY_MS); - await route.continue(); - }); - const transition = { from: '/app/generate', to: '/app/attack', module: 'AttackPage', label: 'Delayed Generate -> Attack' }; - const frameStates = []; - let metrics; - try { - await page.goto(`${baseURL}${transition.from}`, { waitUntil: 'commit', timeout: CONTENT_TIMEOUT_MS }); - await waitForContent(page, transition.from); - const before = await readMainThreadMetrics(client); - const clickPromise = clickDestination(page, transition); - for (let index = 0; index < 8; index += 1) { - await sleep(index === 0 ? 50 : 100); - frameStates.push(await page.evaluate(() => ({ - route: window.location.pathname, - fallbackVisible: Boolean(document.querySelector('[data-route-skeleton]')), - shellVisible: Boolean(document.querySelector('header')), - }))); - await page.screenshot({ path: join(frameDir, `frame-${String(index).padStart(2, '0')}.png`) }); - } - await clickPromise; - await page.waitForFunction((target) => window.location.pathname === target, transition.to, { timeout: CONTENT_TIMEOUT_MS }); - await waitForContent(page, transition.to); - await sleep(150); - metrics = await collectMetrics(page, { - path: transition.to, - kind: 'delayed-click', - profile: profile.name, - cacheState: 'new context, HTTP cache disabled, service workers blocked', - routeModule: transition.module, - label: transition.label, - }); - metrics.mainThread = metricDelta(await readMainThreadMetrics(client), before); - metrics.delayMs = DELAY_MS; - metrics.delayRequestObserved = requestedAt !== null; - metrics.frameStates = frameStates; - } finally { - await context.close(); - } - const videos = (await readdir(videoDir)).filter((file) => file.endsWith('.webm')); - if (videos.length !== 1) throw new Error(`Expected one ${profile.name} video, found ${videos.length}.`); - const videoPath = join(outputDir, `${profile.name}-delayed.webm`); - const filmstripPath = join(outputDir, `${profile.name}-delayed-filmstrip.png`); - await copyFile(join(videoDir, videos[0]), videoPath); - const ffmpeg = spawnSync('ffmpeg', [ - '-hide_banner', '-loglevel', 'error', '-y', - '-pattern_type', 'glob', '-i', join(frameDir, 'frame-*.png'), - '-vf', 'scale=320:200:force_original_aspect_ratio=decrease,pad=320:200:(ow-iw)/2:(oh-ih)/2:black,tile=4x2:padding=8:margin=8', - '-frames:v', '1', filmstripPath, - ], { encoding: 'utf8' }); - if (ffmpeg.status !== 0) throw new Error(`ffmpeg filmstrip failed: ${ffmpeg.stderr}`); - await rm(tempRoot, { recursive: true, force: true }); - if (!metrics) throw new Error(`Delayed ${profile.name} run produced no metrics.`); - return { ...metrics, video: relative(ROOT, videoPath), filmstrip: relative(ROOT, filmstripPath) }; -} - -function normalizeModule(moduleId) { - const absoluteRoot = `${ROOT}/`; - return moduleId.startsWith(absoluteRoot) ? moduleId.slice(absoluteRoot.length) : moduleId; -} - -async function captureBundleGraph() { - const manifestPath = join(DIST_DIR, '.vite/manifest.json'); - const graphPath = join(DIST_DIR, '.vite/bundle-graph.json'); - if (!existsSync(manifestPath) || !existsSync(graphPath)) { - throw new Error('dist/.vite/manifest.json and bundle-graph.json are required. Run npm run build first.'); - } - const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); - const graph = JSON.parse(await readFile(graphPath, 'utf8')); - const routeEntries = ROUTES.map((route) => { - const key = route.path === '/' ? 'src/pages/LandingPage.tsx' : `src/pages/${route.module}.tsx`; - const entry = manifest[key]; - if (!entry) throw new Error(`Manifest is missing ${key}.`); - return { route: route.path, module: key, ...entry }; - }); - const entryKey = Object.keys(manifest).find((key) => manifest[key].isEntry && manifest[key].src === 'index.html'); - if (!entryKey) throw new Error('Manifest is missing the index.html entry.'); - routeEntries.unshift({ route: 'index.html', module: entryKey, ...manifest[entryKey] }); - - const staticGraph = (file) => { - const result = new Set(); - const pending = [file]; - while (pending.length) { - const current = pending.pop(); - if (!current || result.has(current)) continue; - result.add(current); - pending.push(...(graph[current]?.imports || [])); - } - return result; - }; - const membership = new Map(); - for (const entry of routeEntries) { - for (const file of staticGraph(entry.file)) { - if (!membership.has(file)) membership.set(file, []); - membership.get(file).push(entry.route); - } - } - const chunks = []; - for (const [file, item] of Object.entries(graph)) { - const filePath = join(DIST_DIR, file); - const bytes = (await stat(filePath)).size; - chunks.push({ - file, - bytes, - gzipBytes: gzipSync(await readFile(filePath)).length, - imports: item.imports, - modules: item.modules.map(normalizeModule), - staticMembership: membership.get(file) || [], - dynamicEntry: Object.entries(manifest).find(([, value]) => value.file === file)?.[0] || null, - }); - } - return { - manifestEntries: Object.entries(manifest).map(([key, value]) => ({ key, ...value })), - routeStaticGraphs: routeEntries.map((entry) => ({ route: entry.route, file: entry.file, files: [...staticGraph(entry.file)] })), - chunks, - }; -} - -function summarize(results) { - const byKind = {}; - for (const item of results) { - byKind[item.kind] = (byKind[item.kind] || 0) + 1; - } - return { - measurements: results.length, - byKind, - delayedFilmstrips: results.filter((item) => item.kind === 'delayed-click').map((item) => item.profile), - }; -} - -async function runCleanupSelfTest() { - const calls = []; - const fakeServer = { child: {}, logDir: '/tmp/fake-vite-log' }; - const stopServerFn = async () => { calls.push('server'); }; - const removeLogDirectory = async () => { calls.push('logs'); }; - - const launchFailureCleanup = await cleanupResources({ - browser: null, - server: fakeServer, - stopServerFn, - removeLogDirectory, - }); - if (launchFailureCleanup || calls.join(',') !== 'server,logs') { - throw new Error(`Launch-failure cleanup self-test failed: ${calls.join(',')}`); - } - - calls.length = 0; - const browserCloseFailureCleanup = await cleanupResources({ - browser: { close: async () => { calls.push('browser'); throw new Error('simulated close failure'); } }, - server: fakeServer, - stopServerFn, - removeLogDirectory, - }); - if (!browserCloseFailureCleanup || calls.join(',') !== 'browser,server,logs') { - throw new Error(`Browser-close cleanup self-test failed: ${calls.join(',')}`); - } - console.log('cleanup self-test passed: launch failure and browser.close failure both clean Vite and logs'); -} - -async function main() { - await mkdir(OUTPUT_DIR, { recursive: true }); - const port = await freePort(); - const server = await startServer(port); - let browser = null; - let browserInfo = null; - const results = []; - const delayed = []; - let operationError = null; - try { - browser = await chromium.launch({ headless: true }); - browserInfo = { - name: 'Chromium', - version: browser.version(), - executablePath: chromium.executablePath(), - playwrightVersion: require('@playwright/test/package.json').version, - }; - for (const profile of PROFILES) { - for (const route of ROUTES) { - console.error(`[baseline] ${profile.name} cold ${route.path}`); - results.push(await measureColdRoute(browser, profile, route, server.baseURL)); - } - for (const transition of TRANSITIONS) { - console.error(`[baseline] ${profile.name} first ${transition.label}`); - results.push(await measureTransition(browser, profile, transition, server.baseURL, false)); - console.error(`[baseline] ${profile.name} warm ${transition.label}`); - results.push(await measureTransition(browser, profile, transition, server.baseURL, true)); - } - console.error(`[baseline] ${profile.name} delayed Generate -> Attack`); - const delayedMetrics = await captureDelayedTransition(browser, profile, server.baseURL, OUTPUT_DIR); - delayed.push(delayedMetrics); - } - } catch (error) { - operationError = error; - throw error; - } finally { - const cleanupError = await cleanupResources({ browser, server }); - if (cleanupError) { - if (operationError) { - console.error(cleanupError.stack); - } else { - throw cleanupError; - } - } - } - const bundleGraph = await captureBundleGraph(); - const metadata = { - runLabel: RUN_LABEL, - capturedAt: new Date().toISOString(), - sourceCommit: git(['rev-parse', 'HEAD']), - harnessCommit: git(['rev-parse', 'HEAD']), - nodeVersion: process.version, - npmVersion: execFileSync('npm', ['--version'], { encoding: 'utf8' }).trim(), - browser: browserInfo, - vite: { - serverMode: 'development', - productionGraphMode: 'production', - version: require('vite/package.json').version, - port, - cacheState: 'cold routes disable HTTP cache through CDP and block service workers; clicks use a fresh or warmed browser cache', - }, - profiles: PROFILES.map((profile) => ({ - name: profile.name, - viewport: profile.device.viewport, - deviceScaleFactor: profile.device.deviceScaleFactor, - isMobile: profile.device.isMobile || false, - hasTouch: profile.device.hasTouch || false, - userAgent: profile.device.userAgent || null, - networkProfile: profile.networkProfile, - cpuProfile: profile.cpuProfile, - })), - }; - const output = { metadata, summary: summarize([...results, ...delayed]), results, delayed, bundleGraph }; - const outputPath = join(OUTPUT_DIR, `${RUN_LABEL}.json`); - await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`); - console.log(JSON.stringify({ output: relative(ROOT, outputPath), ...output.summary, port, sourceCommit: metadata.sourceCommit, harnessCommit: metadata.harnessCommit })); -} - -const run = process.argv.includes('--self-test-cleanup') ? runCleanupSelfTest : main; -run().catch((error) => { - console.error(error instanceof Error ? error.stack : error); - process.exitCode = 1; -}); diff --git a/scripts/update-model-pricing.ts b/scripts/update-model-pricing.ts deleted file mode 100644 index bff53e53e..000000000 --- a/scripts/update-model-pricing.ts +++ /dev/null @@ -1,155 +0,0 @@ -// Refresh the model pricing snapshot from official provider pricing pages. -// -// Usage: npx tsx scripts/update-model-pricing.ts -// -// What it does: -// 1. Downloads the official pricing pages into docs/pricing-snapshots/. -// 2. Records retrieval timestamp, source URL, and sha256 per file. -// 3. Verifies every model id listed below appears in its provider page. -// 4. Reports which models were found, missing, or renamed. -// -// What it deliberately does NOT do: auto-rewrite -// supabase/functions/_shared/model-pricing.ts. Provider pages change layout -// often and silent mis-parsing would fabricate prices, which the cost -// calculator must never do. When verification reports a change, update the -// MODEL_PRICING entries by hand from the fresh snapshot and bump -// PRICING_SNAPSHOT_ID. - -import { createHash } from "node:crypto"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); -const SNAPSHOT_DIR = join(ROOT, "docs", "pricing-snapshots"); - -const SOURCES = [ - { provider: "openai", url: "https://platform.openai.com/docs/pricing.md", file: "openai.md" }, - { provider: "anthropic", url: "https://platform.claude.com/docs/en/about-claude/pricing.md", file: "anthropic.md" }, - // Google serves HTML only; no official .md endpoint. - { provider: "google", url: "https://ai.google.dev/gemini-api/docs/pricing", file: "google-gemini.html" }, -]; - -const TRACKED_MODELS: Record = { - openai: ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-4o", "gpt-4o-mini"], - anthropic: ["claude-opus-4-7", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"], - // gemini-3.1-flash-lite-preview has no entry on the official page; it stays - // listed here so each refresh run reports it MISSING instead of forgetting it. - google: ["gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-3.5-flash", "gemini-3.1-flash-lite-preview"], -}; - -// A model id must not be followed by an id character; otherwise "gpt-5.4" -// would match the "gpt-5.4-mini" row and hide a removed model. -function mentions(body: string, needle: string): boolean { - let from = 0; - for (;;) { - const at = body.indexOf(needle, from); - if (at === -1) return false; - const next = body.charAt(at + needle.length); - if (!/[a-z0-9.-]/i.test(next)) return true; - from = at + 1; - } -} - -// "claude-opus-4-7" -> "Claude Opus 4.7", "gemini-3.1-pro-preview" -> "Gemini 3.1 Pro Preview". -function displayLabel(model: string): string { - const base = model.replace(/-\d{8}$/, ""); // drop date suffixes like -20251001 - const out: string[] = []; - let digits: string[] = []; - for (const token of base.split("-")) { - if (/^\d+$/.test(token)) { - digits.push(token); - continue; - } - if (digits.length) { - out.push(digits.join(".")); - digits = []; - } - out.push(token.charAt(0).toUpperCase() + token.slice(1)); - } - if (digits.length) out.push(digits.join(".")); - return out.join(" "); -} - -async function main() { - mkdirSync(SNAPSHOT_DIR, { recursive: true }); - const retrievedAt = new Date().toISOString(); - const findings: Record = {}; - const sources: Array<{ provider: string; url: string; file: string; format: string; sha256: string }> = []; - const buffered: Array<{ file: string; body: string }> = []; - - for (const source of SOURCES) { - // Pin the English/US page: Google in particular localizes currency and - // chrome by request geography, which would poison the snapshot. - const url = source.provider === "google" ? `${source.url}?hl=en` : source.url; - const response = await fetch(url, { headers: { "Accept-Language": "en-US,en;q=0.9" } }); - if (!response.ok) throw new Error(`${source.provider}: HTTP ${response.status} from ${source.url}`); - const body = await response.text(); - buffered.push({ file: source.file, body }); - - const sha256 = createHash("sha256").update(body).digest("hex"); - sources.push({ ...source, url: source.url.replace(/\.md$/, ""), format: source.url.endsWith(".md") ? "markdown (official .md endpoint)" : "html", sha256 }); - - // Strip docs-navigation links so a model id appearing only as a URL is not - // mistaken for a pricing entry. - const pricingBody = body.replace(/\/gemini-api\/docs\/models\/[a-z0-9.-]+/g, ""); - - for (const model of TRACKED_MODELS[source.provider] ?? []) { - // Match either the raw id or its human label (e.g. "Claude Opus 4.7"), - // requiring a token boundary so "gpt-5.4" cannot match "gpt-5.4-mini". - findings[model] = mentions(pricingBody, model) || mentions(pricingBody, displayLabel(model)) - ? "found in current page" - : "NOT FOUND in current page; treat as unknown-priced until verified"; - } - } - - // Only touch disk once every source fetched cleanly, so a mid-run failure - // cannot leave a mixed snapshot behind. - for (const item of buffered) { - writeFileSync(join(SNAPSHOT_DIR, item.file), item.body); - } - - // Byte-level fingerprint of all captures. Provider pages embed dynamic - // scripts, so this changes on refreshes even when prices do not; it lives in - // metadata for byte-resolution while snapshotId stays stable per day. - const contentHash = createHash("sha256") - .update(sources.map((s) => s.sha256).join("|")) - .digest("hex"); - - const metadataPath = join(SNAPSHOT_DIR, "metadata.json"); - let previousModelFindings: Record = {}; - try { - previousModelFindings = JSON.parse(readFileSync(metadataPath, "utf8")).modelFindings ?? {}; - } catch { /* first run */ } - - const metadata = { - snapshotId: `official-docs-${retrievedAt.slice(0, 10)}`, - snapshotContentHash: contentHash, - retrievedAt, - currency: "USD", - sources, - modelFindings: findings, - billingNotes: { - openai: "reasoning tokens are billed as output tokens and are included in completion_tokens; cached input is a subset of prompt_tokens", - anthropic: "input_tokens excludes cache_creation_input_tokens and cache_read_input_tokens; output_tokens includes thinking", - google: "output price includes thinking tokens; candidatesTokenCount excludes thoughtsTokenCount, so billable output = candidates + thoughts", - }, - }; - writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + "\n"); - - console.log(`Snapshot refreshed at ${retrievedAt}`); - for (const [model, status] of Object.entries(findings)) { - const marker = status.startsWith("found") ? "OK " : "MISSING"; - console.log(` ${marker} ${model} (${status})`); - } - const changed = Object.entries(findings).filter(([m, s]) => previousModelFindings[m] !== s); - if (changed.length > 0) { - console.log("\nModel findings changed since last run. Review docs/pricing-snapshots/, then update"); - console.log("supabase/functions/_shared/model-pricing.ts by hand and bump PRICING_SNAPSHOT_ID."); - } -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/src/App.css b/src/App.css deleted file mode 100644 index b9d355df2..000000000 --- a/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/src/App.tsx b/src/App.tsx index ec3763dff..0370e716a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,6 @@ // src/App.tsx import { Component, Suspense, lazy, useEffect, useState, type ErrorInfo, type ReactNode } from "react"; -import { Toaster } from "@/components/ui/toaster"; -import { Toaster as Sonner } from "@/components/ui/sonner"; +import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom"; @@ -18,6 +17,7 @@ import { captureException, captureProductEvent } from "@/lib/observability"; import { routeLoaders } from "@/lib/route-modules"; import { RouteSkeleton, type RouteSkeletonVariant } from "@/components/loading/RouteSkeleton"; import { useTranslation } from "react-i18next"; +import { COLLABORATION_URL } from "@/lib/workspace/collaboration-config"; // Lazy load all pages for code splitting const LandingPage = lazy(routeLoaders["/"]); @@ -240,9 +240,13 @@ function AppRoutes() { } > } /> - } /> - } /> - } /> + {COLLABORATION_URL && ( + <> + } /> + } /> + } /> + + )} } /> } /> } /> @@ -270,7 +274,6 @@ const App = () => ( - diff --git a/src/components/ModelSelector.tsx b/src/components/ModelSelector.tsx index e081f35fe..86423ded4 100644 --- a/src/components/ModelSelector.tsx +++ b/src/components/ModelSelector.tsx @@ -3,8 +3,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Badge } from '@/components/ui/badge'; import { AUTO_MODEL_SELECTIONS, + AVAILABLE_MODELS, DEFAULT_MODEL, - DEFAULT_PROVIDERS, + PROVIDER_ID, isAutoModelSelection, isReasoningEffort, } from '@/lib/ai/providers/types'; @@ -21,11 +22,11 @@ interface ModelSelectorProps { showLabel?: boolean; } +// Every catalog model routes through OpenRouter, so the provider fan-out that +// used to wrap this collapses to one tag. const allModels = [ - ...AUTO_MODEL_SELECTIONS.map((model) => ({ ...model, provider: 'openrouter', providerName: 'OpenRouter', isAuto: true })), - ...DEFAULT_PROVIDERS.flatMap((provider) => - provider.models.map((model) => ({ ...model, provider: provider.id, providerName: provider.name, isAuto: false })) - ), + ...AUTO_MODEL_SELECTIONS.map((model) => ({ ...model, provider: PROVIDER_ID, providerName: 'OpenRouter', isAuto: true })), + ...AVAILABLE_MODELS.map((model) => ({ ...model, provider: PROVIDER_ID, providerName: 'OpenRouter', isAuto: false })), ]; const reasoningOptions: { value: ReasoningEffort; labelKey: string; fallback: string }[] = [ @@ -60,7 +61,7 @@ export function ModelSelector({ feature, className, showLabel = false }: ModelSe const found = allModels.find(m => m.id === model); if (found) { if (found.isProOnly && !isPro) return; - setFeatureConfig(feature, { model, provider: found.provider }); + setFeatureConfig(feature, { model, provider: PROVIDER_ID }); } }} > diff --git a/src/components/landing/index.ts b/src/components/landing/index.ts deleted file mode 100644 index 0ab8395d0..000000000 --- a/src/components/landing/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { default as Navbar } from "./Navbar"; -export { default as Hero } from "./Hero"; -export { default as SocialProof } from "./SocialProof"; -export { default as WhatsNew } from "./WhatsNew"; -export { default as Features } from "./Features"; -export { default as Pricing } from "./Pricing"; -export { default as DeepDive } from "./DeepDive"; -export { default as UseCases } from "./UseCases"; -export { default as HowItWorks } from "./HowItWorks"; -export { default as FAQ } from "./FAQ"; -export { default as FinalCTA } from "./FinalCTA"; -export { default as Footer } from "./Footer"; diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 3c9115d7d..c3b01b5d2 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -29,6 +29,7 @@ import { LanguageSwitcher } from '@/components/LanguageSwitcher'; import { AnnouncementBanner } from '@/components/layout/AnnouncementBanner'; import { useRoutePrefetch } from '@/hooks/use-route-prefetch'; import { useAuth } from '@/lib/supabase/AuthProvider'; +import { COLLABORATION_URL } from '@/lib/workspace/collaboration-config'; import { dismissAnnouncement, listActiveAnnouncements, @@ -49,7 +50,7 @@ const navItems = [ { path: '/app/sparring', key: 'sparring', icon: Dumbbell }, { path: '/app/flowchart', key: 'flowchart', icon: GitBranch }, { path: '/app/history', key: 'history', icon: Clock }, -]; +].filter((item) => item.key !== 'workspaces' || COLLABORATION_URL); const primaryNavItems = navItems.filter((item) => ['home', 'workspaces', 'generate', 'attack', 'ask', 'crossfire', 'sparring'].includes(item.key), diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx deleted file mode 100644 index a1f82e9d6..000000000 --- a/src/components/ui/form.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/* eslint-disable react-refresh/only-export-components */ -import * as React from "react"; -import * as LabelPrimitive from "@radix-ui/react-label"; -import { Slot } from "@radix-ui/react-slot"; -import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from "react-hook-form"; - -import { cn } from "@/lib/utils"; -import { Label } from "@/components/ui/label"; - -const Form = FormProvider; - -type FormFieldContextValue< - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, -> = { - name: TName; -}; - -const FormFieldContext = React.createContext(undefined); - -const FormField = < - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, ->({ - ...props -}: ControllerProps) => { - return ( - - - - ); -}; - -const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext); - const itemContext = React.useContext(FormItemContext); - const { getFieldState, formState } = useFormContext(); - - if (!fieldContext) { - throw new Error("useFormField should be used within "); - } - if (!itemContext) { - throw new Error("useFormField should be used within "); - } - - const fieldState = getFieldState(fieldContext.name, formState); - - const { id } = itemContext; - - return { - id, - name: fieldContext.name, - formItemId: `${id}-form-item`, - formDescriptionId: `${id}-form-item-description`, - formMessageId: `${id}-form-item-message`, - ...fieldState, - }; -}; - -type FormItemContextValue = { - id: string; -}; - -const FormItemContext = React.createContext(undefined); - -const FormItem = React.forwardRef>( - ({ className, ...props }, ref) => { - const id = React.useId(); - - return ( - -
- - ); - }, -); -FormItem.displayName = "FormItem"; - -const FormLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { error, formItemId } = useFormField(); - - return