B136 — corrigir grade 3×2 no Início e FAB overlay

- layout=grid no botão com w-full/min-w-0 sem box-content no mobile
- li min-w-0, gap-2, data-layout=grid-3 no strip
- e2e com geometria (boundingBox) em viewport 390
- impl plan docs/plans/inicio-acoes-grade-3cols-2rows-impl.md

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-02 23:04:36 +00:00
parent 6539d0cc55
commit 2f2c8563a5
No known key found for this signature in database
8 changed files with 218 additions and 10 deletions

View file

@ -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 `<ul>` 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

View file

@ -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 = {

View file

@ -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}
>
<ul role="list" className={listClassName}>
<ul
role="list"
className={listClassName}
data-layout={isGridLikeLayout ? 'grid-3' : undefined}
>
{actions?.map(({ id, ...button }) => (
<li key={id ?? button.href ?? button.label} className="m-0 list-none p-0">
<li key={id ?? button.href ?? button.label} className="m-0 min-w-0 list-none p-0">
<CampaignHomeActionButton
{...button}
layout={isStripLayout ? 'strip' : 'responsive'}
layout={isStripLayout ? 'strip' : isGridLayout ? 'grid' : 'responsive'}
/>
</li>
))}

View file

@ -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()

View file

@ -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,

View file

@ -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<ActionBoundingBox[]> {
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)
}
}

View file

@ -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/)
})

View file

@ -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()
})