Pular para conteúdo

0012 — Iconografia Phosphor, conforme o design system 2.3.0

Status CONCLUÍDA
Branch spec/0012-iconografia-phosphor
Criada em 2026-08-02
Apps afetados api (sem impacto) · web · e2e

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

1. Problema

O produto fala duas línguas de iconografia e nenhuma delas é a do design system.

A versão 2.0.0 do @garantora/design-system (2026-08-02) é a primeira mudança incompatível do pacote, e o motivo é exatamente este: família única, Phosphor Icons. Lucide e Material Symbols Rounded foram removidos, o componente Icon perdeu a prop set e ganhou weight. O texto é explícito — "a única família de ícones do produto, em desktop e mobile. Nunca misture com outra biblioteca."

O apps/web usa @mui/icons-material: 195 importações, 78 ícones distintos, 57 arquivos. Não é uma das duas famílias removidas — é uma terceira. As consequências aparecem na tela:

  • O mesmo conceito tem desenhos diferentes conforme a página. "Imobiliárias" é Apartment na sider e Business em outros pontos; imóvel ora é HomeWork, ora Home, ora HomeOutlined. Documento aparece como Description, ArticleOutlined, InsertDriveFileOutlined e FolderOutlined, sem regra.
  • Não há peso. O design system distingue navegação inativa (regular) de ativa (fill), status (fill) e decorativo de empty state (duotone). Hoje o ativo e o inativo têm o mesmo traço, e a diferença fica só na cor.
  • Ícone colorido usa a cor viva. A regra manda usar o token -ink; a cor viva de danger sobre fundo claro mede 4,20:1 no pior caso.

Ligado a isso, a 2.1.0 corrigiu o par de cores do item ativo de navegação: o texto era --color-structure, que no escuro dá 4,03:1 sobre a pílula #1C3F57. A 2.3.0 completou a varredura e achou que o ícone do item ativo tinha ficado para trás quando o rótulo foi trocado — "dois azuis diferentes na mesma linha no escuro".

Os dois defeitos existem no apps/web hoje:

  • Gaveta mobile (spec 0011, publicada hoje): ícone e rótulo do ativo em primary.main, que no escuro é #4DA6CC sobre #1C3F57 — os mesmos 4,03:1.
  • Sider do desktop: idem, e com um agravante. A 2.2.0 corrigiu a sobrescrita de MuiListItemButton no tema para usar navActiveFg, mas o custom-sider pinta o &.Mui-selected por conta própria no sx — color: "primary.main" e bgcolor: alpha(primary, 0.09). O sx local vence o tema, então subir a versão não conserta a sider sozinho: a sobrescrita local precisa sair.

2. Objetivo

Uma família de ícones só, a do design system, com peso e tamanho definidos por situação e cor de ícone vinda de token acessível — de modo que o mesmo conceito tenha o mesmo desenho em qualquer tela, no desktop e no mobile.

3. Fora de escopo

  • onDanger / onSuccess em CountBadge, Toast e botão destrutivo. A 2.1.0 corrigiu o branco sobre vermelho/verde clareados (2,86:1 e 2,31:1), e a 2.3.0 estendeu ao glifo do disco do Modal. É contraste de texto e glifo sobre preenchimento, não a cor do ícone em si; entra numa spec própria de contraste — que agora tem os tokens prontos em theme.garantora.onDanger/onSuccess.
  • Título de painel e cabeçalho de tabela ordenado. A 2.3.0 trocou os dois de --color-structure para --nav-active-fg (4,49:1 no escuro). No apps/web esses dois usam secondary.main, um par diferente do que a 2.3.0 mediu — precisa de medição própria antes de mexer, e é assunto da mesma spec de contraste.
  • Adotar o Icon.jsx do pacote. É referência visual, em React puro com custom properties. Em produção vale MUI + o tema, e aqui vale o @phosphor-icons/react embrulhado pelo app.
  • EmptyState como componente. A 2.0.0 adicionou a peça, e ela é o único lugar do sistema que usa duotone. Esta spec entrega o peso duotone disponível no Icon; construir o componente EmptyState com título, explicação e próximo passo é outra entrega.
  • Redesenhar telas. Trocar a família muda o desenho do ícone, não o layout. Onde o tamanho novo desalinhar, ajusta-se o alinhamento — não a composição da tela.
  • API. Nenhuma mudança de contrato.

