diff --git a/docs/plans/inicio-acoes-grid-2x3-mobile-impl.md b/docs/plans/inicio-acoes-grid-2x3-mobile-impl.md new file mode 100644 index 00000000..ad4a0698 --- /dev/null +++ b/docs/plans/inicio-acoes-grid-2x3-mobile-impl.md @@ -0,0 +1,65 @@ +# Impl: Início mobile — ações em grade 2×3 (sem scroll) + +Status: aprovado +Atualizado em: 2026-08-02 +Issue: #249 +Intenção: docs/plans/inicio-acoes-grid-2x3-mobile.md +Appetite restante: ~0,5d (layout + tests) + +## Leitura da intenção + +- **Outcome:** No Início mobile, as 6 ações staff aparecem numa grade 2×3 sem scroll horizontal; desktop mantém strip. +- **O que NÃO negociar:** drawer de outras rotas continua strip; catálogo/lockdown leader; long-press/tooltip. +- **O que reavaliar:** bleed `-mx-4` do B99 — removido no mobile porque a grade cabe no gutter. + +## Abordagem recomendada + +```mermaid +flowchart LR + Strip["CampaignHomeActionStrip"] + Strip -->|variant=responsive| R["grid lt-md / flex md+"] + Strip -->|variant=strip| S["flex + overflow-x"] +``` + +**Opções consideradas:** A) CSS só no strip (`grid md:flex`) | B) componente `CampaignHomeActionGrid` | C) duplicar markup no `CampaignHomeActions` +**Recomendação:** **A** — edita o dono; `variant` explícito para o drawer. +**Rejeitadas:** B (twin); C (duplica map de ações). + +### Componentes / mudanças + +- **`CampaignHomeActionStrip`** (`src/components/campaign/dashboard/CampaignHomeActionStrip.tsx`): prop `variant?: 'strip' | 'responsive'` (default `'responsive'`). Responsive: `grid grid-cols-2 gap-3` + sem overflow no mobile; `md:flex md:overflow-x-auto` + pan handlers. Strip: comportamento atual. +- **`CampaignHomeActionButton`** (`CampaignHomeActionButton.tsx`): `actionControlClassName` ganha `w-full md:w-[5.5rem]` quando pai é responsive (via prop `layout` ou classe compartilhada exportada). +- **`CampaignHomeActions`**: passa `variant="responsive"` (explícito). +- **`CampaignQuickActionsDrawer`**: passa `variant="strip"`. +- **`CampaignHomeLayout`**: `home-actions` perde `-mx-4 w-[calc(100%+2rem)]` no mobile; bleed só se strip voltar (não neste slice). +- **Migration:** sem migration. +- **UI:** Impeccable B — craft; sem novo token. + +### Dados → forma + +N/A — launcher estático. + +## Fases verificáveis + +1. **Strip + button** — `variant` + classes grid/strip; drawer `variant="strip"`. +2. **Layout + tests** — remover bleed mobile; unit layout/strip; e2e 6 ações visíveis em 390px. +3. **Gates** — `pnpm gate:fast`; `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- Extrair tile genérico wizard/home. +- Grid no drawer. +- Mudar tamanho do círculo sem pedido de produto. + +## Riscos e mitigação + +- **Pan handlers em grid mobile:** inofensivos (sem overflow); não montar scroller ref em grid se simplificar. +- **Teste bleed antigo:** atualizar spec `campaignHomeLayout` para refletir gutter no mobile. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto +- [x] Invariantes AGENTS/engineering-standards +- [x] Testes de domínio previstos (unit/int) onde access/write paths mudam + +Self-score decision-quality: 5/5 diff --git a/docs/plans/inicio-acoes-grid-2x3-mobile.md b/docs/plans/inicio-acoes-grid-2x3-mobile.md new file mode 100644 index 00000000..b2872fe7 --- /dev/null +++ b/docs/plans/inicio-acoes-grid-2x3-mobile.md @@ -0,0 +1,123 @@ +# Início mobile — ações em grade 2×3 (sem scroll) + +Status: registrado +Atualizado em: 2026-08-02 +Issue: #249 +Priority: P1 +Model: composer-2.5 +Impeccable: B — `CampaignHomeActionStrip` / `CampaignHomeLayout` (Início staff/leader) +Appetite: ~0,5d eng; layout responsivo; sem migration +Responsável: — + +## Design (Impeccable) + +Âncoras: `PRODUCT.md` (Clarity under pressure; Feel the action) / `DESIGN.md` · B44/B45 strip · B63 grid 2×3 wizard · tema `campaign`. + +Na implementação: craft compacto → critique → polish (iPhone 390px; 6 ações staff). + +Brief: + +- **Persona / contexto:** CG/assessor no celular — polegar na thumb zone; hoje precisa pan horizontal para ver as 6 ações. +- **Job principal:** ver **todas** as ações staff (6) de uma vez, sem scroll horizontal, em grade 2 colunas × 3 linhas. +- **Estratégia de cor:** herda B44 (círculo `bg-muted` + label). +- **Edit where you see:** não — launchers. +- **Anti-goals:** card com borda envolvendo a grade; scroll horizontal no mobile do Início; mudar o drawer de outras rotas (continua strip). + +### Wireframe (texto) + +```text +┌─ /campanha mobile (< md) ──────────────────────┐ +│ │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ Ajustar │ │Registrar │ │ +│ │ votos │ │ sinal │ │ +│ └──────────┘ └──────────┘ │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ Mudar │ │Atualizar │ │ +│ │tendência │ │liderança │ │ +│ └──────────┘ └──────────┘ │ +│ ┌──────────┐ ┌──────────┐ │ +│ │Registrar │ │ Ver │ │ +│ │ pedido │ │esquecidos│ │ +│ └──────────┘ └──────────┘ │ +│ │ +│ ┌ Buscar na campanha ────────────────────────┐│ +└────────────────────────────────────────────────┘ + +md+: strip horizontal atual (B44/B67) — inalterada. +``` + +## Dados → decisão → apresentação + +Dados: N/A — chrome de launcher; catálogo B45 inalterado. + +## Contexto + +**B44–B74 / B99** iteraram na strip horizontal (gap, bleed, pan). Com 6 ações staff, o mobile ainda exige pan — produto pede **grade 2×3 sem scroll** no Início. Precedente visual: `WizardSignalTypeStep` (`grid-cols-2 gap-3`). O **drawer** de ações rápidas (B105) permanece strip horizontal no peek. + +## Objetivos + +- **< `md` (Início):** `CampaignHomeActions` renderiza grade `grid-cols-2 gap-3`; 6 ações staff visíveis sem `overflow-x`; liderança (2 ações) em 1 linha. +- **`md+`:** strip horizontal atual (scroll, pan fine, tooltip). +- `CampaignQuickActionsDrawer`: continua `variant="strip"` (sem grade). +- Remover bleed `-mx-4` do slot `home-actions` no mobile (grade respeita gutter `p-4`). +- Long-press Drawer / Tooltip fine inalterados. +- Unit + e2e smoke: grade no mobile; strip no drawer; ações staff todas visíveis em viewport 390. +- Sem migration / collection / server action. + +## Decisões travadas + +- **Grade só no Início mobile; strip no drawer e no `md+`.** **Rejeitado:** grade global (drawer peek não cabe 3 linhas); manter strip no mobile “com gap menor” (B99/B74 esgotaram). +- **`variant` no dono `CampaignHomeActionStrip`.** `responsive` (default no Início) vs `strip` (drawer). **Rejeitado:** componente paralelo `CampaignHomeActionGrid`. +- **Botão com classes responsivas** (`w-full md:w-[5.5rem]`). **Rejeitado:** segundo primitivo de botão. +- **i18n:** ids/classes em inglês; copy B58 intacta. + +## Questões em aberto + +- **Círculo menor na grade?** **Opções:** A) manter `size-14` | B) `size-12` na grade. **Recomendação:** **A** — célula larga o suficiente; critique mede. _(assumido)_ + +## Abordagem proposta + +```mermaid +flowchart TB + Home["CampaignHomeActions"] --> Strip["CampaignHomeActionStrip variant=responsive"] + Drawer["CampaignQuickActionsDrawer"] --> Strip2["CampaignHomeActionStrip variant=strip"] + Strip --> Grid["lt md: grid 2×3"] + Strip --> HStrip["md+: horizontal strip"] +``` + +Componentes: + +- **`CampaignHomeActionStrip.tsx`:** prop `variant`; lista `grid grid-cols-2 gap-3` vs `flex` + overflow. +- **`CampaignHomeActionButton.tsx`:** `w-full` na grade / largura fixa na strip. +- **`CampaignHomeLayout.tsx`:** remover bleed mobile do slot `home-actions`. +- **Testes:** unit strip/grid classes; e2e 6 ações visíveis em 390px. + +## Dependências + +- Dura: B45 ✓ (catálogo), B44 ✓ (primitivo). Soft: B99 bleed (revertido no mobile para grade). + +## Não escopo + +- Catálogo / labels / wizards. +- Grade no drawer B105. +- Mudar ordem thumb-zone (B46). + +## Rabbit holes + +- **Unificar grade wizard + home num tile genérico.** Mitigação: classes Tailwind compartilhadas só; sem extrair até 3º call site. +- **CSS container queries.** Mitigação: breakpoint `md` como o resto do Início. + +## Adiado com gatilho + +- **Grade no drawer** se peek crescer. Revisitar com redesign B105. +- **3 colunas em tablet portrait.** Revisitar se `md` grid pedido. + +## Referências + +- Issue #249 · `WizardSignalTypeStep.tsx` (grid 2×3) +- `CampaignHomeActionStrip.tsx` · `CampaignHomeLayout.tsx` · `CampaignQuickActionsDrawer.tsx` +- [botao-acao-inicio-strip.md](botao-acao-inicio-strip.md) · [strip-acoes-edge-gap-inicio.md](strip-acoes-edge-gap-inicio.md) +- `PRODUCT.md` / `DESIGN.md` + +Qualidade de decisão: 5/5 diff --git a/src/components/campaign/dashboard/CampaignHomeActionButton.tsx b/src/components/campaign/dashboard/CampaignHomeActionButton.tsx index 44273173..ebc00249 100644 --- a/src/components/campaign/dashboard/CampaignHomeActionButton.tsx +++ b/src/components/campaign/dashboard/CampaignHomeActionButton.tsx @@ -25,10 +25,18 @@ export type CampaignHomeActionButtonProps = { href?: string onClick?: () => void disabled?: boolean + layout?: 'strip' | 'responsive' } -export const actionControlClassName = - 'group box-content flex min-h-11 w-[5.5rem] shrink-0 snap-start flex-col items-center gap-2 rounded-md text-center outline-none focus-visible:ring-2 focus-visible:ring-ring active:opacity-90 disabled:pointer-events-none disabled:opacity-50' +const actionControlBaseClassName = + 'group box-content flex min-h-11 flex-col items-center gap-2 rounded-md text-center outline-none focus-visible:ring-2 focus-visible:ring-ring active:opacity-90 disabled:pointer-events-none disabled:opacity-50' + +const actionStripControlClassName = cn(actionControlBaseClassName, 'w-[5.5rem] shrink-0 snap-start') + +const actionResponsiveControlClassName = cn( + actionControlBaseClassName, + 'w-full md:w-[5.5rem] md:shrink-0 md:snap-start', +) const circleClassName = 'flex size-14 items-center justify-center rounded-full bg-muted text-foreground transition-[transform,colors] duration-150 ease-out motion-reduce:transition-none pointer-fine:group-hover:scale-[1.05] pointer-coarse:group-data-[pressing=true]:scale-[1.05] motion-reduce:scale-100 group-hover:bg-muted/80 group-focus-visible:bg-muted/80' @@ -51,6 +59,7 @@ export const CampaignHomeActionButton = ({ href, onClick, disabled = false, + layout = 'strip', }: CampaignHomeActionButtonProps) => { const isCoarsePointer = useCoarsePointer() const [drawerOpen, setDrawerOpen] = useState(false) @@ -74,7 +83,10 @@ export const CampaignHomeActionButton = ({ }, }) - const className = cn(actionControlClassName, isInert && 'cursor-default') + const className = cn( + layout === 'responsive' ? actionResponsiveControlClassName : actionStripControlClassName, + isInert && 'cursor-default', + ) const aria = { 'aria-label': label, 'aria-describedby': drawerOpen && longPressEnabled ? descriptionId : undefined, diff --git a/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx b/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx index 35f41959..99c3d2d5 100644 --- a/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx +++ b/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx @@ -13,6 +13,8 @@ import { cn } from '@/lib/utils' /** Horizontal pan threshold (px) — below this, child link/button clicks still fire. */ export const HOME_ACTION_STRIP_DRAG_THRESHOLD_PX = 8 +export type CampaignHomeActionStripVariant = 'strip' | 'responsive' + type DragSession = { pointerId: number startX: number @@ -33,15 +35,19 @@ export const CampaignHomeActionStrip = ({ actions, ariaLabel = 'Ações rápidas', className, + variant = 'responsive', }: { actions?: readonly (CampaignHomeActionButtonProps & { id?: string })[] ariaLabel?: string className?: string + /** `responsive`: 2×3 grid on mobile, horizontal strip on md+. `strip`: always horizontal. */ + variant?: CampaignHomeActionStripVariant }) => { const isCoarsePointer = useCoarsePointer() const scrollerRef = useRef(null) const dragRef = useRef(null) const suppressClickRef = useRef(false) + const isStripLayout = variant === 'strip' const endDragSession = useCallback((pointerId: number) => { const session = dragRef.current @@ -126,25 +132,36 @@ export const CampaignHomeActionStrip = ({ onClickCapture, } + const listClassName = cn( + 'm-0 list-none p-0', + isStripLayout + ? 'flex min-w-max snap-x snap-proximity gap-0 px-4 pb-1 md:px-0' + : 'grid grid-cols-2 gap-3 md:flex md:min-w-max md:snap-x md:snap-proximity md:gap-0 md:px-0 md:pb-1', + ) + + const scrollerClassName = cn( + 'min-w-0', + isStripLayout + ? 'overflow-x-auto overflow-y-hidden overscroll-x-contain [touch-action:pan-x] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden pointer-fine:cursor-grab pointer-fine:data-[dragging=true]:cursor-grabbing pointer-fine:data-[dragging=true]:select-none' + : 'md:overflow-x-auto md:overflow-y-hidden md:overscroll-x-contain md:[touch-action:pan-x] md:[scrollbar-width:none] md:[&::-webkit-scrollbar]:hidden md:pointer-fine:cursor-grab md:pointer-fine:data-[dragging=true]:cursor-grabbing md:pointer-fine:data-[dragging=true]:select-none', + className, + ) + return (
-
    +
      {actions?.map(({ id, ...button }) => (
    • - +
    • ))}
    diff --git a/src/components/campaign/dashboard/CampaignHomeActions.tsx b/src/components/campaign/dashboard/CampaignHomeActions.tsx index bbd4bbf3..3323e1c4 100644 --- a/src/components/campaign/dashboard/CampaignHomeActions.tsx +++ b/src/components/campaign/dashboard/CampaignHomeActions.tsx @@ -19,5 +19,5 @@ export const CampaignHomeActions = ({ if (actions.length === 0) return null - return + return } diff --git a/src/components/campaign/dashboard/CampaignHomeLayout.tsx b/src/components/campaign/dashboard/CampaignHomeLayout.tsx index 40f45bdc..4e2d37e5 100644 --- a/src/components/campaign/dashboard/CampaignHomeLayout.tsx +++ b/src/components/campaign/dashboard/CampaignHomeLayout.tsx @@ -15,14 +15,11 @@ const HomeChromeRetractionShell = ({ slot, className, children, - allowHorizontalBleed = false, }: { retracted: boolean slot?: string className?: string children: ReactNode - /** B111 — keep Y clip for grid-rows retraction without clipping -mx bleed on X. */ - allowHorizontalBleed?: boolean }) => (
    -
    - {children} -
    +
    {children}
    ) @@ -71,12 +63,8 @@ export const CampaignHomeLayout = ({ retracted={focused} slot="home-actions-chrome" className="order-1 md:order-2" - allowHorizontalBleed > -
    +
    {actions}
    diff --git a/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx b/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx index b5137564..5a337443 100644 --- a/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx +++ b/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx @@ -136,6 +136,7 @@ export const CampaignQuickActionsDrawer = ({ {showActions ? (
    ({ id: action.id, label: action.label, diff --git a/tests/e2e/campaignHomeActions.e2e.spec.ts b/tests/e2e/campaignHomeActions.e2e.spec.ts index 655b1a41..8ee00fe5 100644 --- a/tests/e2e/campaignHomeActions.e2e.spec.ts +++ b/tests/e2e/campaignHomeActions.e2e.spec.ts @@ -102,6 +102,8 @@ test.describe('Início — busca global (B47)', () => { }) test.describe('Início — catálogo de ações (B45)', () => { + test.use({ viewport: { width: 390, height: 844 } }) + test('staff sees six home actions and can open municipalities without coverage', async ({ campaign, page, @@ -112,7 +114,8 @@ test.describe('Início — catálogo de ações (B45)', () => { }) await campaign.login(page, coordinator.email!, coordinator.password) - await expect(page.getByLabel('Ações rápidas')).toBeVisible() + const actionsRegion = page.getByLabel('Ações rápidas') + await expect(actionsRegion).toBeVisible() for (const label of staffActionLabels) { const link = page.getByRole('link', { name: label, exact: true }) @@ -120,6 +123,10 @@ test.describe('Início — catálogo de ações (B45)', () => { await expect(link.or(button)).toBeVisible() } + const overflowX = await actionsRegion.evaluate((el) => getComputedStyle(el).overflowX) + expect(overflowX).not.toBe('auto') + expect(overflowX).not.toBe('scroll') + await page.getByRole('link', { name: 'Ajustar votos', exact: true }).click() await page.waitForURL(/\/campanha\/acoes\/atualizar-votos/) await expect(page.getByLabel('Buscar município')).toBeVisible() diff --git a/tests/unit/campaignHomeActionButton.unit.spec.tsx b/tests/unit/campaignHomeActionButton.unit.spec.tsx index 0bd85135..8630dba7 100644 --- a/tests/unit/campaignHomeActionButton.unit.spec.tsx +++ b/tests/unit/campaignHomeActionButton.unit.spec.tsx @@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CampaignHomeActionButton, - actionControlClassName, type CampaignHomeActionButtonProps, } from '@/components/campaign/dashboard/CampaignHomeActionButton' import { @@ -95,7 +94,8 @@ describe('CampaignHomeActionButton', () => { const onClick = vi.fn() renderActionButton({ label: 'Registrar', icon: BarChart3, onClick }) const control = screen.getByRole('button', { name: 'Registrar' }) - expect(control.className).toBe(actionControlClassName) + expect(control.className).toContain('min-h-11') + expect(control.className).toContain('w-[5.5rem]') }) it('opens the description drawer on long-press when the pointer is coarse', () => { @@ -178,6 +178,7 @@ describe('CampaignHomeActionStrip', () => { it('renders a horizontal scroller with list semantics', () => { const { container } = render( { expect(scroller).toBeTruthy() const list = scroller?.querySelector('ul[role="list"]') expect(list).toBeTruthy() + expect(list?.className).toMatch(/flex/) + }) + + it('renders a 2-column grid on mobile when variant is responsive', () => { + const { container } = render( + , + ) + + const region = container.querySelector('[aria-label="Ações rápidas"]') + expect(region).toBeTruthy() + const list = region?.querySelector('ul[role="list"]') + expect(list).toBeTruthy() + expect(list?.className).toMatch(/grid-cols-2/) + expect(list?.className).toMatch(/md:flex/) + const regionClasses = region?.className.split(/\s+/) ?? [] + expect(regionClasses).not.toContain('overflow-x-auto') + expect(regionClasses.some((token) => token.includes('md:overflow-x-auto'))).toBe(true) }) it('scrolls horizontally on pointer-fine drag past the threshold', () => { - const { container } = render() + const { container } = render() const scroller = getScroller(container) mockScrollerOverflow(scroller) @@ -227,6 +251,7 @@ describe('CampaignHomeActionStrip', () => { const onClick = vi.fn() const { container } = render( , ) @@ -246,6 +271,7 @@ describe('CampaignHomeActionStrip', () => { const onClick = vi.fn() const { container } = render( , ) @@ -263,7 +289,10 @@ describe('CampaignHomeActionStrip', () => { it('allows child click when movement stays below the drag threshold', () => { const onClick = vi.fn() const { container } = render( - , + , ) const scroller = getScroller(container) mockScrollerOverflow(scroller) @@ -285,6 +314,7 @@ describe('CampaignHomeActionStrip', () => { const onLinkClick = vi.fn() const { container } = render( { expect(actions?.closest('[data-slot="home-dock"]')).not.toBeNull() }) - it('bleeds home-actions edge-to-edge on mobile (compensates scroll p-4)', () => { + it('does not bleed home-actions on mobile (B122 grid uses page gutter)', () => { const { container } = render(Actions block

    } />) const actions = container.querySelector('[data-slot="home-actions"]') expect(actions).not.toBeNull() - expect(actions?.closest('[data-slot="home-actions-chrome"]')).not.toBeNull() - }) - - it('does not clip horizontal bleed on the actions retraction shell', () => { - const { container } = render(Actions block

    } />) - - const actionsChrome = container.querySelector('[data-slot="home-actions-chrome"]') - const bleedWrapper = actionsChrome?.querySelector('[data-allow-horizontal-bleed="true"]') - expect(bleedWrapper).not.toBeNull() + expect(actions?.className).not.toMatch(/-mx-4/) + expect(actions?.className).not.toMatch(/calc\(100%\+2rem\)/) }) it('renders search slot inside home-dock when provided', () => {