feat(a11y): flag aria-label on elements whose role cannot hold a name - #2108
Merged
Conversation
ARIA prohibits naming the `generic` role, so `aria-label` on a bare `<div>` or `<span>` is not a weak name — it is no name at all. The failure is silent: the markup looks labelled, and review reads as if it were. This is how the Permission Center lost its capability list's accessible name when those rows moved off Astryx `List` (`role="list"`, nameable) onto a CollapsibleGroup whose wrapper is a role-less div (#2100). The new rule flags aria-label / aria-labelledby on tags whose implicit role is name-prohibited, and only those: div, span, p, pre, code, em, … . Tags with a nameable implicit role — nav, section, img, table, ul, svg, form — are legitimately labelled and never fire. An explicit `role=` is the fix, so it silences the rule; a `{...spread}` may carry a role we cannot see, so those stay quiet rather than cry wolf. Honest limit, documented at the rule: only intrinsic lowercase elements are judged. A capitalized component may set its own role internally — `List` does — so flagging `<SomeComponent aria-label>` would be guesswork over 51 sites. Which means this rule would NOT have caught the #2100 regression itself. It catches the directly checkable half; the component half stays a review question. The gate found 27 pre-existing violations, so the rule ships with them resolved rather than with a red build: - 18 are container semantics and take the obvious role. Action clusters, panels and list wrappers become `role="group"`; the artifact and browser panels `role="region"`; the two loading skeletons (already aria-busy) `role="status"`; the plan step marker, whose span renders a glyph and whose label IS its content, `role="img"`. - 9 are a different shape: `<span aria-label={richer}>{visible text}</span>`, where the label is meant to give screen readers more than the visible text. Those labels do nothing today, and the fix — delete them, or move them onto the interactive ancestor that should carry the name — changes what gets announced. That is a UX decision, not a mechanical one, so they carry an `a11y-allow` with that reasoning and are raised for design. Adding those exceptions exposed that the allow mechanism did not actually work where this rule fires: `// a11y-allow:` is not legal inside a JSX tag, and no exception existed anywhere in the tree to prove the convention. The rule now also accepts the JSX block form (`{/* a11y-allow: … */}`) on the line above, and inside an attribute expression, which is the only comment syntax valid in each of those positions. Verified: build, typecheck, format:check, check-dead-css, test:checks, and the desktop + ui workspace suites. storage and cli remain the known Node v25 node:sqlite failures. runtime-host failed twice under the parallel runner — two different ownership/lifecycle tests — and then passed 3/3 standalone; it is flaky under load, and this change touches no runtime code.
Follow-up to review on the aria-label-on-generic rule.
`blockquote` leaves NAME_PROHIBITED: ARIA 1.2 gives the blockquote role
Name From: author, so listing it would have flagged legitimate labelling.
(Only div, span, p and pre actually carry aria-label in this tree today, so
the entry was wrong rather than harmful — but wrong is enough.)
The nine `<span aria-label={richer}>{visible text}</span>` sites are now
deleted rather than excepted. Two reasons, both decided in review:
Removing them is a no-op for users. The span is role-less, so the label was
never announced; keeping it only preserved markup that reads as labelled
without being labelled, which is the exact thing this rule exists to find.
And they should not come back with a role bolted on. Screen readers and eyes
should get the same thing. Everything these labels wanted to inject —
default markers, connection status, card descriptions — already has a
visible carrier beside the text (Badge, StatusDot, description), which
assistive tech reaches through normal reading order. Putting it in the
accessible name just says it twice, and an information channel only screen
readers can hear is its own kind of asymmetry.
The two markdown-body cases keep their `title` tooltip, which is what
actually reaches the user; if the "why is this not clickable" reason should
reach AT later, that is VisuallyHidden text, not a name on a generic span.
Orphaned copy follows the labels out: chipAria / chipAriaLabel, manageAria,
connectAria, both cardAria variants, currentAriaLabel, redactedAriaLabel,
in zh and en.
One test changes with the behavior, deliberately and not to make anything
pass: markdown-body's unsafe-scheme case asserted `aria-label="Unsafe link"`.
It now asserts `title="Unsafe link"` and asserts the aria-label is gone —
the guarantee that the reason reaches the user is preserved, and pinned to
the attribute that delivers it.
Verified: build, typecheck, lint, format:check, check-dead-css, test:checks
(the new rule reports clean with zero exceptions in the tree), and the
desktop + ui workspace suites. storage and cli remain the known Node v25
node:sqlite failures.
jackwener
force-pushed
the
pr-a11y-generic-name-4
branch
from
August 4, 2026 07:52
2d51f5e to
ee75eed
Compare
…ontent The e2e failure was a real regression from deleting them, and the mechanism in review is right: the accessible-name algorithm walks descendants, so a label on an inner span still lands in an interactive ancestor's name. What does not hold is the prescribed fix — moving the label onto the Item. Measured it: `aria-label` on an Astryx Item lands on the outer `<li>`, while the click target is a separate invisible `<button>` Item renders inside, which has no aria-label and takes its name from content. So the label never reaches the element the test queries by role. Item exposes no naming prop either. That leaves the inner label as the only mechanism that names these rows, which is what the code already did. So the five row labels come back with the exception reason corrected to say what they actually do, and their copy comes back with them. The four standalone cases stay deleted — no interactive ancestor is reading those, so they really were inert. settings.spec's remote-access assertion passes with its original expectations untouched. The rule keeps its scope but gains the missing instruction: before deleting a flagged label, check whether it is naming something else, and prefer moving it onto the genuinely interactive element — where a component gives you no way to do that, the inner label is the mechanism and belongs in an a11y-allow rather than in the bin. Verified: build, typecheck, lint, format:check, check-dead-css, test:checks, settings.spec e2e (5/5), and the desktop + ui workspace suites. storage and cli remain the known Node v25 node:sqlite failures.
Member
Author
|
Review by maka-审美专家 — 通过(三轮收敛):规则实现 + 18 处 role 修复通过;blockquote 规范修正落实;9 处悬案最终形态 = 4 处真 inert 删除 + 5 处恢复(Astryx Item 的 label-slot span aria-label 是行命名唯一机制,源码双方独立核实:Item 内部隐形 button 无命名入口)。settings.spec 可及名断言未改、5/5 绿。过程中的两轮错误概括(我方裁定)均被实测/CI 拦下并修正,机制知识已双向入册。 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Task #142 —【Astryx 落地 ⑥】check-a11y 补规则:aria-label 落在无 role 元素上(静默死属性)。
为什么
ARIA 禁止给
generic角色命名,所以aria-label落在裸<div>/<span>上不是「名字弱」,是根本没有名字。而且它是静默的:markup 看着像标注过了,review 也会当成标注过了。#2100 就是这么丢的 —— 能力行从 Astryx
List(role="list",可命名)搬到CollapsibleGroup(wrapper 是裸 div),「功能能力列表」这个可及名直接消失。规则
扫
aria-label/aria-labelledby落在隐式角色不可命名的标签上,且只扫这些:div span p pre code em strong small b i u s mark q cite dfn abbr time data kbd samp var blockquote figcaption sub sup。不误伤:
nav section img table ul svg form…)永不触发role=→ 那正是修法,静默{...spread}→ 可能带着我们看不见的 role,宁可不叫一个必须讲清楚的能力边界(已写进规则注释)
只判断小写 intrinsic 元素。 大写组件可能自己在内部设 role(
List就是),静态判断等于猜,全仓 51 处会变成误报洪水。这意味着这条规则抓不到 #2100 那个 bug 本身 —— 那是个组件。它抓的是可静态确定的那一半,组件那一半仍然只能靠 review。我不想让这条规则看起来覆盖了它实际没覆盖的场景。
存量 27 处:修掉,不带红 CI 合入
a11y-allow+ 提请设计裁定18 处的 role 选择:动作簇 / 面板 / 列表容器 →
role="group";artifact 与 browser 面板 →role="region";两个 loading skeleton(本来就有 aria-busy)→role="status";plan 步骤标记(span 渲染的是符号,label 就是它的内容)→role="img"。9 处是另一种形状:
<span aria-label={更详细}>{可见文本}</span>—— 想让读屏听到比肉眼更多的信息。这些 label 今天根本不生效;而修法(删掉,或挪到真正该承载名字的可交互祖先上)会改变播报内容,那是 UX 决策不是机械替换。所以挂a11y-allow并写明理由,提请设计侧裁定。顺带修好了 allow 机制本身
加这 9 个例外时发现:
// a11y-allow:在 JSX 标签里根本不合法,而且全仓没有任何一处用过这个机制 —— 约定从未被验证过。规则现在同时接受 JSX 里唯一合法的注释形式:上一行的
{/* a11y-allow: … */},以及属性表达式内的attr={/* a11y-allow: … */ …}。验证
npm run buildnpm run typechecknpm run format:checkcheck-dead-css --checktest:checks(含新规则)已知失败:
storage/cli是 Node v25node:sqlite既有问题。runtime-host在并行 runner 下挂了两次(两个不同的 ownership/lifecycle 测试),随后单独连跑 3/3 全过 —— 并行抢资源导致的 flake,本 PR 不碰任何 runtime 代码。@maka-审美专家 请 review,尤其是那 9 处的处理方式和 18 处的 role 选择。