4. Regras de negócio

  • RN1. O apps/web usa uma única família de ícones: Phosphor, via @phosphor-icons/react. Ao fim desta spec não resta nenhuma importação de @mui/icons-material em src/ — a verificação é grep, e ela vale como critério.
  • RN2. Todo ícone passa por um componente Icon do app, que encapsula família, peso e tamanho. Chamada direta ao pacote Phosphor fora dele só em caso justificado em comentário.
  • RN3. Peso por situação: navegação inativa regular · navegação ativa fill · dentro de botão regular · status e alerta fill · decorativo de empty state duotone.
  • RN4. Tamanho por situação: 20px em navegação e listas · 16px em botões e chips · 48px em empty states.
  • RN5. O mapa por domínio é normativo (definido na 2.1.0, inalterado na 2.3.0). Os 20 módulos recebem exatamente o ícone da tabela: Painel gauge · Análise de Crédito file-text · Fila de análise scales · Contratos certificate · Cobranças receipt · Sinistros gavel · Inadimplência warning-octagon · Inquilinos users · Imóveis house-line · Imobiliárias handshake · Canais chat-circle-text · Minha Garantidora buildings · Equipe users-three · Perfis de Acesso shield-check · Parâmetros sliders-horizontal · Tipos de Documento folder · Documentos da Análise files · Auditoria clipboard-text · Relatórios chart-line-up · Onboarding rocket-launch.
  • RN6. Estados e ações seguem a tabela do Icon.prompt.md: aprovado check-circle fill · pendente clock fill · recusado x-circle fill · inadimplente warning-octagon fill · aprovar check · recusar x · filtrar funnel-simple · buscar magnifying-glass · exportar download-simple · novo registro plus.
  • RN7. Conceito fora do mapa escolhe o Phosphor mais próximo e justifica em comentário — não inventa ícone novo, e não mantém o MUI "porque não tem equivalente". Dos 78 ícones em uso, cerca de 30 têm entrada nas tabelas; o restante cai nesta regra.
  • RN8. Ícone colorido usa o token -ink, nunca a cor viva. Vale para os estados da RN6 e para qualquer ícone que receba cor semântica. Os valores vêm de theme.garantora (successInk, dangerInk, highlightInk, neutralInk, accentInk) — não se duplica hex no app.
  • RN9. O texto e o ícone do item ativo de navegação usam theme.garantora.navActiveFg: #005577 no claro (6,90:1) e #8FCEE8 no escuro (6,41:1), sobre surfaceNavActive. Substitui primary.main, que no escuro é #4DA6CC e mede 4,03:1. Vale para a sider e para a gaveta, que compartilham o par — e ícone e rótulo mudam juntos, senão sobram dois azuis diferentes na mesma linha, que é o defeito que a 2.3.0 encontrou.
  • RN10. O pino do design system sobe para #v2.3.0. A sobrescrita local de &.Mui-selected no custom-sider sai, para o item ativo passar a herdar a correção que o tema já traz em MuiListItemButton desde a 2.2.0. Onde o app precisar do par explicitamente, usa os tokens — nunca alpha(primary, …) ad-hoc.
  • RN11. O bundle é medido antes e depois. O @phosphor-icons/react tem 33 MB desempacotados; se a importação não sacudir a árvore, o custo aparece no usuário. A implementação compara o tamanho do bundle de produção e registra os dois números.

5. Impacto por app

apps/api

Sem impacto. Nenhum modelo, endpoint, migration ou regra de service muda.

