Pular para conteúdo

0019 — Contraste dos neutros e adoção da paleta de marca

Status CONCLUÍDA
Branch spec/0019-contraste-e-adocao-da-paleta
Criada em 2026-08-05
Apps afetados web · e2e (api: sem impacto)

Status: RASCUNHO → APROVADA → EM IMPLEMENTAÇÃO → CONCLUÍDA

1. Problema

A interface é ao mesmo tempo difícil de ler e apagada. São duas queixas com uma raiz comum: a paleta foi pensada com cuidado e o app usa uma fração dela.

1.1 Os separadores não se veem

divider e borderField medem 1,2:1 contra o painel, nos dois temas. A WCAG 1.4.11 (Non-text Contrast) pede 3:1 para o que delimita componente. A 1,2:1 a linha existe no CSS e não na percepção: não se distingue onde um painel termina e outro começa, nem uma linha de tabela da seguinte.

1.2 O texto cinza cansa em jornada inteira

text.secondary mede 5,47:1 no claro e 5,80:1 no escuro: passa na WCAG AA, cujo mínimo é 4,5:1. Mas AA é critério de acessibilidade, não de conforto para quem passa oito horas no sistema — e o dado que decide é o tamanho. Os 199 usos de text.secondary estão todos em caption (12px, 74×) e body2 (13px, 72×): não é texto de apoio ocasional, é o corpo do conteúdo, em corpo pequeno. A tolerância da WCAG a contraste menor pressupõe texto grande; aqui vale o contrário, e a régua de leitura prolongada é a AAA, 7:1.

Que a paleta já sabe disso é o mais revelador: neutralInk mede 7,19:1 — o token de texto atenuado já está na régua AAA. Foi o text.secondary, o que o app de fato usa, que ficou para trás.

Além dele, falha o texto que o app atenua por fora da paleta:

Onde Como está pintado Claro
DataRow sem valor text.disabled × opacity .6 1,73:1
Placeholder de campo default do MUI (42%) 2,62:1
Rótulos de seção do sider text.disabled 2,68:1
Título de painel / seção secondary.main × opacity .85 3,24:1

1.3 Painel e fundo quase não se distinguem

paper contra background.default mede 1,07:1 no claro. Sem diferença de superfície, toda a estrutura da tela depende justamente das linhas de 1,2:1.

O caminho para separar as duas não é escurecer o fundo, que era a saída óbvia: fundo mais escuro derruba o contraste de todo texto que assenta nele. O caminho é clarear o painel para branco puro — sobe o contraste de tudo que está dentro dos painéis, que é onde vive a maior parte do conteúdo, e separa as superfícies escurecendo o fundo bem menos.

1.4 A marca tem cinco matizes e a paleta adotou três

O logo é um swoosh de três braços mais a assinatura, e cada peça é um gradiente:

Cor Onde vive no logo Na paleta
#005577 navy fim do gradiente da assinatura primary ✓
#008B8B teal braço do meio do swoosh secondary ✓
#FFA94D laranja braço de cima do swoosh highlight ✓
#00ACF0 ciano início da assinatura e do braço azul ausente
#FFCB53 ouro início do braço laranja ausente

O ciano não é acessório: a palavra "GARANTORA" inteira é o gradiente #00ACF0 → #005577 — metade da assinatura é ciano. No app ele aparece uma vez, cravado em minúsculas com opacity: .8, numa linha do login.

1.5 A tela é majoritariamente cinza

386 ocorrências de neutro contra 254 de cor somadas, e a cor que existe está mal distribuída:

primary 109 · secondary 76 · error 42 · success 11 · warning 10 · highlight 5 · info 1

O laranja — um dos três braços do swoosh, o elemento mais quente e distintivo da marca — tem cinco usos no sistema inteiro. A paleta o define como "a única CTA mais desejada da tela", então alguma escassez é proposital; cinco é escassez além da intenção.

E info é cor duplicada: info.main é #005577, idêntico ao primary. Um slot de paleta que não carrega informação nenhuma — e é exatamente onde o ciano da marca cabe.

