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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/verify-debatelab/features/ai-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The single-shot coaching tools: Attack finds weaknesses in a pasted argument, Br

## How to get to it (user POV)

- Header links: `Attack` and `Ask`. `More` menu (desktop) or `Open menu` (mobile): `Brainstorm` and `Factoids`.
- Home cards under Tools. The phone tab bar lists Tools, not each tool.
- Routes: `/app/attack`, `/app/brainstorm`, `/app/factoids`, `/app/ask`.

## Driving it with verify.sh + Playwright
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ A signed-in user enters a motion on Generate and receives a structured debate ca
## How to get to it (user POV)

- The header link `Generate`, or the Home card with `data-tour="mode-generate"`, or `/app/generate`.
- History: `More` → `History` on desktop, `Open menu` → `History` on mobile, or `/app/history`.
- History: Home utilities tile `History`, header nav `History` from 768px. Phones have no History tab. Or `/app/history`.

## Driving it with verify.sh + Playwright

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Users save reference texts to their Library. The Library belongs to the Current

## How to get to it (user POV)

- Desktop: `More` → `Library`. Mobile: `Open menu` → `Library`. Or go to `/app/library`.
- Phone (below 768px): bottom tab `Library`. From 768px: header nav `Library`. Or go to `/app/library`.
- Settings (`/app/settings`) → `Delete all my data`, for the erase path.

