diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md
index b707b474..fa1c8000 100644
--- a/docs/CHANGELOG-AGENTS.md
+++ b/docs/CHANGELOG-AGENTS.md
@@ -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 `` 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).
diff --git a/docs/plans/municipios-mobile-ajustes-cards-impl.md b/docs/plans/municipios-mobile-ajustes-cards-impl.md
new file mode 100644
index 00000000..79ea5e7c
--- /dev/null
+++ b/docs/plans/municipios-mobile-ajustes-cards-impl.md
@@ -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 barra 41→45px, 1º card colado] --> B[card: gap-2 · nome/território gap-0+leading-tight chips gap-y-1.5 · grupos gap-1]
+ B --> C[avatares: pilha à esquerda, overlap medido 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.
diff --git a/src/components/campaign/municipality/MunicipalityMobileCard.tsx b/src/components/campaign/municipality/MunicipalityMobileCard.tsx
index c37adad0..08a5a7d1 100644
--- a/src/components/campaign/municipality/MunicipalityMobileCard.tsx
+++ b/src/components/campaign/municipality/MunicipalityMobileCard.tsx
@@ -122,7 +122,7 @@ const RelationGroupTrigger = ({
entries: MunicipalityRelationEntry[]
emptyState: ReactNode
}) => (
-
{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. */}
-
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). */}
{trailing}
{onClearAll && hasChips ? (
diff --git a/src/components/campaign/shared/MunicipalityRelationAvatarStack.tsx b/src/components/campaign/shared/MunicipalityRelationAvatarStack.tsx
index 76131332..ee3c9d68 100644
--- a/src/components/campaign/shared/MunicipalityRelationAvatarStack.tsx
+++ b/src/components/campaign/shared/MunicipalityRelationAvatarStack.tsx
@@ -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(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.
- {visible.map((entry) => (
-
-
- {/* The full names ride in the sr-only span below (or the trigger's
- aria-label); initials alone would be announced twice. */}
-
- {campaignUserInitials(entry.initialsLabel ?? entry.label)}
-
-
-
+ {visible.map((entry, index) => (
+
+ {/* The full names ride in the sr-only span below (or the trigger's
+ aria-label); initials alone would be announced twice. */}
+
+ {campaignUserInitials(entry.initialsLabel ?? entry.label)}
+
+
))}
{entries.map((entry) => entry.label).join(', ')}
diff --git a/src/components/campaign/shell/CampaignPageShell.tsx b/src/components/campaign/shell/CampaignPageShell.tsx
index 346b2b25..3b8619bd 100644
--- a/src/components/campaign/shell/CampaignPageShell.tsx
+++ b/src/components/campaign/shell/CampaignPageShell.tsx
@@ -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'
diff --git a/src/lib/relationAvatarOverlap.ts b/src/lib/relationAvatarOverlap.ts
new file mode 100644
index 00000000..5055a77b
--- /dev/null
+++ b/src/lib/relationAvatarOverlap.ts
@@ -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)
+}
diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts
index c41f121e..e98dbb8d 100644
--- a/tests/e2e/campaignMunicipalities.e2e.spec.ts
+++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts
@@ -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.
*/
diff --git a/tests/unit/relationAvatarOverlap.unit.spec.ts b/tests/unit/relationAvatarOverlap.unit.spec.ts
new file mode 100644
index 00000000..054b6444
--- /dev/null
+++ b/tests/unit/relationAvatarOverlap.unit.spec.ts
@@ -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,
+ )
+ })
+})