You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[a11y] O role="progressbar" do DS entra no nome acessível dos botões do dashboard — categoria em 150% se anuncia como "Transporte 100 R$ 450,00 / R$ 300,00" #138
Nenhum dos 4 passa aria-label, aria-labelledby ou aria-valuetext: grep -n "aria-" CategoryGroupProgress.tsx MetasList.tsx InstallmentGroupCard.tsx → 0 ocorrências.
Os três consumidores são renderizados (exigência 7): CategoryGroupProgress ← app/(app)/dashboard/page.tsx:19,178; MetasList ← app/(app)/metas/page.tsx:5,44; InstallmentGroupCard ← components/parcelas/ParcelasToolbar.tsx:12,135.
Evidência
// components/ui/progress.tsx:20-25<divrole="progressbar"aria-valuemin={0}aria-valuemax={max}aria-valuenow={value}// ← cru, sem clamp; e sem nome acessível nenhum
Duas falhas distintas saem daí, e as duas foram medidas na árvore de acessibilidade real do Chromium (Accessibility.getFullAXTree via CDP, /opt/pw-browsers/chromium-1194), não deduzidas da spec.
(a) O número cru vaza para o nome do botão que o contém. Os sites 1 e 2 ficam dentro de um <button>, cujo nome é computado por name from content — e o passo 2E do accname usa o valor de widget de faixa embutido. Réplica exata da ordem do JSX da linha de categoria (:223-271), medida:
No caso normal entra um float de 4 casas (45.6667) entre o nome da categoria e os valores — ruído sem unidade, lido em voz alta em todo item da lista.
No caso estourado o Chromium clampaaria-valuenow em aria-valuemax: 150 vira 100. O botão anuncia Transporte 100 R$ 450,00 / R$ 300,00 — o 100 contradiz os próprios R$ 450,00 / R$ 300,00 ao lado dele, e é indistinguível de uma categoria exatamente no orçamento.
O clamp foi medido nos quatro pares de valor dos call sites:
(b) Nos sites 3 e 4 sobra uma progressbar sem nome.name é "" e ignored é false — o nó está exposto, navegável, e se anuncia como "barra de progresso, 100%" sem dizer de quê. role="progressbar" é role de widget: a ARIA exige nome acessível.
WCAG 4.1.2 Name, Role, Value e 1.3.1 Info and Relationships.
Verificações feitas (tentativa de falsificar o achado)
git log -S 'role="progressbar"' -- components/ui/progress.tsx → só o commit de importação inicial (98c85f4, que trouxe o repo inteiro). Não é escolha recente e deliberada; é omissão de origem.
Cheguei a supor que <button> achatasse o descendente e o número não aparecesse no nome. Falso — a medição acima mostra o contrário. Era a hipótese que mataria o achado.
Cheguei a supor que a correção óbvia (aria-label no Progress) resolvesse. Também falso, e é o resultado mais importante daqui — ver a proposta.
Nenhum teste toca progress.tsx.grep -rl "[Pp]rogress" __tests__/ devolve 3 arquivos, e os três são sobre goal.progress/dados de investimento, não sobre o componente. Não há asserção afirmando o comportamento atual, então a correção não deixa a suíte vermelha.
Script de reprodução (roda sem node_modules): sobe o Chromium com --headless=new --remote-debugging-port=N, navega para um file:// com o markup acima e imprime os nós de rolebutton/progressbar de Accessibility.getFullAXTree.
Impacto
Usuário de leitor de tela no /dashboard, que é a tela inicial do app. Ao percorrer a lista de gastos por grupo, cada linha de grupo e cada linha de categoria carrega um número solto no próprio nome do botão. No caso que mais importa — categoria ou grupo acima do orçamento, exatamente o que a lista existe para sinalizar — esse número é 100, quando o gasto está em 150%. A informação visual correta (a barra vermelha, os valores) está na tela; a auditiva está errada.
Em /metas e /parcelas o efeito é menor: uma barra anônima a mais na leitura linear, e em /metas uma meta superada é anunciada como exatamente 100%.
Cobertura
Não existe teste sobre components/ui/progress.tsx (item 5 acima) — não há nada afirmando o bug, e nada que proteja contra a recaída.
Não há @testing-library/react no projeto, então o gate viável é sobre o texto-fonte, no mesmo molde de __tests__/unit/a11y-estado-selecao.test.ts (precedente versionado, criado para o #107, que documenta por que ancorar a asserção na linha inteira com ^\s*…$ e flag m).
O gate precisa reprovar hoje e continuar reprovando com a correção errada mais provável (só acrescentar aria-label), então duas asserções, não uma:
constsrc=readFileSync(join(process.cwd(),'components/ui/progress.tsx'),'utf-8')// 1. o role não pode ser incondicional — só é exposto quando há nomeexpect(src).not.toMatch(/^\s*role="progressbar"$/m)// 2. aria-valuenow não pode ser o `value` cruexpect(src).not.toMatch(/^\s*aria-valuenow=\{value\}$/m)
Uma asserção genérica do tipo expect(src).toMatch(/aria-label/) passaria com a correção errada e não cobriria nada.
Proposta
Tornar a barra presentacional por padrão e só expor o widget quando o chamador der um nome. Em components/ui/progress.tsx:
sem aria-label/aria-labelledby: não emitir role="progressbar" nem os aria-value* (a barra vira decoração);
com nome: emitir o role, com aria-valuenow={Math.min(Math.max(value, 0), max)} e um aria-valuetext legível (ex.: "150% do orçamento"), que é o que o accname usa em vez do número quando presente.
Por que essa e não aria-label nos 4 call sites (exigência 6) — é o caminho mais curto, o repo já usa aria-label em botão de ícone, e é o que a memória do projeto sugere primeiro. Medido, ele não fecha o achado:
// só aria-label no progressbar aninhado:
button name="Transporte 100 R$ 450,00 / R$ 300,00" ← número errado continua no nome
// aria-valuetext humano:
button name="Transporte 150% do orçamento R$ 450,00 / R$ 300,00"
// barra presentacional:
button name="Transporte R$ 450,00 / R$ 300,00" ← limpo
Nomear o widget conserta a metade (b) e deixa a metade (a) intacta, porque o passo 2E do accname contribui o valor, não o nome, do descendente. Só aria-valuetext ou a saída presentacional mexem no nome do botão.
Nos 4 call sites os mesmos números já estão em texto adjacente (R$ 1.500,00 / R$ 1.000,00, Parcela 3 de 12, 120.0%), então a saída presentacional não perde informação — o que ela remove é a duplicata errada. Se em algum site a barra for considerada informação própria, o caminho é passar aria-labele deixar o aria-valuetext fazer o trabalho; a assinatura acomoda os dois sem tocar nos outros três.
Custo estimado
M (components/ui/progress.tsx + o arquivo de gate; os 4 call sites não precisam mudar na saída padrão)
Onde
components/ui/progress.tsx:20-25— a origem. Os 4 pontos de render:value/max<button>?value > maxpossível?components/dashboard/CategoryGroupProgress.tsx:194-197group.totalSpent/groupMax:172)groupMax = totalBudget || totalSpent || 1(:157), então grupo estourado dánow > maxcomponents/dashboard/CategoryGroupProgress.tsx:244-246catPct/100:223)catPct = (cat.spent / cat.budget) * 100(:220), sem tetocomponents/metas/MetasList.tsx:85-89goal.currentBalance/goal.targetAmountcomponents/parcelas/InstallmentGroupCard.tsx:102-106paidInstallments/totalInstallmentsNenhum dos 4 passa
aria-label,aria-labelledbyouaria-valuetext:grep -n "aria-" CategoryGroupProgress.tsx MetasList.tsx InstallmentGroupCard.tsx→ 0 ocorrências.Os três consumidores são renderizados (exigência 7):
CategoryGroupProgress←app/(app)/dashboard/page.tsx:19,178;MetasList←app/(app)/metas/page.tsx:5,44;InstallmentGroupCard←components/parcelas/ParcelasToolbar.tsx:12,135.Evidência
Duas falhas distintas saem daí, e as duas foram medidas na árvore de acessibilidade real do Chromium (
Accessibility.getFullAXTreevia CDP,/opt/pw-browsers/chromium-1194), não deduzidas da spec.(a) O número cru vaza para o nome do botão que o contém. Os sites 1 e 2 ficam dentro de um
<button>, cujo nome é computado por name from content — e o passo 2E do accname usa o valor de widget de faixa embutido. Réplica exata da ordem do JSX da linha de categoria (:223-271), medida:Duas coisas nessa saída:
45.6667) entre o nome da categoria e os valores — ruído sem unidade, lido em voz alta em todo item da lista.aria-valuenowemaria-valuemax:150vira100. O botão anunciaTransporte 100 R$ 450,00 / R$ 300,00— o100contradiz os própriosR$ 450,00 / R$ 300,00ao lado dele, e é indistinguível de uma categoria exatamente no orçamento.O clamp foi medido nos quatro pares de valor dos call sites:
(b) Nos sites 3 e 4 sobra uma
progressbarsem nome.nameé""eignoredéfalse— o nó está exposto, navegável, e se anuncia como "barra de progresso, 100%" sem dizer de quê.role="progressbar"é role de widget: a ARIA exige nome acessível.WCAG 4.1.2 Name, Role, Value e 1.3.1 Info and Relationships.
Verificações feitas (tentativa de falsificar o achado)
git log -S 'role="progressbar"' -- components/ui/progress.tsx→ só o commit de importação inicial (98c85f4, que trouxe o repo inteiro). Não é escolha recente e deliberada; é omissão de origem..claude/ds-components.mdnão tem regra sobre nome acessível deProgress. A única linha sobre o componente é visual ("Progressimplica progresso em direção a algo… se o usuário precisar de legenda para entender a barra, ela não está comunicando sozinha") — não cobre isto, e o precedente de escopo já está firmado: [a11y] Button apaga o outline nativo e não devolve indicador de foco — e o token de ring do DS mede 1.19:1, abaixo dos 3:1 da WCAG #52, [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, [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 e [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 foram todos achados de a11y dentro decomponents/ui/e foram aceitos.<button>achatasse o descendente e o número não aparecesse no nome. Falso — a medição acima mostra o contrário. Era a hipótese que mataria o achado.aria-labelnoProgress) resolvesse. Também falso, e é o resultado mais importante daqui — ver a proposta.progress.tsx.grep -rl "[Pp]rogress" __tests__/devolve 3 arquivos, e os três são sobregoal.progress/dados de investimento, não sobre o componente. Não há asserção afirmando o comportamento atual, então a correção não deixa a suíte vermelha.Script de reprodução (roda sem
node_modules): sobe o Chromium com--headless=new --remote-debugging-port=N, navega para umfile://com o markup acima e imprime os nós derolebutton/progressbardeAccessibility.getFullAXTree.Impacto
Usuário de leitor de tela no
/dashboard, que é a tela inicial do app. Ao percorrer a lista de gastos por grupo, cada linha de grupo e cada linha de categoria carrega um número solto no próprio nome do botão. No caso que mais importa — categoria ou grupo acima do orçamento, exatamente o que a lista existe para sinalizar — esse número é100, quando o gasto está em 150%. A informação visual correta (a barra vermelha, os valores) está na tela; a auditiva está errada.Em
/metase/parcelaso efeito é menor: uma barra anônima a mais na leitura linear, e em/metasuma meta superada é anunciada como exatamente 100%.Cobertura
Não existe teste sobre
components/ui/progress.tsx(item 5 acima) — não há nada afirmando o bug, e nada que proteja contra a recaída.Não há
@testing-library/reactno projeto, então o gate viável é sobre o texto-fonte, no mesmo molde de__tests__/unit/a11y-estado-selecao.test.ts(precedente versionado, criado para o #107, que documenta por que ancorar a asserção na linha inteira com^\s*…$e flagm).O gate precisa reprovar hoje e continuar reprovando com a correção errada mais provável (só acrescentar
aria-label), então duas asserções, não uma:Uma asserção genérica do tipo
expect(src).toMatch(/aria-label/)passaria com a correção errada e não cobriria nada.Proposta
Tornar a barra presentacional por padrão e só expor o widget quando o chamador der um nome. Em
components/ui/progress.tsx:aria-label/aria-labelledby: não emitirrole="progressbar"nem osaria-value*(a barra vira decoração);aria-valuenow={Math.min(Math.max(value, 0), max)}e umaria-valuetextlegível (ex.:"150% do orçamento"), que é o que o accname usa em vez do número quando presente.Por que essa e não
aria-labelnos 4 call sites (exigência 6) — é o caminho mais curto, o repo já usaaria-labelem botão de ícone, e é o que a memória do projeto sugere primeiro. Medido, ele não fecha o achado:Nomear o widget conserta a metade (b) e deixa a metade (a) intacta, porque o passo 2E do accname contribui o valor, não o nome, do descendente. Só
aria-valuetextou a saída presentacional mexem no nome do botão.Nos 4 call sites os mesmos números já estão em texto adjacente (
R$ 1.500,00 / R$ 1.000,00,Parcela 3 de 12,120.0%), então a saída presentacional não perde informação — o que ela remove é a duplicata errada. Se em algum site a barra for considerada informação própria, o caminho é passararia-labele deixar oaria-valuetextfazer o trabalho; a assinatura acomoda os dois sem tocar nos outros três.Custo estimado
M (
components/ui/progress.tsx+ o arquivo de gate; os 4 call sites não precisam mudar na saída padrão)