1.6 As cores semânticas estão pela metade

A paleta declara quatro semânticas — success, warning, error, info — mas só metade do aparato que elas precisam para funcionar. O que existe e o que falta:

success warning error info
cor viva ✓ ✓ ✓ ✓ (= primary)
tinta de texto (*Ink) ✓ 5,13:1 ausente ✓ 4,98:1 ausente
texto sobre preenchimento (on*) ✓ ausente ✓ ausente
fundo de alerta ✓ ✓ ✓ ✓
fundo de chip ✓ ✓ ✓ ausente

Duas consequências medidas:

  • warning não tem tinta e é usado como texto. #F59E0B mede 2,15:1 sobre o painel — e SummaryCards.tsx:24 pinta a taxa de inadimplência com warning.main quando ela passa de 5%. O número que avisa que algo vai mal é o mais difícil de ler da tela. A própria paleta reconhece o problema no comentário das tintas ("âmbar 1,70") e mesmo assim nunca criou o warningInk.
  • info é a severidade padrão do sistema de notificação global. O providers/notification.tsx faz SEVERITY[params.type] ?? "info" — toda notificação sem tipo conhecido cai em info, que hoje é o navy do primary, sem tinta e sem chip.

O alcance não é pequeno: <Alert> aparece em 42 arquivos, Dialog em 14, Tooltip em 19, Snackbar em 3.

Há ainda um efeito de ordem: as razões anotadas na paleta (successInk "6,36:1 no chip", dangerInk "5,17:1 no chip") foram medidas sobre o paper atual. Clarear o painel para branco (RN5) muda todos esses fundos — os números melhoram, mas ficam desatualizados no comentário, que é a única coisa que impede o token de derivar de novo.

1.7 A raiz comum

Em 1.2 e 1.5 o padrão é o mesmo, e é o da spec 0018: o design system declara a coisa certa e o app passa por fora. accentInk existe (5,92:1) e os títulos usam secondary.main a 85% (3,24:1). neutralInk existe (7,19:1) e o texto atenuado usa text.disabled, que a paleta nunca declarou e por isso cai no default do MUI.

Com um agravante: o trabalho de acessibilidade foi feito — os comentários de successInk, dangerInk e highlightInk trazem as razões medidas uma a uma — e parou nas tintas coloridas, sem alcançar divider, text.disabled, os multiplicadores de opacity nem os dois matizes que a marca tem e a paleta não.

1.8 Por que as duas metades andam juntas

Todas as cores vivas da marca são de preenchimento, não de texto: sobre painel branco o teal mede 4,15:1, o ciano 2,57:1, o laranja 1,90:1, o ouro 1,51:1. Nenhuma passa.

Isso é o que impede a resposta ingênua: "usar mais cor" sem a tinta correspondente é piorar o contraste. Adotar marca e consertar legibilidade não são duas tarefas disputando o mesmo arquivo — é uma tarefa só, e foi por isso que esta spec fundiu as duas.

2. Objetivo

Que a estrutura da tela se veja e que a marca apareça. Separador que delimita cumpre 3:1, texto de conteúdo cumpre 7:1, painel se distingue do fundo — e as cinco cores do logo existem na paleta, cada uma com sua tinta de texto e uma regra de emprego que dispense gosto pessoal a cada tela.

3. Fora de escopo

  • Redesenhar o logo ou recolorir a marca. Os .svg oficiais não se tocam — o Logo.prompt.md é explícito. Esta spec lê as cores do logo, não as altera.
  • Mudar o matiz das cores que já estão na paleta. primary, secondary, highlight e as semânticas continuam onde estão. A spec acrescenta o que falta e corrige neutros, superfícies e o uso das tintas.
  • Recolorir componentes em massa. A adoção segue a regra da RN11; onde não houver regra, fica neutro. Colorir por decoração é o defeito oposto ao relatado, não a cura.
  • Modo de alto contraste ou prefers-contrast. Primeiro o padrão precisa estar certo.
  • Contraste de gráfico e mapa de calor. O Centro de Operações tem visualizações com regras próprias; ficam para uma spec de dataviz.
  • text.disabled de controle genuinamente desabilitado. A WCAG isenta componente inativo (1.4.3), e não é o que está errado — o defeito é usar essa cor como "texto terciário". Ver RN4.
  • Auditoria de contraste em todas as telas. Esta spec corrige os tokens e o uso indevido que a medição encontrou; não promete varrer cada tela.

