Fatia (a) de #106. Site 1 dos 9. As demais: #127 e #128 (os seis lápis de editar), #129 (SplitSection), #131 (gate repo-wide, bloqueado por estas quatro).
Onde
1 site, 6 pontos de render. components/ui/delete-button.tsx:49-56 — o gatilho do DeleteButton:
// components/ui/delete-button.tsx:49-56 (linhas conferidas em 2026-08-29)
const trigger = (
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-text-secondary hover:text-negative"
onClick={() => setOpen(true)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)
Sem aria-label, aria-labelledby, title ou sr-only. grep -n 'aria-label' components/ui/delete-button.tsx devolve zero.
Pontos de render, confirmados por grep (exigência 7 — nenhum é código morto):
| arquivo:linha |
tela |
app/(app)/categorias/page.tsx:71 |
/categorias (grupo) |
app/(app)/categorias/page.tsx:110 |
/categorias (categoria) |
app/(app)/contas/page.tsx:79 |
/contas |
components/metas/MetasList.tsx:75 |
/metas |
components/metas/MetasList.tsx:141 |
/metas |
components/investimentos/WithdrawalTable.tsx:74 |
/investimentos |
Evidência
O <svg> do ícone não pode servir de nome, e isso é comportamento da versão fixada, não suposição. lucide-react está pinado em 1.8.0 (package.json:45), e o Icon dessa versão marca o <svg> como escondido quando não recebe filho nem prop de a11y:
// lucide-react@1.8.0 — dist/esm/Icon.js:36 (lido do tarball do registry, não de memória)
...!children && !hasA11yProp(rest) && { "aria-hidden": "true" },
Logo o <svg> sai da árvore de acessibilidade por decisão da própria biblioteca, e a computação do nome do role=button fica sem nenhuma fonte. Medido na árvore real via CDP (Accessibility.getFullAXTree) na auditoria original da #106: role=button name="", contra name="Editar" / name="Fechar" nos controles do mesmo repo que rotulam.
WCAG 4.1.2 Name, Role, Value (nível A).
Falsificação (exigência 2)
Impacto
É o botão destrutivo, e é o de maior alcance dos 9 sites — 6 pontos de render em 4 telas. Quem usa leitor de tela em /categorias, /contas, /metas e /investimentos percorre listas em que cada linha termina em dois controles anunciados como "botão", "botão", sem saber qual edita e qual exclui. O próprio diálogo diz "Essa ação não pode ser desfeita" (delete-button.tsx:28): o custo de errar é perder um grupo de categorias, uma conta, uma meta ou um resgate, sem desfazer.
Atinge também comando de voz — "clicar em Excluir" não encontra alvo quando o alvo não tem nome.
Proposta
Uma linha, aditiva, sem mudança visual:
<Button
size="icon"
variant="ghost"
+ aria-label="Excluir"
className="h-7 w-7 text-text-secondary hover:text-negative"
onClick={() => setOpen(true)}
>
Rotular o botão, não o ícone. <Trash2 aria-label="Excluir" /> também funcionaria — o hasA11yProp do lucide desliga o aria-hidden nesse caso — mas põe o nome no <svg> em vez do controle: o alvo de clique e o nó nomeado passam a ser diferentes, e o padrão diverge dos 20 controles do repo que já rotulam o <Button>.
Cobertura (exigência 3)
Nenhum teste pega hoje e o lint está verde — é o caso em que a suíte verde é o sinal errado.
Não há @testing-library/react nas devDependencies, então o gate proporcional é varredura de texto-fonte, padrão já usado em __tests__/unit/row-actions.test.ts (#54) e __tests__/unit/a11y-estado-selecao.test.ts (#107). Arquivo novo: __tests__/unit/a11y-nomes-acessiveis.test.ts — e não extensão do focus-ring-contrast.test.ts, que concentra os gates dependentes da maquinaria OKLCH→sRGB, ausente aqui.
A asserção precisa ancorar no botão que contém o ícone, não no arquivo. Um expect(source).toMatch(/aria-label/) passaria com o bug intacto assim que qualquer outro botão do arquivo fosse rotulado — é o mesmo modo de falha que row-actions.test.ts documenta para a className do gatilho. O recorte que só a correção certa satisfaz:
// fatia o bloco do <Button> mais próximo que envolve o <Trash2>:
// (?:(?!<Button)[\s\S])*? impede que a fatia comece num <Button> anterior do arquivo
const gatilho = source.match(/<Button\b(?:(?!<Button)[\s\S])*?<Trash2\b/)?.[0]
expect(gatilho).toBeDefined()
expect(gatilho).toMatch(/^\s*aria-label="[^"]+"$/m)
A âncora ^\s*...$ com flag m é a mesma de a11y-estado-selecao.test.ts e existe pelo mesmo motivo: sem ela, o atributo comentado (// aria-label=...) deixa o teste verde com o bug de volta.
Custo estimado
P — 2 arquivos: components/ui/delete-button.tsx + __tests__/unit/a11y-nomes-acessiveis.test.ts.
Nota de ordenação
Esta fatia cria o arquivo de teste. As fatias #127, #128 e #129 acrescentam blocos describe a ele. Se duas fatias estiverem em voo ao mesmo tempo o conflito é trivial (blocos independentes no fim do arquivo), mas mergear esta primeira evita que duas PRs criem o mesmo arquivo do zero.
Fecha parcialmente #106 — usar refs #106, não closes (exigência 8).
Fatia (a) de #106. Site 1 dos 9. As demais: #127 e #128 (os seis lápis de editar), #129 (
SplitSection), #131 (gate repo-wide, bloqueado por estas quatro).Onde
1 site, 6 pontos de render.
components/ui/delete-button.tsx:49-56— o gatilho doDeleteButton:Sem
aria-label,aria-labelledby,titleousr-only.grep -n 'aria-label' components/ui/delete-button.tsxdevolve zero.Pontos de render, confirmados por
grep(exigência 7 — nenhum é código morto):app/(app)/categorias/page.tsx:71/categorias(grupo)app/(app)/categorias/page.tsx:110/categorias(categoria)app/(app)/contas/page.tsx:79/contascomponents/metas/MetasList.tsx:75/metascomponents/metas/MetasList.tsx:141/metascomponents/investimentos/WithdrawalTable.tsx:74/investimentosEvidência
O
<svg>do ícone não pode servir de nome, e isso é comportamento da versão fixada, não suposição.lucide-reactestá pinado em1.8.0(package.json:45), e oIcondessa versão marca o<svg>como escondido quando não recebe filho nem prop de a11y:Logo o
<svg>sai da árvore de acessibilidade por decisão da própria biblioteca, e a computação do nome dorole=buttonfica sem nenhuma fonte. Medido na árvore real via CDP (Accessibility.getFullAXTree) na auditoria original da #106:role=button name="", contraname="Editar"/name="Fechar"nos controles do mesmo repo que rotulam.WCAG 4.1.2 Name, Role, Value (nível A).
Falsificação (exigência 2)
eslint.config.mjs:25-32ligajsx-a11y/label-has-associated-controlescopada acomponents/ui/field.tsx(herança da [a11y] Field nunca liga o label ao controle — 94 campos sem associação; três campos de dinheiro no mesmo dialog se anunciam todos como "R$ 0,00" #53) e nada mais; fora disso vale só o subconjunto doeslint-config-next/core-web-vitals. O CI rodanpm run lint --max-warnings 0em todo push e está verde com o site presente.git logdedelete-button.tsx: 3 commits, dois sobre mensagem de erro (e8127d3,1b7cc5b) e um de docs. Nenhum toca rotulagem..claude/ds-components.mddocumenta oDeleteButton("Confirmação inline responsiva. Nunca criar botão de delete ad-hoc") sem uma linha sobre nome acessível.Button; [a11y] Field nunca liga o label ao controle — 94 campos sem associação; três campos de dinheiro no mesmo dialog se anunciam todos como "R$ 0,00" #53 era<Label>↔controle noField(nomeia campos, não botões); [a11y] RowActions fica opacity-0 no desktop e só reaparece no hover do mouse — o foco de teclado é invisível em 29 telas #54 era oRowActionsinvisível por opacidade; [a11y] Combobox não expõe papel nem estado — o seletor de categoria de todo lançamento é um campo de texto solto para leitor de tela #75 era papel/estado doCombobox; [a11y] Tokens semânticos sólidos reprovam contraste AA — cada um no tema em que.darknão o redefine; a mensagem de erro de todo formulário mede 3,40:1 #76/[a11y]text-negative/text-positivesobre--bg-subtleno escuro seguem abaixo de AA após a correção da #76 #94 são contraste; [a11y] Três superfícies focáveis sem nenhum indicador de foco: itens dos dropdowns de filtro e de exportação, e o Switch inteiro #77 era indicador de foco; [a11y] Chip e Segment não expõem qual opção está selecionada — o seletor de tipo do formulário de lançamento é um grupo de botões sem estado para leitor de tela #107 eraaria-pressedemChip/Segment. Nenhuma toca nome acessível de botão de ícone.Impacto
É o botão destrutivo, e é o de maior alcance dos 9 sites — 6 pontos de render em 4 telas. Quem usa leitor de tela em
/categorias,/contas,/metase/investimentospercorre listas em que cada linha termina em dois controles anunciados como "botão", "botão", sem saber qual edita e qual exclui. O próprio diálogo diz "Essa ação não pode ser desfeita" (delete-button.tsx:28): o custo de errar é perder um grupo de categorias, uma conta, uma meta ou um resgate, sem desfazer.Atinge também comando de voz — "clicar em Excluir" não encontra alvo quando o alvo não tem nome.
Proposta
Uma linha, aditiva, sem mudança visual:
<Button size="icon" variant="ghost" + aria-label="Excluir" className="h-7 w-7 text-text-secondary hover:text-negative" onClick={() => setOpen(true)} >Rotular o botão, não o ícone.
<Trash2 aria-label="Excluir" />também funcionaria — ohasA11yPropdo lucide desliga oaria-hiddennesse caso — mas põe o nome no<svg>em vez do controle: o alvo de clique e o nó nomeado passam a ser diferentes, e o padrão diverge dos 20 controles do repo que já rotulam o<Button>.Cobertura (exigência 3)
Nenhum teste pega hoje e o lint está verde — é o caso em que a suíte verde é o sinal errado.
Não há
@testing-library/reactnas devDependencies, então o gate proporcional é varredura de texto-fonte, padrão já usado em__tests__/unit/row-actions.test.ts(#54) e__tests__/unit/a11y-estado-selecao.test.ts(#107). Arquivo novo:__tests__/unit/a11y-nomes-acessiveis.test.ts— e não extensão dofocus-ring-contrast.test.ts, que concentra os gates dependentes da maquinaria OKLCH→sRGB, ausente aqui.A asserção precisa ancorar no botão que contém o ícone, não no arquivo. Um
expect(source).toMatch(/aria-label/)passaria com o bug intacto assim que qualquer outro botão do arquivo fosse rotulado — é o mesmo modo de falha querow-actions.test.tsdocumenta para a className do gatilho. O recorte que só a correção certa satisfaz:A âncora
^\s*...$com flagmé a mesma dea11y-estado-selecao.test.tse existe pelo mesmo motivo: sem ela, o atributo comentado (// aria-label=...) deixa o teste verde com o bug de volta.Custo estimado
P — 2 arquivos:
components/ui/delete-button.tsx+__tests__/unit/a11y-nomes-acessiveis.test.ts.Nota de ordenação
Esta fatia cria o arquivo de teste. As fatias #127, #128 e #129 acrescentam blocos
describea ele. Se duas fatias estiverem em voo ao mesmo tempo o conflito é trivial (blocos independentes no fim do arquivo), mas mergear esta primeira evita que duas PRs criem o mesmo arquivo do zero.Fecha parcialmente #106 — usar
refs #106, nãocloses(exigência 8).