Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a519253
fix: load admin navigation from the cached user role
Programcode Sep 7, 2026
e0f2bcc
fix: decouple app startup from Jam recorder
Programcode Sep 7, 2026
cf2034c
fix: hide seo shell on login startup
Programcode Sep 7, 2026
40abea9
implement grouped homepage navigation
Programcode Sep 7, 2026
02a702e
restore focus after closing navigation menus
Programcode Sep 7, 2026
67ff666
test: anchor startup smoke to stable page heading
Programcode Sep 7, 2026
fb8836c
fix: compact sparring setup form
Programcode Sep 7, 2026
d8ff282
copy: clarify landing and app labels
Programcode Sep 7, 2026
48ca425
fix: target visible onboarding elements
Programcode Sep 7, 2026
0fc9f58
fix: align factoids form controls
Programcode Sep 7, 2026
44e58b3
test: scope startup heading to main content
Programcode Sep 7, 2026
95a83f8
fix: fit app header on narrow screens
Programcode Sep 9, 2026
9b0a570
test: align history page heading assertion
Programcode Sep 9, 2026
27b4b8a
test: scope sparring disclosure selector
Programcode Sep 9, 2026
56f46ea
test: speed up app navigation reachability
Programcode Sep 9, 2026
42e038c
test: cover factoids responsive alignment
Programcode Sep 9, 2026
b293160
test: use factoids settings breakpoint
Programcode Sep 9, 2026
fb702ca
feat: expand the OpenRouter model catalog
Programcode Sep 19, 2026
75023b9
feat: calibrate debate prompts for plain language
Programcode Sep 19, 2026
545f072
fix: complete navigation and landing cleanup
Programcode Sep 19, 2026
979b2cd
test: cover the expanded OpenRouter catalog
Programcode Sep 19, 2026
6a29c27
test: align landing preview assertion
Programcode Sep 19, 2026
d670fad
fix: migrate trial settings and streaming prompt
Programcode Sep 19, 2026
c9c55e3
fix: preserve Pro settings during hydration
Programcode Sep 19, 2026
0e070f2
Merge main into UI and model catalog cleanup
Programcode Sep 19, 2026
63725f6
test: open grouped navigation before title checks
Programcode Sep 19, 2026
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 docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ Supabase Auth owns user sessions. Protected routes provide a client navigation b

Checkout and portal functions create Stripe sessions after authenticated ownership and redirect validation. The webhook updates subscription state and records event processing for idempotency. AI handlers record provider usage and calculated cost using shared pricing logic.

All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The only selectable and default model is `z-ai/glm-5.3-flash`; the shared allowlist and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost.
All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, Luna Basic default, Pro-only entries, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost.

Historical provider pricing evidence is retained under [`docs/pricing-snapshots/`](pricing-snapshots/). Those snapshots support legacy calculator records and are evidence, not runtime configuration for the OpenRouter route.

Expand Down
34 changes: 34 additions & 0 deletions e2e/authed-pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,40 @@ for (const locale of LOCALES) {
});
}

test('aligns Factoids timeframe and model controls responsively', async ({ page }) => {
await setLocale(page, 'en');
await stubSupabaseSession(page);
await page.goto('/app/factoids', { waitUntil: 'domcontentloaded' });

const timeframe = page.locator('#timeframe');
const model = page.getByRole('combobox', { name: /Select AI model for factoids/i });
await expect(timeframe).toBeVisible();
await expect(model).toBeVisible();
const layout = await page.evaluate(() => {
const timeframeElement = document.querySelector('#timeframe');
const modelElement = document.querySelector('[aria-label*="Select AI model for factoids"]');
if (!timeframeElement || !modelElement) throw new Error('Factoids alignment controls are missing.');
const timeframeBox = timeframeElement.getBoundingClientRect();
const modelBox = modelElement.getBoundingClientRect();
return {
viewportWidth: window.innerWidth,
timeframe: { top: timeframeBox.top, bottom: timeframeBox.bottom, left: timeframeBox.left },
model: { top: modelBox.top, left: modelBox.left },
horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth,
};
});

expect(layout.horizontalOverflow).toBe(false);
// FactoidsForm switches its settings grid at Tailwind `sm` (640px).
if (layout.viewportWidth >= 640) {
expect(Math.abs(layout.timeframe.top - layout.model.top)).toBeLessThanOrEqual(1);
expect(layout.model.left).toBeGreaterThan(layout.timeframe.left);
} else {
expect(layout.model.top).toBeGreaterThanOrEqual(layout.timeframe.bottom);
expect(Math.abs(layout.timeframe.left - layout.model.left)).toBeLessThanOrEqual(1);
}
});

