Register the B42 review debts on the roadmap.

The aria-live region that unmounts with the overlay becomes B32+ F4, and the
catalog bundle debt in B33+ stops being conditional now that B41 and B42 both
shipped on that route without paying it.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-27 20:54:12 -03:00
parent 59d89ce287
commit 2983011bfe
4 changed files with 10 additions and 5 deletions

View file

@ -42,6 +42,7 @@ Um quarto controle, `MunicipalityListAdvisorsControl.tsx` (B27, chips + `Command
- **F1 (dominante):** decidir, com auditoria de código (não de intenção), se um hook `useCampaignListAutosave<TValue>` genérico compensa nos três call sites do debounce — e, se sim, extraí-lo; se não, registrar a rejeição medida (precedente B33+: a auditoria pode derrubar a premissa do plano).
- **F2 (condicional a F1 ter concluído "vale a pena mexer aqui"):** avaliar um helper de preâmbulo de rota (`guardCampaignJsonMutationRequest` ou nome equivalente) que combine `isSameOriginRequest` + `parseCampaignJsonRequestBody`, reduzindo o boilerplate dos 4 `route.ts`.
- **F3 (condicional, menor prioridade que F2):** avaliar um wrapper fino de `fetch` cliente (`postCampaignJson<TResponse>`) para os 4 controles, cobrindo só o preâmbulo comum (`credentials`/headers/`cast`), não a máquina de estado.
- **F4 (a11y, entrou pela triage do B42 ✓ em 2026-07-27; independente de F1 "extrair ou não"):** a região viva de cada controle é um `<p className="sr-only" aria-live="polite">` **dentro** do overlay, e fechar é justamente o que faz o flush da gravação — então "Salvando…" é anunciado numa região que acabou de desmontar, e o leitor de tela não recebe nada. É o mesmo furo que o `toast.error` do B42 tapou para o **erro**, mas o sucesso e o "salvando" continuam mudos. A casca (`CampaignCellEditOverlay`) é o lugar natural de uma região que sobrevive à superfície — o que muda o scaffold, e é por isso que é fase deste plano e não do B42. **Guardrail:** uma região por controle, não uma global por página; `escala-dry-pos-b33.md` já corrigiu uma página com **três** regiões vivas competindo, e a cura não pode reintroduzir isso.
- Guardrail comum às três fases: **zero mudança de contrato de URL/rede e zero mudança de comportamento visível** — é refactor puro, testável pelos specs/e2e já existentes (`campaignLeaderships.e2e.spec.ts`, `campaignMunicipalities.e2e.spec.ts`).
## Decisões travadas
@ -93,6 +94,7 @@ Componentes (condicionais à decisão de cada fase):
- **F2/F3 se F1 rejeitar a extração do hook principal.** Ver "Questões em aberto" — reavaliar isoladamente só com um 5º call site (rota ou controle).
- **`MunicipalityListAdvisorsControl` adotar o preâmbulo de fetch do F3** (se F3 for feito) — hoje ele tem uma política de erro diferente (`pendingCountRef`/sequência, sem revert único), então adotar herdaria só o `fetch`+`cast`, não a lógica de estado; barato, mas não é o call site que motivou o item. Gatilho: F3 concluído e aprovado.
- **Convergir o _corpo_ de multi-seleção (chips + `Command`), não só o container.** Contagem as-built, medida na triage do B42 ✓ (2026-07-27), para o próximo leitor não re-derivar: `shared/LeadershipStateDeputyRelationCell.tsx` (B36 ✓, já em `shared/` e já parametrizada por um record de `COPY`, servindo as duas direções da relação) e `MunicipalityListAdvisorsControl.tsx` (B27 ✓) repetem o mesmo corpo **string por string** — `w-80 p-0`, header `relative flex flex-col gap-2 p-3 pb-0`, Spinner, linha de chips `flex flex-wrap gap-1.5 px-3 pt-2`, o `Badge` de remover (`max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15`), `Command shouldFilter={false}` e a linha vazia `px-3 py-6 text-center`. `AdvisorMunicipalityCell` é uma terceira grafia, mas **inline** (sem overlay), então não conta como 3º call site do mesmo corpo. **Gatilho:** um 3º corpo em overlay (B34/B37 são os candidatos nomeados) — e aí a peça de destino é a que já mora em `shared/`, não uma quarta. Os planos B31/B34 registram este mesmo adiado do lado do widget; esta é a contagem do lado do markup.
## Referências

View file

@ -102,7 +102,7 @@ Achados do `/simplify` (2026-07-27) que são maiores que limpeza:
- **`<Field>` de "Ordenar" extraído (`CampaignListSortSelect`).** Os três blocos de 21 linhas diferem só nos identificadores de domínio — e **já divergiram**: `MunicipalityFilters` usa `className="min-h-11 w-full"` onde os outros dois usam `"w-full [&_select]:h-11"`, duas alturas para o mesmo controlo na mesma família de listas. O precedente de extração está na mesma pasta e tem os mesmos três call sites (`CampaignMobileMultiFilterField`). Não foi feito aqui por não haver decisão de qual altura é a correta — é escolha de design, não de código. **Gatilho:** a decisão de altura, ou o shell acima (que engole este bloco).
- **`serializeXSortValue` / `parseXSortValue` triplicados verbatim** (`municipalityListUrl.ts`, `territoryListUrl.ts`, `stateDeputyListUrl.ts`): `` `${key}|${dir}` `` e um `split('|')` com dois `Set.has`. `campaignListUrl.ts` é a casa desse tipo de parsing partilhado (já tem `normalizedText`, `allParamValues`, `parseExhaustiveEnumParam`) e não tem nenhum helper de sort. Genérico lá remove as três cópias sem tocar em contrato de URL. **Gatilho:** o 4º módulo de URL com sort (B29), ou o primeiro bug de deriva entre as cópias.
- **`SupporterFilters` / `ActivityFilters` adotarem o hook.** Ganharam `useCampaignListTransition`, mas não a navegação. **A razão registada aqui antes era factualmente falsa** e fica corrigida (reuse review, 2ª rodada): não é "montam o href sem passo de parse" — `buildSupporterListSearchParams` e `buildActivityListSearchParams` chamam `parseXListParams(...)` na primeira linha, exatamente a propriedade que justificou colapsar os outros três num só `toHref`. O impedimento real é o `valuesRef`/`useOptimistic` espelhando todos os filtros, para o qual o hook não tem equivalente; e migrar lhes **daria** dedup de no-op, mudança de comportamento em duas superfícies vivas. **Gatilho:** quando um deles precisar de dedup ou de busca com debounce. Independente disso, os dois reconstroem o href à mão a partir de `usePathname()` embora `buildSupporterListHref`/`buildActivityListHref` já existam e estejam pinados — dívida separada, no roadmap.
- **`/campanha/municipios` embarca ~10 kB gzip do catálogo dos 435 municípios no bundle do cliente** — achado da performance review, **anterior a este item** e a única coisa que ela encontrou: um `isMunicipalitySlug` num componente cliente arrasta `municipalityCatalog` inteiro, exatamente a violação de fronteira que o B14 pagou 21 kB para aprender ("client components não importam módulos de dados do servidor para valores"). Fora de escopo aqui porque a correção é no lado que consome o predicado, não no hook. **Gatilho:** próximo trabalho de bundle em `/campanha/municipios` (o B41/B42 tocam a mesma rota).
- **`/campanha/municipios` embarca ~10 kB gzip do catálogo dos 435 municípios no bundle do cliente** — achado da performance review, **anterior a este item** e a única coisa que ela encontrou: um `isMunicipalitySlug` num componente cliente arrasta `municipalityCatalog` inteiro, exatamente a violação de fronteira que o B14 pagou 21 kB para aprender ("client components não importam módulos de dados do servidor para valores"). Fora de escopo aqui porque a correção é no lado que consome o predicado, não no hook. **Gatilho:** próximo trabalho de bundle em `/campanha/municipios` (o B41/B42 tocam a mesma rota). **Atualização 2026-07-27: o gatilho disparou duas vezes e ninguém pagou** — **B41 ✓** e **B42 ✓** entregaram na rota sem tocar nisto, e a performance review do B42 reconfirmou o achado no mesmo endereço (`municipalityListUrl.ts` importa `isMunicipalitySlug`; os clientes que o arrastam são `MunicipalityFilters`, `MunicipalitySortableHead` e `MunicipalityHeaderFilter`). Ou seja: já não é condicional a um futuro — é a dívida de bundle **corrente** da rota, e o próximo item que abrir estes arquivos deveria pagá-la em vez de re-registrar o gatilho. Escopo real da correção: mover o predicado para um módulo client-safe (ou fazer o servidor validar o slug antes de serializar o href), não tocar no hook.
- **Terceiro idioma de mock de router nos testes.** `campaignListFilterNavigation.unit.spec.ts` usa `vi.hoisted` + `vi.mock('next/navigation')` com spread do módulo real; a casa já tem `stub<AppRouterInstance>` dentro de `AppRouterContext.Provider` (`campaignComponents.unit.spec.ts`), que dá as mesmas asserções sem mock de módulo. Não convertido por ser troca de harness num spec de 10 casos × 3 listas que está verde. **Gatilho:** o 4º spec que precise de router, ou o primeiro sintoma de deriva do spread.
## Referências

View file

@ -116,6 +116,9 @@ Depth check: reusa `Drawer` shadcn + padrão B26; não cria design system de ove
- **Montar o Drawer só no primeiro `open`** (`everOpened`, não `open`). Gatilho: a lista mobile aparecer num profile de render, ou o caminho `pagination: false` (435 linhas × 4 overlays) passar a ser rota quente. Números medidos no As-built.
- **`prefers-reduced-motion` no `Drawer`.** O `Dialog`/`Sheet` do kit também não respeita; a animação de entrada é do base-ui e vem do kit, então a correção é do componente `ui/`, não deste item. Gatilho: qualquer trabalho de acessibilidade de movimento no kit.
- **`DrawerVirtualKeyboardProvider`.** Sem ele, o teclado do Android pode cobrir os inputs de votos dentro da folha; hoje mitigado por não focar campo na abertura. Gatilho: relato de campo em Android.
- **A ficha do município mantém Popover em qualquer viewport.** `MunicipalityGoalAccountCard` passa sempre `variant="popover"`, então no celular aquela edição rápida ainda briga com o teclado — o problema que este item resolveu uma rota ao lado. Não é esquecimento: a ficha é **uma** árvore (não o par `md:hidden`/`hidden md:block` das listas), então escolher o container ali exige `useIsMobile`, que este plano rejeitou por trocar o container depois da hidratação. Gatilho: queixa de campo sobre editar votos na ficha pelo celular — e aí a decisão a tomar é `useIsMobile` na ficha, não copiar o branch.
- **`DrawerBody` no kit.** `Drawer.tsx` exporta header/footer/title/description mas nenhum corpo, então há **dois** containers de scroll escritos à mão (a casca e o Sinal) que já divergem em `gap-3`/`pt-3`. Adicionar `DrawerBody` resolve, mas põe uma peça não-upstream no kit (`shadcn add` não a recoloca). Gatilho: o 3º corpo à mão.
- **Stub de `ResizeObserver`/`scrollIntoView` compartilhado.** Duas cópias (este spec e `campaignCompositionCleanup.unit.spec.ts`), abaixo da barra de 3 do repo; `vitest.unit.config.mts` não declara `setupFiles`, então não existe casa para mover hoje. Gatilho: o 3º spec jsdom que precise deles → `setupFiles: ['./tests/helpers/jsdomStubs.ts']`, não uma terceira cópia.
## As-built (2026-07-27)
@ -146,7 +149,7 @@ O critique rodou **sobre o código**, não sobre a tela: a coleta de evidência
- **Reuso — o 4º call site migrou.** `LeadershipListSupportStatusControl` (B32) era o branch Popover da casca redigitado à mão, classe de trigger byte-a-byte inclusive: migrado com `variant="popover"`, ~25 linhas a menos e nenhum prop novo. `MunicipalityListSignalControl` passou a importar `CampaignCellEditOverlayVariant` em vez de re-escrever a união. A string de trigger, que estava em três arquivos, virou `campaignCellEditTriggerClassName` (precedente: `campaignHoverExplanationClassName`).
- **API da casca:** o `<button>` do trigger era escrito duas vezes (só o `onClick` diferia) → uma vez; `sheetBodyClassName` era o único prop de classe que **substituía** em vez de compor, o que obrigava o chamador de assessores a re-espalhar `px-4` por seção → agora compõe (`px-0 pt-2` no chamador) e o `sectionPaddingClassName` condicional morreu; `title` era obrigatório e morto no Popover → virou o `aria-label` do `role="dialog"`, que o Radix não nomeia; o docblock estava colado no `type` do variant (hover do IDE mostrava a doc errada) e os props agora estão agrupados em blocos `// Popover only` / `// Sheet only`.
**Medido e não feito** (o revisor de performance mediu; a decisão é não pagar agora): cada Drawer fechado ainda executa o corpo de `DrawerContent`, e o `cn()` do popup roda `tailwind-merge` sobre **4.068 caracteres** — 6,2 µs contra 0,8 µs do Popover que ele substituiu, ou **+0,40 ms** em 25 linhas e **+7 ms** no caminho `pagination: false` de 435. Não é adição, é troca (o número de overlays por página não mudou), e a correção é montar o Drawer no primeiro `open` (`{everOpened && …}`, não `{open && …}`, senão o `data-starting-style` perde a animação de entrada). Registrado como débito com gatilho.
**Medido e não feito** (o revisor de performance mediu; a decisão é não pagar agora): cada Drawer fechado ainda executa o corpo de `DrawerContent`, e o `cn()` do popup roda `tailwind-merge` sobre **4.068 caracteres** — 6,2 µs contra 0,8 µs do Popover que ele substituiu, ou **+0,40 ms** em 25 linhas e **+7 ms** no caminho `pagination: false` de 435. Não é adição, é troca (o número de overlays por página não mudou), e a correção é montar o Drawer no primeiro `open` (`{everOpened && …}`, não `{open && …}`, senão o `data-starting-style` perde a animação de entrada). Registrado como débito com gatilho. **Bundle: zero delta nas duas rotas** — a lista já importava `ui/Drawer` pelo Sinal, e a ficha do município (que ganhou uma aresta nova via `MunicipalityGoalAccountCard`) já o tinha no grafo porque `InstallPwaToast`, no layout de `(app)`, o importa. Vale saber: se o `InstallPwaToast` sair do layout, a rota de detalhe passa a pagar a subárvore do Drawer só por essa aresta.
**Cuidado operacional aprendido aqui:** `git stash` é **compartilhado entre worktrees** do mesmo repositório. Fazer stash para medir uma baseline enquanto outra sessão trabalha em paralelo faz o `pop` trazer o WIP alheio (e o índice do `drop` deslizar) — aconteceu nesta sessão e foi revertido com `git stash store <hash>`. Para baseline com sessões concorrentes, prefira patch local (`git diff > /tmp/…` + `git checkout --`) a `git stash`.

File diff suppressed because one or more lines are too long