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 }) => ( -
+
{label}
@@ -135,7 +135,7 @@ const RelationGroupTrigger = ({ const RelationGroup = ({ label, children }: { label: string; children: ReactNode }) => (
{children ?? ( -
+
{label}
@@ -199,15 +199,15 @@ export const MunicipalityMobileCard = ({ return (
-
+

{isCity ? Cidade : null}

-

+

{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. */} -

+
@@ -345,7 +345,7 @@ export const MunicipalityMobileCard = ({ triggerClassName={relationTriggerClassName} /> ) : ( -
+
Assessores
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. */} - - -
+ {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. */} + + ))} {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, + ) + }) +})