test('visible Ask advanced controls use the non-streaming contract', async ({ page }) => {
await setLocale(page, 'en');
await stubSupabaseSession(page);
Expand Down
142 changes: 142 additions & 0 deletions e2e/home-navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { test, expect } from '@playwright/test';
import { setLocale, stubSupabaseSession } from './helpers/auth';

test.beforeEach(async ({ page }) => {
await setLocale(page, 'en');
await stubSupabaseSession(page);
await page.addInitScript(() => {
window.localStorage.setItem('debate-trainer-onboarding', JSON.stringify({
state: { hasCompletedOnboarding: true, isTourRunning: false },
version: 0,
}));
});
});

test('home groups the eight main tools and keeps utility routes visible', async ({ page }) => {
await page.goto('/app', { waitUntil: 'domcontentloaded' });

const main = page.locator('main');
await expect(main.getByRole('heading', { name: 'DebateLab' })).toBeVisible({ timeout: 15_000 });
await expect(main.getByRole('heading', { name: 'Prepare' })).toBeVisible();
await expect(main.getByRole('heading', { name: 'Practice' })).toBeVisible();
await expect(main.getByRole('heading', { name: 'Utilities' })).toBeVisible();
await expect(page.getByText('Choose a mode to start.')).toHaveCount(0);

for (const href of ['/app/generate', '/app/brainstorm', '/app/factoids', '/app/flowchart', '/app/attack', '/app/ask', '/app/crossfire', '/app/sparring']) {
await expect(page.locator('main').locator(`a[href="${href}"]`)).toBeVisible();
}
for (const href of ['/app/library', '/app/timer', '/app/history']) {
await expect(page.locator('main').locator(`a[href="${href}"]`)).toBeVisible();
}
});

test('onboarding follows visible home targets when secondary navigation is closed', async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('debate-trainer-onboarding', JSON.stringify({
state: { hasCompletedOnboarding: false, isTourRunning: false },
version: 0,
}));
});
await page.goto('/app', { waitUntil: 'domcontentloaded' });

const desktopMoreMenu = page.locator('details').filter({ has: page.locator('summary[aria-label="More"]') });
if ((page.viewportSize()?.width ?? 0) >= 1024) {
await expect(desktopMoreMenu).not.toHaveAttribute('open');
}

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']) {
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();
}

await page.locator('[data-test-id="button-skip"]').click();
});

test('header keeps the brand and controls inside a 320px viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 844 });
await page.goto('/app', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: 'Prepare' })).toBeVisible({ timeout: 15_000 });

const languageControl = page.getByRole('button', { name: 'Language' });
const themeControl = page.getByRole('button', { name: 'Toggle theme' });
const settingsControl = page.getByRole('link', { name: 'Settings' });
const menuControl = page.getByRole('button', { name: 'Open menu' });
const controls = [languageControl, themeControl, settingsControl, menuControl];

await expect(page.locator('header img[alt="DebateLab"]')).toBeVisible();
for (const control of controls) await expect(control).toBeVisible();

const boxes = await Promise.all(controls.map((control) => control.boundingBox()));
expect(boxes.every((box) => box && box.x >= 0 && box.x + box.width <= 320)).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(320);
});

test('desktop More menu exposes secondary destinations without horizontal scrolling', async ({ page }) => {
test.skip((page.viewportSize()?.width ?? 0) < 1024, 'Desktop-only More menu check');
await page.goto('/app', { waitUntil: 'domcontentloaded' });
await page.locator('summary[aria-label="More"]').click();

const moreMenu = page.locator('details').filter({ has: page.locator('summary[aria-label="More"]') });
for (const [label, href] of [
['Brainstorm', '/app/brainstorm'],
['Factoids', '/app/factoids'],
['Flowchart', '/app/flowchart'],
['Library', '/app/library'],
['Timer', '/app/timer'],
['History', '/app/history'],
] as const) {
await expect(moreMenu.getByRole('link', { name: label })).toHaveAttribute('href', href);
}
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(await page.evaluate(() => window.innerWidth));
});

test('desktop header fits without internal or document overflow at supported widths', async ({ page }) => {
for (const width of [1024, 1280, 1440, 1920]) {
await page.setViewportSize({ width, height: 900 });
await page.goto('/app', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: 'Prepare' })).toBeVisible({ timeout: 15_000 });

const navigation = page.getByRole('navigation', { name: 'Main navigation' });
await expect(navigation).toBeVisible();
expect(await navigation.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
}
});

