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 ( -
+
- - {/* Mobile Nav: a modal panel under the header, with a scrim over the page. */} - - - - {t('nav.mobileNavigation')} - - - -
@@ -397,6 +345,7 @@ export function AppLayout() { +
{/* Main Content */}
@@ -413,6 +362,9 @@ export function AppLayout() { + + {/* After main so keyboard order follows the page, then the bar. Fixed, so the flow is unchanged. */} + ); } diff --git a/src/components/layout/PhoneTabBar.tsx b/src/components/layout/PhoneTabBar.tsx new file mode 100644 index 00000000..af187ef1 --- /dev/null +++ b/src/components/layout/PhoneTabBar.tsx @@ -0,0 +1,198 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Database, LayoutGrid, Settings, Target } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useRoutePrefetch } from '@/hooks/use-route-prefetch'; +import { useCurrentDebate } from '@/lib/stores/debate-store'; +import { navSectionFor } from '@/components/layout/nav-sections'; +import { MAIN_CONTENT_ID } from '@/components/layout/SkipLink'; +import { PHONE_TAB_MEDIA, isTextEntry, keyboardIsOpen, keyboardOverlap } from '@/components/layout/phone-tab-bar'; + +// Below `md` (768px) only. iPad portrait is 820px and keeps the header nav: +// four tabs would drop History and repeat the Current debate row already in the header. +const tabClass = cn( + 'flex min-h-14 min-w-0 touch-manipulation flex-col items-center justify-center gap-0.5 rounded-lg px-1 py-1.5', + 'text-xs font-bold leading-tight text-muted-foreground transition-[color,background-color,transform] duration-150 active:scale-[0.97]', + 'motion-reduce:transition-none motion-reduce:active:scale-100', +); +const tabActiveClass = 'bg-brand-tint text-primary'; + +const tabs = [ + { to: '/app', key: 'tools', icon: LayoutGrid }, + { to: '/app/debate', key: 'currentDebate', icon: Target }, + { to: '/app/library', key: 'library', icon: Database }, + { to: '/app/settings', key: 'settings', icon: Settings }, +] as const; + +function pathOf(pathname: string) { + return pathname.replace(/\/+$/, '') || '/'; +} + +function PhoneTab({ + to, + label, + icon: Icon, + active, + current, +}: { + to: string; + label: string; + icon: typeof LayoutGrid; + active: boolean; + current: boolean; +}) { + const prefetch = useRoutePrefetch(to); + return ( + +