apps/web

  • Componentes:
  • src/components/icon/ (novo) — o Icon da RN2, com name, weight, size e color, mais o mapa por domínio (RN5) e o de estados (RN6) como constantes nomeadas, para a página pedir ICON.creditAnalysis em vez de decorar file-text.
  • src/components/navigation/nav-groups.tsx — os ícones dos 15 destinos passam pelo mapa por domínio; é o ponto que serve sider e gaveta ao mesmo tempo.
  • src/components/custom-sider/ e src/components/mobile-shell/ — peso fill no ativo (RN3) e --nav-active-fg no par ativo (RN9).
  • 57 arquivos com importação de @mui/icons-material, das páginas aos compartilhados (StatusBadge, DocumentsPanel, RecordCard, FilterBar, …).
  • custom-sider/index.tsx — além dos ícones, some a sobrescrita local de &.Mui-selected (RN10), que hoje impede a correção do tema de chegar à sider.
  • package.json — entra @phosphor-icons/react, sobe o pino para #v2.3.0.
  • data-testid: nenhum atributo novo e nenhum removido. Ícone não é alvo de teste; o que sustenta o e2e são os data-testid dos elementos interativos, que não mudam. É o que permite trocar 195 importações sem reescrever a suíte.
  • Sider: sem módulo novo — a lista de destinos não muda, só o desenho de cada um.
  • Responsividade & PWA: a troca vale para desktop e mobile no mesmo passo. Sem mudança de manifest; o service worker serve o bundle cacheado, então validar no dev-remote exige rebuild do web.

apps/e2e

  • Specs: nenhuma spec nova de fluxo — a troca de família não muda comportamento. Entra uma verificação de regressão: a suíte existente precisa continuar passando, e é ela que prova que nenhum data-testid se perdeu no caminho pelos 57 arquivos.
  • Seeds: sem dados novos.

6. Contrato de API

Sem mudança de contrato. Nenhum endpoint criado, alterado ou removido.

7. Critérios de aceite

  • [x] CA1. grep -r "@mui/icons-material" apps/web/src não devolve nada.
  • [x] CA2. Todo ícone da interface vem do Icon do app; as exceções estão justificadas em comentário.
  • [x] CA3. Na sider e na gaveta, o destino ativo tem ícone fill e os inativos regular, distinguíveis lado a lado.
  • [x] CA4. Os 15 destinos usam o ícone que o mapa por domínio determina.
  • [x] CA5. Ícones de status (aprovado, pendente, recusado, inadimplente) usam o ícone, o peso e a cor -ink da tabela de estados.
  • [x] CA6. No tema escuro, o texto e o ícone do item ativo medem ≥ 4,5:1 sobre a pílula (o alvo do design system é 6,41:1).
  • [x] CA7. O tsc do web passa limpo e a suíte e2e passa sem alteração de spec — prova de que nenhum data-testid se perdeu.
  • [x] CA8. O tamanho do bundle de produção está registrado antes e depois, e o crescimento é justificado.
  • [x] CA9. Em 375px, nenhuma tela ganha rolagem horizontal por conta do tamanho novo dos ícones.
  • [x] CA10. O package.json pina #v2.3.0.
  • [x] CA11. Nenhum hex de cor semântica é duplicado no apps/web: os -ink e o navActiveFg vêm de theme.garantora.

8. Plano de implementação

  • [x] apps/api — sem impacto
  • [x] apps/web — medir o bundle de produção antes (RN11)
  • [x] apps/web — @phosphor-icons/react + pino em #v2.3.0
  • [x] apps/web — componente Icon com peso e tamanho por situação (RN2–RN4)
  • [x] apps/web — mapa por domínio e mapa de estados como constantes (RN5, RN6)
  • [x] apps/web — navegação primeiro (nav-groups, sider, gaveta), com fill no ativo
  • [x] apps/web — os 57 arquivos restantes, com a RN7 aplicada e justificada
  • [x] apps/web — par de cores do item ativo na sider e na gaveta, ícone e rótulo juntos, com a sobrescrita local removida (RN9, RN10)
  • [x] apps/web — cor -ink nos ícones de status (RN8)
  • [x] apps/web — medir o bundle depois e registrar
  • [x] make test (segue igual — mudança só de web) e make lint sem apontamento novo nos arquivos tocados
  • [x] Validado no browser em desktop e mobile, nos dois temas, com script de screenshot
  • [x] Suíte e2e como regressão de data-testid

