fix(C221): "Limpar" do omnibox não quebra linha no mobile (Closes #1313)

This commit is contained in:
Francisco Solla 2026-09-24 16:00:11 -03:00
parent 02ca466469
commit 59f70dccf3
7 changed files with 954 additions and 4 deletions

View file

@ -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`).

View file

@ -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 `<form>` 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<br/>estende o ui-design do omnibox] --> B[e2e RED a 390<br/>chip longo: X órfão]
B --> C[C221 no CampaignListOmnibox<br/>X fora do fluxo do wrap + folga no campo]
C --> D[e2e GREEN + regressão 390/1280]
D --> E[gates - changelog - push<br/>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=<texto longo>')` (o `?q=` renderiza o chip `Busca: <texto>` 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 `<form>` 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`.**

View file

@ -0,0 +1,702 @@
<!doctype html>
<html lang="pt-BR" data-theme="campaign">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<title>Design — C221 · Limpar do omnibox no mobile</title>
<style type="text/tailwindcss">
@theme inline {
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-border: var(--sidebar-border);
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
}
[data-theme='campaign'] {
--background: #ffffff;
--foreground: #1c1917;
--card: #ffffff;
--card-foreground: #1c1917;
--primary: #c51414;
--primary-foreground: #ffffff;
--secondary: #f5f5f4;
--secondary-foreground: #292524;
--muted: #f4f4f5;
--muted-foreground: #6b7280;
--border: rgb(28 25 23 / 12%);
--input: rgb(28 25 23 / 16%);
--ring: #c51414;
--sidebar: #fafaf9;
--sidebar-border: rgb(28 25 23 / 12%);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
button,
input {
font: inherit;
}
.grid-bg {
background-color: #efeeec;
background-image: radial-gradient(rgb(28 25 23 / 8%) 0.7px, transparent 0.7px);
background-size: 16px 16px;
}
.frame {
box-shadow: 0 18px 54px rgb(28 25 23 / 11%);
}
.icon {
width: 1rem;
height: 1rem;
stroke-width: 2;
}
.icon-sm {
width: 0.875rem;
height: 0.875rem;
stroke-width: 2;
}
.mobile-frame {
width: 390px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 28px;
background: var(--background);
}
.mobile-topbar {
height: 56px;
background: var(--primary);
color: var(--primary-foreground);
}
.mobile-sticky {
border-bottom: 1px solid var(--border);
background: var(--background);
padding: 4px 16px 0;
}
/* C221 craft target: one dense line below md. The 44px end reserve is
36px for the touch target plus 8px of content breathing room. */
.mobile-field {
position: relative;
display: flex;
min-height: 40px;
width: 100%;
align-items: center;
gap: 6px;
padding: 0 44px 0 8px;
border: 0;
border-radius: 8px;
background: transparent;
}
.mobile-chip {
display: inline-flex;
max-width: 168px;
min-width: 0;
flex: 0 1 auto;
align-items: center;
gap: 4px;
border-radius: 6px;
background: var(--muted);
padding: 4px 8px;
color: var(--foreground);
font-size: 12px;
font-weight: 600;
line-height: 20px;
white-space: nowrap;
}
.mobile-chip-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-input {
min-height: 40px;
min-width: 128px;
flex: 1 1 128px;
border: 0;
background: transparent;
color: var(--foreground);
font-size: 16px;
outline: none;
}
.mobile-input::placeholder {
color: var(--muted-foreground);
}
.clear-mobile {
position: absolute;
top: 50%;
right: 0;
display: inline-flex;
width: 36px;
height: 36px;
transform: translateY(-50%);
align-items: center;
justify-content: center;
border: 0;
border-radius: 9999px;
background: transparent;
}
.clear-mobile-core {
display: flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: var(--muted);
color: var(--muted-foreground);
}
.clear-mobile.is-invisible {
visibility: hidden;
pointer-events: none;
}
.mode-control {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
border-radius: 8px;
background: var(--muted);
padding: 4px;
font-size: 14px;
}
.mode-active {
border: 1px solid var(--input);
border-radius: 6px;
background: var(--background);
padding: 10px 8px;
font-weight: 600;
box-shadow: 0 1px 2px rgb(28 25 23 / 8%);
}
.mode-idle {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 8px;
color: var(--muted-foreground);
font-weight: 500;
}
.spec {
border-left: 3px solid rgb(197 20 20 / 24%);
padding-left: 12px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
}
</style>
</head>
<body class="grid-bg min-h-screen bg-stone-100 text-foreground antialiased">
<!-- Lucide-compatible symbols used by the real surface. -->
<svg class="absolute size-0 overflow-hidden" aria-hidden="true">
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M18 6 6 18M6 6l12 12" />
</symbol>
<symbol id="i-sparkles" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z" />
<path d="m19 14-.9 2.1L16 17l2.1.9L19 20l.9-2.1L22 17l-2.1-.9L19 14Z" />
</symbol>
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M10.3 21h3.4M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9" />
</symbol>
<symbol id="i-filter" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M4 5h16l-6 7v5l-4 2v-7z" />
</symbol>
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="m6 9 6 6 6-6" />
</symbol>
</svg>
<main class="mx-auto flex max-w-[1480px] flex-col gap-14 px-4 py-10 md:px-8">
<header class="max-w-3xl">
<div class="flex flex-wrap gap-2">
<span class="rounded-full bg-primary px-2.5 py-1 text-xs font-semibold text-white">
C221 · gate hi-fi
</span>
<span class="rounded-full border border-border bg-white px-2.5 py-1 text-xs font-medium">
Campanha · omnibox compartilhado
</span>
</div>
<h1 class="mt-4 text-3xl font-semibold tracking-tight">O “Limpar” fica preso ao campo</h1>
<p class="mt-2 text-sm leading-6 text-muted-foreground">
Referência do encaixe abaixo de <strong>md</strong>: o chip pode truncar, mas o alvo de
limpar não desce sozinho nem cria uma faixa vazia antes do seletor de busca.
</p>
</header>
<section aria-labelledby="mobile-scenes-title">
<p class="text-xs font-semibold text-primary">CENAS 01–03 · VIEWPORT 390</p>
<h2 id="mobile-scenes-title" class="mt-1 text-xl font-semibold">
Barra aderida sob o top bar · três estados críticos
</h2>
<p class="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
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.
</p>
<div class="mt-5 grid items-start gap-8 xl:grid-cols-3">
<!-- Scene 01: long chip -->
<article>
<div class="mb-3 flex w-[390px] items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold">01 · Chip longo</p>
<p class="text-xs text-muted-foreground">alvo principal do fix</p>
</div>
<span
class="rounded-full bg-white px-2 py-1 text-[11px] font-semibold ring-1 ring-border"
>
46px
</span>
</div>
<div class="mobile-frame frame min-h-[408px]">
<header class="mobile-topbar flex items-center justify-between px-4">
<div class="min-w-0">
<p class="truncate text-sm font-semibold">Acervo de falas</p>
<p class="truncate text-xs text-white/85">
Busque nas falas da Câmara ou nas gravações da equipe
</p>
</div>
<div class="relative ml-3 grid size-9 shrink-0 place-items-center rounded-full">
<svg class="icon"><use href="#i-bell" /></svg>
<span
class="absolute right-0 top-0 rounded-full bg-white px-1 text-[9px] font-bold text-primary"
>3</span
>
</div>
</header>
<div class="mobile-sticky">
<div class="mobile-field" aria-label="Buscar e filtrar falas">
<span class="mobile-chip" title="Busca: saúde da mulher e atenção materna">
<span class="mobile-chip-label">Busca: saúde da mulher e atenção materna</span>
<span
class="grid size-5 shrink-0 place-items-center rounded-sm text-muted-foreground"
aria-hidden="true"
>
<svg class="size-3.5"><use href="#i-x" /></svg>
</span>
</span>
<span class="mobile-input" aria-hidden="true"></span>
<button class="clear-mobile" type="button" aria-label="Limpar">
<span class="clear-mobile-core"
><svg class="icon-sm" aria-hidden="true"><use href="#i-x" /></svg
></span>
</button>
</div>
<div class="mode-control mt-1.5 mb-2">
<div class="mode-active text-center">Termo exato</div>
<div class="mode-idle">
<svg class="icon-sm"><use href="#i-sparkles" /></svg>Por tema
</div>
</div>
</div>
<div class="px-4 pt-3">
<div class="flex gap-2 overflow-hidden" aria-hidden="true">
<span
class="inline-flex h-9 shrink-0 items-center gap-2 rounded-full border border-border px-3 text-xs text-muted-foreground"
><svg class="size-3.5"><use href="#i-filter" /></svg>Ano<svg class="size-3">
<use href="#i-chevron-down" /></svg
></span>
<span
class="inline-flex h-9 shrink-0 items-center gap-2 rounded-full border border-border px-3 text-xs text-muted-foreground"
><svg class="size-3.5"><use href="#i-filter" /></svg>Tema<svg class="size-3">
<use href="#i-chevron-down" /></svg
></span>
<span
class="inline-flex h-9 shrink-0 items-center gap-2 rounded-full border border-border px-3 text-xs text-muted-foreground"
><svg class="size-3.5"><use href="#i-filter" /></svg>Alcance<svg class="size-3">
<use href="#i-chevron-down" /></svg
></span>
</div>
<div class="mt-5 rounded-xl border border-dashed border-border p-5 text-center">
<p class="text-sm font-semibold">Resultados da busca</p>
<p class="mt-1 text-xs text-muted-foreground">
A lista começa sem uma faixa vazia intermediária.
</p>
</div>
</div>
</div>
<div class="spec mt-4 w-[390px] text-xs leading-5 text-muted-foreground">
<strong class="text-foreground">Decisão:</strong> 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.
</div>
</article>
<!-- Scene 02: short chip and typed query -->
<article>
<div class="mb-3 flex w-[390px] items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold">02 · Chip curto + digitado</p>
<p class="text-xs text-muted-foreground">folga não rouba o input</p>
</div>
<span
class="rounded-full bg-white px-2 py-1 text-[11px] font-semibold ring-1 ring-border"
>
46px
</span>
</div>
<div class="mobile-frame frame min-h-[408px]">
<header class="mobile-topbar flex items-center justify-between px-4">
<div class="min-w-0">
<p class="truncate text-sm font-semibold">Acervo de falas</p>
<p class="truncate text-xs text-white/85">
Busque nas falas da Câmara ou nas gravações da equipe
</p>
</div>
<div class="relative ml-3 grid size-9 shrink-0 place-items-center rounded-full">
<svg class="icon"><use href="#i-bell" /></svg>
<span
class="absolute right-0 top-0 rounded-full bg-white px-1 text-[9px] font-bold text-primary"
>3</span
>
</div>
</header>
<div class="mobile-sticky">
<div class="mobile-field" aria-label="Buscar e filtrar falas">
<span class="mobile-chip">
<span class="mobile-chip-label">Busca: saúde</span>
<span
class="grid size-5 shrink-0 place-items-center rounded-sm text-muted-foreground"
aria-hidden="true"
>
<svg class="size-3.5"><use href="#i-x" /></svg>
</span>
</span>
<span class="mobile-input flex items-center truncate">mulher</span>
<button class="clear-mobile" type="button" aria-label="Limpar">
<span class="clear-mobile-core"
><svg class="icon-sm" aria-hidden="true"><use href="#i-x" /></svg
></span>
</button>
</div>
<div class="mode-control mt-1.5 mb-2">
<div class="mode-active text-center">Termo exato</div>
<div class="mode-idle">
<svg class="icon-sm"><use href="#i-sparkles" /></svg>Por tema
</div>
</div>
</div>
<div class="px-4 pt-3">
<div class="flex gap-2 overflow-hidden" aria-hidden="true">
<span
class="inline-flex h-9 shrink-0 items-center gap-2 rounded-full border border-border px-3 text-xs text-muted-foreground"
><svg class="size-3.5"><use href="#i-filter" /></svg>Ano<svg class="size-3">
<use href="#i-chevron-down" /></svg
></span>
<span
class="inline-flex h-9 shrink-0 items-center gap-2 rounded-full border border-border px-3 text-xs text-muted-foreground"
><svg class="size-3.5"><use href="#i-filter" /></svg>Tema<svg class="size-3">
<use href="#i-chevron-down" /></svg
></span>
<span
class="inline-flex h-9 shrink-0 items-center gap-2 rounded-full border border-border px-3 text-xs text-muted-foreground"
><svg class="size-3.5"><use href="#i-filter" /></svg>Alcance<svg class="size-3">
<use href="#i-chevron-down" /></svg
></span>
</div>
<div class="mt-5 rounded-xl border border-border p-4">
<div class="h-3 w-2/3 rounded bg-muted"></div>
<div class="mt-3 h-2.5 w-full rounded bg-muted"></div>
<div class="mt-2 h-2.5 w-4/5 rounded bg-muted"></div>
</div>
</div>
</div>
<div class="spec mt-4 w-[390px] text-xs leading-5 text-muted-foreground">
<strong class="text-foreground">Decisão:</strong> a reserva final é fixa, não um
espaçador no fluxo. Depois do chip curto ainda há uma área legível para “mulher”.
</div>
</article>
<!-- Scene 03: empty -->
<article>
<div class="mb-3 flex w-[390px] items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold">03 · Vazio</p>
<p class="text-xs text-muted-foreground">geometria estável</p>
</div>
<span
class="rounded-full bg-white px-2 py-1 text-[11px] font-semibold ring-1 ring-border"
>
46px
</span>
</div>
<div class="mobile-frame frame min-h-[408px]">
<header class="mobile-topbar flex items-center justify-between px-4">
<div class="min-w-0">
<p class="truncate text-sm font-semibold">Acervo de falas</p>
<p class="truncate text-xs text-white/85">
Busque nas falas da Câmara ou nas gravações da equipe
</p>
</div>
<div class="relative ml-3 grid size-9 shrink-0 place-items-center rounded-full">
<svg class="icon"><use href="#i-bell" /></svg>
<span
class="absolute right-0 top-0 rounded-full bg-white px-1 text-[9px] font-bold text-primary"
>3</span
>
</div>
</header>
<div class="mobile-sticky">
<div class="mobile-field" aria-label="Buscar e filtrar falas">
<input
class="mobile-input"
aria-label="Buscar e filtrar falas"
placeholder="Busque por assunto, tema, município ou palavra-chave…"
readonly
/>
<button
class="clear-mobile is-invisible"
type="button"
aria-label="Limpar"
tabindex="-1"
>
<span class="clear-mobile-core"
><svg class="icon-sm" aria-hidden="true"><use href="#i-x" /></svg
></span>
</button>
</div>
<div class="mode-control mt-1.5 mb-2">
<div class="mode-active text-center">Termo exato</div>
<div class="mode-idle">
<svg class="icon-sm"><use href="#i-sparkles" /></svg>Por tema
</div>
</div>
</div>
<div class="px-4 pt-3">
<div class="flex gap-2 overflow-hidden" aria-hidden="true">
<span
class="inline-flex h-9 shrink-0 items-center gap-2 rounded-full border border-border px-3 text-xs text-muted-foreground"
><svg class="size-3.5"><use href="#i-filter" /></svg>Ano<svg class="size-3">
<use href="#i-chevron-down" /></svg
></span>
<span
class="inline-flex h-9 shrink-0 items-center gap-2 rounded-full border border-border px-3 text-xs text-muted-foreground"
><svg class="size-3.5"><use href="#i-filter" /></svg>Tema<svg class="size-3">
<use href="#i-chevron-down" /></svg
></span>
<span
class="inline-flex h-9 shrink-0 items-center gap-2 rounded-full border border-border px-3 text-xs text-muted-foreground"
><svg class="size-3.5"><use href="#i-filter" /></svg>Alcance<svg class="size-3">
<use href="#i-chevron-down" /></svg
></span>
</div>
<div class="mt-5 rounded-xl border border-border p-4">
<div class="h-3 w-1/2 rounded bg-muted"></div>
<div class="mt-3 h-2.5 w-full rounded bg-muted"></div>
<div class="mt-2 h-2.5 w-3/4 rounded bg-muted"></div>
</div>
</div>
</div>
<div class="spec mt-4 w-[390px] text-xs leading-5 text-muted-foreground">
<strong class="text-foreground">Decisão:</strong> o X continua no mesmo lugar, mas
<code>invisible</code>, sem presença visual nem foco. A barra não salta ao digitar.
</div>
</article>
</div>
</section>
<section aria-labelledby="desktop-title">
<p class="text-xs font-semibold text-primary">CENA 04 · VIEWPORT 1280</p>
<h2 id="desktop-title" class="mt-1 text-xl font-semibold">Desktop preservado</h2>
<p class="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
O ajuste móvel não atravessa <strong>md</strong>: label, campo emoldurado, ring de foco e
“Limpar” textual continuam no vocabulário atual.
</p>
<div class="frame mt-5 w-[1280px] overflow-hidden rounded-xl border border-border bg-white">
<div class="flex min-h-[520px]">
<aside
class="flex w-60 shrink-0 flex-col border-r border-sidebar-border bg-sidebar p-3"
>
<div class="flex h-12 items-center gap-2 px-2">
<div
class="grid size-8 place-items-center rounded-lg bg-primary font-bold text-white"
>
S
</div>
<div>
<p class="text-sm font-semibold">Solla</p>
<p class="text-xs text-muted-foreground">Campanha</p>
</div>
</div>
<nav class="mt-4 space-y-1 text-sm">
<div class="rounded-md px-3 py-2 text-muted-foreground">Início</div>
<div class="rounded-md px-3 py-2 text-muted-foreground">Municípios</div>
<div class="rounded-lg bg-secondary p-1">
<div class="px-2 py-2 font-medium">Comunicação</div>
<div class="ml-4 border-l border-border pl-3">
<div
class="rounded-md bg-white px-2 py-2 font-medium text-primary ring-1 ring-border"
>
Acervo de falas
</div>
<div class="px-2 py-2 text-muted-foreground">Biblioteca de cortes</div>
</div>
</div>
</nav>
</aside>
<div class="min-w-0 flex-1">
<header class="flex h-16 items-center border-b border-border px-8">
<div>
<p class="font-semibold">Acervo de falas</p>
<p class="text-xs text-muted-foreground">Comunicação</p>
</div>
</header>
<div class="mx-auto max-w-[940px] px-8 py-7">
<div>
<h3 class="text-xl font-semibold">Falas da Câmara</h3>
<p class="mt-1 text-sm text-muted-foreground">
Busque nas falas, filtre o acervo e abra o trecho certo.
</p>
</div>
<div class="mt-6 flex items-start gap-3">
<div class="min-w-0 flex-1">
<label class="mb-1.5 block text-sm font-medium">Buscar e filtrar falas</label>
<div
class="flex min-h-11 flex-wrap items-center gap-1.5 rounded-lg border border-ring bg-white px-2 py-1.5 shadow-xs ring-3 ring-ring/15"
>
<span
class="inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium"
>
<span class="truncate">Busca: saúde da mulher e atenção materna</span>
<span
class="grid size-5 shrink-0 place-items-center rounded-sm text-muted-foreground"
><svg class="size-3.5"><use href="#i-x" /></svg
></span>
</span>
<span class="min-h-10 min-w-32 flex-1"></span>
</div>
</div>
<div class="flex shrink-0 items-center gap-2 pt-7">
<button
class="inline-flex min-h-11 items-center rounded-lg px-3 text-sm font-semibold"
>
Limpar
</button>
</div>
</div>
<div class="mt-3 grid grid-cols-2 rounded-lg bg-muted p-1 text-sm">
<div
class="rounded-md border border-input bg-white py-2.5 text-center font-semibold shadow-sm"
>
Termo exato
</div>
<div
class="flex items-center justify-center gap-2 py-2.5 font-medium text-muted-foreground"
>
<svg class="icon-sm"><use href="#i-sparkles" /></svg>Por tema
</div>
</div>
<div class="mt-3 flex gap-2">
<span
class="inline-flex h-9 items-center gap-2 rounded-full border border-border px-3 text-xs text-muted-foreground"
><svg class="size-3.5"><use href="#i-filter" /></svg>Ano<svg class="size-3">
<use href="#i-chevron-down" /></svg
></span>
<span
class="inline-flex h-9 items-center gap-2 rounded-full border border-border px-3 text-xs text-muted-foreground"
><svg class="size-3.5"><use href="#i-filter" /></svg>Tema<svg class="size-3">
<use href="#i-chevron-down" /></svg
></span>
<span
class="inline-flex h-9 items-center gap-2 rounded-full border border-border px-3 text-xs text-muted-foreground"
><svg class="size-3.5"><use href="#i-filter" /></svg>Alcance<svg class="size-3">
<use href="#i-chevron-down" /></svg
></span>
</div>
<div class="mt-7 grid grid-cols-2 gap-3">
<div class="rounded-xl border border-border p-4">
<div class="h-3 w-1/2 rounded bg-muted"></div>
<div class="mt-3 h-2.5 w-full rounded bg-muted"></div>
<div class="mt-2 h-2.5 w-4/5 rounded bg-muted"></div>
</div>
<div class="rounded-xl border border-border p-4">
<div class="h-3 w-2/3 rounded bg-muted"></div>
<div class="mt-3 h-2.5 w-full rounded bg-muted"></div>
<div class="mt-2 h-2.5 w-3/4 rounded bg-muted"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section
aria-labelledby="spec-title"
class="rounded-xl border border-border bg-white p-6 shadow-sm"
>
<p class="text-xs font-semibold text-primary">PORT CONTRACT</p>
<h2 id="spec-title" class="mt-1 text-lg font-semibold">Medidas aprováveis</h2>
<div class="mt-4 grid gap-5 text-sm leading-6 md:grid-cols-4">
<div>
<p class="font-semibold">Folga final</p>
<p class="text-muted-foreground">44px abaixo de md: alvo de 36px + 8px de respiro.</p>
</div>
<div>
<p class="font-semibold">Ancoragem</p>
<p class="text-muted-foreground">
X a 0px da direita, centrado verticalmente no campo de 40px.
</p>
</div>
<div>
<p class="font-semibold">Chip longo</p>
<p class="text-muted-foreground">
Cap de 168px, uma linha, ellipsis; sem aumentar a barra.
</p>
</div>
<div>
<p class="font-semibold">Acessibilidade</p>
<p class="text-muted-foreground">
Alvo <code>size-9</code>, miolo <code>size-6</code>, <code>aria-label="Limpar"</code>;
vazio invisível.
</p>
</div>
</div>
</section>
</main>
</body>
</html>

View file

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

View file

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

View file

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

View file

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