Merge pull request #509 from fsolla/B183-pwa-ios-foco-em-inputs-aplica-auto-zoom-que-nao-volta-f

fix(campanha): impede auto-zoom do iOS em inputs do PWA (B183)
This commit is contained in:
Francisco Solla 2026-08-09 19:10:58 -03:00 committed by GitHub
commit 7af76b9f68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 144 additions and 0 deletions

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-09):** **B183** (Issue #501) — o auto-zoom do Safari iOS em inputs do PWA `/campanha` (font < 16px ao focar, escala que não volta no standalone) foi eliminado pela **causa**: uma regra CSS única em `styles.css` — `@media (pointer: coarse) and (hover: none)` + `[data-theme='campaign'] :is(input, select, textarea, [contenteditable='true']) { font-size: 1rem }` — cobrindo omnibox B127, Command search, native-select e textareas em qualquer orientação do iPhone (landscape chega a 932px de largura, o que descartou escopo por `max-width`). Unlayered vence as utilities `text-sm` sem `!important`; desktop (pointer fine) fica intocado; `Input`/`Textarea` do ui já eram `text-base md:text-sm`. Sem migration, access, Consent ou mudança de componente. E2E novo `tests/e2e/campaignIosInputZoom.e2e.spec.ts` pina o contrato: emulação `devices['iPhone 13']` → omnibox de `/campanha/municipios` computa **16px**; control desktop → permanece **14px**. Intenção [`pwa-ios-autozoom-input-foco.md`](plans/pwa-ios-autozoom-input-foco.md); impl [`pwa-ios-autozoom-input-foco-impl.md`](plans/pwa-ios-autozoom-input-foco-impl.md).
**Recently resolved (2026-08-09):** **B179** (Issue #462) — o Sollinha passou a falar de si **no masculino**: o system prompt (`src/utilities/ai/systemPrompt.ts`) abre com "Você é **o** Sollinha" e ganhou regra explícita (auto-referência masculina em primeira pessoa, nunca flexões femininas sobre si) — o welcome da UI já era masculino; o placeholder do chat virou "Pergunte para o Sollinha..." (`CampaignAIChat.tsx`) com os seletores e2e acompanhando (2 specs). Sem migration, access, Consent ou guarda automatizada (decisão do gate: só édito de texto). Os planos entregues da família (B162/B166/B167/B173/B174/B177) permanecem frozen com "a Sollinha" histórico — o que vale é a voz no runtime e o que se escreve daqui pra frente. Intenção [`sollinha-genero-masculino.md`](plans/sollinha-genero-masculino.md); impl [`sollinha-genero-masculino-impl.md`](plans/sollinha-genero-masculino-impl.md).
**Recently resolved (2026-08-09):** **B181** (Issue #480) — a `/campanha/agenda` não exibe mais o bloco "Nenhum compromisso nesta janela e neste filtro" + botão "Criar atividade" acima do calendário em janela vazia (só ocupava espaço, redundante com slot-click C91, botão "Nova atividade" dos filtros e FAB C94). Remoção cirúrgica em `ActivityAgenda.tsx` (bloco condicional + órfãos `emptyHref`/imports `Link` e `buildActivityCreateHref`); `buildActivityCreateHref` permanece com 2 consumidores ("Mais detalhes" no inline create, "Nova atividade" nos filtros); loading/erro e `noEventsText` do FullCalendar (vista "Lista") intactos. Teste unitário de regressão: janela vazia não renderiza aviso nem link de criação. Sem migration, access, Consent ou schema. Intenção [`agenda-remover-aviso-vazio.md`](plans/agenda-remover-aviso-vazio.md); impl [`agenda-remover-aviso-vazio-impl.md`](plans/agenda-remover-aviso-vazio-impl.md).

View file

@ -0,0 +1,75 @@
# Impl: PWA iOS: foco na omnibox de filtros aplica auto-zoom que não volta
Status: aprovado
Atualizado em: 2026-08-09
Issue: #501
Intenção: docs/plans/pwa-ios-autozoom-input-foco.md
Appetite restante: herdado (~0,5–1 dia eng) — cabe com folga
## Leitura da intenção
- **Outcome:** nenhum input do PWA `/campanha` (omnibox, login, formulários, wizard) dispara o auto-zoom do Safari iOS ao receber foco; desktop e telas grandes mantêm a aparência atual.
- **O que NÃO negociar:** regra única (gate decidido: "todos de uma vez", inclui `contenteditable`/`select`); nada de `maximum-scale` global; anti-goal "subir para 16px no desktop".
- **O que reavaliar:** a hipótese de "fix campo a campo" — confirmado que é sistêmico: regra CSS única cobre. O ponto de escopo ("onde o trigger do iOS existe") pede decisão de engenharia (abaixo).
## Abordagem recomendada
```mermaid
flowchart LR
A[styles.css: media query touch] --> B[regra unlayered 16px em inputs do data-theme=campaign]
B --> C[omnibox / Command input / native-select / textarea / contenteditable]
D[e2e iPhone emulation: computed font-size 16px] --> E[desktop control: 14px intacto]
```
**Opções consideradas:**
- **A — Regra CSS global no touch (recomendada):** bloco em `src/app/(frontend)/styles.css` (arquivo compartilhado frontend+campanha) dentro de `@media (pointer: coarse) and (hover: none)`, seletor `[data-theme='campaign'] :is(input, select, textarea, [contenteditable='true'])` com `font-size: 1rem`. Um bloco cobre omnibox (B127), Command search (ui `Command.tsx` `data-slot=command-input`), `native-select`, textareas e futuros contenteditable — nenhuma mudança de componente.
- **B — Bump por componente** (`text-base md:text-sm` no omnibox, CommandInput, native-select, …): o padrão que `Input`/`Textarea` já seguem, mas estendido caso a caso.
- **C — Regra global por largura** (`max-width: 767.98px`, espelhando o `md:` do repo).
**Recomendação:** **A** — a intenção mandou regra única (gate 2026-08-09), e ela é a única que cobre o trigger em **todas** as orientações do iPhone: o auto-zoom do Safari existe também em landscape (larguras 844–932px em iPhones modernos), que a opção C perderia. `(pointer: coarse) and (hover: none)` é a expressão CSS canônica de "telefone touch" — o repo já usa variantes pointer (`pointer-fine:`/`pointer-coarse:` em `RelationChipCell`). Desktop/laptop (pointer fine) fica intocado — aceite 3 preservado. Como a regra é **unlayered**, ela vence as utilities `text-sm` em camada (cascata), mesmo com especificidade menor — sem `!important`.
**Rejeitadas:**
- **B** porque é whack-a-mole (rabbit hole nº 1 da intenção): hoje já há 3+ bespoke offenders e o trigger é regra do navegador, não do componente; um `<input>` novo em `text-sm` reintroduz o bug silenciosamente.
- **C** porque perde o iPhone landscape (bug permanece vivo no aparelho do usuário) e cria dois regimes de fonte (16px portrait / 14px landscape) sem ganho.
- **`maximum-scale=1`/viewport fixo** — rejeitado pela própria intenção (degrada acessibilidade; vive no B182 como fallback se for preciso).
### Componentes / mudanças
- **`styles.css`** (`src/app/(frontend)/styles.css`, junto ao bloco `[data-theme='campaign'] :is([data-slot=…])` de bordas): um bloco `@media (pointer: coarse) and (hover: none)` com `[data-theme='campaign'] :is(input, select, textarea, [contenteditable='true']) { font-size: 1rem; }` + comentário curto explicando o limiar de 16px do Safari iOS (precedente de comentários explicativos no arquivo).
- **`Input`/`Textarea`/`InputGroup`** (`src/components/ui/`): **intocados** — já são `text-base md:text-sm`; a regra é no-op para eles no touch (16px) e não vaza para desktop.
- **Migration:** nenhuma. **Access / Consent:** nenhum.
- **UI:** Impeccable A (bug fix, sem redesenho).
- **Verificação:** e2e novo com emulação de device (abaixo).
## Fases verificáveis
1. **Regra CSS** — editar `styles.css`; conferir com o dev server que a omnibox em emulação touch computa 16px e desktop 14px.
2. **E2E** — `tests/e2e/campaignIosInputZoom.e2e.spec.ts`:
- describe com `test.use({ ...devices['iPhone 13'] })` (390×664, `isMobile`+`hasTouch` → `pointer: coarse`, `hover: none`): fixture `campaign` cria coordinator → login → `/campanha/municipios` → `getByRole('combobox', { name: 'Filtrar municípios' })` → `expect.poll(computed font-size) === '16px'`. Falha hoje (14px), passa com a regra.
- describe desktop (projeto campaign padrão): mesma omnibox permanece **14px** — pina o aceite "desktop continua como hoje".
3. **Gates** — `pnpm gate:fast`; `pnpm test:e2e` (spec novo + vizinhos de lista para regressão); `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm build`. Entrega com `pnpm push`.
4. **CHANGELOG** — entrada curta em `docs/CHANGELOG-AGENTS.md`.
## Rabbit holes / Não escopo (engenharia)
- Inventário exaustivo de inputs (intenção já cortou; a regra cobre todos por definição).
- `maximum-scale` / política de escala global (vive no B182).
- Público site (`(frontend)`): fora do escopo, o seletor exige `data-theme='campaign'`.
- Bump de fonte em **buttons** (Select/Combobox triggers são botões — iOS não zooma botões).
- `RelationChipCell` inline input: é `pointer-fine:block` (oculto no touch) — a regra não o afeta, e não é preciso.
## Riscos e mitigação
- **iPad (touch, ≥768px) passa a 16px** (hoje `md:text-sm` = 14px): o iPadOS não tem o trigger de zoom; é mudança cosmética no tamanho padrão de formulário do iOS. Não afeta desktop. Mitigação documentada no impl plan — aceite "telas maiores" refere-se a desktop, que fica intacto.
- **Inputs com fonte > 16px em touch seriam reduzidos para 1rem:** nenhum no codebase hoje (varredura por `text-lg|xl|2xl` em inputs não achou nada); se um dia houver, a regra é o limite inferior correto (16px é o teto do trigger).
- **E2E mobile depende de emulação Chromium** (`devices['iPhone 13']`), não de Safari real: valida o contrato CSS (pointer/hover) que o Safari iOS avalia; a regra é a padrão da indústria (West-Wind/t3code/BookStack).
- **Layout reflow no touch:** 16px em inputs raros (ex. chips) — os chips da omnibox não são inputs; o campo em si cresce ~2px de altura (min-h fixos absorvem).
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (regra única; desktop intacto; sem maximum-scale)
- [x] Invariantes AGENTS/engineering-standards (sem migration/access/Consent; copy pt-BR intacta; identificadores em inglês)
- [x] Testes previstos: e2e touch (16px) + e2e desktop (14px) — comportamento pina o aceite
- [x] Depth check: zero módulos novos; um bloco CSS no arquivo dono do theming

View file

@ -269,6 +269,18 @@ body,
border-radius: var(--radius-md);
}
/* B183 — Safari iOS auto-zooms any form control whose computed font-size is
below 16px when focused, and a standalone PWA does not always restore the
scale when the keyboard closes. One unlayered rule for touch phones (coarse
pointer, no hover) keeps every campaign input — omnibox, Command search,
native selects, textareas — at the 16px threshold in any orientation, while
desktop keeps its own size utilities (unlayered beats the utilities layer). */
@media (pointer: coarse) and (hover: none) {
[data-theme='campaign'] :is(input, select, textarea, [contenteditable='true']) {
font-size: 1rem;
}
}
/* The public-site base `h1` is centered + display-sized for article heroes.
Campaign dashboard headings are left-aligned section titles, so undo the
base centering and text balancing here; each heading keeps its own

View file

@ -0,0 +1,55 @@
import { devices, type Page } from '@playwright/test'
import { expect, test } from './fixtures/campaignE2EFixtures.js'
const municipalityOmnibox = (page: Page) =>
page.getByRole('combobox', { name: 'Filtrar municípios' })
const omniboxFontSize = (page: Page) =>
municipalityOmnibox(page).evaluate((element) => getComputedStyle(element).fontSize)
/**
* B183 — Safari iOS auto-zooms any focused form control whose computed
* font-size is below 16px, and a standalone PWA does not always restore the
* scale when the keyboard closes. The fix is one CSS rule for touch phones
* (`(pointer: coarse) and (hover: none)`), so these specs pin the computed
* size contract: 16px on a touch device, unchanged 14px on desktop.
*/
test.describe('B183 — inputs no touch ficam no limiar de 16px do auto-zoom iOS', () => {
// The project pins Desktop Chromium, so spread only the mobile-emulation
// bits of the iPhone descriptor (its `defaultBrowserType: 'webkit'` would
// force a new worker inside a describe group).
test.use({
userAgent: devices['iPhone 13'].userAgent,
viewport: devices['iPhone 13'].viewport,
deviceScaleFactor: devices['iPhone 13'].deviceScaleFactor,
isMobile: devices['iPhone 13'].isMobile,
hasTouch: devices['iPhone 13'].hasTouch,
})
test('omnibox de municípios computa 16px em dispositivo touch', async ({ campaign, page }) => {
const coordinator = await campaign.fixtures.createCampaignUser('coordinator', {
name: campaign.fixtures.value('Coordenadora touch'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
const omnibox = municipalityOmnibox(page)
await expect(omnibox).toBeVisible()
await expect.poll(() => omniboxFontSize(page)).toBe('16px')
})
})
test.describe('B183 — desktop preserva o tamanho atual', () => {
test('omnibox de municípios continua em 14px', async ({ campaign, page }) => {
const coordinator = await campaign.fixtures.createCampaignUser('coordinator', {
name: campaign.fixtures.value('Coordenadora desktop'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
const omnibox = municipalityOmnibox(page)
await expect(omnibox).toBeVisible()
await expect.poll(() => omniboxFontSize(page)).toBe('14px')
})
})