feat(B200): encaixes dos cards mobile de municípios — densidade, alinhamento de labels e pilha de avatares (pós-B196)

This commit is contained in:
Francisco Solla 2026-08-12 01:02:24 -03:00
parent 66e0321cd9
commit 3ee2d83350
9 changed files with 534 additions and 63 deletions

View file

@ -4,12 +4,15 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-12):** **B200** (Issue #704) — encaixes dos cards mobile de `/campanha/municipios` (pós-B196): o card lê como um bloco só. **Barra (chassis omnibox):** form `py-1` → `pt-1 pb-0` + campo `min-h-8` → `min-h-10` + X `size-8` → `size-9` → barra 41 → **45px** (aceite "≈48" do plano — o draft gateado manda: min-h-10 sem voltar ao campo alto do B184); **a causa real do vão entre barra e primeiro card não era o pb do form, e sim o `gap-8` da coluna do shell** (32px — medido ao vivo: form.bottom 100.67 vs card.top 132.67) → `max-md:-mb-8` no `campaignListOmniboxFormClassName` cancela exatamente esse gap (primeiro card colado; demais seções do shell mantêm o ritmo; desktop `md:mb-0` intacto). **Card:** article `gap-2.5` → `gap-2`; nome/território `gap-0` + `leading-tight` no território (vão ≈0–2px, e2e ≤2); chips `gap-y-2` → `gap-y-1.5`; grupos de relação `gap-1.5` → `gap-1`. **Avatares (decisão cara):** o modo `overlapRow` troca as células `flex-1 justify-center` + `overflow-hidden` (origem do "um em cada canto" com 2 e das pontas cortadas com 5+) por **pilha à esquerda com sobreposição medida** — função pura `relationAvatarOverlapPx(count, width)` (`src/lib/relationAvatarOverlap.ts`, unit-testada: min 8px garantido até com 2 avatares, fit exato `28+(n−1)(28−overlap) ≤ W` para qualquer n/viewport, fallback no mínimo para width não-finita) + `ResizeObserver` no grupo (`'use client'` no stack; modo default desktop `-space-x-2`/cap 3 intacto; `overflow-hidden` permanece como guarda de último recurso, nunca ativa no fit). **E2E:** B196 da barra (44–48px + primeiro card colado com limite duplo — nem vão nem card embaixo da barra) e de avatares reescritos (2 avatares levemente sobrepostos à esquerda — a asserção antiga "sem overlap" pinava o bug); novo describe B200 (barra 44–48 + colagem + eixo label/valor nos seis blocos + pilha: mesma linha, distância entre avatares 8–20px — sobreposição mínima E sliver visível, ancorada à esquerda, nada invade o vizinho via fronteira do grupo); **endurecimento anti-flake de medição** nas specs de geometria — wait `S:` settle + footer "1 município encontrado" (o settle sozinho passava com o card mid-stream num layout transiente de célula ~15px — medido via debug) + `expect.poll` 30s de layout único por leitura (o webServer dev sob workers paralelos recarrega página no meio do teste — medições em frames diferentes capturavam estados distintos). Sem migration/access/Consent/schema. Gates: unit 2085 (6 novos), int 748, e2e B196+B200 14/14 (2 repeats) + spec completa 33/34 (única falha B24 — reproduzida **sem** a mudança, flake de ambiente pré-existente; 5 falhas ambientais do 1º run sumiram), build OK, lint/format/cycles OK (knip: erro pré-existente ledgerado P3 do payload.config). Intenção [`municipios-mobile-ajustes-cards.md`](plans/municipios-mobile-ajustes-cards.md); impl [`municipios-mobile-ajustes-cards-impl.md`](plans/municipios-mobile-ajustes-cards-impl.md).
**Recently resolved (2026-08-12):** **Remoção do `migration-lock`** (CI) — o check de `ci-pr.yml` que falhava quando >1 PR aberto tocava `src/migrations/`/`payload-types.ts` foi **removido** (deadlock de CI: com 2 PRs de schema abertos, AMBOS falhavam o check e nenhum mergeava sem bypass manual — 9 de 13 runs de CI falhos desde 11/08 foram por isso). A ameaça que ele serializava (cadeia de migrations com drift de snapshot) já é coberta pelo CI em todo PR e na main: `pnpm migrate` + `test:int` (incl. `campaignMigrationReconciliation.int.spec.ts`, checksum do schema migrado vs config); o conflito real (index.ts/payload-types.ts) resolve no rebase. Removido: job `migration-lock` do `ci-pr.yml` + `needs` do rollup `checks` + contexto required da branch protection (`pnpm configure:branch-protection` agora aplica só `checks`); pool mantém a serialização de spawn de issues `needs:migration` (`countOpenSchemaPrs` em `agent-pool-github.mjs` — independe do CI). Docs/skills atualizados (AGENT-OPS contrato de PR, GUARDRAILS, engineering-standards, agent-pool, work-issue, agent-pr-workflow). Sem migration/access/Consent/UI/schema.
**Recently resolved (2026-08-11):** **C131** (Issue #698) — "Salvador" como opção agregada no dropdown de municípios das células de capacidade (pessoas ×3, assessores, lideranças, dobradinhas): o hit "Salvador — Todas as zonas" entra **primeiro** na busca (`searchMunicipalityPortfolio` ganha `kind: 'city'` derivado do catálogo — constante `NORMALIZED_ZONE_CITIES` com a contagem de zonas por cidade-zona; um toque adiciona as 19 ZEs num lote via máquina existente do `RelationChipCell` — delta otimista, cap/floor, undo, colapso "Salvador (19)" B178, e o exact-match C116 faz "salvador"+Enter eleger o agregado). **Gate de escopo por contagem:** o agregado só aparece quando a cidade inteira está no escopo `addableIds` do ator (assessor com carteira parcial segue com as opções individuais — a label decidida no gate "Todas as zonas" nunca mente); o matcher de word-start é query-prefixo-de-label, então um query "Salvador — ZE N" só surfacia o hit exato da ZE (o agregado nunca rouba o keyboard contract do teste B34 — verificado, sem guard); sem `count` no hit (descrição fixa); célula ganhou só um branch em `hitDescription`. Sem migration/access/Consent/UI shape; superfícies fora da célula não usam essa busca (wizard tem busca própria). Testes: unit busca pura (19 ids, soma com selecionado, supressão por escopo parcial, supressão tudo-atribuído, primeiro da lista), cell render (opção "Salvador"/"Todas as zonas"), e2e novo em `campaignPeople` (Lidera → agregado → chip "Remover Salvador — 19 municípios" → 19 zonas no DB, assert condicional se o claim caiu em zona). Gates: unit+int 2084/748 verdes, e2e pessoas+lideranças 11/11 (modo prod local `E2E_PROD=1` — prewarm dev estourou o orçamento de 180s sob carga, flake de contenção conhecida; C117 flakeou 1× em paralelo e passou isolado), build OK; knip segue na falha conhecida P3 de ambiente (payload.config.ts). Intenção [`pessoas-salvador-completa-dropdown.md`](plans/pessoas-salvador-completa-dropdown.md); impl [`pessoas-salvador-completa-dropdown-impl.md`](plans/pessoas-salvador-completa-dropdown-impl.md).
**Recently resolved (2026-08-11):** **C115** (Issue #636) — **Google Calendar → Teqo**: quem tem permissão no calendário remarca/renomeia/cancela o compromisso direto no Google e a mudança volta para a atividade (bidirecional sobre o espelho C114). **Motor único bidirecional** no `runSyncPass` do C114 (estende o dono): para cada evento nosso — conteúdo difere? → **regra do relógio**: Google mais novo aplica **título + horário** (all-day incluído; prefixo `[Município] ` só é destripado quando bate com o município — desfazemos só a nossa prefixação; cap 160) com **registro auditável** no `updates` feed (`Google Calendar: …`, `author: null`, feed mostra "Google Calendar" como autor pelo prefixo) — senão o Teqo re-asserta. **Baseline do relógio = `activity.lastMirroredChangeAt`** (novo campo system-write, carimbado no beforeChange só quando um campo espelhado muda): task toggle/update/resultado bumpam `updatedAt` sem tocar o espelho — comparar contra `updatedAt` silenciaria uma edição pendente do Google. **Escrita reversa sem usuário** (`req.user` stripped): staff que dispara a passada não ganha autoria do registro nem passa pelo gate de re-agendamento do deputado. **Cancelamento:** `listEvents` com `showDeleted=true` — evento `status: 'cancelled'` (lixeira) com atividade `confirmado` → cancela **se o cancelamento for mais novo que o último carimbo** (senão é reopen do staff: o trashed é limpo e o evento recriado — nunca re-cancela); `realizado` é histórico protegido. **Remoção permanente** distinguida de "nunca criado" por snapshot `lastSeenEventIds` (JSONB `{calendarId, ids}`, gravado só em passada bem-sucedida e **excluindo ids que o próprio motor deletou** — senão o reopen virava re-cancelamento fantasma); troca de `calendarId` → snapshot stale → recria sem cancelar. **Webhook público** `POST /campanha/agenda/google-webhook/[secret]` (fora de `(app)`, precedente `ical/[secret]`, `maxDuration=60`, `force-dynamic`, allowlist do `campaignJsonMutationRoute` com razão): pre-filter barato (secret ≥32 + headers) ANTES do bootstrap do Payload + validação fail-closed em lib pura testada (`isGooglePushNotificationValid` — secret da URL + `X-Goog-Channel-ID` + `X-Goog-Resource-ID` + `X-Goog-Channel-Token`, constant-time, 404; `not_exists` → estado de erro; `sync` → ignora); ping sem payload → passada completa; 200 mesmo se a passada falhar (sem retry storm). **Canal lazy sem cron (Hobby):** `ensureGoogleCalendarPushChannel` em toda passada — watch com UUID novo, TTL 30d, **segredo rotaciona a cada criação** (URL vazada auto-cura na renovação), **novo canal persistido ANTES do stop do antigo** (falha do stop não orfana o novo); expira em <48h → watch com id novo + `channels.stop` do antigo; troca de calendário → re-watch; falha → `pushChannelError` (state), nunca pausa (o reverso roda em todo trigger local; canal só dá imediatismo — limitação de 30d de silêncio documentada). Campos novos na `googleCalendarSync` (system-write; **identity fields com `read: false`** — o conjunto de credenciais do webhook nunca sai por path de usuário; create admin-only + leitura prefere linha configurada); migration **hand-written** (mesmo motivo do C114). **Título editável pelo Google** exigiu `mutationKind: 'googleCalendarSync'` dedicado (título muda, slug canônico congelado) + **skip do hook de sync na própria escrita reversa** (sem passada aninhada). UI (Impeccable B): seção "Edições pelo Google" no dialog (copy + expiração do canal + erro). Testes: unit (reverse mapping 18, validação do webhook 7, dialog/feed render 6, allowlist), int (13 novos no motor com stub: Google novo aplica+converge, task toggle não silencia edição pendente, Teqo novo re-asserta, cancelled cancela+limpa, reopen pós-cancelamento não re-cancela, remoção permanente via snapshot, create falhou não cancela, troca de calendário não cancela+re-watch, all-day ida-e-volta, realizado protegido, description-only sem registro, ciclo do canal cria/renova/rotaciona/stop, falha de canal não pausa — asserções herméticas a atividades ambiente de arquivos irmãos no DB compartilhado), e2e (webhook 404 fail-closed + caminhos 200 validados: sync/not_exists/change). Suíte completa verde (unit 2064, int 749, e2e 5/5). Intenção [`c115-google-calendar-edicao-bidirecional.md`](plans/c115-google-calendar-edicao-bidirecional.md); impl [`c115-google-calendar-edicao-bidirecional-impl.md`](plans/c115-google-calendar-edicao-bidirecional-impl.md).
**Recently resolved (2026-08-11):** **C129** (Issue #696) — dobradinha ganha **nome de legenda** (campo + display discreto sob o nome): campo `ballotName` (texto opcional, `maxLength: 30` — limite real do nome de urna do TSE; **sem index** — nunca é chave de busca/ordenação/facet, o nome real continua sendo a chave) em `stateDeputy`, ao lado de `party`. **Migration** `20260811_234822_add_state_deputy_ballot_name` (gerada; o diff do gerador re-emitia um no-op de `ADD VALUE 'lembranca'` por snapshot stale — limpa à mão para só o `ADD COLUMN`; nullable, sem backfill). **Display "discreto sob o nome"** (mesma família, `text-xs text-muted-foreground`, sem label — a posição é o rótulo) em 3 pontos: 2ª linha da célula de Nome de `/campanha/pessoas` via seam pura `peopleNameSubline` em `peopleData` (C130 estende com o fallback `?? city` — regra combinada final legenda-sobrepõe-base registrada nos dois planos; nesta branch, sem legenda nada muda na linha, aceite da intenção), sublinha fora da região click-to-edit da célula de Nome na lista de Dobradinhas (irmã da célula no flex-col — clicar nela nunca abre o editor do nome), e sublinha no Nome da seção Contato da ficha. **Edição** (onde já se edita dado de dobradinha, B163): coluna "Nome de legenda" na lista de Dobradinhas (mesma máquina de célula do partido, `editTrigger="cell"`, **default-hidden** em `DEFAULT_HIDDEN_COLUMN_IDS.dobradinhas` — precedente E-mail B197; a sublinha é o display always-on e a coluna o opt-in de edição) + campo no `StateDeputyForm` (criar e editar; schemas create/update/inline `trimmedOptionalText(30)`/`trimmedNullableText(30)` — vazio limpa o campo). Escrita: `updateStateDeputyBallotNameRecord` espelhando o partido (`runStaffEntityMutation`, staff-only, sem transação/locks/Consent novos — escrita single-collection); `CampaignInlineEditableField` ganhou `'ballotName'` (aditivo). Sem migration destrutiva/access/Consent. Testes: unit (merge carrega `ballotName`; `peopleNameSubline` legenda-only/null; default de colunas dobradinhas `['email','ballotName']`), int novo `campaignStateDeputyBallotName` (staff seta/limpa, advisor ok, leader negado, criação com legenda); verificado ao vivo em dev (pessoas/dobradinhas/ficha, edição inline persistindo na sublinha). Gates: unit 2062, int 750, build OK; knip = P3 ledgered pré-existente (idêntico no baseline). Intenção [`pessoas-nome-de-legenda-dobradinha.md`](plans/pessoas-nome-de-legenda-dobradinha.md); impl [`pessoas-nome-de-legenda-dobradinha-impl.md`](plans/pessoas-nome-de-legenda-dobradinha-impl.md). (feat(C129): nome de legenda da dobradinha — campo ballotName + display discreto sob o nome (Closes #696))
**Recently resolved (2026-08-11):** **OPS42** (Issue #691) — o e2e dev não perde mais fill/Enter para a **navegação RSC tardia do login**: em dev sob carga, ~150–500 ms após o load do `/campanha` o router do Next dev dispara um `history.replaceState` sem request (framenav) e remonta o subtree do layout (provider do chat novo, textarea novo com `defaultValue=''`) — interações imediatas caem na janela e são silenciosamente perdidas (input volta a `''`, envio nunca dispara; CI/prod imune — main verde). **Mecanismo medido por sonda** (3 runs nesta máquina, load 45–61): o `waitForURL` do login resolve **no commit** do redirect (não otimista — a hipótese da intenção caiu), e o commit perigoso é um **segundo** framenav pós-`goto` do próprio teste, cuja remontagem do DOM chega 150–250 ms depois; interação dentro da janela ainda induz cascade do overlay de dev (`POST /__nextjs_original-stack-frames`); fill atrasado ~2 s sobrevive. **Fix:** helper `waitForRouterSettled(page)` no fixture (`campaignE2EFixtures.ts`, padrão `checkRadixWhenHydrated`) — retorna após janela de **500 ms sem `framenavigated`** (fail-soft em 15 s com `console.warn`; **no-op em prod** via `CI`/`E2E_PROD` — custo zero no CI), chamado no `campaign.login` (pós-`waitForURL`) e nos pontos de interação imediata da família do chat: `openChatAndSend`/`openChat`/`askViaInput` em `campaignSollinhaContext`, `campaignAiChatResize`, `campaignAiLinks`, `campaignAiTranscribe`, `campaignAiChatFollowUps`; `openMobileDrawer` e os 2 cliques de drawer diretos de `campaignAiChatFollowUps`/`campaignAiChatOpeningChips` + o "Fechar" desktop do `campaignAiChatResize` (o run 3 pegou o clique do drawer na mesma janela — a classe é qualquer interação no layout, não só fill/Enter). **Rejeitadas:** retry de interação (double-send com o mock gated do B199), só-no-login (não enxerga o commit pós-goto), networkidle/wrap global de goto. **Evidência:** `campaignSollinhaContext` 10/10 ×3 dev (load 31–61; o B188 "reload na mesma aba" que flakeava 5/5 na sessão B199) + 8/8 prod (`E2E_PROD=1`, 30,7 s — no-op); irmão `campaignAiChatResize` 8/8 dev. Gates: tsc/lint/format/cycles verdes (knip falha pré-existente P3 — payload.config). Sem migration/access/Consent/UI/app. Intenção [`e2e-dev-router-quiescencia.md`](plans/e2e-dev-router-quiescencia.md); impl [`e2e-dev-router-quiescencia-impl.md`](plans/e2e-dev-router-quiescencia-impl.md).
**Recently resolved (2026-08-11):** **C123** (Issue #667) — a agenda ganha **uma superfície única** de criação/edição de atividade: o overlay (modal central no desktop, drawer do C103 no mobile) com **todas** as seções do formulário completo; as páginas `/campanha/atividades/nova` e `/[slug]/editar` saem **sem redirecionamento** (404). `ActivityInlineCreate` virou `ActivityOverlay` (`ActivityOverlay.tsx`): slot click → criação com o intervalo do slot; **event click → edição** (Google Calendar; o detalhe fica acessível pelo link "Ver detalhes" no rodapé do overlay; middle-click no evento ainda navega ao detalhe via `url` preservada); **`Local` renderizado uma única vez** (o bloco duplicado do popover C91 morreu); Início/Término com **data e hora separados e visíveis sem rolar** (`ActivityDateTimeField` reescrito: trigger de data em popover/bottom-sheet + selects Hora/Minuto inline; contrato civil `YYYY-MM-DDTHH:mm` intacto; mobile `[dia][hora][minuto]` por linha). **Submit por FormData nas actions `createActivityOverlay`/`updateActivityOverlay`** (reusam os parsers `parseActivity*FormData` + os `*Record` transacionais; `status` só na criação — na edição não envia, evita realizado→confirmado) e **`loadActivityEditDraft`** (findByID `activityFormSelect`, `overrideAccess: false`, int testado por escopo). **Bug latente C14 consertado:** o `<input name="id">` que o update perdia (o `/editar` estava quebrado desde `adc29c03`) volta no modo edição. Quick actions `new-activity`/`edit-activity` viraram `onAction` bridged por contexto (`openActivityCreate`/`openActivityEdit`, precedente C94/C114); hosts novos: `ActivityCreateOverlayHost` (lista `/atividades`) e `ActivityEditOverlayHost` (detalhe, botão Editar + FAB). Morrem: páginas/formActions/`ActivityForm.tsx`, `buildActivityCreateHref`/`parseActivityCreatePrefill`/`parseActivityAgendaReturnHref` (activityUi), `atividadesNova` (chrome catalog), `ACTIVITY_NEW_PATH`, `getActivityEditPageData`, `createActivityInline` (vira `createActivityOverlay`), `activityInlineErrors` (vira `activityOverlayErrors`, com duplicate-demand + boundary/zod). Sem migration/access/Consent. Testes: unit overlay 14 (create/edit/erro/sheet), unit mappers/Ui/quickActions/agendaInteractions (eventClick→edit), int `loadActivityEditDraftRecord` por papel, e2e `campaignActivity` 7/7 + agenda mobile/feed/google 9/9 no modo prod (dev-mode flakeou por cold-compile do dev server sob carga da máquina — prewarm 180s estourou; `E2E_PROD=1` é o modo de verificação). Intenção [`agenda-criacao-modal-central.md`](plans/agenda-criacao-modal-central.md); impl [`agenda-criacao-modal-central-impl.md`](plans/agenda-criacao-modal-central-impl.md).

View file

@ -0,0 +1,80 @@
# Impl: Municípios mobile: encaixes dos cards — densidade, alinhamento e avatares (pós-B196)
Status: aprovado
Atualizado em: 2026-08-11
Issue: #704
Intenção: docs/plans/municipios-mobile-ajustes-cards.md
Appetite restante: herdado (~0,5–1 dia eng) — superfície em 1 chassis + 1 card + 1 avatar + 1 lib pura + testes
## Leitura da intenção
- **Outcome:** em `/campanha/municipios` mobile (< md) o card lê como um bloco só: barra de filtro ~48px no total com o primeiro card colado, nome/território colados, ritmo vertical enxuto entre votos/chips/avatares, labels e valores dos seis blocos alinhados à esquerda, avatares agrupados à esquerda com sobreposição mínima garantida e nenhum avatar cortado ou invadindo o grupo vizinho.
- **O que NÃO negociar:** desktop (`md+`) intacto; sheets/edit-where-you-see e o que cada controle edita intactos; labels/dados dos chips intactos; sem cap de avatares com "…"; toques `min-h-11` dos triggers de chip/sheet e `text-sm` do input do omnibox intactos; sr-only com os nomes dos avatares.
- **O que reavaliar:** a hipótese da intenção aponta o acerto para as células `flex-1 justify-center` do modo `overlapRow` — confirmado como origem dos sintomas, mas a correção "por caixa" não basta: o draft exige sobreposição MÍNIMA garantida mesmo com 2 avatares e fit exato em qualquer quantidade/viewport, o que células flex-1 (que espalham com poucos) ou margem fixa por contagem (que invade o vizinho em viewport < 390px com 5+) não dão. Decisão de engenharia no Passo "Abordagem" (opção B).
## Abordagem recomendada
```mermaid
flowchart LR
A[omnibox: pt-1 pb-0 · campo min-h-10 · X size-9<br/>barra 41→45px, 1º card colado] --> B[card: gap-2 · nome/território gap-0+leading-tight<br/>chips gap-y-1.5 · grupos gap-1]
B --> C[avatares: pilha à esquerda, overlap medido<br/>ResizeObserver + função pura em lib]
C --> D[e2e B196 atualizados + novo B200 + unit da overlap]
```
**Opções consideradas:** A | B
**Recomendação:** B — pilha à esquerda com sobreposição **medida** (ResizeObserver no grupo, `overlap = max(8, (28n − W)/(n−1))` por contagem/largura reais, via função pura `relationAvatarOverlapPx` em `src/lib/`). É a única opção que satisfaz o aceite literalmente em toda a faixa mobile real: "sobreposição mínima garantida" mesmo com 2 avatares (8px, `-ml-2`), "nenhum avatar cortado", "nada invade o grupo vizinho" (fit exato por construção, qualquer n, qualquer viewport 320–430px) e "a sobreposição pode crescer com a quantidade" (sem teto: n grandes degradam para pilha). O draft gateado mostra exatamente essa pilha à esquerda (primeiro avatar sem margem, seguintes `-ml-2`/`-ml-3` crescendo) — B é o draft sem hardcode de viewport.
**Rejeitadas:**
- **A — pilha com margem fixa por contagem (draft-faithful, CSS puro):** classes `-ml-2`/`-ml-3` escolhidas por `n`, sem células flex-1, sem `overflow-hidden`. Zero JS e bate com o draft no viewport 390px, mas a margem fixa não conhece a largura do grupo: a 360px (Android médio, comum no BR) o grupo tem ~101px e 5 avatares a `-ml-2` esticam 108px → 7px invadindo o grupo vizinho; 6+ a 390px idem. O aceite "nada invade o grupo vizinho" quebra justamente no caso que o aceite pede. Revisitável se um dia quisermos zero JS, com gatilho: produto aceitar overlap fixo profundo demais para o caso extremo.
- **C — células flex-1 com margem negativa (fit por construção, CSS puro):** mantém o fit exato dos cells atuais, mas a sobreposição só nasce com n≥5 (célula < 28px) — com 2–4 avatares eles voltam a se espalhar pela largura do grupo, contradizendo "sobreposição mínima garantida" e o draft (2 avatares ligeiramente sobrepostos). Rejeitada.
### Componentes / mudanças
- **`campaignListOmniboxFormClassName`** (`src/components/campaign/shared/CampaignListOmnibox.tsx`): `py-1` → `pt-1 pb-0` no mobile (`md:py-0` intacto). Efeito: barra 41 → 45px (40 do campo `min-h-10` + 4 do pt-1 + 1 do border). **Divergência descoberta na execução (medida ao vivo, não na exploração do plano):** o vão entre a barra e o primeiro card NÃO era (só) o pb do form — o form é filho DIRETO da coluna do shell (`CampaignPageShell`, `gap-8` = 32px), e o vão real era esse gap entre o form e a região de resultados (form.bottom 100.67 vs card.top 132.67). Fix: `max-md:-mb-8` no mesmo chassis (cancela exatamente o gap-8; o resto das seções do shell mantém o ritmo; `md:mb-0` restaura o desktop). O `pb-0` continua (tira os 4px residuais) mas não era o dominante. Chassis, vale para as 11 listas (mesmo precedente B196). O sticky/`-mx-4`/border-b ficam. **Nota de divergência:** o aceite diz "≈48px", mas o draft gateado desenha campo `min-h-10` + `pt-1 pb-0` = 45px; voltar a `min-h-11` seria o "campo alto do B184" que o próprio aceite proíbe. Draft é a autoridade; 45px.
- **Campo do omnibox** (mesmo arquivo): container `min-h-8` → `min-h-10`; input `min-h-8` → `min-h-10`; botão X `size-8` → `size-9` (span interno `size-6` e ícone `size-3.5` intactos). `text-sm` do input INTOCADO (contrato B183). `md:min-h-11` do desktop intacto. Anel de foco continua só `md:` (sem anel mobile — caret é o indicador).
- **`MunicipalityMobileCard`** (`src/components/campaign/municipality/MunicipalityMobileCard.tsx`):
- Article: `gap-2.5` → `gap-2` (ritmo vertical de todos os blocos; toques `min-h-11` intactos).
- Header: `gap-0.5` → `gap-0`; o parágrafo de território ganha `leading-tight` (a linha-box enxuta fecha o vão visual; `text-sm` intacto).
- Linha de chips: `gap-y-2` → `gap-y-1.5`; blocos `ChipBlock` internos (`gap-0.5`, label acima) intactos.
- Grupos de relação: colunas label→avatares `gap-1.5` → `gap-1` nos 3 caminhos (`RelationGroupTrigger`, bloco read-only de assessores, fallback `CityDash`).
- **`MunicipalityRelationAvatarStack`** (`src/components/campaign/shared/MunicipalityRelationAvatarStack.tsx`), modo `overlapRow` (só o card mobile usa; desktop `-space-x-2`/cap 3/tooltip intocado): sai a linha de células `flex-1 justify-center` com `overflow-hidden`; entra pilha à esquerda — avatares `size-7` em sequência, primeiro sem margem, seguintes `marginLeft: -overlap` medido; `data-view="relation-avatars"` e o sr-only dos nomes permanecem; `overflow-hidden` PERMANECE como guarda de último recurso (fit correto nunca o ativa; e2e prova que nada clipa). O arquivo ganha `'use client'` (hooks). Modo default (não-overlapRow) intocado.
- **`src/lib/relationAvatarOverlap.ts`** (novo, puro): `RELATION_AVATAR_SIZE_PX = 28`, `RELATION_AVATAR_MIN_OVERLAP_PX = 8` e `relationAvatarOverlapPx(count, groupWidthPx)`: 0 para n≤1; `Math.max(8, (28·n − W)/(n−1))` — 2 avatares → 8px (mínimo garantido), n≥5 → fit exato por construção (total = W). Invariante testável: para qualquer n, o total `28 + (n−1)·(28 − overlap) ≤ W`.
- **Componente usa a função** via `ResizeObserver` no próprio elemento da linha (`clientWidth` → overlap em estado; inicial 8px; correção pós-mount imperceptível). Precedente de RO no repo: `RelationChipCell`, `BahiaMap`, `ActivityAgenda`.
- **Migration:** sem migration (zero schema).
- **Access / Consent:** nenhum (zero paths de escrita).
- **UI:** Impeccable B — encaixe; shape (classes acima) → craft (browser 390px + 360px) → critique (vãos, alinhamento de eixo label/valor, overlap/clipping) → polish.
### Dados → forma
N/A — nenhuma métrica nova (intenção: "Vou apresentar dados? Não").
## Fases verificáveis
1. **Tracer — chassis da barra** (menor parte do appetite): `pt-1 pb-0` + `min-h-10`/`size-9`. Verificar: tsc + e2e B196 da barra atualizado (altura 44–48) + e2e do feed de atualizações (sticky/border intactos) + `campaignIosInputZoom` (16px/14px intactos).
2. **UI — card + avatares**: `gap-2`/`gap-0`/`leading-tight`/`gap-y-1.5`/`gap-1` no card; pilha medida no avatar stack. Verificar: e2e B196 de nome/território (folga ≤2px) e de avatares reescrito (2 avatares levemente sobrepostos, pilha à esquerda, 5–6 todos visíveis e inteiros), novo e2e B200 (barra 44–48 + primeiro card colado + eixo label/valor).
3. **Unit da função pura** (`tests/unit/relationAvatarOverlap.unit.spec.ts`): n≤1 → 0; n=2 → 8; n=5/6/8/12 → fit exato (invariante total ≤ W); mínimo garantido nunca abaixo de 8.
4. **Gates**: `pnpm gate:fast` (tsc, lint, format, knip, cycles, unit+int, e2e do conjunto afetado) e `pnpm build`.
## Rabbit holes / Não escopo (engenharia)
- Não tocar o modo default do avatar stack (desktop), `MunicipalityRelationEditor`, `TerritoryListColumns`, tooltips.
- Não mudar `text-sm` do input nem `min-h-11` dos triggers do card.
- Não criar sistema de avatares global — só o modo `overlapRow` do componente existente.
- Não polir outras arestas do card/feed/agenda (item separado se o product pedir).
- Não mexer em sheets, busca/sugestões do omnibox, FAB, rodapé de atualização.
## Riscos e mitigação
- **e2e B196 regressar:** a altura da barra (≤44) e o "2 avatares sem overlap" PINAM o comportamento antigo — são as duas asserções que B200 muda por design; reescritas na Fase 1/2 com os novos contratos. Nada mais das specs atuais conflita (verificado: `campaignUpdatesMobile` pina sticky/border; `campaignIosInputZoom` pina font-size; B193 clica labels que permanecem).
- **`'use client'` novo no avatar stack:** o desktop (`MunicipalityList` é server) passa a renderizar o stack como cliente — sem dependência server-only no módulo (`campaignUserInitials` é util puro), sem impacto de bundle relevante; `TerritoryListColumns`/`MunicipalityRelationEditor` usam o modo default intocado.
- **RO medir antes do layout assentar:** RO dispara em layout estável; o estado inicial (8px) cobre o primeiro paint; correção é subpixel na prática.
- **Overlap exata com n gigante lê como pilha ilegível:** aceite do draft ("degradam para pilha"); sr-only lista todos os nomes.
- **Chips de cidade (Tendência/Nível "Não registrada")**: blocos intactos — apenas o gap da linha muda.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (barra mais alta + 1º card colado; nome/território colados; ritmo enxuto; labels e valores no mesmo eixo; avatares à esquerda, sobrepostos, nunca cortados, nada invade o vizinho; desktop intacto)
- [ ] Invariantes AGENTS/engineering-standards (sem schema/access/Consent; nomes de props em inglês; copy pt-BR preservada; função pura testada em `src/lib/`)
- [ ] Testes previstos: unit `relationAvatarOverlap` (fit invariante por contagens); e2e B196 da barra/avatares atualizados; novo e2e B200 (barra 44–48, colagem do 1º card, eixo label/valor, pilha à esquerda sem clipping)
Self-score decision-quality: 5/5 — decisão cara (estratégia de overlap) com A/C rejeitadas e porquês; cabe no appetite; rabbit holes nomeados; depth check: reusa chassis/card/stack existentes e a convenção de matemática pura em `src/lib/` (mapScaleClasses, engagementLevel); intenção preservada — engenharia não reescreveu o outcome, só escolheu como garantir "nunca cortado e nada invade" sem hardcode de viewport.

View file

@ -122,7 +122,7 @@ const RelationGroupTrigger = ({
entries: MunicipalityRelationEntry[]
emptyState: ReactNode
}) => (
<div className="flex w-full min-w-0 flex-col gap-1.5">
<div className="flex w-full min-w-0 flex-col gap-1">
<ChipLabel>{label}</ChipLabel>
<MunicipalityRelationAvatarStack entries={entries} emptyState={emptyState} overlapRow />
</div>
@ -135,7 +135,7 @@ const RelationGroupTrigger = ({
const RelationGroup = ({ label, children }: { label: string; children: ReactNode }) => (
<div className="min-w-0 flex-1">
{children ?? (
<div className="flex w-full flex-col gap-1.5">
<div className="flex w-full flex-col gap-1">
<ChipLabel>{label}</ChipLabel>
<CityDash />
</div>
@ -199,15 +199,15 @@ export const MunicipalityMobileCard = ({
return (
<article
className={cn(
// B196 — denser vertical rhythm: gap-2.5 between blocks and py-3
// B196/B200 — denser vertical rhythm: gap-2 between blocks and py-3
// (the horizontal p-4 keeps the B184 edge-to-edge bleed). Touch
// targets (min-h-11 controls) are untouched.
'relative flex flex-col gap-2.5 rounded-none border-b px-4 py-3 last:border-b-0 md:rounded-xl md:border md:last:border-b',
'relative flex flex-col gap-2 rounded-none border-b px-4 py-3 last:border-b-0 md:rounded-xl md:border md:last:border-b',
isPriority && !isCity && 'border-r-[6px] border-r-primary',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex min-w-0 flex-col gap-0">
<h3 className="flex items-center gap-1.5 text-lg leading-tight font-medium">
<Link
href={`/campanha/municipios/${municipality.slug}`}
@ -217,7 +217,7 @@ export const MunicipalityMobileCard = ({
</Link>
{isCity ? <Badge variant="secondary">Cidade</Badge> : null}
</h3>
<p className="text-sm text-muted-foreground">
<p className="text-sm leading-tight text-muted-foreground">
{isCity ? (
<>
{SALVADOR_CITY_AGGREGATE_LABEL} · {region}
@ -267,7 +267,7 @@ export const MunicipalityMobileCard = ({
{/* B196 (gate B) — every chip is a label-above-value block; the row
accepts 1–2 lines, but no chip ever breaks in the middle. */}
<div className="flex flex-wrap items-start gap-x-3 gap-y-2">
<div className="flex flex-wrap items-start gap-x-3 gap-y-1.5">
<ChipBlock label="Classe">
<TerritorialClassCardReadout municipality={municipality} />
</ChipBlock>
@ -345,7 +345,7 @@ export const MunicipalityMobileCard = ({
triggerClassName={relationTriggerClassName}
/>
) : (
<div className="flex w-full flex-col gap-1.5">
<div className="flex w-full flex-col gap-1">
<ChipLabel>Assessores</ChipLabel>
<MunicipalityAdvisorAvatarStack
advisors={advisors}

View file

@ -48,8 +48,13 @@ export type CampaignListOmniboxProps = {
* sticky clamps 16px below the header and cards roll visibly through the gap.
*/
export const campaignListOmniboxFormClassName =
'campaign-list-omnibox-form sticky top-0 z-20 -mx-4 border-b border-border bg-background px-4 py-1 ' +
'md:static md:z-auto md:mx-0 md:border-b-0 md:bg-transparent md:px-0 md:py-0'
'campaign-list-omnibox-form sticky top-0 z-20 -mx-4 border-b border-border bg-background px-4 pt-1 pb-0 ' +
// B200 — below `md` the form is a direct child of the shell column
// (`gap-8`), which is what left a 32px vão between the bar and the first
// card; the negative bottom margin cancels that one gap so the first card
// starts glued to the bar, while every other shell section keeps its rhythm.
'max-md:-mb-8 ' +
'md:static md:z-auto md:mx-0 md:border-b-0 md:bg-transparent md:px-0 md:py-0 md:mb-0'
/**
* Shared list omnibox chassis (B127): chips inside the field, caret to the
@ -207,12 +212,12 @@ export const CampaignListOmnibox = ({
<PopoverAnchor asChild>
<div
className={cn(
// B184/B196: below `md` the field is a borderless dense bar
// (min-h-8, no inner vertical padding → bar ≈ 40px total)
// B184/B196/B200: below `md` the field is a borderless dense
// bar (min-h-10, no inner vertical padding → bar ≈ 45px total)
// with NO focus ring — the caret is the focus indicator; the
// `md:` variants restore the framed desktop field with its
// ring/colored border as the focus affordance.
'flex min-h-8 w-full flex-wrap items-center gap-1.5 rounded-lg border-0 bg-transparent px-2 py-0 shadow-none',
'flex min-h-10 w-full flex-wrap items-center gap-1.5 rounded-lg border-0 bg-transparent px-2 py-0 shadow-none',
'md:min-h-11 md:border md:border-input md:py-1.5 md:shadow-xs',
'md:focus-within:border-ring md:focus-within:ring-3 md:focus-within:ring-ring/50',
)}
@ -276,7 +281,7 @@ export const CampaignListOmnibox = ({
// mousedown always fires, so this is the reliable reopen path.
onMouseDown={() => setOpen(true)}
onKeyDown={onKeyDown}
className="min-h-8 min-w-[8rem] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
className="min-h-10 min-w-[8rem] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
{onClearAll ? (
// B184: the mobile replacement for the text "Limpar" — a
@ -289,9 +294,10 @@ export const CampaignListOmnibox = ({
type="button"
aria-label="Limpar"
className={cn(
// B196 — size-8 keeps the bar at ~40px total; the field
// itself is the real touch target (the X lives inside it).
'inline-flex size-8 shrink-0 items-center justify-center rounded-full',
// B196/B200 — size-9 with the taller field keeps the bar
// at ~45px total; the field itself is the real touch
// target (the X lives inside it).
'inline-flex size-9 shrink-0 items-center justify-center rounded-full',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'md:hidden',
!(hasChips || query.length > 0) && 'invisible pointer-events-none',
@ -379,7 +385,7 @@ export const CampaignListOmnibox = ({
{/* C100 — the trailing cluster is desktop-only: below `md` the page
registers its own header controls, so the cluster never joins the
mobile sticky bar (an empty container would also inflate its height
— B196 keeps the bar ≈ 40px). */}
— B200 keeps the bar ≈ 45px). */}
<div className="hidden shrink-0 flex-wrap items-center gap-2 md:flex md:pt-7">
{trailing}
{onClearAll && hasChips ? (

View file

@ -1,6 +1,12 @@
import type { ReactNode } from 'react'
'use client'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import {
RELATION_AVATAR_MIN_OVERLAP_PX,
relationAvatarOverlapPx,
} from '@/lib/relationAvatarOverlap'
import { campaignUserInitials } from '@/utilities/campaignUserProfile'
export type MunicipalityRelationEntry = {
@ -22,37 +28,55 @@ export const MunicipalityRelationAvatarStack = ({
emptyState: ReactNode
maxVisible?: number
/**
* B196 — dense card mode: every entry gets its own avatar (no cap), in ONE
* row that never wraps. Each avatar sits centered in a flex-1 cell, so few
* entries render apart (cell wider than the avatar) and many overlap
* progressively — the overlap is proportional to the count, no avatar is
* capped or hidden behind a "…", and the row always fills the group width.
* B196/B200 — dense card mode: every entry gets its own avatar (no cap), in
* ONE row that never wraps, anchored LEFT as a pile. The overlap between
* consecutive avatars is measured against the group's real width (see
* `relationAvatarOverlapPx`): at least 8px so even two avatars read as a
* deliberate stack, and deeper as the count grows so the pile always fits —
* no avatar is capped, hidden behind a "…", clipped at the box edge, or
* poking into the neighbouring group (the sr-only names list everyone).
*/
overlapRow?: boolean
}) => {
const rowRef = useRef<HTMLDivElement>(null)
const [overlap, setOverlap] = useState(RELATION_AVATAR_MIN_OVERLAP_PX)
useEffect(() => {
const row = rowRef.current
if (!row) return
const observer = new ResizeObserver(() => {
setOverlap(relationAvatarOverlapPx(entries.length, row.clientWidth))
})
observer.observe(row)
return () => observer.disconnect()
}, [entries.length, overlapRow])
if (entries.length === 0) return emptyState
const visible = overlapRow ? entries : entries.slice(0, maxVisible)
if (overlapRow) {
return (
// `overflow-hidden` clips the pile at the group's box on extreme counts
// (the sr-only names below still list everyone) — no slivers into the
// neighbouring group.
// `overflow-hidden` stays as a last-resort guard: with the measured
// overlap the pile fits the group exactly, so it never clips or
// slivers into the neighbouring group.
<div
ref={rowRef}
className="flex w-full min-w-0 items-center overflow-hidden"
data-view="relation-avatars"
>
{visible.map((entry) => (
<div key={entry.id} className="flex min-w-0 flex-1 justify-center">
<Avatar className="size-7 shrink-0 border-0">
{/* The full names ride in the sr-only span below (or the trigger's
aria-label); initials alone would be announced twice. */}
<AvatarFallback aria-hidden="true">
{campaignUserInitials(entry.initialsLabel ?? entry.label)}
</AvatarFallback>
</Avatar>
</div>
{visible.map((entry, index) => (
<Avatar
key={entry.id}
className="size-7 shrink-0 border-0"
style={index === 0 ? undefined : { marginLeft: -overlap }}
>
{/* The full names ride in the sr-only span below (or the trigger's
aria-label); initials alone would be announced twice. */}
<AvatarFallback aria-hidden="true">
{campaignUserInitials(entry.initialsLabel ?? entry.label)}
</AvatarFallback>
</Avatar>
))}
<span className="sr-only">{entries.map((entry) => entry.label).join(', ')}</span>
</div>

View file

@ -2,7 +2,11 @@ import type { ComponentProps, ReactNode } from 'react'
import { cn } from '@/lib/utils'
const campaignPageShellClassName = 'mx-auto flex w-full max-w-screen-2xl flex-col gap-8'
const campaignPageShellClassName =
// B200 — the mobile filter bar (campaignListOmniboxFormClassName) cancels
// exactly this gap-8 with `max-md:-mb-8` so the first list card glues to
// the bar; change the gap here and that cancellation goes stale.
'mx-auto flex w-full max-w-screen-2xl flex-col gap-8'
export const campaignPrioritySurfaceClassName =
'shadow-[0_4px_24px_rgb(28_25_23/0.08)] ring-1 ring-foreground/10'

View file

@ -0,0 +1,20 @@
/**
* B200 — the mobile municipality card's relation avatar pile (overlapRow):
* how many pixels each following avatar overlaps the previous one, given the
* group's measured width. The pile is anchored left and the overlap grows
* with the count so that every avatar stays fully visible and nothing ever
* pokes into the neighbouring group: for `count > 1` the fit overlap is
* `(count·28 − width)/(count − 1)` (total span = width exactly), floored at
* `RELATION_AVATAR_MIN_OVERLAP_PX` so even two avatars read as a deliberate
* stack instead of spreading to the group's corners. Extreme counts degrade
* to a deep stack — the sr-only names still list everyone.
*/
export const RELATION_AVATAR_SIZE_PX = 28
export const RELATION_AVATAR_MIN_OVERLAP_PX = 8
export const relationAvatarOverlapPx = (count: number, groupWidthPx: number): number => {
if (count <= 1) return 0
const fitOverlap = (count * RELATION_AVATAR_SIZE_PX - groupWidthPx) / (count - 1)
if (!Number.isFinite(fitOverlap)) return RELATION_AVATAR_MIN_OVERLAP_PX
return Math.max(RELATION_AVATAR_MIN_OVERLAP_PX, fitOverlap)
}

View file

@ -1200,7 +1200,7 @@ test.describe('Municípios — barra colada e card denso (B196)', () => {
.filter({ has: page.getByRole('link', { name, exact: true }) })
.first()
test('bar is glued to the header, ~40px tall, no focus ring, nothing passes above on scroll', async ({
test('bar is glued to the header, ~45px tall, no focus ring, nothing passes above on scroll', async ({
campaign,
page,
}) => {
@ -1219,12 +1219,27 @@ test.describe('Municípios — barra colada e card denso (B196)', () => {
const form = omniboxInput.locator('xpath=ancestor::form[1]')
const field = omniboxInput.locator('..')
await expect(form).toBeVisible()
// The bar's own `pt-1` applying proves the stylesheet reached the page —
// before that the form renders unstyled and measures ~79px (visible label).
await expect(form).toHaveCSS('padding-top', '4px')
// Glued: no gap between the app top bar and the filter bar.
await expect.poll(async () => (await form.boundingBox())!.y).toBe(await headerBottom(topBar))
await expect
.poll(async () => (await form.boundingBox())!.y, { timeout: 30_000 })
.toBe(await headerBottom(topBar))
// Dense: the whole bar stays around 40px (32px field + py + border).
await expect.poll(async () => (await form.boundingBox())!.height).toBeLessThanOrEqual(44)
// Taller (B200): the whole bar sits between 44 and 48px (40px field +
// pt-1 + border) — up from the B196 ~40px, without the B184 field height.
// One poll asserts the whole band so both bounds read the same frame.
await expect
.poll(
async () => {
const height = (await form.boundingBox())!.height
return height >= 44 && height <= 48
},
{ timeout: 30_000 },
)
.toBe(true)
// No focus ring: the field gains no shadow/border color on focus.
const shadowBefore = await field.evaluate((el) => getComputedStyle(el).boxShadow)
@ -1233,11 +1248,32 @@ test.describe('Municípios — barra colada e card denso (B196)', () => {
.poll(() => field.evaluate((el) => getComputedStyle(el).boxShadow))
.toBe(shadowBefore)
// First card glued to the bar: no vão between the bar's bottom edge and
// the first card's top edge (B200 — the form's `-mb-8` cancels the shell
// column's gap-8; `pb-0` only removes the 4px padding remnant). Bounded
// on both sides so the card can't tuck UNDER the opaque bar either.
const firstCard = page.locator('[data-view="mobile-cards"] article').first()
await expect(firstCard).toBeVisible()
const formBottom = (await form.boundingBox())!.y + (await form.boundingBox())!.height
await expect
.poll(
async () => {
const y = (await firstCard.boundingBox())!.y
return Math.abs(y - formBottom) <= 1
},
{ timeout: 30_000 },
)
.toBe(true)
// Scroll: the bar stays glued to the header — cards roll UNDER the
// opaque bar, never above it.
await contentScroll(page).evaluate((el) => el.scrollTo(0, el.scrollHeight))
await expect.poll(async () => (await form.boundingBox())!.y).toBe(await headerBottom(topBar))
await expect.poll(async () => (await form.boundingBox())!.height).toBeLessThanOrEqual(44)
await expect
.poll(async () => (await form.boundingBox())!.y, { timeout: 30_000 })
.toBe(await headerBottom(topBar))
await expect
.poll(async () => (await form.boundingBox())!.height, { timeout: 30_000 })
.toBeLessThanOrEqual(48)
})
test('name is 18px with the territory glued; Classe/Tendência/Nível chips are label-above blocks', async ({
@ -1254,24 +1290,37 @@ test.describe('Municípios — barra colada e card denso (B196)', () => {
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
// C106 — dynamic pages stream a transient hidden `#S:*` copy of the shell;
// geometry assertions must wait it out or they measure the stream copy.
await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)
const card = cardFor(page, municipality.name)
await expect(card).toBeVisible()
// Smaller name (18px) with the territory glued right below it.
const heading = card.getByRole('heading', { level: 3 })
await expect(heading).toHaveCSS('font-size', '18px')
const headingBox = (await heading.boundingBox())!
const territory = card.locator('h3 + p')
const territoryBox = (await territory.boundingBox())!
expect(territoryBox.y - headingBox.y - headingBox.height).toBeLessThanOrEqual(6)
const headerGap = await card.evaluate((el) => {
const headingBox = el.querySelector('h3')!.getBoundingClientRect()
const territoryBox = el.querySelector('h3 + p')!.getBoundingClientRect()
return territoryBox.y - headingBox.y - headingBox.height
})
expect(headerGap).toBeLessThanOrEqual(6)
// Every chip is a label-above block: the value renders below the label
// (gate B). "Classe" debuts as a label; Tendência/Nível keep theirs.
// All three pairs are read in ONE layout snapshot (the dev server may
// reload a page mid-test under parallel workers, which would otherwise
// straddle two layouts).
for (const label of ['Classe', 'Tendência', 'Nível']) {
const chipLabel = card.getByText(label, { exact: true })
await expect(chipLabel).toBeVisible()
const labelY = (await chipLabel.boundingBox())!.y
const valueY = (await chipLabel.locator('xpath=following-sibling::*[1]').boundingBox())!.y
const { labelY, valueY } = await chipLabel.evaluate((el) => {
const sibling = el.nextElementSibling
return {
labelY: el.getBoundingClientRect().y,
valueY: sibling?.getBoundingClientRect().y ?? 0,
}
})
expect(valueY).toBeGreaterThan(labelY)
}
})
@ -1318,29 +1367,88 @@ test.describe('Municípios — barra colada e card denso (B196)', () => {
// Five advisors: all on ONE row, each covering the start of the next —
// the overlap is proportional to the count (no wrap, no cap, no "…").
await page.goto(`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(crowded.name)}`)
await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)
const crowdedCard = cardFor(page, crowded.name)
await expect(crowdedCard).toBeVisible()
// The completion footer proves the filtered list finished streaming — the
// `S:` settle alone can pass while the list still re-renders (measured:
// a mid-stream card can report a transient narrow layout).
await expect(page.getByText('1 município encontrado')).toBeVisible()
const crowdedRow = crowdedCard.locator('[data-view="relation-avatars"]').first()
await expect(crowdedRow.locator('.size-7')).toHaveCount(5)
const crowdedBoxes = await crowdedRow
.locator('.size-7')
.evaluateAll((avatars) => avatars.map((a) => a.getBoundingClientRect()))
expect(new Set(crowdedBoxes.map((box) => Math.round(box.y))).size).toBe(1)
for (let i = 1; i < crowdedBoxes.length; i++) {
expect(crowdedBoxes[i]!.x).toBeLessThan(crowdedBoxes[i - 1]!.x + crowdedBoxes[i - 1]!.width)
}
// ONE layout snapshot per read, polled until the settled layout satisfies
// the whole contract: a mid-stream card can transiently report a narrow
// cell layout (measured) that no single geometry assertion should pin.
await expect
.poll(
async () => {
const geometry = await crowdedRow.evaluate((row) => {
// Coordinator path: row → flex-col group → trigger button → flex-1
// cell (the advisor fallback renders the stack without the button).
const groupColumn = row.parentElement!
const cell = groupColumn.parentElement!.parentElement!
const nextGroup = cell.nextElementSibling
return {
labelX: groupColumn.querySelector('span')!.getBoundingClientRect().x,
nextGroupX: nextGroup
? nextGroup.getBoundingClientRect().x
: Number.POSITIVE_INFINITY,
boxes: [...row.querySelectorAll('.size-7')].map((avatar) => {
const box = avatar.getBoundingClientRect()
return { x: box.x, width: box.width, y: box.y }
}),
}
})
// Same single row.
if (new Set(geometry.boxes.map((box) => Math.round(box.y))).size !== 1) return false
// The pile reads as a stack: each covers the next, and each avatar
// keeps a visible sliver (8px ≤ distance ≤ 20px = overlap 8..20).
for (let i = 1; i < geometry.boxes.length; i++) {
const distance = geometry.boxes[i]!.x - geometry.boxes[i - 1]!.x
if (distance < 8 || distance > 20) return false
}
// B200 — anchored LEFT: the first avatar starts at the label's axis.
if (Math.abs(geometry.boxes[0]!.x - geometry.labelX) > 2) return false
// Nothing clipped, nothing invades the neighbour: the last avatar
// ends before the next group's box (the old flex-1 row clipped it).
const last = geometry.boxes[geometry.boxes.length - 1]!
return last.x + last.width <= geometry.nextGroupX + 0.5
},
{ timeout: 30_000 },
)
.toBe(true)
// Two advisors: same single row, no overlap (few entries keep apart).
// Two advisors: same single row, a slight deliberate overlap — a pile at
// the left, not one avatar in each corner (B200).
await page.goto(`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(sparse.name)}`)
await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)
const sparseCard = cardFor(page, sparse.name)
await expect(sparseCard).toBeVisible()
await expect(page.getByText('1 município encontrado')).toBeVisible()
const sparseRow = sparseCard.locator('[data-view="relation-avatars"]').first()
await expect(sparseRow.locator('.size-7')).toHaveCount(2)
const sparseBoxes = await sparseRow
.locator('.size-7')
.evaluateAll((avatars) => avatars.map((a) => a.getBoundingClientRect()))
expect(new Set(sparseBoxes.map((box) => Math.round(box.y))).size).toBe(1)
expect(sparseBoxes[1]!.x).toBeGreaterThanOrEqual(sparseBoxes[0]!.x + sparseBoxes[0]!.width)
await expect
.poll(
async () => {
const geometry = await sparseRow.evaluate((row) => {
const groupColumn = row.parentElement!
return {
labelX: groupColumn.querySelector('span')!.getBoundingClientRect().x,
boxes: [...row.querySelectorAll('.size-7')].map((avatar) => {
const box = avatar.getBoundingClientRect()
return { x: box.x, width: box.width, y: box.y }
}),
}
})
return (
new Set(geometry.boxes.map((box) => Math.round(box.y))).size === 1 &&
geometry.boxes[1]!.x < geometry.boxes[0]!.x + geometry.boxes[0]!.width &&
Math.abs(geometry.boxes[0]!.x - geometry.labelX) <= 2
)
},
{ timeout: 30_000 },
)
.toBe(true)
})
test('chassis elsewhere: the actions row keeps its breathing room and the bar glues on scroll (atividades)', async ({
@ -1390,6 +1498,171 @@ test.describe('Municípios — barra colada e card denso (B196)', () => {
})
})
test.describe('Municípios — encaixes dos cards (B200)', () => {
test.use({ viewport: { width: 390, height: 844 } })
const cardFor = (page: Page, name: string) =>
page
.locator('[data-view="mobile-cards"] article')
.filter({ has: page.getByRole('link', { name, exact: true }) })
.first()
test('bar taller (44–48px), first card glued, labels and values on the same left axis', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora B200 Cards'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)
// The bar's own `pt-1` applying proves the stylesheet reached the page —
// before that the form renders unstyled and measures ~79px (visible label).
const form = page
.getByRole('combobox', { name: 'Filtrar municípios' })
.locator('xpath=ancestor::form[1]')
await expect(form).toHaveCSS('padding-top', '4px')
// The filter bar is visibly taller than the B196 ~40px bar.
await expect
.poll(async () => (await form.boundingBox())!.height, { timeout: 30_000 })
.toBeGreaterThanOrEqual(44)
await expect
.poll(async () => (await form.boundingBox())!.height, { timeout: 30_000 })
.toBeLessThanOrEqual(48)
// First card starts glued to the bar: no vão between them.
const card = cardFor(page, municipality.name)
await expect(card).toBeVisible()
const formBox = (await form.boundingBox())!
await expect
.poll(async () => (await card.boundingBox())!.y, { timeout: 30_000 })
.toBeLessThanOrEqual(formBox.y + formBox.height + 1)
// Territory glued to the name: B200 tightens the B196 ≤6px to ≤2px.
const headerGap = await card.evaluate((el) => {
const headingBox = el.querySelector('h3')!.getBoundingClientRect()
const territoryBox = el.querySelector('h3 + p')!.getBoundingClientRect()
return territoryBox.y - headingBox.y - headingBox.height
})
expect(headerGap).toBeLessThanOrEqual(2)
// Every chip label and its value start on the same left axis (single
// layout snapshot per pair — see the B196 note on dev reloads).
for (const label of ['Classe', 'Tendência', 'Nível']) {
const chipLabel = card.getByText(label, { exact: true })
const { labelX, valueX } = await chipLabel.evaluate((el) => {
const sibling = el.nextElementSibling
return {
labelX: el.getBoundingClientRect().x,
valueX: sibling?.getBoundingClientRect().x ?? 0,
}
})
expect(Math.abs(valueX - labelX)).toBeLessThanOrEqual(1)
}
// Relation groups: label and value (avatar pile or empty-state badge)
// share the same left axis.
for (const label of ['Assessores', 'Lideranças', 'Dobradinhas']) {
const groupLabel = card.getByText(label, { exact: true })
await expect(groupLabel).toBeVisible()
const { labelX, valueX } = await groupLabel.evaluate((el) => {
const sibling = el.nextElementSibling
return {
labelX: el.getBoundingClientRect().x,
valueX: sibling?.getBoundingClientRect().x ?? 0,
}
})
expect(Math.abs(valueX - labelX)).toBeLessThanOrEqual(2)
}
})
test('avatars: 5–6 all visible in the pile, nothing clipped, nothing invades the neighbour', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora B200 Pilha'),
})
const sixAdvisors = await Promise.all(
Array.from({ length: 6 }, (_, i) =>
fixtures.createCampaignUser('advisor', {
name: fixtures.value(`Assessora B200 seis ${i + 1}`),
}),
),
)
const crowded = await fixtures.claimMunicipality()
await fixtures.payload.update({
collection: 'municipality',
id: crowded.id,
data: { advisors: sixAdvisors.map((advisor) => advisor.id) },
depth: 0,
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(crowded.name)}`)
await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)
const card = cardFor(page, crowded.name)
await expect(card).toBeVisible()
// The completion footer proves the filtered list finished streaming — the
// `S:` settle alone can pass while the list still re-renders (measured:
// a mid-stream card can report a transient narrow layout).
await expect(page.getByText('1 município encontrado')).toBeVisible()
const row = card.locator('[data-view="relation-avatars"]').first()
const avatars = row.locator('.size-7')
await expect(avatars).toHaveCount(6)
// ONE layout snapshot per read, polled until the settled layout satisfies
// the whole contract: a mid-stream card can transiently report a narrow
// cell layout (measured) that no single geometry assertion should pin.
await expect
.poll(
async () => {
const geometry = await row.evaluate((el) => {
// Coordinator path: row → flex-col group → trigger button → flex-1
// cell (the advisor fallback renders the stack without the button).
const groupColumn = el.parentElement!
const cell = groupColumn.parentElement!.parentElement!
const nextGroup = cell.nextElementSibling
return {
labelX: groupColumn.querySelector('span')!.getBoundingClientRect().x,
nextGroupX: nextGroup
? nextGroup.getBoundingClientRect().x
: Number.POSITIVE_INFINITY,
boxes: [...el.querySelectorAll('.size-7')].map((avatar) => {
const box = avatar.getBoundingClientRect()
return { x: box.x, width: box.width, y: box.y }
}),
}
})
// Same single row.
if (new Set(geometry.boxes.map((box) => Math.round(box.y))).size !== 1) return false
// The pile reads as a stack: each covers the next, and each avatar
// keeps a visible sliver (8px ≤ distance ≤ 20px = overlap 8..20).
for (let i = 1; i < geometry.boxes.length; i++) {
const distance = geometry.boxes[i]!.x - geometry.boxes[i - 1]!.x
if (distance < 8 || distance > 20) return false
}
// Anchored LEFT: the first avatar starts at the label's axis.
if (Math.abs(geometry.boxes[0]!.x - geometry.labelX) > 2) return false
// Nothing clipped, nothing invades the neighbour: the last avatar
// ends before the next group's box.
const last = geometry.boxes[geometry.boxes.length - 1]!
return last.x + last.width <= geometry.nextGroupX + 0.5
},
{ timeout: 30_000 },
)
.toBe(true)
})
})
/**
* B126: FAB quick-actions overlay — no persistent drawer; opens on demand.
*/

View file

@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import {
RELATION_AVATAR_MIN_OVERLAP_PX,
RELATION_AVATAR_SIZE_PX,
relationAvatarOverlapPx,
} from '@/lib/relationAvatarOverlap'
/**
* The mobile relation group's content width at the 390px e2e viewport:
* (390 − 2·16 card padding − 2·12 gaps) / 3 groups = 111.33 → clientWidth 111.
*/
const GROUP_WIDTH_390 = 111
/** Total span of the pile: first avatar full, each next one overlapped. */
const pileSpan = (count: number, overlap: number) =>
RELATION_AVATAR_SIZE_PX + (count - 1) * (RELATION_AVATAR_SIZE_PX - overlap)
describe('relationAvatarOverlapPx', () => {
it('returns 0 for a single avatar (nothing to overlap)', () => {
expect(relationAvatarOverlapPx(1, GROUP_WIDTH_390)).toBe(0)
expect(relationAvatarOverlapPx(0, GROUP_WIDTH_390)).toBe(0)
})
it('guarantees the minimum overlap even with two avatars on a wide group', () => {
expect(relationAvatarOverlapPx(2, GROUP_WIDTH_390)).toBe(RELATION_AVATAR_MIN_OVERLAP_PX)
expect(relationAvatarOverlapPx(2, 300)).toBe(RELATION_AVATAR_MIN_OVERLAP_PX)
})
it('grows the overlap with the count so the pile fits the group exactly', () => {
for (const count of [2, 3, 5, 6, 8, 12, 20]) {
const overlap = relationAvatarOverlapPx(count, GROUP_WIDTH_390)
expect(overlap).toBeGreaterThanOrEqual(RELATION_AVATAR_MIN_OVERLAP_PX)
expect(pileSpan(count, overlap)).toBeLessThanOrEqual(GROUP_WIDTH_390 + 0.01)
if (overlap > RELATION_AVATAR_MIN_OVERLAP_PX) {
expect(pileSpan(count, overlap)).toBeGreaterThan(GROUP_WIDTH_390 - 0.01)
}
}
})
it('fits narrower viewports too (360px group ≈ 101px)', () => {
const width = 101
for (const count of [2, 5, 6, 8, 12]) {
const overlap = relationAvatarOverlapPx(count, width)
expect(pileSpan(count, overlap)).toBeLessThanOrEqual(width + 0.01)
}
})
it('overlap never goes below the minimum once there are two or more avatars', () => {
for (const count of [2, 4, 9]) {
expect(relationAvatarOverlapPx(count, 500)).toBe(RELATION_AVATAR_MIN_OVERLAP_PX)
}
})
it('falls back to the minimum overlap when the width is not a finite number', () => {
expect(relationAvatarOverlapPx(3, Number.NaN)).toBe(RELATION_AVATAR_MIN_OVERLAP_PX)
expect(relationAvatarOverlapPx(3, Number.POSITIVE_INFINITY)).toBe(
RELATION_AVATAR_MIN_OVERLAP_PX,
)
})
})