test('mobile menu exposes every tool destination and closes after navigation', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/app', { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: 'Open menu' }).click();

const mobileNavigation = page.getByRole('navigation', { name: 'Mobile navigation' });
for (const label of ['Home', 'Generate', 'Attack', 'Brainstorm', 'Factoids', 'Ask', 'Crossfire', 'Library', 'Timer', 'Sparring', 'Flowchart', 'History']) {
await expect(mobileNavigation.getByRole('link', { name: new RegExp(`^${label}`) })).toBeVisible();
}

await mobileNavigation.getByRole('link', { name: /^Brainstorm/ }).click();
await expect(page).toHaveURL(/\/app\/brainstorm$/);
await expect(page.getByRole('navigation', { name: 'Mobile navigation' })).toHaveCount(0);
});
3 changes: 3 additions & 0 deletions e2e/quality-backlog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ for (const locale of locales) {
.first()
.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}$`));
Expand Down
22 changes: 18 additions & 4 deletions e2e/sparring.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,11 @@ test('completes an LD sparring round through mocked opponent turns and judging',
});

await page.goto('/app/sparring', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: 'Spar with an opponent who listens.' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Sparring' })).toBeVisible();

await page.getByLabel('Motion').fill('This House would make public transport free at the point of use.');
await page.getByRole('button', { name: /Lincoln-Douglas · 5 speeches/ }).click();
await page.getByRole('combobox', { name: 'Round format' }).click();
await page.getByRole('option', { name: /Lincoln-Douglas · 5 speeches/ }).click();
await page.getByRole('button', { name: 'Start round' }).click();

const userSpeeches = [
Expand Down Expand Up @@ -126,11 +127,24 @@ test('uses the school 4-6-6-3 roles and coaching in the default round', async ({
test('keeps the setup usable on mobile with reduced motion', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/app/sparring', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: 'Spar with an opponent who listens.' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Sparring' })).toBeVisible();
const logo = page.locator('header img[alt="DebateLab"]');
await expect(logo).toHaveAttribute('src', '/favicon.png');
await expect.poll(() => logo.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBeGreaterThan(0);
await expect(page.getByRole('button', { name: /School 4-6-6-3 · 8 speeches/ })).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByRole('combobox', { name: 'Round format' })).toHaveText('School 4-6-6-3 · 8 speeches');
await expect(page.getByText('Write the resolution as you would hear it in the room.')).toHaveCount(0);
await expect(page.getByText('A precise motion gives the opponent something concrete to attack.')).toHaveCount(0);
await expect(page.getByText('Pro Introductor')).toBeHidden();
const speakingSummary = page.locator('main details > summary');
const summaryMetrics = await speakingSummary.evaluate((element) => {
const icon = element.querySelector('svg');
if (!icon) throw new Error('Speaking order disclosure is missing its chevron.');
const summaryBox = element.getBoundingClientRect();
const iconBox = icon.getBoundingClientRect();
return Math.abs((summaryBox.top + summaryBox.height / 2) - (iconBox.top + iconBox.height / 2));
});
expect(summaryMetrics).toBeLessThanOrEqual(1);
await page.getByText('Speaking order', { exact: true }).click();
await expect(page.getByText('Pro Introductor')).toBeVisible();
await expect(page.getByText('Pro Conclusor')).toBeVisible();

Expand Down
81 changes: 81 additions & 0 deletions e2e/startup-shell.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { expect, test } from '@playwright/test';
import { setLocale, stubSupabaseSession } from './helpers/auth';

test('shows a dark private startup shell while the entry module is delayed', async ({ page }) => {
await setLocale(page, 'en');
await page.addInitScript(() => {
const settings = JSON.parse(window.localStorage.getItem('debate-trainer-settings') || '{}');
settings.state = { ...settings.state, theme: 'dark' };
window.localStorage.setItem('debate-trainer-settings', JSON.stringify(settings));
});
await stubSupabaseSession(page);
await page.route('**/src/bootstrap.ts*', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1500));
await route.continue();
});

await page.goto('/app', { waitUntil: 'commit' });
await expect(page.locator('[data-startup-shell]')).toBeVisible();
await expect(page.locator('#root > .seo-fallback')).toBeHidden();
await expect(page.locator('[data-startup-status]')).toHaveText('Loading DebateLab');
await expect.poll(() => page.evaluate(() => ({
appShell: document.documentElement.classList.contains('app-shell'),
dark: document.documentElement.classList.contains('dark'),
}))).toEqual({ appShell: true, dark: true });
});

test('shows the startup shell on login while the entry module is delayed', 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('/login', { waitUntil: 'commit' });
await expect(page.locator('[data-startup-shell]')).toBeVisible();
await expect(page.locator('#root > .seo-fallback')).toBeHidden();
await expect.poll(() => page.evaluate(() => document.documentElement.classList.contains('app-shell'))).toBe(true);
});

test('mounts the app before Jam recorder modules resolve', async ({ page }) => {
await setLocale(page, 'en');
await stubSupabaseSession(page);
await page.addInitScript(() => {
if (typeof window.MediaRecorder === 'undefined') {
Object.defineProperty(window, 'MediaRecorder', { value: class MediaRecorderStub {} });
}
});
await page.route('**/js.jam.dev/*.js', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 6000));
await route.fulfill({ status: 200, contentType: 'application/javascript', body: '' });
});

await page.goto('/app', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('main').getByRole('heading', { name: 'DebateLab', exact: true, level: 1 })).toBeVisible({ timeout: 5_000 });
await expect(page.locator('#root > .seo-fallback')).toHaveCount(0);
expect(await page.evaluate(() => {
const jam = (window as Window & { __jam__?: { exports?: { metadata?: unknown } } }).__jam__;
return typeof jam?.exports?.metadata === 'function';
})).toBe(true);
});

test('shows a recoverable message when the 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' });
await expect(page.locator('[data-bootstrap-error]')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Unable to load DebateLab' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Reload' })).toBeEnabled();
});

test('keeps the public SEO root visible before a delayed 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);
});
Loading
Loading