From 59f70dccf39c5a72c98ad7bc2bd05212e7ae8440 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 24 Sep 2026 16:00:11 -0300 Subject: [PATCH] =?UTF-8?q?fix(C221):=20"Limpar"=20do=20omnibox=20n=C3=A3o?= =?UTF-8?q?=20quebra=20linha=20no=20mobile=20(Closes=20#1313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/2026-09-24-c221.md | 1 + docs/plans/omnibox-limpar-mobile-impl.md | 140 ++++ .../omnibox-limpar-mobile-ui-design.html | 702 ++++++++++++++++++ docs/plans/omnibox-limpar-mobile.md | 3 +- scripts/lib/e2e-affected-manifest.mjs | 9 + .../campaign/shared/CampaignListOmnibox.tsx | 29 +- tests/e2e/campaignMunicipalities.e2e.spec.ts | 74 ++ 7 files changed, 954 insertions(+), 4 deletions(-) create mode 100644 docs/changelog/2026-09-24-c221.md create mode 100644 docs/plans/omnibox-limpar-mobile-impl.md create mode 100644 docs/plans/omnibox-limpar-mobile-ui-design.html diff --git a/docs/changelog/2026-09-24-c221.md b/docs/changelog/2026-09-24-c221.md new file mode 100644 index 00000000..f7d6c99c --- /dev/null +++ b/docs/changelog/2026-09-24-c221.md @@ -0,0 +1 @@ +**C221 — "Limpar" do omnibox não quebra mais linha no mobile (2026-09-24):** débito capturado na crítica de design da C219, agora corrigido no dono do encaixe — no mobile, um chip de busca longo empurrava o "X" de limpar do `CampaignListOmnibox` (o X circular dentro do campo, `md:hidden`) para uma linha própria e órfã à esquerda, inflando a barra para ~74px e abrindo uma faixa vazia antes do seletor de modo (reproduzido na Câmara e nas gravações enviadas; comportamento pré-existente do shell, não regressão da C219). Fix de apresentação, sem schema, access, Consent, URL ou contrato de filtros: abaixo de `md` o campo vira `relative` e reserva a faixa do X com `pr-11` (44px = alvo de 36px + 8px de respiro), o botão sai do fluxo de wrap (`absolute right-0 top-1/2 -translate-y-1/2`, centrado na altura do campo) e o chip de busca ganha cap de 168px com ellipsis, mantendo o input de `8rem` na mesma linha densa — desktop (`md:`) intocado (borda/ring/label/"Limpar" textual), a11y intocada (`aria-label="Limpar"`, `invisible`/fora da árvore quando vazio, anúncios `sr-only`) e a barra de volta à faixa de 44–48px do B196. Design: artefato novo `docs/plans/omnibox-limpar-mobile-ui-design.html` (Impeccable B) com as cenas 390 dos três estados (chip longo, chip curto + digitado, vazio) e a cena 1280 do desktop preservado; port classe-a-classe do contrato aprovado (folga de 44px, X a 0px da direita, cap de 168px, alvo `size-9`/miolo `size-6`). Verificação: e2e novo no describe B184 de `campaignMunicipalities` com **RED provado antes do fix** (campo 74px > teto de 48px) e asserções de geometria em 390 (X ancorado à direita com inset ≤ 16px, centrado no campo, input nunca sob o X — a lane reservada — e campo ≤ 48px), mais o desktop preservado no mesmo teste; `campaignIosInputZoom` entra no `E2E_AFFECTED_MANIFEST` do prefixo do omnibox (o diff toca o dono do campo, então o contrato de font-size no toque passa a rodar no PR), com a regressão local da superfície (`campaignMunicipalities`, `campaignIosInputZoom`, `campaignZoneMap`, `campaignNearestMunicipality`, `campaignSavedFilters`, `campaignColumnPicker`, `campaignUpdatesMobile`, `campaignActivity`) e `pnpm gate:fast`. Crítica final do `designer` (trigger c) **certificada em tier primário** (`Design tier: openai/gpt-5.6-sol`). diff --git a/docs/plans/omnibox-limpar-mobile-impl.md b/docs/plans/omnibox-limpar-mobile-impl.md new file mode 100644 index 00000000..41cf58fe --- /dev/null +++ b/docs/plans/omnibox-limpar-mobile-impl.md @@ -0,0 +1,140 @@ +# Impl: C221 — "Limpar" do omnibox não pode quebrar linha no mobile + +Status: aprovado +Atualizado em: 2026-09-24 +Issue: #1313 +Intenção: docs/plans/omnibox-limpar-mobile.md +Appetite restante: herdado (~2h eng, fill-in) — cabe: gate de design + e2e RED/GREEN + fix de classes no dono + gates; sem schema, sem server +Aprovação: `--auto` (work-issue) — plano auto-aprovado em 2026-09-24; pausa do GATE 3c dispensada pela flag + +## Leitura da intenção + +- **Outcome:** com chip de busca longo no mobile, o "X" de limpar do `CampaignListOmnibox` continua ancorado ao campo/chips — nunca vira linha órfã abaixo do chip, em **todas** as 17 listas que usam o shell — sem tocar desktop, a11y ou contrato de URL/filtros. +- **O que NÃO negociar:** comportamento do X (limpa tudo, refoca o input, não reabre o popover) e a11y (`aria-label="Limpar"`, `invisible`/fora da a11y tree quando vazio, anúncio sr-only); desktop `md:` (borda/ring/altura/label/"Limpar" texto/trailing cluster); barra mobile 44–48px no estado padrão (B196); marcador `campaign-list-omnibox-form` (contrato do `:has()` em `CampaignQuickActionsHost.tsx:17`) e o sticky no `
` do caller; font-size 16px touch (B183); URL/filtros e adaptadores intocados. +- **O que reavaliar:** + 1. "O cluster `flex-wrap` é o culpado" — **confirmada**, com a geometria: a 390 o conteúdo útil do campo é 342px (form 358 − `px-2` do campo); um chip entre ~166px e ~208px deixa chip + input (min `8rem` = 128px) na linha 1 e o X (`size-9`, `shrink-0`) **sozinho na linha 2**, encostado à esquerda (não há `justify`). A faixa vazia é essa linha do X. + 2. "X fora do fluxo é a estratégia mais simples" — é a recomendação de engenharia, mas **a estrutura visual final é do `designer`** (trigger (a)): o impl plan fixa o mecanismo e os invariantes, não as classes finais. + 3. "Limitar a largura do chip no mobile" — só vira decisão se o artefato aprovado optar por cap/truncamento; não é decisão de engenharia (mexe em legibilidade do filtro ativo). + +## Abordagem recomendada + +```mermaid +flowchart LR + A[designer - trigger a
estende o ui-design do omnibox] --> B[e2e RED a 390
chip longo: X órfão] + B --> C[C221 no CampaignListOmnibox
X fora do fluxo do wrap + folga no campo] + C --> D[e2e GREEN + regressão 390/1280] + D --> E[gates - changelog - push
crítica do designer - trigger c] +``` + +**Opções consideradas:** A | B | C | D +**Recomendação:** A — **tirar o X do fluxo de wrap** (abaixo de `md`, posicionado de forma absoluta dentro do próprio campo, que vira `relative`; a folga do final da linha é reservada por um padding mobile no campo, de modo que chips/input não passem sob o X). É o menor diff que garante o invariante por construção: um elemento fora do fluxo nunca quebra linha. Mantém o pai do input como o campo (âncora do popover), não muda ordem DOM/tab order, não existe no desktop (`md:hidden`), e tem precedente no repo (`relative` + filho `absolute right-*` em `RelationChipCell`, `MunicipalityRelationEditor`, `CampaignInlineEditableCell`). +**Rejeitadas:** + +- **B — invólucro flex interno para chips+input + X ancorado à linha do input:** muda o pai do input, quebrando `omniboxInput.locator('..')` — locator que B184/B196 usam para medir o campo (`toHaveCSS('border-top-width', …)`, `boxShadow`) — e adiciona um nível de DOM/ARIA numa área pinada por e2e; risco desproporcional ao appetite. +- **C — cap de largura do chip abaixo de `md` (`max-md:max-w-[9rem]` etc.):** tecnicamente garante o encaixe, mas trunca agressivamente o rótulo do filtro ativo (perda de contexto do recorte) e é craft/copy — se o designer quiser o campo em uma linha, ele especifica o cap; não é o fix de engenharia. +- **D — `max-md:min-w-0` no input/chip para forçar linha única:** comprime o chip até sobrar só o botão de remover e o input a ~0; destrói a leitura do filtro e o alvo de toque. + +### Decisões de engenharia + +- **D1 — Mecanismo do não-órfão.** + - **Opções:** A) X absoluto no campo com folga reservada por padding mobile; B) invólucro interno chips+input; C) cap de largura de chip; D) `min-w-0` no input/chip. + - **Recomendação:** A — invariante por construção (fora do fluxo = nunca quebra), menor superfície, sem mudar o pai do input nem a âncora do popover. + - **Rejeitadas:** B/C/D pelos motivos acima. +- **D2 — Fronteira engenharia × craft (designer).** Engenharia garante: o X permanece **dentro da div do campo** (o `PopoverAnchor`; o popover usa `var(--radix-popover-trigger-width)`), a folga é **padding no content box** do campo (não espaçador absoluto) para `max-w-full`/`truncate` dos chips e o `flex-1` do input continuarem corretos, e o móvel é escopado por variantes `max-md:`/`md:`. Craft (artefato do designer): offset e tamanho exato da folga, ancoragem vertical do X (centro do campo vs. linha do input), cap/truncamento de chip se houver, e se o campo pode permanecer em 2 linhas com chip longo (comportamento pré-existente; a intenção proíbe apenas o X órfão). +- **D3 — Camada de teste.** + - **Opções:** A) estender o e2e de municípios (B184) com cenário de chip longo medindo geometria a 390; B) unit de componente com Testing Library; C) snapshot/visual regression. + - **Recomendação:** A — é a única camada que mede layout de verdade (jsdom não faz layout; `getBoundingClientRect` é zero); o idioma de medição (`boundingBox` + `expect.poll`) já existe em B196; o spec irmão (`campaignMunicipalities`) já é acordado pelo prefixo do shell no manifesto. + - **Rejeitadas:** B — não prova quebra de linha e pinaria strings de classe (frágil); C — o repo não tem infra de baseline visual (inventar infra viola o appetite). +- **D4 — Manifesto e2e / blast radius do PR.** + - **Opções:** A) não tocar o manifesto (PR seleciona a família municípios, que já contém B184/B196); B) adicionar `campaignIosInputZoom` ao entry que contém o prefixo `src/components/campaign/shared/CampaignListOmnibox`; C) adicionar também os pesados (`campaignActivity`, `campaignSpeechAcervo`). + - **Recomendação:** B — `campaignIosInputZoom` é o pin barato e direto do contrato do campo (font-size 16px touch) e hoje está **fora do manifesto** (só roda no full); este diff toca exatamente o dono do campo, então o check obrigatório `checks` deve vê-lo. A família municípios (A dentro dela: B184/B196) continua sendo o grosso da cobertura. + - **Rejeitadas:** A — deixa um contrato tocado sem cobertura no gate de PR; C — encarece todo diff futuro da área com specs que pinam superfícies que este item não muda; o full verify do deploy cobre as verticais do acervo. + +### Componentes / mudanças + +- **`CampaignListOmnibox`** (`src/components/campaign/shared/CampaignListOmnibox.tsx`) — único arquivo de produto: + - cluster do campo (`:213-223`): passa a `relative` e ganha folga de fim de linha **só abaixo de `md`** (padding mobile; `md:` restaura `px-2`/borda/ring/altura); + - botão de limpar (`:293-322`): abaixo de `md` sai do fluxo (`absolute`, ancorado à direita do campo) mantendo `size-9 shrink-0 md:hidden`, o `invisible pointer-events-none` quando `!(hasChips || query.length > 0)` e todo o `onClick`/a11y atuais; + - comentário do fix com tag **C221** (a convenção do arquivo cita B184/B196/B200/C100/C125); + - **nada mais** muda: `campaignListOmniboxFormClassName`, chips, input, popover, trailing cluster desktop e regiões `sr-only` intocados. +- **`E2E_AFFECTED_MANIFEST`** (`scripts/lib/e2e-affected-manifest.mjs`): `campaignIosInputZoom` entra no entry que já contém `src/components/campaign/shared/CampaignListOmnibox` (D4). +- **Teste** (`tests/e2e/campaignMunicipalities.e2e.spec.ts`): novo `test` dentro do describe `Municípios — filtro e cards sem moldura no celular (B184)` (viewport 390 herdado). +- **Design:** `docs/plans/omnibox-limpar-mobile-ui-design.html` (novo artefato; o design da C219 não cobre o X dentro do campo nem a quebra — trigger (a)). +- **Changelog:** `docs/changelog/2026-09-24-c221.md` (uma entrada, formato dos recentes). +- **Migration:** sem migration. +- **Access / Consent:** nenhum (zero paths de escrita/PII; componente de apresentação). +- **UI:** Impeccable B — ajuste de encaixe do shell existente; shape→craft→critique→polish com o `designer` (trigger (a) antes do markup; trigger (c) no fechamento, screenshot 390/1280 contra o artefato; tier registrado no PR; `DEGRADED` fail-closed: `work-issue` para antes do push para sign-off, `agent-work-issue` comenta e bloqueia sem PR). + +### Superfícies afetadas (17 call sites, zero edições por consumidor) + +Todas usam o mesmo shell; o fix no dono as cobre sem tocar arquivo por arquivo: + +- **Acervo — Câmara:** `speech/SpeechAcervoFilters.tsx:215`, `speech/SpeechCutLibraryFilters.tsx` +- **Acervo — Gravações:** `recording/RecordingAcervoFilters.tsx:232` +- **Fora do acervo (amostras de regressão):** `municipality/MunicipalityFilters`, `municipality/CampaignUpdatesFilters`, `municipality/TerritoryFilters`, `activity/ActivityFilters`, `activity/ActivityAgendaFilters`, `advisor/AdvisorFilters`, `contacts/ContactFilters`, `content/ContentPieceFilters`, `demand/DemandFilters`, `leadership/LeadershipFilters`, `organization/OrganizationFilters`, `people/PeopleFilters`, `stateDeputy/StateDeputyFilters`, `supporter/SupporterFilters` + +### Dados → forma + +N/A — nenhuma métrica/dado novo; é encaixe de superfície existente. + +## Fases verificáveis + +1. **Design (trigger a) — bloqueante, antes de qualquer markup.** O `designer` estende/cria o artefato hi-fi com cenas 390 (campo com chip de busca longo commitado, chip curto + texto digitado, vazio — X visível e invisível) e 1280 (intocado), usando os tokens da campanha. Saída: artefato certificado em tier primário, ou `DEGRADED` com sign-off humano registrado. Referências do débito: `/tmp/opencode/c219-shots/06-mobile-empty.png` e `11-mobile-camara-long-chip.png` (se ainda existirem na máquina da sessão). +2. **Tracer — e2e RED (~30–40min).** Adicionar ao describe B184: login, `page.goto('/campanha/municipios?q=')` (o `?q=` renderiza o chip `Busca: ` de forma determinística, sem depender de sugestão do popover), esperar o chip e o X (`getByRole('button', { name: 'Limpar', exact: true })`), e assertar com `boundingBox` + `expect.poll` (idioma de B196): + - **(1) X ancorado à direita do campo:** `field.x + field.width − (x.x + x.width) <= 16` (o bug deixa o X à esquerda: inset ≈ 300px); + - **(2) X compartilha a faixa vertical do conteúdo:** centro do X `< input.y + input.height`; + - **calibrar o texto do `?q=` até o teste ficar vermelho no código atual** (janela do órfão: chip entre ~166 e ~208px; documentar a aritmética num comentário do teste). Se o comprimento não cair na janela, ajustar o texto — nunca afrouxar a asserção. Prova: teste vermelho com o componente pré-fix. +3. **Fix (~30–45min).** Portar o artefato aprovado classe-a-classe, restrito ao dono (D1/D2). Invariantes: pai do input continua o campo; X dentro da âncora; `invisible` quando vazio; `md:` restaura tudo; nenhum adapter/URL tocado. +4. **Verde + regressão (~30min).** E2e novo verde; rodar localmente os e2e da mesma superfície — `campaignMunicipalities`, `campaignIosInputZoom`, `campaignUpdatesMobile`, `campaignActivity` (discricionário OPS72; o CI do PR seleciona a família municípios + `campaignIosInputZoom`); capturas manuais a **390** em uma lista de cada vertical do acervo (Câmara em `/campanha/comunicacao/acervo`, Gravações na mesma rota com `?source=enviadas`) + uma fora do acervo (`/campanha/municipios`), com chip longo, chip curto + digitado e vazio; desktop **1280** no mesmo estado. `pnpm gate:fast`. +5. **Fechamento (~15min).** `/simplify`; crítica final do `designer` (trigger c) contra o app renderizado; changelog; `pnpm push`; PR ready cuja descrição lista as superfícies, o tier de design e a nota do manifesto. + +## Rabbit holes / Não escopo (engenharia) + +- **Invólucro em volta do input** (para agrupar chips+input) — proibido: quebra `omniboxInput.locator('..')` de B184/B196 e a âncora do popover; se o artefato do designer propuser isso, é divergência material e volta ao humano. +- Renomear `campaign-list-omnibox-form` ou mover o sticky do `` do caller — contrato do `:has()`/B196. +- Redesenhar a barra, os facet popovers, o `CampaignSearchModeControl` ou o trailing desktop. +- Cap/truncamento de chip como decisão de engenharia — só se o artefato pedir. +- "Consertar" o campo em 2 linhas com chip longo — comportamento pré-existente; o aceite é o X não ser linha própria. +- Unit de layout em jsdom ou infra de snapshot visual nova — não existe no repo; não inventar. +- Tocar `styles.css` (regra B183) ou o `text-sm` do input. +- Editar os 17 consumidores ou qualquer adapter/URL. + +## Riscos e mitigação + +- **Falso verde do e2e** (asserção que passa no código quebrado): mitigado por (i) **prova RED obrigatória** na Fase 2 antes do fix; (ii) asserção principal com margem enorme (inset ≈ 300px no bug vs ≤ 16px no fix); (iii) janela do órfão documentada no comentário para calibrar o texto. Se a fonte do CI mudar métricas, o teste pode voltar a passar por acidente — o segundo cenário de regressão manual (390, chip longo) é o backstop; gatilho para reabrir: B184 quebrar sem o teste acusar. +- **Regressão de desktop:** `relative` é inerte e a folga/posicionamento entram sob variantes `max-md:`; B184 já asserta a volta do desktop (borda 1px, label visível, "Limpar" texto); 1280 manual na Fase 4. +- **Altura da barra:** absoluto não adiciona linha; a folga horizontal não muda a altura; B196 segue pinando 44–48px no estado padrão. +- **X sumir/sobrepor conteúdo:** folga via padding de content box (chips `max-w-full`/`truncate` e input `flex-1` respeitam); X continua dentro da âncora, então o popover mantém `var(--radix-popover-trigger-width)` e a área de toque. +- **A11y:** ordem DOM/tab order inalterada; `invisible` continua tirando o X da a11y tree quando vazio (B184: `toHaveCount(0)`); `aria-label`/ring/sr-only intocados. +- **iOS 16px (B183):** nenhum font-size de form control é alterado; a regra `(pointer: coarse)` continua dona. `campaignIosInputZoom` entra no selecionado (D4). +- **Design `DEGRADED`:** fail-closed conforme pipeline — sem tier primário não há certificação; `work-issue` para antes do push, pool não abre PR. +- **Cobertura das verticais do acervo no PR:** o prefixo do shell no manifesto só acorda a família municípios; as demais verticais ficam com a rodada local discricionária + full verify do deploy — registrado no PR (D4). + +## Débitos (triage do /simplify, 2026-09-24) + +- **Deferido — viewports <390px (ex. iPhone SE, 375px):** o cap de 168px + o input `min-w-[8rem]` podem voltar a ocupar 2 linhas (a barra cresce ~46px), com o X ainda ancorado/centrado (aceite preservado). O design aprovado e o e2e cobrem o alvo 390. **Gatilho:** reporte real em aparelho estreito ou o próximo ajuste do shell de filtros. +- **Deferido — warning React de key (dev) no omnibox de assessores:** o console guard local acusa `Each child in a list should have a unique "key" prop — CampaignListOmnibox` na página de assessores (pré-existente, não determinístico, ausente em prod/build). **Gatilho:** warning determinístico no console guard ou a próxima passagem pelo `campaignColumnPicker`. +- **Descartados:** hidratação divergente do `AISidebarSurfaces` em capturas temporárias (intermitente, alheia); flakiness de login/POST do e2e local sob carga alta (ambiente, não débito do item). + +## Aceite de engenharia + +- [ ] Com chip de busca longo a 390, o X permanece ancorado ao campo/chips (novo e2e com **RED provado antes do fix**), em todas as listas (mesmo shell, sem edição por consumidor) +- [ ] A11y intacta: `aria-label="Limpar"`, ring de foco, `invisible` fora da a11y tree quando vazio, anúncio sr-only +- [ ] Desktop `md:` intocado (B184 desktop leg + captura 1280); barra mobile 44–48px (B196); font-size touch 16px (B183) +- [ ] URL/filtros e adaptadores intocados (diff não toca nenhum) +- [ ] Sem migration, sem access, sem Consent, sem PII +- [ ] Regressão manual 390: Câmara (acervo), Gravações (`?source=enviadas`) e uma lista fora do acervo (municípios), nos estados chip longo/curto+digitado/vazio +- [ ] `pnpm gate:fast` + e2e locais da superfície; changelog `docs/changelog/2026-09-24-c221.md`; `pnpm push` +- [ ] Design: artefato `docs/plans/omnibox-limpar-mobile-ui-design.html` estendido/certificado (trigger a) e crítica final (trigger c); tier registrado no PR + +## Self-score (decision-quality, gate ≥4) + +| Critério | Nota | Justificativa | +| ----------------------------- | ---- | ---------------------------------------------------------------------------------------------------- | +| Decisões caras com rejeitadas | 5/5 | D1–D4 no formato Opções/Recomendação/Rejeitadas; mecanismo, craft, teste e manifesto deliberados | +| Abordagem cabe no appetite | 5/5 | 1 arquivo de produto + 1 teste + 1 linha de manifesto; sem schema/server; ~2h | +| Rabbit holes nomeados | 5/5 | Invólucro no input, cap de chip, campo em 2 linhas, unit jsdom, 17 consumidores, `styles.css` | +| Depth check (reusa donos) | 5/5 | Fix no dono do encaixe (`CampaignListOmnibox`), e2e no spec dono do B184, sem módulo/collection novo | +| Intenção preservada | 5/5 | Aceite de produto (X nunca órfão) intacto; engenharia não reescreveu outcome nem escopo | + +**Agregado: 5/5 — aprovado em modo `--auto`.** diff --git a/docs/plans/omnibox-limpar-mobile-ui-design.html b/docs/plans/omnibox-limpar-mobile-ui-design.html new file mode 100644 index 00000000..d8e80ea3 --- /dev/null +++ b/docs/plans/omnibox-limpar-mobile-ui-design.html @@ -0,0 +1,702 @@ + + + + + + + Design — C221 · Limpar do omnibox no mobile + + + + + + +
+
+
+ + C221 · gate hi-fi + + + Campanha · omnibox compartilhado + +
+

O “Limpar” fica preso ao campo

+

+ Referência do encaixe abaixo de md: o chip pode truncar, mas o alvo de + limpar não desce sozinho nem cria uma faixa vazia antes do seletor de busca. +

+
+ +
+

CENAS 01–03 · VIEWPORT 390

+

+ Barra aderida sob o top bar · três estados críticos +

+

+ A linha sob o seletor é a separação do formulário sticky. O campo continua borderless; no + toque, o caret é o indicador de foco. As três cenas preservam a altura da barra. +

+ +
+ +
+
+
+

01 · Chip longo

+

alvo principal do fix

+
+ + 46px + +
+
+
+
+

Acervo de falas

+

+ Busque nas falas da Câmara ou nas gravações da equipe +

+
+
+ + 3 +
+
+
+
+ + Busca: saúde da mulher e atenção materna + + + + +
+
+
Termo exato
+
+ Por tema +
+
+
+
+ +
+

Resultados da busca

+

+ A lista começa sem uma faixa vazia intermediária. +

+
+
+
+
+ Decisão: chip com cap de 168px e ellipsis. O + filtro continua identificável; o input conserva seu mínimo e o X ocupa a mesma faixa. +
+
+ + +
+
+
+

02 · Chip curto + digitado

+

folga não rouba o input

+
+ + 46px + +
+
+
+
+

Acervo de falas

+

+ Busque nas falas da Câmara ou nas gravações da equipe +

+
+
+ + 3 +
+
+
+
+ + Busca: saúde + + + mulher + +
+
+
Termo exato
+
+ Por tema +
+
+
+
+ +
+
+
+
+
+
+
+
+ Decisão: a reserva final é fixa, não um + espaçador no fluxo. Depois do chip curto ainda há uma área legível para “mulher”. +
+
+ + +
+
+
+

03 · Vazio

+

geometria estável

+
+ + 46px + +
+
+
+
+

Acervo de falas

+

+ Busque nas falas da Câmara ou nas gravações da equipe +

+
+
+ + 3 +
+
+
+
+ + +
+
+
Termo exato
+
+ Por tema +
+
+
+
+ +
+
+
+
+
+
+
+
+ Decisão: o X continua no mesmo lugar, mas + invisible, sem presença visual nem foco. A barra não salta ao digitar. +
+
+
+
+ +
+

CENA 04 · VIEWPORT 1280

+

Desktop preservado

+

+ O ajuste móvel não atravessa md: label, campo emoldurado, ring de foco e + “Limpar” textual continuam no vocabulário atual. +

+ +
+
+ +
+
+
+

Acervo de falas

+

Comunicação

+
+
+
+
+

Falas da Câmara

+

+ Busque nas falas, filtre o acervo e abra o trecho certo. +

+
+
+
+ +
+ + Busca: saúde da mulher e atenção materna + + + +
+
+
+ +
+
+
+
+ Termo exato +
+
+ Por tema +
+
+
+ Ano + + Tema + + Alcance + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+

PORT CONTRACT

+

Medidas aprováveis

+
+
+

Folga final

+

44px abaixo de md: alvo de 36px + 8px de respiro.

+
+
+

Ancoragem

+

+ X a 0px da direita, centrado verticalmente no campo de 40px. +

+
+
+

Chip longo

+

+ Cap de 168px, uma linha, ellipsis; sem aumentar a barra. +

+
+
+

Acessibilidade

+

+ Alvo size-9, miolo size-6, aria-label="Limpar"; + vazio invisível. +

+
+
+
+
+ + diff --git a/docs/plans/omnibox-limpar-mobile.md b/docs/plans/omnibox-limpar-mobile.md index 547ce38f..3660c33b 100644 --- a/docs/plans/omnibox-limpar-mobile.md +++ b/docs/plans/omnibox-limpar-mobile.md @@ -2,10 +2,11 @@ Status: rascunho Atualizado em: 2026-09-24 -Issue: (a registrar) +Issue: #1313 Priority: P3 Impeccable: B — ajuste de encaixe do shell de filtros existente (não é fluxo novo) Appetite: ~2h eng (fill-in) +Design UI: docs/plans/omnibox-limpar-mobile-ui-design.html ## Intenção diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 2dd24636..54fdf519 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -229,6 +229,15 @@ export const E2E_AFFECTED_MANIFEST = [ 'campaignColumnPicker', ], }, + { + // C221 — the shared omnibox chassis gets its own cheap, direct pin: the + // iOS input-zoom spec reads the field's font-size, and this prefix is the + // chassis itself (component + adapter contract). Kept as a dedicated + // entry so a municipality-route diff does not pay for the extra spec; + // diffs in the chassis still wake the municipality family above. + prefixes: ['src/components/campaign/shared/CampaignListOmnibox', 'src/lib/campaignListOmnibox'], + specs: ['campaignIosInputZoom'], + }, { prefixes: [ `${CAMPAIGN_APP}/agenda`, diff --git a/src/components/campaign/shared/CampaignListOmnibox.tsx b/src/components/campaign/shared/CampaignListOmnibox.tsx index 56c617ca..14a39157 100644 --- a/src/components/campaign/shared/CampaignListOmnibox.tsx +++ b/src/components/campaign/shared/CampaignListOmnibox.tsx @@ -69,6 +69,14 @@ export const campaignListOmniboxFormClassName = * actions) — stays out of the sticky region. Desktop keeps the framed look * unchanged via `md:` variants. * + * C221 (2026-09-24): the clear X is taken OUT of the wrap flow below `md` + * (`absolute`, anchored to the field's right edge) and the field reserves + * its width with `pr-11` — a long search chip can no longer push the X to a + * row of its own; the chip caps at 168px so the fixed `8rem` input shares + * the single dense line at the design's 390 target (narrower viewports may + * still wrap the input, never the X). Artifact: + * `docs/plans/omnibox-limpar-mobile-ui-design.html`. + * * Keyboard contract mirrors RelationChipCell (ARIA combobox by hand): arrows * move the active option, Enter picks it, Escape closes. cmdk is not used — * its Root expects an inner CommandInput and would leave this field mouse-only. @@ -217,7 +225,11 @@ export const CampaignListOmnibox = ({ // with NO focus ring — the caret is the focus indicator; the // `md:` variants restore the framed desktop field with its // ring/colored border as the focus affordance. - 'flex min-h-10 w-full flex-wrap items-center gap-1.5 rounded-lg border-0 bg-transparent px-2 py-0 shadow-none', + // C221 — `relative` anchors the absolute X below `md`, and the + // mobile `pr-11` (44px = 36px target + 8px breathing) is the + // X's reserved lane outside the content flow; `md:` returns to + // the plain `px-2` frame (the X is `md:hidden`). + 'relative flex min-h-10 w-full flex-wrap items-center gap-1.5 rounded-lg border-0 bg-transparent px-2 py-0 shadow-none max-md:pr-11', 'md:min-h-11 md:border md:border-input md:py-1.5 md:shadow-xs', 'md:focus-within:border-ring md:focus-within:ring-3 md:focus-within:ring-ring/50', )} @@ -233,7 +245,14 @@ export const CampaignListOmnibox = ({ key={chip.id} data-active={activeChipId === chip.id ? 'true' : undefined} className={cn( - 'inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium text-foreground', + // C221 — below `md` the cap keeps chip + the fixed `8rem` + // input on the single dense line at the 390 target, inside + // the X's reserved lane (168 + 6 + 128 ≤ 306 of content; + // below ~386px viewport the input may wrap again, the X + // never does); the label ellipsizes, the chip identity + // survives. Desktop keeps `max-w-full` (the wrap is the + // desktop frame's own). + 'inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium text-foreground max-md:max-w-[168px]', 'data-[active=true]:ring-2 data-[active=true]:ring-ring', )} > @@ -297,7 +316,11 @@ export const CampaignListOmnibox = ({ // 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', + // C221 — out of the wrap flow and anchored to the field's + // right edge (centered on its height), so no chip length + // can orphan it on a row of its own; it lives inside the + // `pr-11` lane reserved on the field below `md`. + 'absolute right-0 top-1/2 inline-flex size-9 -translate-y-1/2 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', diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index ad91895b..ebafa6bb 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -1041,6 +1041,80 @@ test.describe('Municípios — filtro e cards sem moldura no celular (B184)', () await expect(field).toHaveCSS('border-top-width', '1px') await expect(page.getByRole('button', { name: 'Limpar', exact: true })).toBeVisible() }) + + /** + * C221 (2026-09-24): the shared chassis must keep the clear X on the field's + * own line when the search chip is long. At 390 the field content is ~342px, + * so chip + the fixed 8rem input + the 36px X no longer share one wrapping + * row: pre-fix the field grew to 74px here (the measured RED) and, for chips + * in the ~166–208px window, the X lands ALONE at the left (the reported + * shot). The long query makes the wrap deterministic on any font metric (the + * chip truncates at `max-w-full` and the fixed input can never share that + * line), so the height/centering RED does not depend on a calibrated chip + * width; the right-edge assertion pins the fix's anchoring — pre-fix, with + * this query, the X can also land at the right of the second row. + */ + test('chip de busca longo não empurra o X de limpar para uma linha órfã (C221)', async ({ + campaign, + page, + }) => { + const longQuery = 'município muito distante da capital com nome bem comprido' + await page.goto(`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(longQuery)}`) + // C106 — dynamic pages stream a transient hidden `#S:*` copy of the shell. + await waitForStreamSettled(page) + + const omniboxInput = page.getByRole('combobox', { name: 'Filtrar municípios' }) + const field = omniboxInput.locator('..') + await expect(page.getByRole('button', { name: `Remover Busca: ${longQuery}` })).toBeVisible() + const clearX = page.getByRole('button', { name: 'Limpar', exact: true }) + await expect(clearX).toBeVisible() + + // The field stays one dense line (B196: the bar is 44–48px; the field + // itself ~40px). The wrapped X pushed it to 74px in the measured RED. + await expect + .poll(async () => (await field.boundingBox())!.height, { timeout: 30_000 }) + .toBeLessThanOrEqual(48) + + // The X is anchored to the field's right edge and centered on the field — + // pre-fix, once the field wraps, it leaves both invariants (alone at the + // left in the ~166–208px chip window, or on the second row beside the + // grown input). + const geometry = async () => { + const fieldBox = (await field.boundingBox())! + const xBox = (await clearX.boundingBox())! + return { + rightInset: fieldBox.x + fieldBox.width - (xBox.x + xBox.width), + centerOffset: Math.abs(xBox.y + xBox.height / 2 - (fieldBox.y + fieldBox.height / 2)), + } + } + await expect + .poll(async () => (await geometry()).rightInset, { timeout: 30_000 }) + .toBeLessThanOrEqual(16) + await expect + .poll(async () => (await geometry()).centerOffset, { timeout: 30_000 }) + .toBeLessThanOrEqual(4) + + // The lane is reserved, not improvised: the input's right edge stops + // before the X (the artifact's scene 02 — "a reserva final é fixa, não um + // espaçador no fluxo"), so typed text never runs under the clear button. + await expect + .poll( + async () => { + const inputBox = (await omniboxInput.boundingBox())! + const xBox = (await clearX.boundingBox())! + return inputBox.x + inputBox.width <= xBox.x + }, + { timeout: 30_000 }, + ) + .toBe(true) + + // Desktop keeps the framed field and the text "Limpar" — the mobile + // anchoring never leaks over `md`. + await page.setViewportSize({ width: 1280, height: 900 }) + await expect(page.getByText('Filtrar municípios', { exact: true })).toBeVisible() + await expect(field).toHaveCSS('border-top-width', '1px') + await expect(page.getByRole('button', { name: 'Limpar', exact: true })).toBeVisible() + }) }) /**