9. Decisões em aberto

Nenhuma. A dependência externa que dividia esta spec em duas ondas foi resolvida pelo próprio design system, antes de a implementação começar.

Quando esta spec foi aberta, as correções de contraste da 2.1.0 viviam só em tokens/*.css, e o theme/ — único ponto que o apps/web importa — não as expunha. A decisão foi pedir ao design system e esperar. A 2.2.0 entregou: theme.garantora ganhou successInk, dangerInk, highlightInk, neutralInk, accentInk, accentInkHover, navActiveFg, onDanger, onSuccess e onCanvasAccent, com a razão de contraste anotada em cada valor. A 2.3.0 ainda varreu o par structure/surface-nav-active no projeto inteiro e corrigiu quatro pontos que tinham escapado, entre eles o ícone do item ativo da gaveta.

Consequências para o plano:

  • Não há mais onda 2. RN8 e RN9 saem junto com o resto, consumindo theme.garantora. A RN8 ganhou a proibição explícita de duplicar hex, que só faz sentido agora que existe de onde ler.
  • O pino vai para #v2.3.0, não #v2.1.0.
  • A sider precisa de trabalho que a versão nova não faz sozinha. A 2.2.0 corrigiu MuiListItemButton no tema, mas o custom-sider sobrescreve &.Mui-selected no sx local, que vence o tema. Virou a RN10.

10. Registro

Implementada em 2026-08-02. Os 78 ícones mapearam para 58 Phosphor, todos validados contra os exports do pacote antes de escrever uma linha — o mapa foi conferido por script, não por leitura.

O bundle quebrou o build, e a RN11 é a razão de termos visto. O chunk único saiu de 2028 KB para 2191 KB (+163 KB de glifos) e cruzou o limite de 2 MiB que o Workbox usa para pré-cachear: pnpm build passou a falhar, não a avisar. Subir maximumFileSizeToCacheInBytes faria passar ao custo de pré-cachear um arquivo de 2,24 MB no celular de quem usa. A saída foi dividir o pacote por build.rollupOptions.output.manualChunks.

E a primeira divisão derrubou o app inteiro. Separar react, mui, refine e icons em quatro chunks parecia o corte natural — e produziu tela branca com Cannot access 'wi' before initialization. Há ciclo de importação entre esses pacotes; dividi-los espalha a inicialização por chunks que carregam fora de ordem. Nada disso aparece antes da suíte: o tsc passa, o pnpm build passa, o dev server funciona, e os screenshots que tirei rodaram em modo dev. Quem pegou foi o auth.setup do Playwright, que não conseguiu nem preencher o campo de e-mail — 216 testes não rodaram.

A correção é um chunk só para node_modules: vendor 1800 KB + index 397 KB (2197 KB contra 2034 KB antes, +163 KB, +8%). Com tudo num escopo, o ciclo resolve como sempre resolveu, e a separação app/biblioteca continua valendo — o app muda toda semana, as bibliotecas quase nunca. Os ~163 KB são o piso do Phosphor: cada componente traz os seis pesos, e são 58 ícones.

Lição que fica: para mudança que mexe no empacotamento, validar o build de produção, não o dev server. Depois da correção rodei uma fumaça nas 12 rotas principais sobre o vite preview, conferindo que cada uma monta sem erro de runtime, antes de gastar outro rebuild de imagem.

Dois enganos da passada mecânica, achados pelo tsc e pelo browser:

  • O regex <Icon casava também <IconButton, e converteu color="error" em ink="danger" em seis arquivos — prop que o IconButton não conhece. O tsc acusou as oito chamadas e a reversão foi cirúrgica, só dentro das aberturas de <IconButton.
  • O color semântico do MUI (error, success, warning) foi carregado para o Phosphor, onde color é cor CSS: color="error" não é cor válida. Viraram ink, que é o que a RN8 pedia de todo jeito.

O Icon aceita sx. Não estava na spec, mas 78 chamadas passavam sx do MUI, e converter cada uma para size/color seria retrabalho com risco de desalinhar. O sx vai num <span> em volta do glifo; quando traz fontSize, o glifo é renderizado com size="1em" e acompanha a tipografia — que é como o @mui/icons-material se comportava.

NavItem.icon passou a ser o nome, não o nó pronto. Sem isso não há como o consumidor escolher o peso pelo estado (RN3): o nó já vinha renderizado de buildNavGroups. Sider e gaveta passaram a montar o <Icon> com weight={active ? "fill" : "regular"}.

Medido no browser, desktop e mobile, claro e escuro: o rótulo e o ícone do item ativo em rgb(0,85,119) no claro e rgb(143,206,232) no escuro — os #005577 (6,90:1) e #8FCEE8 (6,41:1) que a 2.1.0 fixou, contra os 4,03:1 de antes. Zero rolagem horizontal em 375px.

Os glifos internos do MUI também foram trocados — a pendência que esta spec chegou a declarar foi resolvida ainda nela, a pedido.

A varredura das 16 rotas achou 29 ocorrências em 6 tipos, não os 14 de uma página só: ArrowDropDown (caret de Select e Autocomplete), SeparatorIcon e ArrowUpward (DataGrid), KeyboardArrowLeft/Right (paginação), CalendarIcon (campo de data) e, descoberto na segunda passada, CancelIcon (remover do Chip, que aparece nas seleções múltiplas do Autocomplete). Todos vêm de componente, não de importação nossa — o Select não pergunta ao tema qual caret desenhar.

A troca vive em components/icon/mui-adapters.tsx, ligada por defaultProps no tema do app (Select, NativeSelect, Autocomplete, Chip, TablePagination, DatePicker) e por slots no DataGrid (components/shared/dataGridSlots.ts, para toda lista futura usar). Dois detalhes que custaram tentativa:

  • O ícone das setas de paginação mora em slots.actions do TablePagination, que é repassado como o slots do componente de ações. O caminho parecido slotProps.actions.*Icon espera SvgIconProps, e o Phosphor não é um SvgIcon.
  • MuiDatePicker só existe no tipo Components depois de importar @mui/x-date-pickers/themeAugmentation.

E a troca quebrou um comportamento — pego por exercitar, não por olhar. O Chip clona o elemento de deleteIcon injetando className e onClick; o Icon não repassa handler, então o chip de filtro ficou impossível de remover. Pior: o defaultProps do tema não cobria o caso, porque a FilterBar passa deleteIcon explícito, que vence o padrão. O ChipDeleteIcon é um <span> que recebe as props de DOM inteiras, e a FilterBar passou a usá-lo.

Depois disso, os quatro controles foram exercitados de verdade: abrir o select e filtrar, remover o chip, navegar pela seta de paginação e ordenar pela coluna — todos funcionando. Varredura final: 0 glifos do MUI em 6 tipos → 0 em 0, nas mesmas 16 rotas. Build de produção refeito (vendor 1805 KB + index 398 KB) e as 12 rotas principais montando sem erro de runtime.

Lint: 53 apontamentos antes e 53 depois — nenhum novo. tsc limpo.

e2e executado a pedido. Primeira rodada: auth.setup falhou e 216 testes não rodaram — era a tela branca do chunking, descrita acima. Segunda rodada, depois da correção: 211 passaram, 6 skipped, 0 falhas (9,5 min, chromium + firefox + webkit), sem uma linha alterada em spec de teste. É o CA7: a migração passou por 57 arquivos e nenhum data-testid se perdeu no caminho.