4. Regras de negócio

Legibilidade

  • RN1 — Separador que delimita componente cumpre 3:1 contra o fundo em que assenta, medido no pior dos dois lados. Vale para borda de painel, de campo, de diálogo, cabeçalho de tabela e qualquer filete que diga "aqui termina uma coisa e começa outra".

Revisada pelo fix 0004. Esta regra era larga demais: a WCAG 1.4.11 pede 3:1 para o que identifica componente, e só a borda de campo identifica. Painel, diálogo e cabeçalho de tabela decoram, e passaram para 2:1. Ver fix/0004-separadores-escuros-demais/.

  • RN2 — Separador interno de lista cumpre 1,5:1 (era 2:1 nesta spec; ajustado pelo fix 0004, porque a 2:1 vinte linhas ainda formavam grade), não 3:1. Linha entre itens de uma mesma lista é ritmo de leitura, não fronteira de componente: a 3:1 uma lista de 20 linhas vira uma grade de traços escuros, e a legibilidade que se ganha na linha se perde no conjunto. A distinção entre RN1 e RN2 é a diferença entre cumprir a norma e obedecê-la ao pé da letra onde ela não se aplica.

  • RN3 — Texto de conteúdo cumpre 7:1 (AAA) sobre o painel. Vale para text.primary e text.secondary, que juntos pintam praticamente todo o conteúdo em 12–13px. O alvo é AAA e não AA porque o critério aqui é jornada de trabalho, não conformidade mínima.

  • RN3a — Todo texto cumpre ao menos 4,5:1 em qualquer superfície do app (painel, fundo, cabeçalho de painel, zebra, chip, alerta). Nenhum texto recebe opacity para parecer secundário — atenuação é escolha de cor, não de transparência, porque opacity reduz o contraste sem que a paleta perceba.

  • RN3b — text.secondary e neutralInk passam a ser o mesmo valor. Significam a mesma coisa — "texto atenuado, mas para ler" — e manter dois tokens para um conceito foi o que deixou um deles (o que o app usa) 1,7 ponto atrás do outro.

  • RN4 — text.disabled volta a significar desabilitado. Passa a ser declarado na paleta e só é usado em controle inativo, onde a WCAG isenta. Texto atenuado que o usuário precisa ler usa neutralInk.

  • RN5 — Painel se distingue do fundo em pelo menos 1,12:1, e a diferença vem de clarear o painel, não de escurecer o fundo — para não pagar a separação com o contraste do texto que assenta no fundo.

  • RN6 — Todo token novo ou alterado entra com a razão medida anotada ao lado, como já fazem successInk e companhia. Token de contraste sem o número documentado é token que volta a derivar.

  • RN7 — As regras valem nos dois temas. Claro e escuro têm valores diferentes e os mesmos alvos. Nenhum dos dois é "o principal".

Marca

  • RN8 — O ciano entra ocupando o slot de info. Hoje info.main é cópia literal do primary e tem um uso no app: o slot está livre na prática. info passa a significar dado e informação neutra — a família azul-claro da assinatura.

  • RN9 — O ouro entra como o degrau claro do laranja (highlight.light), que é exatamente o papel dele no logo: o stop inicial do gradiente do braço laranja. Serve a fundo de chip e alerta de pendência, distinto do âmbar warning genérico do MUI.

  • RN10 — Cor viva de marca nunca entra sozinha. Toda cor nova vem acompanhada da sua tinta de texto, calibrada no pior fundo em que vai aparecer — o próprio chip —, como já fazem successInk, dangerInk e highlightInk. Cor sem tinta é convite a repetir o defeito que esta spec conserta.

  • RN11 — A hierarquia de emprego é a do swoosh, e fica documentada:

Cor Emprego
laranja / ouro ação mais desejada da tela, e pendência que espera o usuário
teal marca e navegação — onde o produto se identifica
navy estrutura: cabeçalho, rótulo, moldura
ciano dado e informação neutra
semânticas sucesso, erro, alerta — nunca decorativas

Sem isso, "usar mais cor" vira decisão de gosto a cada tela, que é como se chegou aos cinco usos do laranja.

  • RN12 — Cor substitui neutro só onde carrega informação — ícone de módulo, estado de linha, cabeçalho que identifica seção. Onde a cor não distingue nada, o neutro fica. Colorir por decoração cansa tanto quanto o cinza apagado.

Semântica

  • RN13 — Toda cor semântica tem o conjunto completo: cor viva, tinta de texto (*Ink), texto sobre preenchimento sólido (on*), fundo de alerta e fundo de chip. Quatro semânticas × cinco peças, sem lacuna. Hoje faltam warningInk, infoInk, onWarning, onInfo e chip.info — e é exatamente nas lacunas que o app improvisou.

  • RN14 — Tinta semântica é calibrada no pior fundo em que aparece, que é o chip a 14%, não o painel. Calibrar no painel deixa a tinta legível solta e apertada dentro do próprio chip — o fundo tingido come parte da diferença. É o critério que successInk e dangerInk já seguem, agora escrito.

  • RN15 — on* é escolhido por medição, não por hábito. Branco sobre âmbar mede 2,15:1 e sobre ciano 2,57:1: nesses dois o texto tem de ser escuro (#0F172A, 8,31:1 e 6,94:1). É a mesma lição que o tema escuro já aprendeu no onDanger, com o comentário "o matiz clareia no escuro, então o que fica em cima precisa escurecer".

  • RN16 — Ao mudar paper, as razões anotadas são reaferidas. Clarear o painel (RN5) altera todos os fundos compostos por transparência — chip, alerta, zebra, cabeçalho. Os números nos comentários da paleta são a defesa contra a deriva; comentário desatualizado é pior que comentário ausente, porque parece verificado.

Valores propostos

Derivados por busca binária sobre a escala slate (neutros) e sobre as cores do logo (marca), verificados contra cada superfície onde aparecem.

Claro

Token Hoje Proposto Medido
background.paper (RN5) #FBFCFE #FFFFFF 1,13:1 vs fundo
background.default (RN5) #F1F5F9 1,07:1 #EDF1F6 —
text.secondary (RN3) #5F6878 5,47:1 #465264 7,92:1 painel · 6,98:1 fundo
neutralInk (RN3b) #4E5666 7,19:1 #465264 idem — mesmo token
divider (estrutural, RN1) #E2E8F0 1,20:1 #7B8A9E 3,52:1 painel · 3,10:1 fundo
dividerSubtle (lista, RN2) — #A9B6C8 2,06:1 painel
borderField #E3E8ED 1,20:1 #7B8A9E 3,52:1
text.disabled (RN4) não declarado #67768B 4,51:1
info.main (RN8) #005577 = primary #00ACF0 preenchimento (2,57:1)
infoInk (RN10) — #0071B5 4,53:1 no chip · 5,21:1 no painel
highlight.light (RN9) — #FFCB53 preenchimento (1,51:1)
highlightLightInk (RN10) — #9C6800 4,51:1 no chip · 4,78:1 no painel

Escuro

Token Hoje Proposto Medido
text.secondary (RN3) #9AA4B2 5,80:1 #ACB4C0 7,00:1 painel · 8,95:1 fundo
neutralInk (RN3b) #9AA4B2 #ACB4C0 idem — mesmo token
divider (estrutural) #2A3648 1,20:1 #64748B 3,07:1 painel · 3,93:1 fundo
dividerSubtle — #475569 1,93:1 painel
borderField #2A3648 #64748B 3,07:1
background.default #0F172A 1,22:1 #0B1220 1,28:1 vs painel
text.disabled não declarado #8290A4 4,51:1
info.main (RN8) #4DA6CC = primary #00ACF0 5,68:1 — passa como texto
highlight.light (RN9) — #FFCB53 9,70:1 — passa como texto

Semânticas — o que falta (RN13), medido no claro

Token Situação Proposto Medido
warningInk ausente — âmbar como texto é 2,15:1 #AE5700 4,53:1 no chip · 5,04:1 no painel
infoInk ausente #0071B5 4,53:1 no chip · 5,21:1 no painel
onWarning ausente #0F172A 8,31:1 sobre o âmbar
onInfo ausente #0F172A 6,94:1 sobre o ciano
chip.info ausente rgba(0,172,240,.14) fundo #DBF3FD
successInk existe, reaferir (RN16) #2E7D32 mantido 5,42:1 no painel branco
dangerInk existe, reaferir (RN16) #B71C1C mantido —

No escuro as quatro semânticas já passam como texto sobre o painel — success 6,33:1, warning 8,30:1, error 5,11:1, info 5,68:1 — e por isso lá cada *Ink continua apontando para a própria cor, como o arquivo já faz e explica.

text.primary não muda: já mede 17,06:1 no claro e 12,16:1 no escuro.

Onde o sistema fica abaixo de 7:1, e por quê. O text.secondary novo mede 6,98:1 sobre o fundo claro e 6,45:1 sobre o cabeçalho de painel no escuro. São superfícies secundárias, o desvio é de centésimos a meio ponto, e forçar 7:1 nelas escureceria o texto a ponto de competir com o text.primary — apagando a hierarquia que o cinza existe para criar. Ambos seguem muito acima dos 4,5:1 da RN3a. Conferidos também: cabeçalho de painel 7,43:1, zebra 7,57:1, chip neutro 6,58:1.

5. Impacto por app

apps/api

Sem impacto. Cor é tema de apresentação; nenhum modelo, endpoint ou migration.

apps/web

Design system (src/design-system/) — o grosso da entrega:

  • theme/palette.ts — neutros, superfícies, text.disabled, dividerSubtle, a unificação text.secondary/neutralInk, e os dois matizes novos com suas tintas. Cada um com a razão medida anotada (RN6).
  • tokens/colors.css e tokens/dark.css — os mesmos valores, que é a segunda metade da fonte única (o README.md do design system manda replicar).
  • theme/components.ts — onde MuiDivider e as bordas de Paper/Card escolhem entre estrutural e sutil.
  • prompts/ — o .prompt.md que descrever cor ganha a tabela de emprego da RN11.

Uso indevido a corrigir (RN3a/RN4):

Arquivo Hoje
components/shared/FormPanel.tsx título com secondary.main + opacity .85
components/shared/MobileDetailSection.tsx idem
components/shared/DocumentsPanel.tsx idem, mais ícones em text.disabled
components/shared/DataRow.tsx vazio com text.disabled + opacity .6 (1,73:1)
components/custom-sider/index.tsx rótulos de seção e badges em text.disabled
pages/credit-analyses/show.tsx (2×), SelectionBlock, DecisionPanel título com opacity .85
pages/renters/show.tsx, pages/settings/document-types/index.tsx idem / ícones
pages/login/index.tsx:148 #00acf0 cravado com opacity .8 → passa a usar info

O título de seção em versalete aparece em sete lugares com o mesmo bloco de sx copiado. Extrair para peça compartilhada entra no mesmo passo — é a razão de o defeito ter se espalhado.

Adoção de cor (RN11/RN12) — onde a cor passa a substituir o neutro, sem varredura cega: ícones de módulo no sider, chip de estado que hoje é neutro por falta de cor, e o info recém-criado nos lugares que hoje pintam primary para dizer "dado".

data-testid: nenhum novo. Mudança de cor não altera estrutura.

Sider: sem impacto estrutural — só a cor dos rótulos de seção e dos ícones.

Responsividade & PWA: cor não depende de viewport. Sem impacto no service worker além do rebuild de sempre.

apps/e2e

  • Spec: cenário que lê as cores computadas de separador, borda de campo, superfície e texto atenuado em telas representativas (lista, formulário, detalhe), calcula a razão WCAG no browser e afere RN1–RN5. Cobre os dois temas — alternando pelo controle do topo — porque metade dos valores só existe no escuro.
  • Seeds: nenhum. As telas existentes bastam.

6. Contrato de API

Sem alteração. Nenhum endpoint criado, removido ou modificado.

7. Critérios de aceite

  • [x] CA1. Toda borda de painel, campo, diálogo e cabeçalho de tabela mede ≥ 3:1 contra o fundo adjacente, nos dois temas.
  • [x] CA2. Separador interno de lista mede ≥ 2:1 e < 3:1 — visível sem virar grade.
  • [x] CA3. text.primary e text.secondary medem ≥ 7:1 sobre o painel, nos dois temas.
  • [x] CA3a. Nenhum texto da interface mede abaixo de 4,5:1 em superfície nenhuma, incluindo os quatro casos da seção 1.2.
  • [x] CA3b. text.secondary e neutralInk têm o mesmo valor no palette.ts.
  • [x] CA4. Nenhum texto da interface é atenuado por opacity — a busca por opacity em regra de cor de texto não retorna ocorrência.
  • [x] CA5. paper e background.default diferem ≥ 1,12:1 nos dois temas, e no claro a diferença veio de clarear o painel — background.default não ficou mais escuro que #EDF1F6.
  • [x] CA6. text.disabled está declarado na paleta e só aparece em controle desabilitado.
  • [x] CA7. As cinco cores do logo existem na paleta, cada uma com tinta de texto que cumpre 4,5:1 no pior fundo em que aparece.
  • [x] CA8. info.main deixou de ser cópia do primary, e o #00acf0 cravado no login sumiu.
  • [x] CA9. A tabela de emprego da RN11 está em docs/design-tokens.md, que hoje só documenta o highlight.
  • [x] CA10. Cada token novo ou alterado tem a razão medida anotada no palette.ts, e tokens/colors.css/tokens/dark.css trazem os mesmos valores.
  • [x] CA11. As cores de marca existentes não mudaram de matiz — o diff em palette.ts toca neutros, superfícies, tintas de texto e os dois matizes novos.
  • [x] CA12. As quatro semânticas têm o conjunto completo da RN13 — cor viva, *Ink, on*, fundo de alerta e fundo de chip —, sem lacuna nos dois temas.
  • [x] CA13. Nenhuma cor viva semântica é usada como cor de texto ou de glifo; onde hoje há warning.main como texto (SummaryCards.tsx:24), passa a haver warningInk.
  • [x] CA14. Toda tinta semântica mede ≥ 4,5:1 sobre o próprio chip, não só sobre o painel.
  • [x] CA15. Toda razão anotada em comentário no palette.ts foi reaferida contra as superfícies novas — nenhuma descreve o estado anterior.
  • [x] CA16. tsc limpo, sem apontamento novo nos arquivos tocados.

8. Plano de implementação

Ordem obrigatória no monorepo: api → web → e2e. Aqui a api não entra.

  • [x] apps/api — sem impacto, nada a fazer
  • [x] apps/web — palette.ts: neutros, superfícies, text.disabled, dividerSubtle, unificação text.secondary/neutralInk, com as razões anotadas (RN6)
  • [x] apps/web — palette.ts: info = ciano e highlight.light = ouro, com as tintas infoInk/highlightLightInk (RN8–RN10)
  • [x] apps/web — tokens/colors.css e tokens/dark.css com os mesmos valores
  • [x] apps/web — components.ts: onde cada separador escolhe entre estrutural e sutil
  • [x] apps/web — remover opacity de texto e trocar text.disabled por neutralInk nos arquivos da tabela (RN3a/RN4)
  • [x] apps/web — extrair o título de seção em versalete, hoje copiado em sete lugares
  • [x] apps/web — palette.ts: completar as semânticas (RN13) — warningInk, infoInk, onWarning, onInfo, chip.info
  • [x] apps/web — trocar cor viva semântica por tinta onde ela é texto ou glifo (CA13)
  • [x] apps/web — reaferir e reescrever as razões anotadas na paleta (RN16/CA15)
  • [x] apps/web — adoção da RN11/RN12 nos pontos listados, sem varredura cega
  • [x] docs/design-tokens.md — tabela de emprego das cinco cores (CA9)
  • [x] apps/e2e — spec medindo as razões nos dois temas
  • [x] make test (sem impacto esperado — só web) e tsc
  • [x] /devremote-rebuild + script de medição de contraste nos dois temas e nos dois viewports, comparando antes e depois

9. Decisões em aberto

Nenhuma. As decisões que abriram a spec foram resolvidas antes da escrita:

  • Separadores: 3:1 no que delimita, ~2:1 na linha interna de lista.
  • Texto: corrigir tudo abaixo de 4,5:1, inclusive o atenuado por opacity.
  • Superfícies: reforçar a distinção entre painel e fundo.
  • Marca: fundir a adoção das cinco cores nesta spec, em vez de uma 0020 na fila.
  • Semântica: completar success/warning/error/info na mesma passada, já que o buraco está no mesmo arquivo e tem a mesma causa.

Duas correções entraram depois da primeira redação, ambas vindas da validação do usuário:

  1. A régua do texto de conteúdo subiu de AA (4,5:1) para AAA (7:1). A primeira versão concluía que "o texto cinza da paleta está correto" porque passava na AA. Passava — e ainda assim cansa, porque os 199 usos estão todos em 12–13px e o critério de quem trabalha o dia inteiro no sistema não é o mínimo de acessibilidade. Junto veio a inversão da estratégia de superfície: clarear o painel em vez de escurecer o fundo.

  2. A adoção da marca entrou no escopo. Era para ser a spec 0020, atrás desta. Fundiu porque as duas reescrevem o mesmo palette.ts e porque adotar cor sem tinta pioraria justamente o contraste que a outra metade conserta (seção 1.7).

10. Registro

O que mudou de fato

Antes Depois
Borda de painel (claro) 1,20:1 3,33:1 aferido no browser
Borda de painel (escuro) 1,20:1 3,53:1
Linha interna de lista 1,20:1 2,06:1 — visível sem virar grade
Texto de conteúdo (claro) 5,47:1 7,92:1
Texto de conteúdo (escuro) 5,80:1 7,00:1
Painel × fundo 1,07:1 1,13:1, clareando o painel
Taxa de inadimplência em âmbar 2,15:1 tinta warningInk, 5,04:1

O que divergiu do planejado

1. Não eram sete cópias do título de seção — eram oito.

A spec listou sete arquivos com o bloco secondary.main + opacity: .85. Ao varrer, apareceram mais quatro (DocumentsStep 2×, DocumentChecklist, properties/show) e uma oitava que a busca por opacity: 0.85 não pegava: o FinancialSummaryPanel tinha um componente SectionLabel local, com opacity: 0.7 — a mais apagada de todas. Todas foram para components/shared/SectionLabel.tsx.

2. O ouro não ganhou tinta própria.

A tabela de valores previa highlightLightInk: #9C6800. Medindo, o highlightInk que já existe (#8F5400) mede 5,77:1 sobre o chip do ouro — bem acima do piso. Um segundo token diria o que o primeiro já diz, e token duplicado foi exatamente o que deixou neutralInk e text.secondary divergirem (RN3b). O ouro compartilha a tinta do laranja, que é a família dele no logo.

3. text.disabled foi separado por natureza, não convertido em bloco.

A RN4 manda text.disabled significar "desabilitado". Havia ~70 usos, quase nenhum de controle inativo. Separei: os 27 usos em texto (estados vazios, legendas, valores) foram para text.secondary; os ~20 em ícone e marcador ficaram, porque text.disabled agora é declarado a 4,62:1 e cumpre a RN3a — e um ícone de apoio em 7,92:1 pesaria mais que o conteúdo ao lado. A fronteira é "o usuário lê isto?", não o nome do token.

4. O colors.css documentava um logo que não existe.

As "paradas do gradiente do logo" eram #F58220, #00857A, #00A0E3 — nenhuma das três aparece em public/logo.svg. Eram aproximações. Substituídas pelos cinco stops reais.

5. O tema escuro tinha text.secondary divergente entre doc e código.

docs/design-tokens.md dizia rgba(232,234,240,0.70); o código usava #9AA4B2. Resolvido em favor de um valor opaco — cor de texto por transparência é justamente o que faz o contraste escapar do controle da paleta.

Adendo — os chips de status não estavam pálidos: não tinham cor

Validando as listas, os chips ("Em análise", "Aprovado", "Em edição") pareciam apagados perto do resto, que havia escurecido. Medindo, o problema era outro e maior: os três renderizavam MuiChip-colorDefault, cinza rgba(0,0,0,.08), idênticos entre si.

A causa é um erro de API que atravessou o app: sx={{ color: cfg.color }} põe a string "success" na propriedade CSS color, não na prop color do Chip. O MUI resolve contra palette.success, que é um objeto — vira CSS inválido e é descartado silenciosamente. Nenhum type check pega, porque sx.color aceita string.

Consequência: a cor de status não comunicava nada desde sempre, e os tokens chip.* e *Ink da paleta — criados exatamente para isso — nunca tiveram um único consumidor.

Corrigido no StatusBadge, que passa a compor fundo tingido + tinta a partir do design system, e nos outros três chips com o mesmo defeito (DecisionPanel, DocumentChecklist, WorkQueueItem), via toneChipSx exportado — um lugar só. Junto foram quatro textos e glifos com color: "error" solto, que caíam no mesmo descarte.

Aferido depois do rebuild, nos dois temas:

Tom Fundo (claro) Tinta sobre ele
info #DBF3FD 4,54:1
success #E2EDE2 6,53:1
default #E5E7E9 6,38:1

No escuro, 5,35 · 6,12 · 6,46:1. Antes: um cinza só, para todos.

O e2e mediu o pixel, não o token — e cobrou três coisas

O spec calcula as razões a partir do estilo computado no browser, não dos hex da paleta. A diferença apareceu logo: três rodadas até passar, e nenhuma das falhas foi da paleta.

  1. eval() dentro de page.evaluate não vaza escopo. Os seis cenários falharam de uma vez porque os helpers passados como string declaravam const dentro de um eval em modo estrito. Trocado por addInitScript, que pendura tudo em window.
  2. O primeiro .MuiPaper-root do documento é a gaveta, não o painel do formulário — o teste media a superfície errada. Passou a usar main .MuiPaper-root.
  3. Os itens "EM BREVE" da gaveta puxavam o pior caso para 2,82:1. Eles têm aria-disabled e opacity: .75: componente inativo, que a WCAG 1.4.3 isenta e a spec põe fora de escopo. Excluídos por seletor — o que é diferente de afrouxar o piso.

A terceira rodada expôs uma imprecisão da minha primeira versão do teste: eu exigia AAA de todo texto, incluindo o rótulo de seção em accentInk (6,08:1). A RN3 escopa os 7:1 a text.primary/text.secondary; tinta de marca responde à RN3a (≥4,5:1). O teste passou a separar as duas réguas — via data-ink="brand" no SectionLabel — em vez de baixar a do conteúdo.

Verificação

  • make test: 206 passaram, zero falhas — inclusive a intermitente test_partner_agency_search, que desta vez caiu do lado bom do desempate. Nenhum arquivo da API foi tocado.
  • tsc: limpo no web e no e2e.
  • color-contrast.spec.ts: 7/7 em chromium, nos dois temas, contra o dev-remote reconstruído.
  • Sem rolagem horizontal em nenhuma das três telas medidas.
  • Suíte e2e completa: 319 passaram, 6 pulados, zero falhas (16,7 min, chromium + firefox + webkit). Os 6 pulados são os semeadores em massa, já test.skip por não serem idempotentes contra o banco compartilhado — nada a ver com esta entrega.

Valia rodar: uma mudança de cor que atravessa o app inteiro é justamente o tipo de coisa que quebra um spec que dependia de valor de cor sem dizer. Nenhum dependia.