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" é
Apartmentna sider eBusinessem outros pontos; imóvel ora éHomeWork, oraHome, oraHomeOutlined. Documento aparece comoDescription,ArticleOutlined,InsertDriveFileOutlinedeFolderOutlined, 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 dedangersobre 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 é#4DA6CCsobre#1C3F57— os mesmos 4,03:1. - Sider do desktop: idem, e com um agravante. A 2.2.0 corrigiu a sobrescrita de
MuiListItemButtonno tema para usarnavActiveFg, mas ocustom-siderpinta o&.Mui-selectedpor conta própria nosx—color: "primary.main"ebgcolor: alpha(primary, 0.09). Osxlocal 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/onSuccessemCountBadge,Toaste 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 doModal. É 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 emtheme.garantora.onDanger/onSuccess.- Título de painel e cabeçalho de tabela ordenado. A 2.3.0 trocou os dois de
--color-structurepara--nav-active-fg(4,49:1 no escuro). Noapps/webesses dois usamsecondary.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.jsxdo pacote. É referência visual, em React puro com custom properties. Em produção vale MUI + o tema, e aqui vale o@phosphor-icons/reactembrulhado pelo app. EmptyStatecomo componente. A 2.0.0 adicionou a peça, e ela é o único lugar do sistema que usaduotone. Esta spec entrega o pesoduotonedisponível noIcon; construir o componenteEmptyStatecom 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/webusa uma única família de ícones: Phosphor, via@phosphor-icons/react. Ao fim desta spec não resta nenhuma importação de@mui/icons-materialemsrc/— a verificação égrep, e ela vale como critério. - RN2. Todo ícone passa por um componente
Icondo 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 ativafill· dentro de botãoregular· status e alertafill· decorativo de empty stateduotone. - 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éditofile-text· Fila de análisescales· Contratoscertificate· Cobrançasreceipt· Sinistrosgavel· Inadimplênciawarning-octagon· Inquilinosusers· Imóveishouse-line· Imobiliáriashandshake· Canaischat-circle-text· Minha Garantidorabuildings· Equipeusers-three· Perfis de Acessoshield-check· Parâmetrossliders-horizontal· Tipos de Documentofolder· Documentos da Análisefiles· Auditoriaclipboard-text· Relatórioschart-line-up· Onboardingrocket-launch. - RN6. Estados e ações seguem a tabela do
Icon.prompt.md: aprovadocheck-circlefill · pendenteclockfill · recusadox-circlefill · inadimplentewarning-octagonfill · aprovarcheck· recusarx· filtrarfunnel-simple· buscarmagnifying-glass· exportardownload-simple· novo registroplus. - 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 detheme.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:#005577no claro (6,90:1) e#8FCEE8no escuro (6,41:1), sobresurfaceNavActive. Substituiprimary.main, que no escuro é#4DA6CCe 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-selectednocustom-sidersai, para o item ativo passar a herdar a correção que o tema já traz emMuiListItemButtondesde a 2.2.0. Onde o app precisar do par explicitamente, usa os tokens — nuncaalpha(primary, …)ad-hoc. - RN11. O bundle é medido antes e depois. O
@phosphor-icons/reacttem 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) — oIconda RN2, comname,weight,sizeecolor, mais o mapa por domínio (RN5) e o de estados (RN6) como constantes nomeadas, para a página pedirICON.creditAnalysisem vez de decorarfile-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/esrc/components/mobile-shell/— pesofillno ativo (RN3) e--nav-active-fgno 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 osdata-testiddos 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-testidse 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/srcnão devolve nada. - [x] CA2. Todo ícone da interface vem do
Icondo app; as exceções estão justificadas em comentário. - [x] CA3. Na sider e na gaveta, o destino ativo tem ícone
fille os inativosregular, 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
-inkda 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
tscdo web passa limpo e a suíte e2e passa sem alteração de spec — prova de que nenhumdata-testidse 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.jsonpina#v2.3.0. - [x] CA11. Nenhum hex de cor semântica é duplicado no
apps/web: os-inke onavActiveFgvêm detheme.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— componenteIconcom 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), comfillno 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-inknos ícones de status (RN8) - [x]
apps/web— medir o bundle depois e registrar - [x]
make test(segue igual — mudança só de web) emake lintsem 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
MuiListItemButtonno tema, mas ocustom-sidersobrescreve&.Mui-selectednosxlocal, 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
<Iconcasava também<IconButton, e converteucolor="error"emink="danger"em seis arquivos — prop que oIconButtonnão conhece. Otscacusou as oito chamadas e a reversão foi cirúrgica, só dentro das aberturas de<IconButton. - O
colorsemântico do MUI (error,success,warning) foi carregado para o Phosphor, ondecoloré cor CSS:color="error"não é cor válida. Viraramink, 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.actionsdoTablePagination, que é repassado como oslotsdo componente de ações. O caminho parecidoslotProps.actions.*IconesperaSvgIconProps, e o Phosphor não é umSvgIcon. MuiDatePickersó existe no tipoComponentsdepois 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.