Conversation
added 11 commits
September 9, 2026 18:14
`semantic_role` mapped `td` and `th` to the same `cell`, so every table in the fleet arrived with no way to tell a column's title from a value under it. A check that means "the Version column" had nothing to ask for. HTML-AAM maps `<th>` to `columnheader` or `rowheader` by its `scope`, and blitz-dom's own accessibility tree already does this in `packages/blitz-dom/src/accessibility.rs`. The two trees over the same document disagreed; this makes them agree, including the fallback to `columnheader` when no `scope` is written, which is the ordinary `<thead>` case.
The role list in `semantic_name` said `cell` and `row` were deliberately absent because their content is a whole subtree. That objection belongs to `generic`, not to these: ARIA gives `cell`, `gridcell`, `columnheader`, `rowheader` and `row` nameFrom author and contents, and a row's name being the run of its cells is what a screen reader announces when the caret enters the row. The cost of leaving them out was every table in the fleet. `<td>worktable</td>`, a direct text child, came back as `cell ""`, so a table of crate names, versions and column types was a grid of anonymous boxes. Two site suites reported their tables as absent from the accessibility tree; the nodes were there and every one of them was nameless, which from outside is the same thing. The list moves to `names_from_contents` because the text walker needs the same answer, and a role list written twice goes stale once.
`semantic_role` had no `section` arm, so `<section aria-label="a named section">` arrived as `generic` with a name on it. HTML-AAM maps a `<section>` with an accessible name to `region` and one without to `generic`, and both halves are load-bearing: the named case is how a page says which part of itself this is, and promoting the unnamed case would put a landmark around every block on a page that uses `<section>` as a synonym for `<div>`. Decided from attributes rather than from the computed name, because the name is not known at this point and computing it here would walk a section's whole subtree once per element in the document. `aria-labelledby` counts towards the landmark even though `semantic_name` does not yet resolve that reference, which is a gap in naming rather than a reason to withhold the role. blitz-dom reports `Role::Section` for every `<section>`; that is the one place in this change where the two trees do not converge, and the HTML-AAM rule is the one a QA suite and a screen reader both act on.
`role="tooltip"` was not on the nameFrom-contents list, so a tooltip arrived as a node saying that some explanation is on screen without saying what the explanation is. ARIA names a tooltip from its contents, and this is the same class of finding as the live region one: the roles whose entire purpose is to say one thing were the roles that reported nothing.
`<option value="u64">` is how a `<datalist>` is written, and naming an option
from its contents alone left every entry in one anonymous. HTML resolves an
option's label as the `label` attribute if present, then the option's text, and
browsers fall back to `value` when there is neither, which is what a person sees
in the list.
Empty contents no longer end the search either. The contents arm returned
`Some("")` for a role on the nameFrom-contents list, which stopped the chain and
made every fallback below it unreachable.
A control with a short label for narrow viewports and a long one for wide ones writes both and shows one. `sm:hidden` on the first and `hidden sm:inline` on the second came back as "Book Book a diagnostic": a name no viewer sees at any width, and one no check can be written against, because the string it asserts depends on which half the author expected to win. `name_text` now skips an element child that is not rendered, which is `display: none`, `visibility: hidden`, the `hidden` attribute and `aria-hidden="true"` -- the set `node_is_individually_visible` already answers, and the set accname states directly. Text nodes are not filtered: a text node has no display of its own and is present exactly when the element holding it is. `LabelIndex` reads its labels the same way, through `name_text` rather than `textContent`. A label becomes a name the moment it reaches a control, so the half of a responsive label that is not rendered, and a `<style>` inside one, had to be left out on that path too.
`inspect_document` skipped every node that was not an element, so text that is not some element's accessible name could not be read at all: a paragraph, a `<pre>`, a code block, the prose of a documentation page. A check could assert that a control exists and not that the page says anything. That is not only a limit on checks. A browser exposes those text runs, and so does blitz-dom, whose accessibility tree gives every text node `Role::TextRun` carrying its content. This tree disagreed with both, and a QA host reading it had no way to tell "the page renders no prose" from "the tree does not carry prose". Exposed as `role: "text"`, with the text as the name, which is the field a harness already matches on and the shape the ARIA snapshot formats use. A text run has nothing to press and no state, so `enabled` is true, `selected` is false and `value` is empty; `visible` follows the chain of boxes it is drawn in rather than a box of its own, because a text node has no client rect. Text that already names a node is left out. It is in the tree, as that node's name, and that node is the one a harness can act on; reporting it twice would make "the page says this once" false and hand a check a node it cannot click for words it can already read. `<style>` and `<script>` content is excluded for the reason `name_text` excludes it. Only the agent snapshot. The diagnostics snapshot in `snapshot_document` reports a layout and computed-style row per node, and those rows describe element boxes; adding text runs to it would add rows with nothing in them.
A version bump on master is the release trigger, and this is what carries the semantic tree fixes to the QA hosts that read it: tables that are named rather than a grid of anonymous boxes, header cells that say which column they head, named sections as landmarks, and the text a page shows in its own right. A patch bump on purpose. The role and name changes correct what a node reports rather than change the shape of the wire type, and the new `text` node is additive, so every consumer pinned at `^0.3` picks these up without a repin.
Exposing text made every text node ask each element above it whether that element is already named by those words, and the owned `semantic_role` built a `String` per ancestor to compare against a fixed list and drop it. On a page of a few thousand nodes that is thousands of allocations per inspection, and the inspector is polled, not called once. A role is either a `&'static str` or the `role` attribute's own text, so `semantic_role_ref` hands both back borrowed and `semantic_role` is one `to_owned` on top of it.
Text runs are new ids in a tree a harness drives by id, so the first thing a suite will do with one is press it by accident. It resolves to no client rect, which the activation path already answers with `notInteractable`; this pins that down so the answer cannot become a panic inside the control server.
An element named by pointing at other elements arrived anonymous, because
the reference was never resolved. Two independent reports: a QA agent
gave a dashboard card on ui-starter-app an identity this way, found the
region came back unnamed and fell back to `aria-label`; and the section
rule added in this branch reads `aria-labelledby` to decide an element is
a `region`, so such a section got the landmark role and an empty name,
which is worse than the wrapper it used to be reported as.
The attribute is a space-separated list of ids. Each referenced element's
rendered text is read in the order the ids are written and joined by a
single space, and the result outranks `aria-label`, which outranks the
host language's own labelling, which outranks name from contents.
Resolved out of `LabelIndex`, whose single pass over the document now
also records every `id` and the node holding it. The node rather than its
text: a reference is read for the few elements that carry one, and naming
every element up front to answer a question almost none of them ask would
walk each subtree twice per snapshot. So the index stays one pass, and a
reference costs a hash lookup per id plus the subtree walk that id's text
requires. Nothing here walks ancestors or scans the document per node.
A referenced element contributes its text even when it is not rendered,
which is the opposite of the rule applied inside a name's own subtree.
Both are accname: a node that is hidden and not referenced returns the
empty string, while one directly referenced is exempt from that check.
The exemption is the whole point of the pattern, since an element written
only to be pointed at is routinely not shown, and it stops at the element
named by the id: hidden elements inside it are still skipped.
A reference resolves through `name_text`, which reads text, and never
through `semantic_name`, which would consult the attribute again. A
self-reference is named by its own contents and terminates, and no cycle
between elements can exist to be guarded against.
An id that matches nothing contributes nothing rather than abandoning the
whole name, and a reference that resolves to no text at all is the
absence of a name rather than an empty one, so the rules below it stay
reachable.
Eight tests. Six fail against this branch's tip. The two that do not are
the fall-through guards, and both fail against the same resolver without
its emptiness filter, which is the shape that made a contents arm
returning `Some("")` unreachable earlier in this branch.
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.
Twelve end-to-end QA suites ran against real sites and the semantic tree was the
biggest cap on what they could assert. Seven findings, all reproduced from one
document and each fixed with a test that fails without the fix.
The reproduction, which is the fixture the tests read:
Before, through
inspect_document:After:
What changed
A cell, a header and a row are named by their contents. The role list said
cellandrowwere deliberately absent because their content is a wholesubtree. That objection belongs to
generic: ARIA givescell,gridcell,columnheader,rowheaderandrownameFrom author and contents, and a row'sname being the run of its cells is what a screen reader announces on entering
the row. This is the finding that mattered most. Two site suites reported their
tables as absent from the accessibility tree; every cell was there and every one
of them was nameless, which from outside is the same thing.
A
<th>is acolumnheaderor arowheader, by itsscope. Both cellkinds mapped to
cell, so nothing distinguished a column's title from a valueunder it. blitz-dom's own accessibility tree already does this
(
packages/blitz-dom/src/accessibility.rs), including the fallback tocolumnheaderwhen noscopeis written; the two trees disagreed over the samedocument and now agree.
A named
<section>is aregion. HTML-AAM maps a section with anaccessible name to
regionand one without togeneric. Decided fromattributes, because the name is not computed at that point and computing it
there would walk a section's whole subtree once per element in the document.
This is the one place the two trees do not converge: blitz-dom reports
Role::Sectionfor every<section>, and the HTML-AAM rule is the one a QAsuite and a screen reader both act on.
A tooltip is named by what it says. Same class as the live-region finding:
the roles whose whole purpose is to say one thing were the roles reporting
nothing.
An option with no text falls back to
label, thenvalue.<option value="u64">is how a<datalist>is written. Empty contents no longer end thename search either: the contents arm returned
Some("")for any role on thenameFrom-contents list, which made every fallback below it unreachable.
A name is made of what is rendered. A responsive control writes a short
label and a long one and shows one of them:
sm:hiddenon the first andhidden sm:inlineon the second came back asBook Book a diagnostic, a nameno viewer sees at any width.
name_textnow skips an element child that is notrendered, which is
display: none,visibility: hidden, thehiddenattribute and
aria-hidden="true", the setnode_is_individually_visiblealready answers and the set accname states.
LabelIndexreads its labels thesame way, since a label becomes a name the moment it reaches a control.
The text a page shows is in the tree, as
role: "text"with the text as thename. This one was posed as a question rather than a defect, so the reasoning
for doing it: text that is not some element's accessible name could not be read
at all, so a
<pre>, a paragraph or a code block was invisible to every check,and a host could not tell "the page renders no prose" from "the tree does not
carry prose". A browser exposes those runs and so does blitz-dom, which gives
every text node
Role::TextRunwith its content, so this tree disagreed withboth. Text that already names a node is left out: it is in the tree as that
node's name, that node is the one a harness can act on, and reporting it twice
would make "the page says this once" false.
<style>and<script>content isexcluded. Only the agent snapshot; the diagnostics snapshot reports a layout and
style row per node and those rows describe element boxes.
Tests
Sixteen tests in
agent::semantic_tests, all reading throughinspect_documentrather than the naming helpers, because a name that is rightinside the crate and wrong at the socket is the defect they exist for. Every one
of them was run against the unfixed code first and fails there.
cargo test --all-features: 60 passed.cargo clippy --all-targets --all-features -- -D warningsand the headless--no-default-features --features agent-controlclippy are clean, as iscargo fmt --check.Version
0.3.8 is in this branch. A patch bump on purpose: the role and name changes
correct what a node reports rather than change the wire type, and the
textnode is additive, so consumers pinned at
^0.3pick these up without a repin.Known gap, not addressed here
aria-labelledbyis still not resolved into a name. A<section aria-labelledby="...">gets theregionrole from this change and will reportan empty name until that lands.
Two commits that are not one of the seven
perf(agent): exposing text made every text run ask each element above itwhether that element already carries those words, and the owned
semantic_roleallocated a
Stringper ancestor to compare against a fixed list. The role iseither a
&'static stror theroleattribute's own text, sosemantic_role_refhands both back borrowed.test(agent): a text run is a new id in a tree a harness drives by id, so asuite will press one by accident. It answers
notInteractable, and the testpins that down so the answer cannot become a panic in the control server.