Skip to content

[a11y] Gate repo-wide: nenhum botão só de ícone pode existir sem nome acessível (bloqueada por #126, #127, #128, #129) #131

Description

@Guiroos

Fatia (d) de #106, e a única que não corrige um site — cria o gate que impede a recaída.

Bloqueada por #126, #127, #128 e #129. Este gate afirma "zero botões só de ícone sem nome" sobre components/ e app/ inteiros. Enquanto qualquer um dos 9 sites da #106 estiver sem rótulo, ele é vermelho por construção — mergear antes deixa o main com CI quebrado. Não aplicar claude-ready aqui até as quatro fatias de correção terem mergeado.

(A label claude-bloqueada prevista em .claude/routines.md não existe no repo; sem ela, o bloqueio está registrado no título e aqui. Vale criar a label — claude-precisa-fatiar existe, claude-bloqueada não.)

A alternativa — mergear o scanner primeiro com uma allowlist dos sites pendentes, que encolhe a cada fatia — foi descartada de propósito: allowlist é exatamente o mecanismo pelo qual um gate apodrece, e cria acoplamento entre PRs que hoje são independentes.

O problema que este gate resolve

As fatias #126#129 corrigem os 9 sites conhecidos e cada uma traz uma asserção ancorada no seu próprio site (recorte <Button ... <Icone, no padrão de row-actions.test.ts e a11y-estado-selecao.test.ts). Essas asserções protegem contra regressão nos 9 sites, e só neles.

Nada impede o 10º: um <Button size="icon"> novo, em arquivo novo, sem rótulo. O lint não pega — eslint.config.mjs:25-32 escopa jsx-a11y/label-has-associated-control a components/ui/field.tsx e nada mais, e fora disso vale só o subconjunto do eslint-config-next/core-web-vitals, que não tem regra de nome acessível. O CI roda npm run lint --max-warnings 0 em todo push e ficou verde durante os meses em que os 9 sites existiram — essa é a evidência direta de que o gate atual não cobre a propriedade.

O padrão se repetiu 9 vezes em 8 arquivos sem ninguém notar. É o critério para valer um gate estrutural em vez de mais uma asserção por arquivo.

Proposta

Estender __tests__/unit/a11y-nomes-acessiveis.test.ts (criado pela #126) com uma varredura de components/**/*.tsx e app/**/*.tsx: para cada <button>/<Button>, se o corpo contiver apenas componentes de ícone auto-fechados (sem texto, sem {expressão} que renderize texto), exigir aria-label, aria-labelledby, title ou um filho sr-only.

expect(botoesSemNome).toEqual([])

A mensagem de falha deve listar arquivo:linha de cada infrator — um toEqual([]) sobre array de strings já entrega isso no diff do Vitest, sem formatação extra.

Armadilha de implementação que já custou uma rodada

A regex óbvia para a tag de abertura, <(button|Button)\b([^>]*?)>, não encontra o site 9 da #106 (SplitSection.tsx:183). O onClick={() => removePerson(entry.uid)} contém um > dentro da arrow function, então [^>]*? fecha a tag cedo, o corpo do botão vira texto solto e o botão é classificado como "tem texto" — sai da lista. O gate nasceria com falso negativo justamente num dos sites que ele deve travar.

O parser precisa andar caractere a caractere pela tag de abertura, respeitando profundidade de {} e aspas (', ", `), para achar o > que realmente fecha a tag. Não é [^>].

Este é o único lugar do fatiamento onde esse parser é necessário. As asserções das fatias #126#129 não precisam dele: elas recortam de <Button até <Icone, atravessando a arrow function sem interpretá-la. Concentrar o custo aqui foi deliberado.

Como validar que o gate não nasce inútil (exigência 3)

Um gate que passa no primeiro run não prova nada. Antes de abrir o PR, rodar contra dois estados:

  1. Com um dos 9 sites revertido — o scanner tem de acusar aquele site, nominalmente. Se passar, o detector não detecta.
  2. Especificamente com o site 9 revertido (SplitSection.tsx, o botão removePerson) — é o caso que a regex ingênua deixa passar. É o teste do teste.

Sem os dois, a implementação mais provável (a regex [^>]*?) entra verde e o gate fica com um furo do tamanho exato do bug original.

Falsos positivos previsíveis, a tratar no PR

O scanner vai varrer botões legítimos com filho não-textual. Os que já existem hoje e precisam ficar fora da lista sem allowlist codificada:

  • botões com ícone e texto (<Plus /> Nova conta em AccountDialog, GroupDialog, GoalDialog, CategoryDialog) — a heurística "só ícones auto-fechados" já os exclui, mas confirmar;
  • botões que já usam sr-only (1 ocorrência no repo) ou title;
  • o DialogClose do DS (components/ui/dialog.tsx:43-46), que rotula — serve de controle positivo: se o scanner o acusar, a heurística está errada.

Se sobrar algum caso genuíno que deva ser isento, o motivo vai em comentário no próprio teste, ao lado da exceção — não numa lista solta no topo.

Cobertura

Este item é a cobertura. A nota de cobertura das outras quatro fatias aponta para as asserções ancoradas por site; esta cobre a propriedade repo-wide.

Vale o registro de .claude/audit.md: "gate automático conta como teste" — não há @testing-library/react no projeto, e propor render de componente significaria propor a instalação da infra inteira, maior que a correção.

Impacto

Nenhum usuário é afetado hoje por esta issue isoladamente (as quatro anteriores é que corrigem o que o usuário sente). O impacto é sobre a próxima regressão: sem este gate, o 10º botão sem nome entra em main com CI verde, exatamente como os 9 primeiros.

Custo estimado

P — 1 arquivo: __tests__/unit/a11y-nomes-acessiveis.test.ts. O parser de tag de abertura é o grosso do trabalho (~60-100 linhas), não a varredura.

Fechamento

Este é o último item da #106. O PR que mergear este gate, com os 9 sites já corrigidos, pode usar closes #106 — a lista da seção Onde da #106 estará inteira (exigência 8). Antes disso, refs #106.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions