Base responsive behavior on the content panel using container queries, preserve territory filtering in the omnibox, and resolve column visibility and single-mount action contracts before implementation.
17 KiB
B158 — Colunas adaptativas na tabela de Municípios
Status: rascunho revisado
Atualizado em: 2026-08-05
Issue: #364
Priority: P1
Model: composer-2.5
Impeccable: Adapt / modo Operate — densidade progressiva dentro do painel de conteúdo
Canvas UI: /Users/francisco.solla/.cursor/projects/Users-francisco-solla-cursor-worktrees-teqo-5hoy/canvases/plan-b158-ui-draft.canvas.tsx (referência histórica; o contrato abaixo prevalece)
Appetite: ~1,5–2 dias eng; um outcome verificável
Responsável: —
Intenção
A tabela de Municípios em /campanha/municipios tem 10+ colunas no modo staff. O espaço útil não depende mais apenas da viewport: a página pode dividir a largura entre sidebar, painel de conteúdo e chat, cada um aberto ou fechado. Breakpoints de tela não representam o espaço que a tabela realmente recebeu.
A lista deve responder à largura do painel de conteúdo, mostrar primeiro as colunas que sustentam a decisão e acrescentar as demais uma a uma conforme houver espaço — sem scroll horizontal e sem exigir configuração prévia do ator.
O seletor manual de colunas (B17) continua existindo. Ele expressa preferência; as container queries expressam capacidade física do painel.
Decisões de produto consolidadas
- Container queries, não media queries. O wrapper da lista vira um named container. Abrir ou fechar sidebar/chat recalcula as colunas sem JavaScript de resize.
- Território na segunda linha do nome. A coluna
regiondeixa de existir.TerritoryLinkaparece sob o nome do município, em texto secundário e truncado. - Filtro de território preservado no omnibox.
MunicipalityFiltersjá oferece sugestões e chips de território; essa passa a ser a entrada canônica. Remover a coluna não removeregionFilterOptions,?region=nem o estado do filtro. - Sort legado por território preservado.
sort=regioncontinua válido por compatibilidade de URL e aparece no resumo quando ativo, mas deixa de ter um header clicável. Não será criada uma segunda affordance de sort neste item. - Ausência de nível vira
—. O filtrosem_nivelcontinua sendo uma resposta operacional real; somente a célula usa o padrão visual de ausência. - Headers curtos:
Assessor,Liderança,Dobradinha,2026,Sinal. O header2026explica a estimativa no tooltip B22. - Cobertura e Sinal ocultos por padrão. Novos estados de preferência começam com
goalCoverageelastSignalocultos; ambos continuam disponíveis no seletor. - Densidade progressiva. Colunas entram individualmente conforme o container cresce.
TendênciaeSinalsão adaptativas: usam ícone compacto quando não cabe o readout completo, sem montar um segundo editor. - Ações cobre apenas ocultação manual. Se o ator ocultar
trendoulastSignal, a coluna internaactionsmonta o editor correspondente. Container query nunca duplica o controle: uma coluna adaptativa compacta no próprio lugar. - Sem fallback horizontal. A tabela não usa
overflow-x-auto; cards continuam sendo a superfície do container realmente estreito.
Persona e job
- Persona: coordenador, candidato ou assessor usando tablet/laptop de campo, com sidebar e/ou chat alternando o espaço disponível.
- Job: escanear nome, base de 2022, estimativa de 2026, nível e classe; reconhecer tendência e manter ações rápidas acessíveis; receber mais contexto de rede conforme o painel cresce.
- Princípio: adaptar ao espaço que o conteúdo recebeu, não ao dispositivo que o hospeda.
Fluxo desejado
- O painel de conteúdo abre estreito: a lista usa cards.
- Ao ganhar largura suficiente, entra a tabela essencial.
- Cada faixa adicional introduz uma coluna, sem salto de três ou quatro colunas de uma vez.
- Abrir o chat ou a sidebar faz o processo inverso imediatamente via CSS.
TendênciaeSinal, quando escolhidos no B17, encolhem para um trigger de ícone antes de desaparecerem.- Se o ator os ocultar manualmente, seus triggers migram no render do servidor para
Ações, no extremo direito. - O filtro de território continua disponível no omnibox e seus chips continuam refletindo
?region=.
Contrato entre preferência e capacidade
A regra efetiva é:
coluna renderizada = permitida pelo papel
AND não ocultada manualmente
AND faixa do container permite a coluna
Exceções:
nameé obrigatória.trendelastSignal, quando manualmente visíveis, permanecem renderizados na tabela e alternam compacto ↔ completo conforme o container.- Se
trendoulastSignalestiver manualmente oculto, seu editor é renderizado uma única vez dentro deactions. - Mostrar manualmente uma coluna não força o painel a ultrapassar sua capacidade. O automático vence apenas na dimensão de espaço; a preferência continua guardada para quando houver largura.
Matriz canônica por largura do container
Os valores abaixo são breakpoints iniciais do painel de conteúdo, não da viewport. Devem ser calibrados no craft contra o conteúdo real, mas a ordem de entrada é contrato de produto. Uma calibração pode mover um corte; não pode agrupar colunas nem trocar a prioridade sem atualizar este plano.
| Faixa do container | Superfície / coluna que entra | Estado acumulado |
|---|---|---|
< 48rem (< 768px) |
Cards | Cards existentes; tabela ausente |
C0 >= 48rem |
Tabela essencial | Nome+Território, 2022, 2026, Nível, Classe, Tendência compacta, Ações se necessária |
C1 >= 54rem |
Assessor | C0 + Assessor |
C2 >= 60rem |
Tendência completa | C1; trigger troca ícone por badge/readout no mesmo controle |
C3 >= 66rem |
Liderança | C2 + Liderança |
C4 >= 72rem |
Dobradinha | C3 + Dobradinha, somente coordinator/candidate |
C5 >= 78rem |
Cobertura opt-in | C4 + Cobertura, se habilitada no B17 |
C6 >= 84rem |
Sinal completo opt-in | C5; Sinal, se habilitado, troca ícone por readout de idade |
Ordem visual estável
Quando todas estão disponíveis:
name— Município + Territóriovotos— 2022expectedVotes— 2026level— Nívelclasse— Classeadvisors— Assessortrend— Tendêncialeaderships— LiderançastateDeputies— Dobradinha, quando o papel permitegoalCoverage— Cobertura, opt-inlastSignal— Sinal, opt-inactions— somente quando Tendência e/ou Sinal foram ocultos manualmente
A ordem prioriza diagnóstico/decisão, depois responsabilidade e rede. A tabela não muda a ordem ao cruzar faixas; apenas acrescenta ou compacta células.
Colunas adaptativas e montagem única
Tendência
- Manualmente visível:
- C0–C1: um trigger de ícone com cor semântica do status atual.
- C2+: o mesmo
MunicipalityListTrendControlmostra o badge completo.
- Manualmente oculta: a coluna
trendnão é renderizada e uma única instância do controle aparece emactions. - Estado sem tendência usa ícone neutro e nome acessível “Tendência não registrada”.
Sinal
- Manualmente visível:
- C0–C5: o mesmo
MunicipalityListSignalControlusa trigger de adicionar/atualizar sinal. - C6+: troca para
SignalAgeReadoutno mesmo controle.
- C0–C5: o mesmo
- Manualmente oculto: uma única instância aparece em
actions.
Ações
- ID interno:
actions. - Não entra em
MunicipalityListColumnId,municipalityColumnLabels,municipalityColumnDescriptionsnem no picker B17. - Usa um tipo local, por exemplo
MunicipalityTableColumnId = MunicipalityListColumnId | 'actions'. - Possui header visualmente oculto “Ações” para manter a semântica da tabela.
- Só entra no DOM quando
trendoulastSignalestá manualmente oculto. - Os triggers têm nome acessível, tooltip redundante e alvo mínimo de 44×44px.
- Nunca existem duas instâncias do mesmo editor na mesma linha.
Defaults do B17 sem ambiguidade
O cookie atual omite uma lista cujo conjunto de ocultas é vazio. Portanto, “nunca configurou” e “restaurou todas” são indistinguíveis. B158 precisa evoluir o formato antes de aplicar defaults.
Contrato escolhido
- O formato passa a representar explicitamente uma lista configurada com zero ocultas, usando um sentinel reservado e versionado pelo parser/serializer.
- Exemplo conceitual:
municipios:__none__significa “configurado; todas visíveis”. O sentinel nunca é exposto como column ID. - Ausência da entrada
municipiossignifica “usar defaults atuais da lista”. - Defaults de Municípios:
['goalCoverage', 'lastSignal']. Restaurar todaspersiste o estado explícito vazio; não volta aos defaults no refresh.- Cookies legados sem entrada de Municípios adotam o novo preset compacto uma vez. Após a primeira interação, o estado fica explícito.
- As outras listas mantêm default vazio e comportamento existente.
- O controle continua sendo preferência por dispositivo/browser, não por usuário autenticado.
Objetivo e aceite
- A tabela responde à largura do named container do painel de conteúdo, inclusive quando sidebar/chat abrem ou fecham sem mudar a viewport.
- Não há barra de rolagem horizontal nem overflow do shell em nenhuma faixa de tabela.
- A transição cards ↔ tabela é baseada no container em 48rem.
- As colunas entram uma por vez na ordem C0–C6.
namecontinua sticky left e passa a ter largura máxima aproximada de 13rem; nome quebra em até duas linhas e território trunca em uma.- Headers podem quebrar apenas em whitespace; números e badges não truncam.
- Território continua filtrável no omnibox; chips e URLs
?region=permanecem intactos. - URLs legadas com
sort=regioncontinuam válidas. Nívelsem valor renderiza—.- Headers e labels usam singular;
2026tem tooltip: “Estimativa de votos do candidato neste município em 2026. Ordena pelo cenário central, independente do cenário selecionado acima.” - Cobertura e Sinal começam ocultos nos defaults de Municípios.
actionsnão aparece no picker e só existe no DOM por ocultação manual de Tendência/Sinal.- Cada editor de Tendência/Sinal é montado no máximo uma vez por linha.
- Cards mantêm conteúdo e interação atuais; somente o gatilho cards/tabela muda de media query para container query.
- Sem migration, collection, Consent, server action ou mudança de contrato público de URL.
Direção no codebase
MunicipalityList.tsx
- Envolver cards + tabela em
@container/municipality-list. - Trocar
md:hidden/md:blockpor variantes do named container. - Remover a definição
region; moverTerritoryLinkpara a segunda linha dename. - Reordenar as definições conforme a ordem visual canônica.
- Aplicar classes de container query simétricas ao
<th>e<td>de cada coluna. - Remover
containerClassName="overflow-x-auto"; usar overflow visível/oculto sem scroller horizontal. - Calcular no servidor somente a ocultação manual para decidir
actions. - Não usar
window,ResizeObserver,useMediaQueryouuseMemode viewport.
Controles
MunicipalityListTrendControl.tsx: adicionar seam tipado de apresentação (compact/fullou triggers responsivos dentro da mesma instância), preservandotriggerLabel, autosave e feedback.MunicipalityListSignalControl.tsx: reutilizar ochildrenjá existente para alternar ícone/readout dentro da mesma instância.- Ícones não duplicam significado apenas por cor:
aria-labelsempre inclui o estado textual.
Filtro e URL
MunicipalityFilters.tsxemunicipalityOmnibox.ts: sem mudança comportamental; manterregionFilterOptions, sugestões e chips.municipalityListUrl.ts: manterregionemMunicipalityListSortKey, parsing, serialização e labels de sort; remover apenasregiondemunicipalityColumnLabelsquando o tipo de coluna deixar de incluí-la.- Estado ativo
sort=regioncontinua anunciado porformatMunicipalityListSortSummary.
Tipos e labels
MunicipalityListColumnIdperderegion.actionsfica em união local da tabela, não nos records canônicos do picker/B22.- Atualizar labels para singular e
expectedVotespara label/header apropriados: picker “Estimativa 2026”, header “2026”. Se necessário, separar label serializável do texto telegráfico do header, seguindo o precedente devotos.
Cookie B17
campaignColumnVisibility.ts: parser/serializer distinguem entrada ausente de conjunto explicitamente vazio; testes de round-trip cobrem o sentinel.campaignColumnVisibilityCookie.ts: aplica defaults por lista somente quando a entrada está ausente.CampaignColumnPicker.tsx:Restaurar todaspersiste vazio explícito.
Shared table
- Evitar alterar
ui/Tableglobalmente. CampaignTablesó ganha seam compartilhado se a implementação provar que classes simétricas de head/cell não podem ficar na definição local. Não criar API genérica por antecipação.
Verificação obrigatória
Unit/static markup
- Ordem das colunas e labels por papel.
regionausente da tabela/picker, mas presente no parser/serializer de URL e no omnibox.actionsausente do picker.actionsausente do DOM quando Tendência e Sinal estão manualmente visíveis.- Cada cenário manual (
trend,lastSignal, ambos ocultos) monta exatamente um editor por linha. - Tendência e Sinal contêm variantes compacta/completa na mesma instância, controladas apenas por CSS.
- Nível nulo renderiza
—. - Cookie: legado ausente → defaults; vazio explícito → todas; restaurar todas sobrevive ao round-trip; outras listas não mudam.
Browser/E2E
Testar o container, não apenas presets de viewport:
- painel
<48rem: cards visíveis, tabela ausente; - C0, C1, C2, C3, C4, C5 e C6, incluindo 1px antes/depois de cada corte;
- mesma viewport com sidebar/chat em combinações aberto/fechado;
tableContainer.scrollWidth <= tableContainer.clientWidthem todas as faixas;- nomes longos de município/território e relações com muitos nomes;
- coordinator/candidate com Dobradinha e advisor sem essa coluna;
- teclado e leitor de tela nos triggers compactos;
- touch target mínimo de 44px;
- Chrome e Safari/iPad para sticky left + container query.
Os cortes podem ser ajustados uma vez no craft com evidência de scrollWidth, em lote. Depois da calibração, os valores finais ficam pinados nos testes; não criar loop de microajustes.
Dependências
- B17 entregue — requer evolução compatível do formato do cookie.
- B41 entregue — B158 substitui o scroller horizontal da lista de Municípios; não altera outras listas.
- B155 e B157 entregues — Liderança e Dobradinha já são colunas reais e entram na matriz.
- Nenhuma dependência dura externa.
Fora de escopo
- Mudar conteúdo ou controles dos cards.
- Aplicar container-query columns às outras listas.
- Reorder manual, resize, drag-and-drop ou prioridade configurável.
- Novo sort de território no omnibox.
- Otimizar loaders conforme coluna visível.
- Novos ícones de ação além de Tendência e Sinal.
Rabbit holes cortados
- Breakpoints de viewport: rejeitados; três painéis tornam viewport um proxy incorreto.
- JavaScript de resize: rejeitado; Tailwind/CSS container queries resolvem o layout sem hidratação adicional.
- Duplicar popovers entre coluna e Ações: rejeitado; cada editor monta uma vez.
table-fixede larguras arbitrárias por coluna: rejeitado; conteúdo dita largura, com apenas limites no nome e modos compactos explícitos.- Scroll horizontal como rede de segurança: rejeitado; teste de
scrollWidthé gate. - Mostrar manualmente acima da capacidade: rejeitado; preferência não pode estourar o painel.
Referências
src/components/campaign/municipality/MunicipalityList.tsxsrc/components/campaign/municipality/MunicipalityFilters.tsxsrc/components/campaign/municipality/MunicipalityListTrendControl.tsxsrc/components/campaign/municipality/MunicipalityListSignalControl.tsxsrc/components/campaign/shared/CampaignTable.tsxsrc/components/campaign/shared/CampaignColumnPicker.tsxsrc/lib/campaignColumnVisibility.tssrc/utilities/campaignColumnVisibilityCookie.tssrc/utilities/municipality/municipalityListUrl.tssrc/utilities/municipality/municipalityLabels.tsdocs/plans/seletor-colunas-lista-municipios.md— B17docs/plans/scroll-horizontal-lista-municipios.md— B41