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
São 3 sites (exigência 8), enumerados por conteúdo — todo <Link> cujo estado "esta é a página
em que você está" existe como active/isActive(href) e alimenta sóclassName:
#
arquivo:linha
quantos links renderiza
onde aparece
1
components/layout/Sidebar.tsx:66 (NavItem)
11 (8 de mainNav:30-39 + 3 de configNav:41-45), 12 quando isAdmin
toda rota de (app) em lg+ — app/(app)/layout.tsx monta <Sidebar> no shell
2
components/layout/BottomNav.tsx:63 (NavItem)
3 (primaryNav.slice(0,2) em :121 + /investimentos em :149)
toda rota de (app) abaixo de lg
3
components/layout/BottomNav.tsx:194 (links do dialog "Menu")
7 (menuItems:39-47)
menu mobile, única via para 7 das 12 rotas
Os 3 têm consumidor confirmado: app/(app)/layout.tsx monta <Sidebar> e <BottomNav> no shell
autenticado (exigência 7).
Evidência
grep -rn "aria-current" components app devolve zero ocorrências no repo inteiro.
Nos três sites o estado nasce do pathname, vira classe e para aí:
isActive (Sidebar.tsx:92-95, BottomNav.tsx:107-110) calcula corretamente qual item é o atual —
inclusive cobrindo sub-rotas via pathname.startsWith(href + '/'), o que faz /devedores/[id]
marcar "Devedores". A informação existe; ela só não sai do CSS.
Medido. Reproduzi o DOM que o NavItem emite (item ativo e itens inativos) e li a árvore via
CDP (Accessibility.getFullAXTree, Chrome/141.0.7390.37). O nó do item ativo é indistinguível dos
inativos:
{"name":"Dashboard", "props":["focusable=true","url=\"…/dashboard\""]} <- o ATIVO
{"name":"Histórico", "props":["focusable=true","url=\"…/historico\""]}
{"name":"Lançamento", "props":["focusable=true","url=\"…/registro\""]}
Nome igual em forma, focusable igual, url é o único campo que difere — e ele diz para onde o
link vai, não onde o usuário está.
Verificações feitas (tentativa de falsificar o achado)
Não é decisão deliberada.git log -- components/layout/Sidebar.tsx components/layout/BottomNav.tsx
não tem commit que discuta semântica de navegação; o estado active foi introduzido junto com o
visual e nunca revisitado.
Não está registrado como gotcha intencional..claude/ds-components.md menciona os dois
componentes uma vez — "select-none em componentes de navegação (BottomNav, Sidebar) previne
seleção de texto acidental" — e nada sobre ARIA. Nenhum .claude/*.md registra a ausência como
escolha.
Nenhuma biblioteca supre. Os três sites usam next/link puro, não Radix: next/link não
emite aria-current (o Link só repassa props). Não há aqui o "de graça" que @radix-ui/react-menu@2.1.16 dá ao aria-expanded dos gatilhos de menu.
O lint não pega.eslint.config.mjs:27 liga uma única regra de a11y, jsx-a11y/label-has-associated-control, escopada a components/ui/field.tsx. Nenhuma regra do jsx-a11y infere "link para a rota atual" — ela depende do pathname em runtime. O CI roda npm run lint (.github/workflows/ci.yml:34, --max-warnings 0 em package.json:16) e está
verde com os 3 sites presentes.
O que quase derrubou o achado, e por que não derrubou. A mitigação óbvia é o <h1> de PageHeader (components/ui/page-header.tsx:4), que nomeia a página e dá orientação a quem lê
a tela inteira — por isso o impacto abaixo não afirma que o usuário fica perdido no app. Ela
não alcança dois casos: (a) navegação por lista de links / rotor, em que os 11 itens da sidebar
são lidos fora do contexto do <h1>; e (b) o site 3, dentro do dialog "Menu" — enquanto o Dialog do Radix está aberto o resto da página fica aria-hidden, então ali o <h1> não existe
e os 7 links são a única informação disponível. É o caso que sobra inteiro.
Não reivindico 1.4.1 (Use of Color). O item ativo tem pistas além de cor — font-semibold,
a barra de 1px à esquerda (Sidebar.tsx:76) e stroke-2 no ícone. O achado é só 1.3.1 Info and Relationships (nível A): a relação "esta é a página atual" é transmitida por
apresentação e não é programaticamente determinável.
Impacto
Quem usa leitor de tela e abre a lista de links de qualquer tela de (app) recebe 11 entradas
("Dashboard, Histórico, Lançamento, Parcelas Futuras, Investimentos, Metas, Panorama Anual,
Devedores, Categorias e Grupos, Contas e Cartões, Configuração do Mês") sem nenhuma marca de onde
está. A consequência prática não é se perder — é não conseguir usar a navegação como navegação:
não dá para saber que um link é o próprio lugar, então cada tentativa de "voltar para onde eu
estava" ou de conferir se o clique surtiu efeito custa uma recarga completa da página.
O caso que fecha sozinho é o site 3, no mobile. O dialog "Menu" é a única via para /historico, /metas, /panorama, /devedores, /categorias, /contas e /configuracao-mes; com o dialog
aberto o Radix marca o restante da página como aria-hidden, e o <h1> da página some da árvore.
Os 7 links ficam sendo a informação inteira disponível, e são idênticos entre si.
Atinge também navegação por comando de voz e quem usa magnificação de tela em nível alto, que vê
uma fração da sidebar por vez e depende do anúncio, não do destaque visual.
Cobertura
Nenhum teste pega hoje: grep -rn "aria-current" __tests__/ devolve zero, e nem Sidebar.tsx nem BottomNav.tsx são tocados por teste algum.
Não há @testing-library/react nas devDependencies, então o gate proporcional é varredura do
texto-fonte — formato já versionado em __tests__/unit/row-actions.test.ts (adotado para a #54).
Cabe no mesmo arquivo que a #117 propõe se ela vier antes
(__tests__/unit/a11y-estado-expansao.test.ts → renomear para a11y-estado-navegacao.test.ts só se
as duas caírem juntas); se vier isolada, arquivo próprio __tests__/unit/a11y-pagina-atual.test.ts. Em nenhum caso estender focus-ring-contrast.test.ts, que existe para concentrar os gates que compartilham a maquinaria
OKLCH→sRGB, inexistente aqui.
As asserções precisam ancorar na expressão de estado:
constsidebar=readFileSync(join(process.cwd(),'components/layout/Sidebar.tsx'),'utf-8')constbottom=readFileSync(join(process.cwd(),'components/layout/BottomNav.tsx'),'utf-8')// sites 1 e 2 — o NavItem de cada arquivo recebe `active: boolean`expect(sidebar).toMatch(/aria-current=\{active\?'page':undefined\}/)expect(bottom).toMatch(/aria-current=\{active\?'page':undefined\}/)// site 3 — os links do dialog calculam na horaexpect(bottom).toMatch(/aria-current=\{isActive\(href\)\?'page':undefined\}/)
Por que ancorar na expressão, e por que ? 'page' : undefined e não aria-current={active}:
são as duas correções erradas mais prováveis, e ambas passariam num expect(src).toMatch(/aria-current/) genérico. aria-current="page" fixo marca todos os 11
links como a página atual — resultado pior que o bug. E aria-current={active} renderiza aria-current="false" no HTML, que a especificação de ARIA trata como o estado false, não
como ausência — o React só omite o atributo para undefined/null, não para false, em atributos aria-*. Isso deixaria a marcação sintaticamente correta e semanticamente inerte, que é o pior
resultado possível: verde no gate genérico, sem correção. As três asserções falham hoje.
Como os sites 1 e 2 passam pelos respectivos NavItem, um caso por arquivo cobre os 14 links; o
site 3 precisa da terceira asserção porque os links do dialog são escritos inline
(BottomNav.tsx:194-210), fora do NavItem.
'page' e não 'true' (exigência 6): os dois são válidos, mas aria-current="page" é o token
específico para "este link aponta para a página em que você está" e é o que os leitores anunciam
como "página atual"; 'true' é o genérico de fallback, para quando não há token que descreva a
relação. Aqui há.
Não trocar <Link> por <a> nem mexer em isActive: o cálculo já está certo, inclusive o pendingHref que antecipa o destaque durante a navegação (Sidebar.tsx:93). A correção é só
publicar o booleano que já existe.
Adjacente, deliberadamente fora deste escopo
As quatro <nav> do shell (Sidebar.tsx:140 "Principal", :157 "Configuração", BottomNav.tsx:115 e :192) não têm aria-label, então a lista de landmarks lê "navigation"
quatro vezes. Mesmo critério (1.3.1) e mesmos dois arquivos, mas é outro achado com outro gate —
fica registrado aqui para não se perder, e não deve entrar num PR que escreva closes nesta
issue.
Custo estimado
P (2 arquivos: components/layout/Sidebar.tsx e components/layout/BottomNav.tsx) + 1 de teste.
Três linhas aditivas no total; nenhuma altera comportamento visual, roteamento ou fluxo de dados.
Onde
São 3 sites (exigência 8), enumerados por conteúdo — todo
<Link>cujo estado "esta é a páginaem que você está" existe como
active/isActive(href)e alimenta sóclassName:components/layout/Sidebar.tsx:66(NavItem)mainNav:30-39+ 3 deconfigNav:41-45), 12 quandoisAdmin(app)emlg+—app/(app)/layout.tsxmonta<Sidebar>no shellcomponents/layout/BottomNav.tsx:63(NavItem)primaryNav.slice(0,2)em:121+/investimentosem:149)(app)abaixo delgcomponents/layout/BottomNav.tsx:194(links do dialog "Menu")menuItems:39-47)Os 3 têm consumidor confirmado:
app/(app)/layout.tsxmonta<Sidebar>e<BottomNav>no shellautenticado (exigência 7).
Evidência
grep -rn "aria-current" components appdevolve zero ocorrências no repo inteiro.Nos três sites o estado nasce do
pathname, vira classe e para aí:isActive(Sidebar.tsx:92-95,BottomNav.tsx:107-110) calcula corretamente qual item é o atual —inclusive cobrindo sub-rotas via
pathname.startsWith(href + '/'), o que faz/devedores/[id]marcar "Devedores". A informação existe; ela só não sai do CSS.
Medido. Reproduzi o DOM que o
NavItememite (item ativo e itens inativos) e li a árvore viaCDP (
Accessibility.getFullAXTree, Chrome/141.0.7390.37). O nó do item ativo é indistinguível dosinativos:
Nome igual em forma,
focusableigual,urlé o único campo que difere — e ele diz para onde olink vai, não onde o usuário está.
Verificações feitas (tentativa de falsificar o achado)
o controle com
aria-current="page"produzindo uma propriedade a mais na árvore. Não produz —e a causa não é o atributo, é a ferramenta: o
AXPropertyNamedo próprio protocolo do Chrome nãotem entrada
current. Conferido no artefato, não de memória —GET /json/protocolno Chromium141 devolve
expanded,pressed,checked,selectedelevel, e nãocurrent. Então:a medição acima sustenta o lado negativo do achado (nada distingue o item ativo hoje), e não
sustenta o lado positivo (que a correção aparece na árvore). Isso é limitação do CDP, não
evidência contra
aria-current, mas está registrado aqui para que ninguém tente reproduzir econclua que a correção é no-op. Vale como nota de ferramental para a seção correspondente do
.claude/audit.md: o truque do CDP que fechou a [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, a [a11y] Nove botões só de ícone não têm nome acessível — o "excluir" do DS e o lápis de editar de seis telas se anunciam apenas como "botão" #106 e a [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 não alcança esteatributo.
git log -- components/layout/Sidebar.tsx components/layout/BottomNav.tsxnão tem commit que discuta semântica de navegação; o estado
activefoi introduzido junto com ovisual e nunca revisitado.
.claude/ds-components.mdmenciona os doiscomponentes uma vez — "
select-noneem componentes de navegação (BottomNav,Sidebar) previneseleção de texto acidental" — e nada sobre ARIA. Nenhum
.claude/*.mdregistra a ausência comoescolha.
next/linkpuro, não Radix:next/linknãoemite
aria-current(oLinksó repassa props). Não há aqui o "de graça" que@radix-ui/react-menu@2.1.16dá aoaria-expandeddos gatilhos de menu.Chip/Segment(aria-pressed) e enumera 9 call sites,nenhum destes três; a [a11y] Cinco controles de expandir/recolher não expõem estado — o acordeão de /investimentos, os grupos do dashboard, "Pagos", o agrupamento de lançamentos e "Vincular a cobranças" #117 é expansão (
aria-expanded) em 5 controles de acordeão, nenhum destestrês. Atributo diferente, componentes diferentes, critério diferente. A [a11y] Nove botões só de ícone não têm nome acessível — o "excluir" do DS e o lápis de editar de seis telas se anunciam apenas como "botão" #106 é nome acessível de
botão de ícone — estes links têm nome (medido acima). [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] RowActions fica opacity-0 no desktop e só reaparece no hover do mouse — o foco de teclado é invisível em 29 telas #54/[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 são foco; [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 é rótulo de
campo; [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 é o
Combobox; [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.eslint.config.mjs:27liga uma única regra de a11y,jsx-a11y/label-has-associated-control, escopada acomponents/ui/field.tsx. Nenhuma regra dojsx-a11yinfere "link para a rota atual" — ela depende dopathnameem runtime. O CI rodanpm run lint(.github/workflows/ci.yml:34,--max-warnings 0empackage.json:16) e estáverde com os 3 sites presentes.
<h1>dePageHeader(components/ui/page-header.tsx:4), que nomeia a página e dá orientação a quem lêa tela inteira — por isso o impacto abaixo não afirma que o usuário fica perdido no app. Ela
não alcança dois casos: (a) navegação por lista de links / rotor, em que os 11 itens da sidebar
são lidos fora do contexto do
<h1>; e (b) o site 3, dentro do dialog "Menu" — enquanto oDialogdo Radix está aberto o resto da página ficaaria-hidden, então ali o<h1>não existee os 7 links são a única informação disponível. É o caso que sobra inteiro.
font-semibold,a barra de 1px à esquerda (
Sidebar.tsx:76) estroke-2no ícone. O achado é só1.3.1 Info and Relationships (nível A): a relação "esta é a página atual" é transmitida por
apresentação e não é programaticamente determinável.
Impacto
Quem usa leitor de tela e abre a lista de links de qualquer tela de
(app)recebe 11 entradas("Dashboard, Histórico, Lançamento, Parcelas Futuras, Investimentos, Metas, Panorama Anual,
Devedores, Categorias e Grupos, Contas e Cartões, Configuração do Mês") sem nenhuma marca de onde
está. A consequência prática não é se perder — é não conseguir usar a navegação como navegação:
não dá para saber que um link é o próprio lugar, então cada tentativa de "voltar para onde eu
estava" ou de conferir se o clique surtiu efeito custa uma recarga completa da página.
O caso que fecha sozinho é o site 3, no mobile. O dialog "Menu" é a única via para
/historico,/metas,/panorama,/devedores,/categorias,/contase/configuracao-mes; com o dialogaberto o Radix marca o restante da página como
aria-hidden, e o<h1>da página some da árvore.Os 7 links ficam sendo a informação inteira disponível, e são idênticos entre si.
Atinge também navegação por comando de voz e quem usa magnificação de tela em nível alto, que vê
uma fração da sidebar por vez e depende do anúncio, não do destaque visual.
Cobertura
Nenhum teste pega hoje:
grep -rn "aria-current" __tests__/devolve zero, e nemSidebar.tsxnemBottomNav.tsxsão tocados por teste algum.Não há
@testing-library/reactnas devDependencies, então o gate proporcional é varredura dotexto-fonte — formato já versionado em
__tests__/unit/row-actions.test.ts(adotado para a #54).Cabe no mesmo arquivo que a #117 propõe se ela vier antes
(
__tests__/unit/a11y-estado-expansao.test.ts→ renomear paraa11y-estado-navegacao.test.tssó seas duas caírem juntas); se vier isolada, arquivo próprio
__tests__/unit/a11y-pagina-atual.test.ts. Em nenhum caso estenderfocus-ring-contrast.test.ts, que existe para concentrar os gates que compartilham a maquinariaOKLCH→sRGB, inexistente aqui.
As asserções precisam ancorar na expressão de estado:
Por que ancorar na expressão, e por que
? 'page' : undefinede nãoaria-current={active}:são as duas correções erradas mais prováveis, e ambas passariam num
expect(src).toMatch(/aria-current/)genérico.aria-current="page"fixo marca todos os 11links como a página atual — resultado pior que o bug. E
aria-current={active}renderizaaria-current="false"no HTML, que a especificação de ARIA trata como o estadofalse, nãocomo ausência — o React só omite o atributo para
undefined/null, não parafalse, em atributosaria-*. Isso deixaria a marcação sintaticamente correta e semanticamente inerte, que é o piorresultado possível: verde no gate genérico, sem correção. As três asserções falham hoje.
Como os sites 1 e 2 passam pelos respectivos
NavItem, um caso por arquivo cobre os 14 links; osite 3 precisa da terceira asserção porque os links do dialog são escritos inline
(
BottomNav.tsx:194-210), fora doNavItem.Proposta
Uma linha por site, aditiva, sem mudança visual:
// components/layout/Sidebar.tsx:66 e components/layout/BottomNav.tsx:63 <Link href={href} onClick={onClick} + aria-current={active ? 'page' : undefined} className={cn(…)} >// components/layout/BottomNav.tsx:194 <Link key={href} href={href} + aria-current={isActive(href) ? 'page' : undefined} onClick={() => { setPendingHref(href); setMenuOpen(false) }}'page'e não'true'(exigência 6): os dois são válidos, masaria-current="page"é o tokenespecífico para "este link aponta para a página em que você está" e é o que os leitores anunciam
como "página atual";
'true'é o genérico de fallback, para quando não há token que descreva arelação. Aqui há.
Não trocar
<Link>por<a>nem mexer emisActive: o cálculo já está certo, inclusive opendingHrefque antecipa o destaque durante a navegação (Sidebar.tsx:93). A correção é sópublicar o booleano que já existe.
Adjacente, deliberadamente fora deste escopo
As quatro
<nav>do shell (Sidebar.tsx:140"Principal",:157"Configuração",BottomNav.tsx:115e:192) não têmaria-label, então a lista de landmarks lê "navigation"quatro vezes. Mesmo critério (1.3.1) e mesmos dois arquivos, mas é outro achado com outro gate —
fica registrado aqui para não se perder, e não deve entrar num PR que escreva
closesnestaissue.
Custo estimado
P (2 arquivos:
components/layout/Sidebar.tsxecomponents/layout/BottomNav.tsx) + 1 de teste.Três linhas aditivas no total; nenhuma altera comportamento visual, roteamento ou fluxo de dados.