From 7f5efdf918413849f977d2285fab1f5c3c576e71 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 09:16:37 +0000 Subject: [PATCH 1/2] Add a bottom tab bar on phone widths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phones below 768px navigate with Tools, Current debate, Library, and Settings. The header menu stays from 768px until the desktop nav, so iPad portrait still includes History. Co-authored-by: Gáborjáni-Szabó Ádám --- .../verify-debatelab/features/ai-tools.md | 2 +- .../features/case-generation.md | 2 +- .../features/library-sources.md | 2 +- .../verify-debatelab/recipes/auth.spec.ts | 11 +- docs/standards/ui.md | 2 +- e2e/admin-announcements.spec.ts | 9 +- e2e/home-navigation.spec.ts | 54 +++++-- e2e/navigation-performance.spec.ts | 5 +- src/components/features/ask/AskChat.tsx | 6 +- .../features/onboarding/OnboardingTour.tsx | 8 +- src/components/layout/AppLayout.tsx | 21 ++- src/components/layout/PhoneTabBar.tsx | 138 ++++++++++++++++++ src/components/layout/phone-tab-bar.test.ts | 17 +++ src/components/layout/phone-tab-bar.ts | 12 ++ src/components/ui/sonner.tsx | 2 +- src/index.css | 25 +++- src/pages/AskPage.tsx | 6 +- 17 files changed, 288 insertions(+), 34 deletions(-) create mode 100644 src/components/layout/PhoneTabBar.tsx create mode 100644 src/components/layout/phone-tab-bar.test.ts create mode 100644 src/components/layout/phone-tab-bar.ts diff --git a/.claude/skills/verify-debatelab/features/ai-tools.md b/.claude/skills/verify-debatelab/features/ai-tools.md index 155e4db3d..9bb999e4f 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 d0faa56c3..ae958aa61 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`, desktop header `History`, or `Open menu` → `History` from 768px until the desktop nav. 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 c19210bee..ae601fb62 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 until the desktop nav: `Open menu` → `Library`. Desktop header: `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 709775760..5fef90ac6 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 DebateLab!"); 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. + // iPad keeps the header menu. 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 17001bb41..746cad8ec 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. The header menu stays from `md` until `lg`. Close that menu on every navigation inside the persistent layout, including the logo link ([#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 52557b32a..e14a479b0 100644 --- a/e2e/admin-announcements.spec.ts +++ b/e2e/admin-announcements.spec.ts @@ -15,7 +15,14 @@ test.beforeEach(async ({ page }) => { }); async function openAppNav(page: Page, label: string) { - if ((page.viewportSize()?.width ?? 1280) < 1024) { + const width = page.viewportSize()?.width ?? 1280; + if (width < 768) { + await page.getByRole('navigation', { name: 'Mobile navigation' }) + .getByRole('link', { name: label, exact: true }) + .click(); + return; + } + if (width < 1024) { await page.getByRole('button', { name: 'Open menu' }).click(); await page.getByRole('navigation', { name: 'Mobile navigation' }) .getByRole('link', { name: new RegExp(`^${label}`) }) diff --git a/e2e/home-navigation.spec.ts b/e2e/home-navigation.spec.ts index b6c6637d5..b528997e2 100644 --- a/e2e/home-navigation.spec.ts +++ b/e2e/home-navigation.spec.ts @@ -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('DebateLab 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,52 @@ 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); +}); + +test('iPad portrait keeps the header menu 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 }); + await expect(page.getByRole('button', { name: 'Open menu' })).toBeVisible(); await expect(page.getByRole('navigation', { name: 'Mobile navigation' })).toHaveCount(0); + await expect(page.getByRole('navigation', { name: 'Main navigation' })).toHaveCount(0); + + await page.getByRole('button', { name: 'Open menu' }).click(); + const menu = page.getByRole('navigation', { name: 'Mobile navigation' }); + const hrefs = await menu.getByRole('link').evaluateAll((links) => links.map((link) => link.getAttribute('href'))); + expect(hrefs).toEqual(['/app', '/app/debate', '/app/library', '/app/history', '/app/settings']); }); diff --git a/e2e/navigation-performance.spec.ts b/e2e/navigation-performance.spec.ts index b3fb103e0..694ac7bde 100644 --- a/e2e/navigation-performance.spec.ts +++ b/e2e/navigation-performance.spec.ts @@ -55,7 +55,10 @@ function homeToolLink(page: Page, tool: string) { } async function openHome(page: Page) { - if ((page.viewportSize()?.width ?? 1280) < 1024) { + const width = page.viewportSize()?.width ?? 1280; + if (width < 768) { + await page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: 'Tools', exact: true }).click(); + } else if (width < 1024) { await page.getByRole('button', { name: 'Open menu' }).click(); await page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: /^Tools/ }).click(); } else { diff --git a/src/components/features/ask/AskChat.tsx b/src/components/features/ask/AskChat.tsx index 726e6898b..55781a82f 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 ( -
+