From 6cef6a724ee277d50fe04dd0cba01074e3b0f679 Mon Sep 17 00:00:00 2001 From: johnlaff <46091881+johnlaff@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:12:08 -0300 Subject: [PATCH] feat(mobile): amplia respiro e compacta resumos --- ...-07-10-mobile-navigation-density-design.md | 50 +++++ e2e/clock-flow.spec.ts | 9 +- e2e/clockout-retry.spec.ts | 22 +- e2e/helpers/request.ts | 4 + e2e/mobile-navigation.spec.ts | 189 ++++++++++++++++++ playwright.config.ts | 19 ++ src/app/globals.css | 6 +- src/components/daily-summary.tsx | 21 +- src/components/navbar.tsx | 5 +- src/components/page-shell.tsx | 2 +- src/components/providers.tsx | 4 +- src/components/ui/sheet.tsx | 4 +- 12 files changed, 307 insertions(+), 28 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-10-mobile-navigation-density-design.md create mode 100644 e2e/helpers/request.ts create mode 100644 e2e/mobile-navigation.spec.ts diff --git a/docs/superpowers/specs/2026-07-10-mobile-navigation-density-design.md b/docs/superpowers/specs/2026-07-10-mobile-navigation-density-design.md new file mode 100644 index 0000000..74f07b6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-mobile-navigation-density-design.md @@ -0,0 +1,50 @@ +# Especificação — navegação e densidade mobile + +## Problem Statement + +Na visão mobile do ArchTime, o conteúdo do dashboard fica muito próximo da borda da tela, escondendo a malha arquitetônica de fundo. Os cartões de resumo de Hoje, Semana e Mês ocupam toda a largura e carregam espaçamento vertical herdado que deixa uma área vazia desproporcional ao conteúdo. O novo menu de navegação também parece lento ao abrir, mesmo sem travar funcionalmente. + +## Solution + +Em telas mobile, aumentar o recuo lateral do conteúdo de 16 px para 32 px, mantendo os breakpoints maiores inalterados. Compactar somente os três cartões de resumo para que tenham largura intrínseca e removam o padding/gap estrutural excedente no mobile. Acelerar exclusivamente a animação do drawer de navegação mobile, preservando a composição e a acessibilidade do Sheet existente. + +## User Stories + +1. Como arquiteta usando o PWA em um celular, quero mais área visível da malha atrás do conteúdo para que a tela não pareça colada na borda. +2. Como usuária mobile, quero que o recuo lateral continue consistente ao rolar entre as páginas autenticadas para que a navegação não pareça saltar. +3. Como usuária, quero que o cartão de Hoje tenha apenas o espaço necessário para seus dados para que eu identifique o resumo sem uma grande área vazia. +4. Como usuária, quero que os cartões de Semana e Mês sigam a mesma densidade do cartão de Hoje para que o resumo tenha ritmo visual coerente. +5. Como usuária, quero que os valores e rótulos completos continuem legíveis mesmo quando um saldo for maior que o habitual. +6. Como usuária com tema claro ou escuro, quero que os cartões compactos preservem contraste e os tokens de aparência para que a compactação não crie regressão visual. +7. Como usuária, quero que o menu hamburguer apareça rapidamente após o toque para que a navegação pareça responsiva. +8. Como usuária que navega por teclado ou leitor de tela, quero que o menu continue usando dialog, foco, rótulos e fechamento existentes para que a melhora visual não reduza acessibilidade. +9. Como usuária em tela desktop ou tablet, quero que a grade de resumo e os espaçamentos existentes não mudem para que a correção permaneça estritamente mobile. +10. Como mantenedora, quero E2E cobrindo a abertura do menu e a geometria mobile para que o feedback não regrida em mudanças futuras. + +## Implementation Decisions + +- O breakpoint mobile é o único alvo de mudança de layout; os breakpoints a partir de `sm` mantêm os valores atuais. +- O recuo lateral mobile passa a usar o próximo múltiplo de 4 px que dobra o valor atual: 32 px. +- Os cartões de resumo usam largura intrínseca e removem, somente no mobile, o padding e o gap estrutural herdados do componente Card. Seus filhos continuam responsáveis pelo espaçamento interno e preservam conteúdo completo. +- O menu permanece um Sheet do Radix/shadcn. A animação continua composta por transform e opacidade, com entrada de 200 ms e saída de 150 ms, em vez dos 500/300 ms herdados. O drawer usa `will-change: transform` somente enquanto está montado, para ajudar a composição em aparelhos menos potentes. +- Quando o sistema pede redução de movimento, o drawer e seu overlay abrem sem animação. A mudança é opt-in por preferência de acessibilidade e não altera a experiência normal em desktop ou mobile. +- A alteração não cria uma nova navegação, não muda as opções do menu e não altera tokens de cor ou o comportamento de tema. + +## Testing Decisions + +- O seam principal é o caminho do usuário no navegador mobile autenticado: abrir o menu, navegar por uma opção e confirmar a página de destino. +- O E2E verifica recuo lateral computado, ausência de overflow horizontal e que cada cartão de resumo mobile cabe no contêiner, sem rolagem interna nem padding estrutural vazio. +- O E2E cobre 390 px, 320 px e o breakpoint `sm`, para garantir que a correção continua estritamente mobile; em 320 px, também injeta saldos excepcionalmente longos para validar quebra de linha sem corte. +- O E2E verifica que a animação aberta do Sheet usa duração de no máximo 200 ms e que a preferência `prefers-reduced-motion` zera a animação do drawer e do overlay. A métrica de FPS não é estável em browser headless; composição e duração são a fronteira verificável. +- A suíte existente de visualização autenticada é usada como referência para os temas e a viewport de 390 px. + +## Out of Scope + +- Alterações de layout em desktop ou tablet. +- Novos itens, rotas ou estado para o menu. +- Troca de biblioteca de drawer, alterações de tema, ou redesenho do ActivityPanel. +- Alterações de dados de Sessão, fila offline ou API. + +## Further Notes + +O feedback da usuária é específico de tela pequena. A compactação é deliberada para os três resumos, que são métricas de leitura rápida, e não deve ser aplicada por padrão a todos os Cards do produto. diff --git a/e2e/clock-flow.spec.ts b/e2e/clock-flow.spec.ts index 95777f0..ecd2f85 100644 --- a/e2e/clock-flow.spec.ts +++ b/e2e/clock-flow.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' +import { sameOriginHeaders } from './helpers/request' // Mutating end-to-end of the activity feature against the REAL prod DB, made safe: // - skips entirely if the user has a real open session (won't touch it); @@ -21,8 +22,9 @@ function brtWall(iso: string, addMinutes = 0): string { return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}` } -test('ponto com atividade: persiste, edita nota, busca e se auto-limpa', async ({ page }) => { +test('ponto com atividade: persiste, edita nota, busca e se auto-limpa', async ({ page, baseURL }) => { test.setTimeout(120_000) // fluxo longo: várias navegações/loads + poll do servidor + const headers = sameOriginHeaders(baseURL) const activeBefore = await (await page.request.get('/api/clock/active')).json().catch(() => null) test.skip(Boolean(activeBefore), 'usuário tem uma sessão aberta real — pulando teste mutante') @@ -72,6 +74,7 @@ test('ponto com atividade: persiste, edita nota, busca e se auto-limpa', async ( const outWall = brtWall(active!.clockIn, 0) const patchRes = await page.request.patch(`/api/clock/${entryId}`, { data: { clockInAt: inWall, clockOutAt: outWall, activityType: 'modelagem' }, + headers, }) expect(patchRes.ok(), `PATCH de horários deveria ter sucesso (${patchRes.status()})`).toBeTruthy() const rowHHmm = inWall.slice(11, 16) @@ -105,8 +108,8 @@ test('ponto com atividade: persiste, edita nota, busca e se auto-limpa', async ( entryId = a?.id } if (entryId) { - await page.request.put(`/api/clock/${entryId}`).catch(() => {}) - await page.request.delete(`/api/clock/${entryId}`).catch(() => {}) + await page.request.put(`/api/clock/${entryId}`, { headers }).catch(() => {}) + await page.request.delete(`/api/clock/${entryId}`, { headers }).catch(() => {}) } } catch { // best-effort diff --git a/e2e/clockout-retry.spec.ts b/e2e/clockout-retry.spec.ts index 5eb5359..8475e65 100644 --- a/e2e/clockout-retry.spec.ts +++ b/e2e/clockout-retry.spec.ts @@ -1,4 +1,5 @@ import { test, expect, type Page } from '@playwright/test' +import { sameOriginHeaders } from './helpers/request' type FailureMode = 'network' | 'response-lost' | 'server' @@ -12,15 +13,17 @@ async function activeSession(page: Page) { async function exerciseRetry( page: Page, + baseURL: string | undefined, failureMode: FailureMode ) { + const headers = sameOriginHeaders(baseURL) const before = await activeSession(page) test.skip(Boolean(before), 'usuário tem uma sessão aberta real — pulando teste mutante') let entryId: string | undefined let primaryClockOutAt: string | undefined try { - const clockIn = await page.request.post('/api/clock', { data: { projectId: null } }) + const clockIn = await page.request.post('/api/clock', { data: { projectId: null }, headers }) expect(clockIn.ok(), `clock-in de teste deveria criar uma Sessão (${clockIn.status()})`).toBeTruthy() entryId = (await clockIn.json()).id @@ -72,6 +75,7 @@ async function exerciseRetry( expect(primaryClockOutAt, 'o PUT primário deve carregar o horário do clique').toBeTruthy() const idempotent = await page.request.put(`/api/clock/${entryId}`, { data: { clockOutAt: '2000-01-01T00:00:00.000Z' }, + headers, }) expect(idempotent.ok(), `PUT idempotente deveria reler a Sessão (${idempotent.status()})`).toBeTruthy() expect((await idempotent.json()).clockOut).toBe(primaryClockOutAt) @@ -82,20 +86,20 @@ async function exerciseRetry( if (entryId) { await page.unroute(`**/api/clock/${entryId}`) // PUT idempotente fecha apenas a Sessão criada neste teste se o retry falhar cedo. - await page.request.put(`/api/clock/${entryId}`).catch(() => {}) - await page.request.delete(`/api/clock/${entryId}`).catch(() => {}) + await page.request.put(`/api/clock/${entryId}`, { headers }).catch(() => {}) + await page.request.delete(`/api/clock/${entryId}`, { headers }).catch(() => {}) } } } -test('clock-out recupera automaticamente de um 5xx sem a rede cair', async ({ page }) => { - await exerciseRetry(page, 'server') +test('clock-out recupera automaticamente de um 5xx sem a rede cair', async ({ page, baseURL }) => { + await exerciseRetry(page, baseURL, 'server') }) -test('clock-out recupera automaticamente de uma falha de rede', async ({ page }) => { - await exerciseRetry(page, 'network') +test('clock-out recupera automaticamente de uma falha de rede', async ({ page, baseURL }) => { + await exerciseRetry(page, baseURL, 'network') }) -test('clock-out preserva o horário quando a escrita conclui mas a resposta se perde', async ({ page }) => { - await exerciseRetry(page, 'response-lost') +test('clock-out preserva o horário quando a escrita conclui mas a resposta se perde', async ({ page, baseURL }) => { + await exerciseRetry(page, baseURL, 'response-lost') }) diff --git a/e2e/helpers/request.ts b/e2e/helpers/request.ts new file mode 100644 index 0000000..00924bc --- /dev/null +++ b/e2e/helpers/request.ts @@ -0,0 +1,4 @@ +/** Cabeçalho que faz mutações do APIRequestContext obedecerem ao CSRF da aplicação. */ +export function sameOriginHeaders(baseURL: string | undefined): Record { + return { Origin: new URL(baseURL ?? 'http://localhost:3000').origin } +} diff --git a/e2e/mobile-navigation.spec.ts b/e2e/mobile-navigation.spec.ts new file mode 100644 index 0000000..5b1c9ff --- /dev/null +++ b/e2e/mobile-navigation.spec.ts @@ -0,0 +1,189 @@ +import { test, expect, type Page } from '@playwright/test' +import { applyAppearance } from './helpers/appearance' + +const dashboardHeading = { name: 'Ponto' } +const extremeMinutes = Number.MAX_SAFE_INTEGER + +async function openDashboard(page: Page, width: number) { + await page.setViewportSize({ width, height: 844 }) + await applyAppearance(page, { dark: false, blueprint: true }) + await page.goto('/dashboard') + await expect(page.getByRole('heading', dashboardHeading)).toBeVisible({ timeout: 20_000 }) +} + +async function expectSummaryCardsFit(page: Page, contentWidth: number) { + for (const card of [ + page.getByTestId('summary-card-today'), + page.getByTestId('summary-card-week'), + page.getByTestId('summary-card-month'), + ]) { + await expect(card).toBeVisible() + const metrics = await card.evaluate((element) => { + const style = getComputedStyle(element) + return { + clientHeight: element.clientHeight, + clientWidth: element.clientWidth, + gap: Number.parseFloat(style.rowGap), + paddingBottom: Number.parseFloat(style.paddingBottom), + paddingTop: Number.parseFloat(style.paddingTop), + scrollHeight: element.scrollHeight, + scrollWidth: element.scrollWidth, + width: element.getBoundingClientRect().width, + } + }) + + // Com os dados normais da conta de teste, os três resumos devem continuar + // intrínsecos — ocupar toda a coluna reintroduziria a área vazia reportada. + expect(metrics.width).toBeLessThan(contentWidth) + expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth) + expect(metrics.scrollHeight).toBeLessThanOrEqual(metrics.clientHeight) + expect(metrics.gap).toBe(0) + expect(metrics.paddingTop).toBe(0) + expect(metrics.paddingBottom).toBe(0) + } +} + +async function stubExtremeSummary(page: Page) { + const balance = { + actualMinutes: extremeMinutes, + balanceMinutes: -extremeMinutes, + expectedMinutes: extremeMinutes, + } + await page.route('**/api/clock/summary', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + entries: [], + month: { + ...balance, + cumulativeBalance: extremeMinutes, + showCumulativeBalance: true, + }, + sessionCount: 0, + today: balance, + totalMinutes: extremeMinutes, + week: balance, + }), + }) + }) +} + +test.describe('Dashboard mobile', () => { + test('preserva a malha lateral e compacta os três resumos em 390 px', async ({ page }) => { + await openDashboard(page, 390) + + const shell = page.locator('[data-page-ready="true"]') + const shellMetrics = await shell.evaluate((element) => { + const style = getComputedStyle(element) + const paddingLeft = Number.parseFloat(style.paddingLeft) + const paddingRight = Number.parseFloat(style.paddingRight) + return { + contentWidth: element.clientWidth - paddingLeft - paddingRight, + paddingLeft, + paddingRight, + } + }) + + expect(shellMetrics.paddingLeft).toBe(32) + expect(shellMetrics.paddingRight).toBe(32) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) + await expectSummaryCardsFit(page, shellMetrics.contentWidth) + }) + + test('mantém conteúdo utilizável sem overflow em uma tela estreita de 320 px', async ({ page }) => { + await openDashboard(page, 320) + + const shell = page.locator('[data-page-ready="true"]') + const shellMetrics = await shell.evaluate((element) => { + const style = getComputedStyle(element) + const paddingLeft = Number.parseFloat(style.paddingLeft) + const paddingRight = Number.parseFloat(style.paddingRight) + return { + contentWidth: element.clientWidth - paddingLeft - paddingRight, + paddingLeft, + paddingRight, + } + }) + + expect(shellMetrics.paddingLeft).toBe(32) + expect(shellMetrics.paddingRight).toBe(32) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) + await expectSummaryCardsFit(page, shellMetrics.contentWidth) + }) + + test('quebra saldos excepcionalmente longos sem cortar o conteúdo em 320 px', async ({ page }) => { + await stubExtremeSummary(page) + await openDashboard(page, 320) + + for (const card of [ + page.getByTestId('summary-card-today'), + page.getByTestId('summary-card-week'), + page.getByTestId('summary-card-month'), + ]) { + await expect(card).toBeVisible() + const metrics = await card.evaluate((element) => ({ + clientHeight: element.clientHeight, + clientWidth: element.clientWidth, + scrollHeight: element.scrollHeight, + scrollWidth: element.scrollWidth, + })) + expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth) + expect(metrics.scrollHeight).toBeLessThanOrEqual(metrics.clientHeight) + } + + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) + }) + + test('abre um menu rápido, preserva foco e navega pelo drawer', async ({ page }) => { + await openDashboard(page, 390) + + const trigger = page.getByRole('button', { name: 'Abrir menu' }) + await trigger.click() + + const drawer = page.locator('[data-slot="sheet-content"]') + await expect(drawer).toBeVisible() + const duration = await drawer.evaluate((element) => Number.parseFloat(getComputedStyle(element).animationDuration) * 1_000) + expect(duration).toBeLessThanOrEqual(200) + await expect.poll(() => drawer.evaluate((element) => element.contains(document.activeElement))).toBe(true) + + await page.keyboard.press('Escape') + await expect(drawer).toBeHidden() + await expect(trigger).toBeFocused() + + await trigger.click() + await expect(drawer).toBeVisible() + await drawer.getByRole('link', { name: 'Histórico' }).click() + await expect(page).toHaveURL(/\/historico/) + await expect(page.getByRole('heading', { name: 'Histórico' })).toBeVisible({ timeout: 15_000 }) + }) +}) + +test.describe('Dashboard a partir de sm', () => { + test('restaura o espaçamento existente no limiar do breakpoint', async ({ page }) => { + await openDashboard(page, 640) + + const padding = await page.locator('[data-page-ready="true"]').evaluate((element) => { + const style = getComputedStyle(element) + return { left: Number.parseFloat(style.paddingLeft), right: Number.parseFloat(style.paddingRight) } + }) + + expect(padding).toEqual({ left: 24, right: 24 }) + }) +}) + +test.describe('Menu com redução de movimento', () => { + test('remove a animação do drawer e do overlay', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches)).toBe(true) + await openDashboard(page, 390) + await page.getByRole('button', { name: 'Abrir menu' }).click() + + const drawer = page.locator('[data-slot="sheet-content"]') + const overlay = page.locator('[data-slot="sheet-overlay"]') + await expect(drawer).toBeVisible() + await expect(overlay).toBeVisible() + + await expect(drawer).toHaveCSS('animation-duration', '0s') + await expect(overlay).toHaveCSS('animation-duration', '0s') + }) +}) diff --git a/playwright.config.ts b/playwright.config.ts index 51a33eb..1815040 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -22,6 +22,25 @@ try { // .env.local optional (e.g. CI provides env directly) } +// Os E2E locais compartilham o banco de produção. Quando o clone ainda só tem +// ENTRY_HASH_SECRET, espelhe o keyring de bootstrap da ADR 0005 para conseguir +// verificar os hashes históricos já identificados. Um keyring explícito (inclusive +// parcial) sempre prevalece, para que a validação de boot continue encontrando erro. +const hasExplicitHashKeyring = [ + 'ENTRY_HASH_KEY_IDS', + 'ENTRY_HASH_ACTIVE_KEY_ID', + 'ENTRY_HASH_LEGACY_KEY_ID', +].some((name) => process.env[name] !== undefined) || + Object.keys(process.env).some((name) => name.startsWith('ENTRY_HASH_SECRET_')) + +if (!hasExplicitHashKeyring && process.env.ENTRY_HASH_SECRET) { + const bootstrapKeyId = 'k2026-07' + process.env.ENTRY_HASH_KEY_IDS = bootstrapKeyId + process.env.ENTRY_HASH_ACTIVE_KEY_ID = bootstrapKeyId + process.env.ENTRY_HASH_LEGACY_KEY_ID = bootstrapKeyId + process.env.ENTRY_HASH_SECRET_K2026_07 = process.env.ENTRY_HASH_SECRET +} + /** True when no base URL is set or it points at a loopback host (so we auto-start dev). */ function isLocalBaseURL(baseURL: string | undefined): boolean { if (!baseURL) return true diff --git a/src/app/globals.css b/src/app/globals.css index 11644f8..15631b2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -524,9 +524,9 @@ html.theme-switching *::after { .stagger > *:nth-child(6) { animation-delay: calc(var(--delay-stagger) * 5); } /* ─── Reduced motion ─── - Intentionally NOT honored: animations are core to the product experience and - play regardless of the OS prefers-reduced-motion setting. The motion/react - counterpart is in providers.tsx. */ + O produto mantém suas animações de feedback via MotionConfig, mas navegação + modal tem alternativa instantânea no próprio Sheet para respeitar a preferência + do sistema sem mudar a experiência normal. */ /* ═══════════════════════════════════════════════════════════════ DENSITY SYSTEM diff --git a/src/components/daily-summary.tsx b/src/components/daily-summary.tsx index ed5ae48..20f693f 100644 --- a/src/components/daily-summary.tsx +++ b/src/components/daily-summary.tsx @@ -14,17 +14,22 @@ function BalanceCard({ title, balance, cumulativeBalance, + testId, }: { title: string balance: BalanceSummary cumulativeBalance?: number + testId: string }) { return ( - + {title} - +

