diff --git a/apps/web/e2e/mocked/browser-behavior.spec.ts b/apps/web/e2e/mocked/browser-behavior.spec.ts index 154cf48..0a6dd14 100644 --- a/apps/web/e2e/mocked/browser-behavior.spec.ts +++ b/apps/web/e2e/mocked/browser-behavior.spec.ts @@ -1,6 +1,44 @@ import { expect, test } from '@playwright/test' import { authenticatedApi, entry, entryId, guestApi, mockApi, project, projectId } from './mock-api' +function backgroundPixels(element: Element) { + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d')! + context.fillStyle = getComputedStyle(element).backgroundColor + context.fillRect(0, 0, 1, 1) + return Array.from(context.getImageData(0, 0, 1, 1).data) +} + +for (const theme of ['dark', 'light']) { + test(`mobile navigation has an opaque ${theme} surface and a dark backdrop without blur`, async ({ page }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }) + const assertNoUnexpected = await mockApi(page, authenticatedApi) + await page.goto('/dashboard') + await page.evaluate((dark) => document.documentElement.classList.toggle('dark', dark), theme === 'dark') + await page.getByRole('button', { name: 'Open navigation menu' }).click() + const drawer = page.getByRole('dialog', { name: 'Sidebar' }) + await expect(drawer).toBeVisible() + + // Browser-computed colors catch transparent surfaces even when the theme's + // CSS variables or generated utility classes change. + const surface = await drawer.evaluate(backgroundPixels) + expect(surface[3], 'Navigation must hide the page behind it').toBe(255) + expect(surface[0]).toBe(theme === 'dark' ? 10 : 255) + await expect(drawer).toHaveCSS('backdrop-filter', 'none') + + const overlay = page.locator('[data-slot="sheet-overlay"]') + await expect(overlay).toHaveCSS('backdrop-filter', 'none') + const backdrop = await overlay.evaluate(backgroundPixels) + expect(backdrop.slice(0, 3)).toEqual([0, 0, 0]) + expect(backdrop[3]).toBeGreaterThanOrEqual(178) + expect(backdrop[3]).toBeLessThanOrEqual(179) + await page.screenshot({ path: testInfo.outputPath(`mobile-navigation-${theme}.png`) }) + await page.keyboard.press('Escape') + await expect(drawer).toBeHidden() + assertNoUnexpected() + }) +} + test('mobile drawer exposes working links and closes after navigation', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }) const assertNoUnexpected = await mockApi(page, authenticatedApi) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 2614ed3..e57e472 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -88,3 +88,17 @@ html.dark { min-width: 20rem; } } + +/* The mobile sidebar is portaled outside the app layout. Give the drawer its + own opaque theme surface so page content cannot compete with navigation. */ +[data-slot='sidebar'][data-mobile='true'] { + @apply bg-background text-foreground; + backdrop-filter: none; +} + +/* Limit the darker scrim to navigation; other sheets keep their own styling. + Radix renders the overlay immediately before its portaled drawer. */ +[data-slot='sheet-overlay']:has(+ [data-slot='sidebar'][data-mobile='true']) { + @apply bg-black/70; + backdrop-filter: none; +} diff --git a/docs/images/issue-18-mobile-navigation-dark.png b/docs/images/issue-18-mobile-navigation-dark.png new file mode 100644 index 0000000..08adc86 Binary files /dev/null and b/docs/images/issue-18-mobile-navigation-dark.png differ diff --git a/docs/images/issue-18-mobile-navigation-light.png b/docs/images/issue-18-mobile-navigation-light.png new file mode 100644 index 0000000..1d67e8a Binary files /dev/null and b/docs/images/issue-18-mobile-navigation-light.png differ