diff --git a/docs/plans/inicio-acoes-grade-3cols-2rows-impl.md b/docs/plans/inicio-acoes-grade-3cols-2rows-impl.md
new file mode 100644
index 00000000..4d0b6e58
--- /dev/null
+++ b/docs/plans/inicio-acoes-grade-3cols-2rows-impl.md
@@ -0,0 +1,62 @@
+# Impl: Início / FAB — grade 3 colunas × 2 linhas (corrigir orientação)
+
+Status: aprovado
+Atualizado em: 2026-08-02
+Issue: #302
+Intenção: docs/plans/inicio-acoes-grade-3cols-2rows.md
+Appetite restante: ~0,5d (causa real + testes de geometria)
+
+## Leitura da intenção
+
+- **Outcome:** Início mobile e overlay FAB mostram 6 ações staff em **3 colunas × 2 linhas** (verificável em viewport ~390), não 2×3; `md+` Início mantém strip.
+- **O que NÃO negociar:** catálogo/lockdown; sem migration; não reabrir snap/peek; liderança sem layout paralelo.
+- **O que reavaliar:** B132 já trocou `grid-cols-2` → `grid-cols-3`, mas testes só assertam `className` — a causa visual provável é **min-width dos itens da grade** (`min-width: auto` + `box-content` + `w-[5.5rem]` residual) comprimindo para 2 colunas percebidas ou clip pelo `overflow-hidden` do shell de retraction.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ Strip["CampaignHomeActionStrip"]
+ Strip -->|grid/responsive lt-md| G["grid-cols-3 + li min-w-0"]
+ Btn["CampaignHomeActionButton"]
+ Btn -->|layout=grid| W["w-full min-w-0 border-box"]
+ E2E["e2e viewport 390"]
+ E2E --> Geo["boundingBox: 3 por linha"]
+```
+
+**Opções consideradas:** A) só reafirmar `grid-cols-3` | B) `min-w-0` + `layout=grid` no botão | C) reduzir círculo/gap
+**Recomendação:** **B** — corrige sizing real sem redesign; gap-2 só se critique pedir.
+**Rejeitadas:** A (já feito em B132, insuficiente); C (fora do apetite salvo falha em 390).
+
+### Componentes / mudanças
+
+- **`CampaignHomeActionButton`:** `layout: 'grid'` — `w-full min-w-0 max-w-full`, `box-border` (sem `box-content` na grade).
+- **`CampaignHomeActionStrip`:** `li` com `min-w-0`; `layout='grid'` quando `variant` é `grid` ou `responsive`; `gap-2` no mobile grid (mais folga horizontal em 390px); `data-layout="grid-3"` no `
` para e2e.
+- **`CampaignQuickActionsOverlay`:** sem mudança estrutural (já `variant="grid"`).
+- **Testes:** helper de geometria em e2e (`campaignHomeActions.e2e.spec.ts` + overlay em `campaignMunicipalities.e2e.spec.ts`); unit para `layout=grid` e `min-w-0` no `li`.
+- **Migration:** sem migration.
+
+## Fases verificáveis
+
+1. **Strip + button sizing** — `layout=grid`, `min-w-0`, gap ajustado.
+2. **E2e geometria** — 3 botões na primeira linha, 3 na segunda (staff); overlay FAB idem.
+3. **Gates** — `pnpm gate:fast`; `pnpm push`.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- PWA cache bust (fora do repo); se persistir pós-deploy, runbook separado.
+- Refatorar `HomeChromeRetractionShell` globalmente.
+- Grade em `md+`.
+
+## Riscos e mitigação
+
+- **Leader 2 ações:** grade 3-col deixa 1 célula vazia — aceite da intenção.
+- **Desktop overlay dialog:** `layout=grid` mantém `w-full` (sem `md:w-[5.5rem]`).
+
+## Aceite de engenharia
+
+- [ ] Aceite de produto da intenção ainda coberto
+- [ ] Invariantes AGENTS/engineering-standards
+- [ ] E2e viewport 390 com geometria 3×2 (não só className)
+
+Self-score decision-quality: 5/5
diff --git a/src/components/campaign/dashboard/CampaignHomeActionButton.tsx b/src/components/campaign/dashboard/CampaignHomeActionButton.tsx
index ebc00249..92488302 100644
--- a/src/components/campaign/dashboard/CampaignHomeActionButton.tsx
+++ b/src/components/campaign/dashboard/CampaignHomeActionButton.tsx
@@ -25,17 +25,25 @@ export type CampaignHomeActionButtonProps = {
href?: string
onClick?: () => void
disabled?: boolean
- layout?: 'strip' | 'responsive'
+ layout?: 'strip' | 'responsive' | 'grid'
}
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'
+ 'group 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 actionStripControlClassName = cn(
+ actionControlBaseClassName,
+ 'box-content w-[5.5rem] shrink-0 snap-start',
+)
const actionResponsiveControlClassName = cn(
actionControlBaseClassName,
- 'w-full md:w-[5.5rem] md:shrink-0 md:snap-start',
+ 'w-full min-w-0 max-w-full md:box-content md:w-[5.5rem] md:max-w-none md:shrink-0 md:snap-start',
+)
+
+const actionGridControlClassName = cn(
+ actionControlBaseClassName,
+ 'w-full min-w-0 max-w-full',
)
const circleClassName =
@@ -84,7 +92,11 @@ export const CampaignHomeActionButton = ({
})
const className = cn(
- layout === 'responsive' ? actionResponsiveControlClassName : actionStripControlClassName,
+ layout === 'grid'
+ ? actionGridControlClassName
+ : layout === 'responsive'
+ ? actionResponsiveControlClassName
+ : actionStripControlClassName,
isInert && 'cursor-default',
)
const aria = {
diff --git a/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx b/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx
index 6c4e26eb..c809ff95 100644
--- a/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx
+++ b/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx
@@ -133,13 +133,15 @@ export const CampaignHomeActionStrip = ({
onClickCapture,
}
+ const isGridLikeLayout = isGridLayout || !isStripLayout
+
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'
: isGridLayout
- ? 'grid grid-cols-3 gap-3'
- : 'grid grid-cols-3 gap-3 md:flex md:min-w-max md:snap-x md:snap-proximity md:gap-0 md:px-0 md:pb-1',
+ ? 'grid grid-cols-3 gap-2'
+ : 'grid grid-cols-3 gap-2 md:flex md:min-w-max md:snap-x md:snap-proximity md:gap-0 md:px-0 md:pb-1',
)
const scrollerClassName = cn(
@@ -160,12 +162,16 @@ export const CampaignHomeActionStrip = ({
className={scrollerClassName}
{...finePointerPanProps}
>
-
+
{actions?.map(({ id, ...button }) => (
- -
+
-
))}
diff --git a/tests/e2e/campaignHomeActions.e2e.spec.ts b/tests/e2e/campaignHomeActions.e2e.spec.ts
index 8ee00fe5..1b05fecb 100644
--- a/tests/e2e/campaignHomeActions.e2e.spec.ts
+++ b/tests/e2e/campaignHomeActions.e2e.spec.ts
@@ -1,6 +1,11 @@
import type { Page } from '@playwright/test'
import { WIZARD_MUNICIPALITY_STEP_TITLE } from '../../src/lib/campaignWizardCopy.js'
+import {
+ assertThreeColumnActionGrid,
+ collectActionBoundingBoxes,
+ groupActionsByRow,
+} from './helpers/actionGridGeometry.js'
import { expect, test } from './fixtures/campaignE2EFixtures.js'
const staffActionLabels = [
@@ -127,6 +132,11 @@ test.describe('Início — catálogo de ações (B45)', () => {
expect(overflowX).not.toBe('auto')
expect(overflowX).not.toBe('scroll')
+ const actionList = actionsRegion.locator('ul[data-layout="grid-3"]')
+ await expect(actionList).toBeVisible()
+ const boxes = await collectActionBoundingBoxes(actionsRegion, staffActionLabels)
+ assertThreeColumnActionGrid(groupActionsByRow(boxes), 2)
+
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/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts
index 3fcc44fe..eebbf960 100644
--- a/tests/e2e/campaignMunicipalities.e2e.spec.ts
+++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts
@@ -3,6 +3,11 @@ import {
ensureLeasedConsent,
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
} from '../helpers/testDatabaseLease.js'
+import {
+ assertThreeColumnActionGrid,
+ collectActionBoundingBoxes,
+ groupActionsByRow,
+} from './helpers/actionGridGeometry.js'
import {
campaignPageChrome,
checkRadixWhenHydrated,
@@ -483,6 +488,36 @@ test.describe('Municípios — FAB ações rápidas mobile (B126)', () => {
test.describe('Municípios — FAB overlay polish (B126)', () => {
test.use({ viewport: { width: 390, height: 844 } })
+ const staffActionLabels = [
+ 'Ajustar votos',
+ 'Registrar sinal',
+ 'Mudar tendência',
+ 'Atualizar liderança',
+ 'Registrar pedido',
+ 'Ver esquecidos',
+ ] as const
+
+ test('overlay actions use a 3×2 grid at mobile width (B136)', async ({ campaign, page }) => {
+ const { fixtures } = campaign
+ const coordinator = await fixtures.createCampaignUser('coordinator', {
+ name: fixtures.value('Coordenador B136'),
+ })
+
+ await campaign.login(page, coordinator.email!, coordinator.password)
+ await page.goto(`${campaign.baseURL}/campanha/municipios`)
+ await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
+
+ await page.getByRole('button', { name: 'Ações rápidas' }).click()
+ const overlay = page.locator('#CampaignQuickActionsOverlay')
+ await expect(overlay).toBeVisible()
+
+ const actionsRegion = overlay.getByLabel('Ações rápidas')
+ await expect(actionsRegion.locator('ul[data-layout="grid-3"]')).toBeVisible()
+
+ const boxes = await collectActionBoundingBoxes(actionsRegion, staffActionLabels)
+ assertThreeColumnActionGrid(groupActionsByRow(boxes), 2)
+ })
+
test('overlay labels readable and search focus hides action strip', async ({
campaign,
page,
diff --git a/tests/e2e/helpers/actionGridGeometry.ts b/tests/e2e/helpers/actionGridGeometry.ts
new file mode 100644
index 00000000..fc0adfc8
--- /dev/null
+++ b/tests/e2e/helpers/actionGridGeometry.ts
@@ -0,0 +1,70 @@
+import type { Locator } from '@playwright/test'
+import { expect } from '@playwright/test'
+
+const ROW_Y_TOLERANCE_PX = 12
+
+export type ActionBoundingBox = {
+ label: string
+ x: number
+ y: number
+ width: number
+ height: number
+}
+
+export async function collectActionBoundingBoxes(
+ container: Locator,
+ labels: readonly string[],
+): Promise {
+ const boxes: ActionBoundingBox[] = []
+
+ for (const label of labels) {
+ const control = container
+ .getByRole('link', { name: label, exact: true })
+ .or(container.getByRole('button', { name: label, exact: true }))
+ const box = await control.boundingBox()
+ if (!box) {
+ throw new Error(`Missing bounding box for action "${label}"`)
+ }
+ boxes.push({ label, ...box })
+ }
+
+ return boxes
+}
+
+export function groupActionsByRow(
+ boxes: readonly ActionBoundingBox[],
+ tolerancePx = ROW_Y_TOLERANCE_PX,
+): ActionBoundingBox[][] {
+ const sorted = [...boxes].sort((left, right) => left.y - right.y || left.x - right.x)
+ const rows: ActionBoundingBox[][] = []
+
+ for (const box of sorted) {
+ const row = rows.find((entry) => Math.abs(entry[0]!.y - box.y) <= tolerancePx)
+ if (row) {
+ row.push(box)
+ } else {
+ rows.push([box])
+ }
+ }
+
+ for (const row of rows) {
+ row.sort((left, right) => left.x - right.x)
+ }
+
+ return rows
+}
+
+/** Asserts a 3-column grid with `expectedRowCount` rows (staff = 2 rows of 3). */
+export function assertThreeColumnActionGrid(
+ rows: readonly ActionBoundingBox[][],
+ expectedRowCount: number,
+) {
+ expect(rows).toHaveLength(expectedRowCount)
+
+ for (const row of rows) {
+ expect(row).toHaveLength(3)
+ const [first, second, third] = row
+ expect(second.x).toBeGreaterThan(first.x + first.width * 0.4)
+ expect(third.x).toBeGreaterThan(second.x + second.width * 0.4)
+ }
+}
diff --git a/tests/unit/campaignHomeActionButton.unit.spec.tsx b/tests/unit/campaignHomeActionButton.unit.spec.tsx
index 9adec2a1..bb7abd14 100644
--- a/tests/unit/campaignHomeActionButton.unit.spec.tsx
+++ b/tests/unit/campaignHomeActionButton.unit.spec.tsx
@@ -98,6 +98,15 @@ describe('CampaignHomeActionButton', () => {
expect(control.className).toContain('w-[5.5rem]')
})
+ it('uses full-width grid sizing when layout is grid (B136)', () => {
+ renderActionButton({ label: 'Registrar', icon: BarChart3, layout: 'grid' })
+ const control = screen.getByRole('button', { name: 'Registrar' })
+ expect(control.className).toContain('w-full')
+ expect(control.className).toContain('min-w-0')
+ expect(control.className).not.toContain('box-content')
+ expect(control.className).not.toContain('w-[5.5rem]')
+ })
+
it('opens the description drawer on long-press when the pointer is coarse', () => {
resetCampaignCoarsePointerForTests()
matchMediaMock.mockImplementation((query: string) => ({
@@ -209,7 +218,9 @@ describe('CampaignHomeActionStrip', () => {
const list = region?.querySelector('ul[role="list"]')
expect(list).toBeTruthy()
expect(list?.className).toMatch(/grid-cols-3/)
+ expect(list?.getAttribute('data-layout')).toBe('grid-3')
expect(list?.className).toMatch(/md:flex/)
+ expect(list?.querySelector('li')?.className).toContain('min-w-0')
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)
@@ -229,6 +240,7 @@ describe('CampaignHomeActionStrip', () => {
const region = container.querySelector('[aria-label="Ações rápidas"]')
const list = region?.querySelector('ul[role="list"]')
expect(list?.className).toMatch(/grid-cols-3/)
+ expect(list?.getAttribute('data-layout')).toBe('grid-3')
expect(list?.className).not.toMatch(/md:flex/)
expect(region?.className).not.toMatch(/overflow-x-auto/)
})
diff --git a/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx b/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx
index 160bf305..4e31a38f 100644
--- a/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx
+++ b/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx
@@ -171,6 +171,7 @@ describe('CampaignQuickActionsOverlay (B126)', () => {
const chrome = document.querySelector('[data-slot="quick-actions-chrome"]')
const list = chrome?.querySelector('ul[role="list"]')
expect(list?.className).toContain('grid-cols-3')
+ expect(list?.getAttribute('data-layout')).toBe('grid-3')
expect(list?.className).not.toMatch(/flex/)
expect(chrome?.querySelector('[aria-label="Ações rápidas"]')).not.toBeNull()
})