{formatMinutes(balance.actualMinutes)}

Previsto: {formatMinutes(balance.expectedMinutes)}

@@ -39,25 +44,27 @@ function BalanceCard({ export function DailySummaryCard({ summary }: DailySummaryProps) { return (
-
+
{[ - { title: 'Hoje', balance: summary.today, cumulative: undefined as number | undefined }, - { title: 'Semana', balance: summary.week, cumulative: undefined as number | undefined }, + { title: 'Hoje', testId: 'summary-card-today', balance: summary.today, cumulative: undefined as number | undefined }, + { title: 'Semana', testId: 'summary-card-week', balance: summary.week, cumulative: undefined as number | undefined }, { title: 'Mês', + testId: 'summary-card-month', balance: summary.month, cumulative: summary.month.showCumulativeBalance ? summary.month.cumulativeBalance ?? undefined : undefined as number | undefined, }, - ].map(({ title, balance, cumulative }, i) => ( + ].map(({ title, testId, balance, cumulative }, i) => ( - + ))}
diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index 3c34206..6f7cb8e 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -78,7 +78,10 @@ export function Navbar() { - + diff --git a/src/components/page-shell.tsx b/src/components/page-shell.tsx index 6a3cca4..3b2864f 100644 --- a/src/components/page-shell.tsx +++ b/src/components/page-shell.tsx @@ -2,7 +2,7 @@ export function PageShell({ children }: { children: React.ReactNode }) { return (
{children}
diff --git a/src/components/providers.tsx b/src/components/providers.tsx index bdf6050..8425a34 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -67,8 +67,8 @@ function PreferencesHydrator() { export function Providers({ children }: { children: React.ReactNode }) { return ( - {/* reducedMotion="never": animations are part of the product experience and - play regardless of the OS prefers-reduced-motion setting (deliberate choice). */} + {/* Feedbacks do produto mantêm sua animação; o Sheet oferece, no próprio + componente, uma alternativa instantânea para reduzir movimento na navegação. */} {/* LazyMotion + componente `m` (em vez de `motion`): carrega o conjunto de features uma única vez e mantém os bundles dos componentes enxutos. domMax inclui layout animations (layoutId no SidebarNav) e gestos. */} diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 5963090..c1690e4 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -36,7 +36,7 @@ function SheetOverlay({