diff --git a/.claude/skills/verify-debatelab/features/ai-tools.md b/.claude/skills/verify-debatelab/features/ai-tools.md
index 1b15b22a..ac37bd37 100644
--- a/.claude/skills/verify-debatelab/features/ai-tools.md
+++ b/.claude/skills/verify-debatelab/features/ai-tools.md
@@ -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
diff --git a/.claude/skills/verify-debatelab/features/case-generation.md b/.claude/skills/verify-debatelab/features/case-generation.md
index e6cbd030..943db212 100644
--- a/.claude/skills/verify-debatelab/features/case-generation.md
+++ b/.claude/skills/verify-debatelab/features/case-generation.md
@@ -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
diff --git a/.claude/skills/verify-debatelab/features/library-sources.md b/.claude/skills/verify-debatelab/features/library-sources.md
index 0131426a..7b0f8f94 100644
--- a/.claude/skills/verify-debatelab/features/library-sources.md
+++ b/.claude/skills/verify-debatelab/features/library-sources.md
@@ -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
diff --git a/.claude/skills/verify-debatelab/recipes/auth.spec.ts b/.claude/skills/verify-debatelab/recipes/auth.spec.ts
index 116e8ae3..19d45aba 100644
--- a/.claude/skills/verify-debatelab/recipes/auth.spec.ts
+++ b/.claude/skills/verify-debatelab/recipes/auth.spec.ts
@@ -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();
@@ -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();
diff --git a/docs/standards/ui.md b/docs/standards/ui.md
index 17001bb4..d1c9e5a6 100644
--- a/docs/standards/ui.md
+++ b/docs/standards/ui.md
@@ -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)).
diff --git a/e2e/admin-announcements.spec.ts b/e2e/admin-announcements.spec.ts
index 8dac74f0..8e5969b2 100644
--- a/e2e/admin-announcements.spec.ts
+++ b/e2e/admin-announcements.spec.ts
@@ -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;
}
diff --git a/e2e/home-navigation.spec.ts b/e2e/home-navigation.spec.ts
index 7712cf12..d4d85b65 100644
--- a/e2e/home-navigation.spec.ts
+++ b/e2e/home-navigation.spec.ts
@@ -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 }) => {
@@ -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);
@@ -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);
});
diff --git a/e2e/navigation-performance.spec.ts b/e2e/navigation-performance.spec.ts
index b7994f20..4afcc650 100644
--- a/e2e/navigation-performance.spec.ts
+++ b/e2e/navigation-performance.spec.ts
@@ -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();
}
diff --git a/src/components/features/ask/AskChat.tsx b/src/components/features/ask/AskChat.tsx
index c6418b91..cbb8db5b 100644
--- a/src/components/features/ask/AskChat.tsx
+++ b/src/components/features/ask/AskChat.tsx
@@ -514,7 +514,7 @@ export function AskChat({ messages, streamingMessage, isLoading, error, onDismis
};
return (
-
+