## Driving it with verify.sh + Playwright
Expand Down
11 changes: 10 additions & 1 deletion .claude/skills/verify-debatelab/recipes/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,9 @@ test("auth-switch-account: the next account on this browser starts on /app, gets
const tour = page.getByText("Welcome to DebateOwl");
await expect(tour).toBeVisible({ timeout: 10_000 });
await evidence.shot("01-second-account-tour");
const tabBar = page.getByRole("navigation", { name: "Mobile navigation" });
if (await page.getByRole("button", { name: "Open menu" }).isVisible()) {
// On phones the tour must not block the header's menu: open it and use it.
// Older layouts had a header menu here. The tour must not block it.
await page.getByRole("button", { name: "Open menu" }).click();
const menu = page.getByRole("navigation", { name: "Mobile navigation" });
await expect(menu).toBeVisible();
Expand All @@ -245,6 +246,14 @@ test("auth-switch-account: the next account on this browser starts on /app, gets
// The tour waits on /app and picks up again there.
await page.goBack();
await expect(tour).toBeVisible({ timeout: 10_000 });
} else if (await tabBar.isVisible()) {
// Phones use the tab bar. History stays on the Home utilities row.
await tabBar.getByRole("link", { name: "Library", exact: true }).click();
await expect(page).toHaveURL(/\/app\/library$/);
await evidence.shot("02-tab-bar-during-tour");
await evidence.step("Used the phone tab bar to open Library while the tour showed");
await page.goBack();
await expect(tour).toBeVisible({ timeout: 10_000 });
}
await page.locator('[data-test-id="button-skip"]').click();
await expect(tour).toBeHidden();
Expand Down
2 changes: 1 addition & 1 deletion docs/standards/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,4 @@ When `shouldReduceMotion` is true, render a static element. Passing the flag to
- Keep a confirmation dialog open while its request is pending, and close it only on success. Radix closes on click unless you call `preventDefault()` ([#26](https://github.com/Programcode/DebateLab/pull/26#discussion_r3897947151)).
- Side effects such as scrolling run on a success signal, not on "loading became false" ([#20](https://github.com/Programcode/DebateLab/pull/20#discussion_r3896378580)).
- Open a new tab within the click handler. After an awaited request the user activation has expired and popup blockers drop the tab. Show a fallback link ([#63](https://github.com/Programcode/DebateLab/pull/63#discussion_r4081818952)).
- Close the mobile menu on every navigation inside the persistent layout, including the logo link ([#20](https://github.com/Programcode/DebateLab/pull/20#discussion_r3896378566)).
- On phones (below `md`) the bottom tab bar is the navigation. From `md` up the header nav is the navigation; there is no menu button at any width, so never show two navs at once ([#20](https://github.com/Programcode/DebateLab/pull/20#discussion_r3896378566)).
3 changes: 1 addition & 2 deletions e2e/admin-announcements.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@ test.beforeEach(async ({ page }) => {

async function openAppNav(page: Page, label: string) {
if ((page.viewportSize()?.width ?? 1280) < 768) {
await page.getByRole('button', { name: 'Open menu' }).click();
await page.getByRole('navigation', { name: 'Mobile navigation' })
.getByRole('link', { name: new RegExp(`^${label}`) })
.getByRole('link', { name: label, exact: true })
.click();
return;
}
Expand Down
182 changes: 172 additions & 10 deletions e2e/home-navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test';
import { test, expect, type Page } from '@playwright/test';
import { setLocale, stubSupabaseSession } from './helpers/auth';

test.beforeEach(async ({ page }) => {
Expand Down Expand Up @@ -110,14 +110,19 @@ test('header keeps the brand and controls inside a 320px viewport', async ({ pag
await page.goto('/app', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: 'Prepare' })).toBeVisible({ timeout: 15_000 });

// Mobile keeps the logo, language, and menu on the first row; Settings and the theme live in the menu and Settings.
// A phone keeps the logo, language, and Current debate row. Settings is a tab, not a menu button.
const languageControl = page.getByRole('button', { name: 'Language' });
const menuControl = page.getByRole('button', { name: 'Open menu' });
const debateControl = page.getByTestId('current-debate-mobile');
const controls = [languageControl, menuControl, debateControl];
const tabBar = page.getByRole('navigation', { name: 'Mobile navigation' });
const controls = [languageControl, debateControl];

await expect(page.getByAltText('DebateOwl logo')).toBeVisible();
await expect(page.getByRole('button', { name: 'Open menu' })).toBeHidden();
await expect(tabBar).toBeVisible();
for (const control of controls) await expect(control).toBeVisible();
for (const label of ['Tools', 'Current debate', 'Library', 'Settings']) {
await expect(tabBar.getByRole('link', { name: label, exact: true })).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);
Expand All @@ -132,21 +137,178 @@ test('desktop header fits without internal or document overflow at supported wid

const navigation = page.getByRole('navigation', { name: 'Main navigation' });
await expect(navigation).toBeVisible();
await expect(page.getByRole('navigation', { name: 'Mobile navigation' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Open menu' })).toBeHidden();
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 holds only Tools, Current debate, Library, History, and Settings', async ({ page }) => {
test('phone tab bar holds Tools, Current debate, Library, and Settings', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/app', { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: 'Open menu' }).click();
await expect(page.getByRole('heading', { name: 'Prepare' })).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole('button', { name: 'Open menu' })).toBeHidden();

const mobileNavigation = page.getByRole('navigation', { name: 'Mobile navigation' });
const hrefs = await mobileNavigation.getByRole('link').evaluateAll((links) => links.map((link) => link.getAttribute('href')));
expect(hrefs).toEqual(['/app', '/app/debate', '/app/library', '/app/history', '/app/settings']);
const tabBar = page.getByRole('navigation', { name: 'Mobile navigation' });
await expect(tabBar).toBeVisible();
const hrefs = await tabBar.getByRole('link').evaluateAll((links) => links.map((link) => link.getAttribute('href')));
expect(hrefs).toEqual(['/app', '/app/debate', '/app/library', '/app/settings']);
const widths = await tabBar.getByRole('link').evaluateAll((links) => links.map((link) => link.getBoundingClientRect().width));
expect(Math.max(...widths) - Math.min(...widths)).toBeLessThan(2);

await mobileNavigation.getByRole('link', { name: /^Library/ }).click();
await tabBar.getByRole('link', { name: 'Library', exact: true }).click();
await expect(page).toHaveURL(/\/app\/library$/);
await expect(tabBar.getByRole('link', { name: 'Library', exact: true })).toHaveAttribute('aria-current', 'page');

// The page can scroll its end clear of the bar, including the footer.
await page.goto('/app/settings', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: 'Settings', exact: true })).toBeVisible({ timeout: 15_000 });
const terms = page.getByRole('contentinfo').getByRole('link', { name: 'Terms' });
await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
await expect(terms).toBeVisible();
const [termsBox, tabBox] = await Promise.all([terms.boundingBox(), tabBar.boundingBox()]);
expect(termsBox).not.toBeNull();
expect(tabBox).not.toBeNull();
expect(termsBox!.y + termsBox!.height).toBeLessThanOrEqual(tabBox!.y + 1);
});

async function paintViewport(page: Page, next: { height: number; offsetTop: number; scale: number }) {
await page.evaluate((value) => {
const viewport = window.visualViewport;
if (!viewport) throw new Error('visualViewport is missing');
for (const key of ['height', 'offsetTop', 'scale'] as const) {
Object.defineProperty(viewport, key, { configurable: true, get: () => value[key] });
}
viewport.dispatchEvent(new Event('resize'));
}, next);
}

test('phone tab bar hides for a keyboard and returns, and ignores pinch-zoom', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/app/ask', { waitUntil: 'domcontentloaded' });
const field = page.getByRole('textbox', { name: 'Message to your debate coach' });
const tabBar = page.getByRole('navigation', { name: 'Mobile navigation' });
await expect(field).toBeVisible({ timeout: 15_000 });
await expect(tabBar).toBeVisible();

await field.focus();
await paintViewport(page, { height: 380, offsetTop: 0, scale: 1.5 });
await expect(tabBar).toBeVisible();

await paintViewport(page, { height: 380, offsetTop: 0, scale: 1 });
await expect(tabBar).toBeHidden();
const hidden = await page.evaluate(() => ({
reserve: document.documentElement.style.getPropertyValue('--phone-tab-reserve').trim(),
keyboard: document.documentElement.style.getPropertyValue('--native-keyboard-height').trim(),
}));
expect(hidden.reserve).toBe('0px');
expect(Number.parseFloat(hidden.keyboard)).toBeGreaterThan(120);

await paintViewport(page, { height: 844, offsetTop: 0, scale: 1 });
await expect(tabBar).toBeVisible();
const shown = await page.evaluate(() => document.documentElement.style.getPropertyValue('--phone-tab-reserve').trim());
expect(Number.parseFloat(shown)).toBeGreaterThan(0);
});

test('Ask composer stays above the tab bar while an announcement is up', async ({ page }) => {
await page.route('**/functions/v1/announcements*', (route) => route.fulfill({
json: {
announcements: [{
id: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
title: 'Scheduled maintenance',
body: 'DebateLab will be unavailable for ten minutes.',
starts_at: '2026-08-31T12:00:00.000Z',
expires_at: null,
created_at: '2026-08-31T11:00:00.000Z',
}],
},
}));
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/app/ask', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: 'Scheduled maintenance' })).toBeVisible({ timeout: 15_000 });
const send = page.getByRole('button', { name: 'Send' });
const tabBar = page.getByRole('navigation', { name: 'Mobile navigation' });
await expect(send).toBeVisible();
await expect(tabBar).toBeVisible();
const [sendBox, tabBox, tops] = await Promise.all([
send.boundingBox(),
tabBar.boundingBox(),
page.evaluate(() => ({
main: document.documentElement.style.getPropertyValue('--app-main-top').trim(),
header: document.documentElement.style.getPropertyValue('--app-header-bottom').trim(),
})),
]);
expect(sendBox).not.toBeNull();
expect(tabBox).not.toBeNull();
expect(sendBox!.y + sendBox!.height).toBeLessThanOrEqual(tabBox!.y + 1);
expect(Number.parseFloat(tops.main)).toBeGreaterThan(Number.parseFloat(tops.header));
});

test('a toast at 700px sits above the tab bar', async ({ page }) => {
await page.setViewportSize({ width: 700, height: 800 });
await page.goto('/app', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: 'Prepare' })).toBeVisible({ timeout: 15_000 });
const tabBar = page.getByRole('navigation', { name: 'Mobile navigation' });
await expect(tabBar).toBeVisible();

const toasted = await page.evaluate(async () => {
const match = performance.getEntriesByType('resource')
.map((entry) => entry.name)
.find((name) => name.includes('/sonner'));
if (!match) return 'missing';
const mod = await import(/* @vite-ignore */ match) as { toast?: { success: (message: string, options: { duration: number }) => void } };
if (!mod.toast?.success) return 'no-toast';
mod.toast.success('Copied to clipboard', { duration: 60_000 });
return 'ok';
});
expect(toasted).toBe('ok');

const toast = page.locator('[data-sonner-toast]').last();
await expect(toast).toHaveAttribute('data-mounted', 'true');
const offset = await page.evaluate(() => {
const ol = document.querySelector('[data-sonner-toaster]');
return {
reserve: Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--phone-tab-reserve')),
usedBottom: ol ? Number.parseFloat(getComputedStyle(ol).bottom) : 0,
};
});
expect(offset.reserve).toBeGreaterThan(40);
expect(offset.usedBottom).toBeGreaterThan(offset.reserve);
// The enter slide starts under the offset. The resting box is what has to clear the bar.
await expect.poll(async () => {
const [toastBox, tabBox] = await Promise.all([toast.boundingBox(), tabBar.boundingBox()]);
if (!toastBox || !tabBox) return Number.POSITIVE_INFINITY;
return toastBox.y + toastBox.height - tabBox.y;
}).toBeLessThanOrEqual(1);
});

test('iPad width does not keep a keyboard pad', async ({ page }) => {
await page.setViewportSize({ width: 820, height: 1180 });
await page.goto('/app/ask', { waitUntil: 'domcontentloaded' });
const field = page.getByRole('textbox', { name: 'Message to your debate coach' });
await expect(field).toBeVisible({ timeout: 15_000 });
await field.focus();
await paintViewport(page, { height: 500, offsetTop: 0, scale: 1 });
const pad = await page.evaluate(() => ({
keyboard: document.documentElement.style.getPropertyValue('--native-keyboard-height').trim(),
reserve: document.documentElement.style.getPropertyValue('--phone-tab-reserve').trim(),
main: getComputedStyle(document.querySelector('main')!).paddingBottom,
}));
expect(pad.keyboard).toBe('');
expect(pad.reserve).toBe('');
expect(pad.main).toBe('0px');
});

test('iPad portrait keeps the header nav instead of the tab bar', async ({ page }) => {
await page.setViewportSize({ width: 820, height: 1180 });
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();
await expect(page.getByRole('navigation', { name: 'Mobile navigation' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Open menu' })).toHaveCount(0);
const hrefs = await navigation.getByRole('link').evaluateAll((links) => links.map((link) => link.getAttribute('href')));
expect(hrefs).toEqual(expect.arrayContaining(['/app', '/app/library', '/app/history']));
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
3 changes: 1 addition & 2 deletions e2e/navigation-performance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,7 @@ function homeToolLink(page: Page, tool: string) {

async function openHome(page: Page) {
if ((page.viewportSize()?.width ?? 1280) < 768) {
await page.getByRole('button', { name: 'Open menu' }).click();
await page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: /^Tools/ }).click();
await page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: 'Tools', exact: true }).click();
} else {
await page.getByRole('navigation', { name: 'Main navigation' }).getByRole('link', { name: 'Tools' }).click();
}
Expand Down
6 changes: 3 additions & 3 deletions src/components/features/ask/AskChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ export function AskChat({ messages, streamingMessage, isLoading, error, onDismis
};

return (
<div className="space-y-3">
<div className="space-y-3 max-md:flex max-md:min-h-0 max-md:flex-1 max-md:flex-col">
<div className="flex items-center justify-between gap-2 lg:hidden">
<Button variant="outline" size="sm" onClick={() => setMobileSidebarOpen(true)}>
<PanelLeftOpen className="mr-2 h-4 w-4" />
Expand All @@ -540,7 +540,7 @@ export function AskChat({ messages, streamingMessage, isLoading, error, onDismis
</SheetContent>
</Sheet>

<div className={cn('grid gap-3', desktopSidebarOpen ? 'lg:grid-cols-[280px_minmax(0,1fr)]' : 'lg:grid-cols-[auto_minmax(0,1fr)]')}>
<div className={cn('grid gap-3 max-md:min-h-0 max-md:flex-1', desktopSidebarOpen ? 'lg:grid-cols-[280px_minmax(0,1fr)]' : 'lg:grid-cols-[auto_minmax(0,1fr)]')}>
<aside className="hidden lg:block">
{desktopSidebarOpen ? (
<Card className="flex h-[calc(100dvh-14rem)] min-h-[400px] max-h-[800px] flex-col border-primary/20">
Expand Down Expand Up @@ -572,7 +572,7 @@ export function AskChat({ messages, streamingMessage, isLoading, error, onDismis
)}
</aside>

<Card className="flex flex-col h-[calc(100dvh-14rem)] min-h-[400px] max-h-[800px] border-primary/20">
<Card className="flex h-[calc(100dvh-14rem)] min-h-[400px] max-h-[800px] flex-col border-primary/20 max-md:h-auto max-md:max-h-none max-md:min-h-0 max-md:flex-1">
<CardHeader className="shrink-0 border-b-2 border-border bg-brand-tint/50 pb-4">
{/* Wraps on a phone: the Hungarian title and two buttons do not fit one 375 px row. */}
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
Expand Down
8 changes: 4 additions & 4 deletions src/components/features/onboarding/OnboardingTour.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import { hasCompletedOnboarding as completedFor, useOnboardingStore } from '@/ho
import { focusCurrentDebate, focusTourPrimary } from '@/lib/onboarding-tour-load';
import { useDebateStore } from '@/lib/stores/debate-store';

// Below `lg` the header's menu button is the navigation. There the tour draws no
// overlay, so a tap on the menu (or anything else) still works while it shows,
// and its tooltip sits under the header (z-50) and the open menu and its scrim
// (z-40). Joyride draws the tooltip at zIndex + 100.
// Below `lg` the tour draws no overlay, so a tap still reaches the page.
// On a phone the bottom tab bar is the navigation; from `md` until `lg` it is
// the header menu. The tooltip stays under both (z-40) and the header (z-50).
// Joyride draws the tooltip at zIndex + 100.
const COMPACT_LAYOUT_QUERY = '(max-width: 1023px)';
const COMPACT_TOOLTIP_Z_INDEX = 35;

Expand Down
Loading
Loading