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..d927bdc1 100644 --- a/src/components/campaign/dashboard/CampaignHomeActionButton.tsx +++ b/src/components/campaign/dashboard/CampaignHomeActionButton.tsx @@ -25,11 +25,21 @@ export type CampaignHomeActionButtonProps = { href?: string onClick?: () => void disabled?: boolean + layout?: 'strip' | 'grid' | 'responsive' } -export const actionControlClassName = +export const actionStripControlClassName = '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' +export const actionGridControlClassName = + 'group box-content flex min-h-11 w-full 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' + +export const actionResponsiveControlClassName = + 'group box-content flex min-h-11 w-full 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 md:w-[5.5rem] md:shrink-0 md:snap-start' + +/** @deprecated Use actionStripControlClassName or actionGridControlClassName */ +export const actionControlClassName = actionStripControlClassName + 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 +61,7 @@ export const CampaignHomeActionButton = ({ href, onClick, disabled = false, + layout = 'strip', }: CampaignHomeActionButtonProps) => { const isCoarsePointer = useCoarsePointer() const [drawerOpen, setDrawerOpen] = useState(false) @@ -74,7 +85,14 @@ export const CampaignHomeActionButton = ({ }, }) - const className = cn(actionControlClassName, isInert && 'cursor-default') + const className = cn( + layout === 'grid' + ? actionGridControlClassName + : 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..7345cf1c 100644 --- a/src/components/campaign/dashboard/CampaignHomeLayout.tsx +++ b/src/components/campaign/dashboard/CampaignHomeLayout.tsx @@ -75,7 +75,7 @@ export const CampaignHomeLayout = ({ >
    {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..35e6de86 100644 --- a/tests/e2e/campaignHomeActions.e2e.spec.ts +++ b/tests/e2e/campaignHomeActions.e2e.spec.ts @@ -102,6 +102,32 @@ 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 all six home actions in the mobile grid without horizontal scroll', async ({ + campaign, + page, + }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator', { + name: fixtures.value('Coordenadora Geral'), + }) + + await campaign.login(page, coordinator.email!, coordinator.password) + const actionsRegion = page.getByLabel('Ações rápidas') + await expect(actionsRegion).toBeVisible() + + for (const label of staffActionLabels) { + await expect(page.getByRole('link', { name: label, exact: true }).or( + page.getByRole('button', { name: label, exact: true }), + )).toBeVisible() + } + + const overflowX = await actionsRegion.evaluate((el) => getComputedStyle(el).overflowX) + expect(overflowX).not.toBe('auto') + expect(overflowX).not.toBe('scroll') + }) + test('staff sees six home actions and can open municipalities without coverage', async ({ campaign, page, diff --git a/tests/unit/campaignHomeActionButton.unit.spec.tsx b/tests/unit/campaignHomeActionButton.unit.spec.tsx index 0bd85135..49c0b821 100644 --- a/tests/unit/campaignHomeActionButton.unit.spec.tsx +++ b/tests/unit/campaignHomeActionButton.unit.spec.tsx @@ -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/) + expect(region?.className).not.toMatch(/overflow-x-auto/) + expect(region?.className).toMatch(/md:overflow-x-auto/) }) it('scrolls horizontally on pointer-fine drag past the threshold', () => { - const { container } = render() + const { container } = render( + , + ) const scroller = getScroller(container) mockScrollerOverflow(scroller) @@ -227,6 +252,7 @@ describe('CampaignHomeActionStrip', () => { const onClick = vi.fn() const { container } = render( , ) @@ -246,6 +272,7 @@ describe('CampaignHomeActionStrip', () => { const onClick = vi.fn() const { container } = render( , ) @@ -263,7 +290,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 +315,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('keeps home-actions within page gutter on mobile (B122 grid)', () => { 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() + expect(actions?.className).not.toMatch(/-mx-4/) + expect(actions?.className).not.toMatch(/calc\(100%\+2rem\)/) }) it('does not clip horizontal bleed on the actions retraction shell', () => {