Skip to content

fix(agent): a semantic tree that names tables, headers, regions and text - #56

Open
pathscale wants to merge 11 commits into
masterfrom
fix/semantic-tree-tables-and-text
Open

pathscale wants to merge 11 commits into
masterfrom
fix/semantic-tree-tables-and-text

Conversation

@pathscale

@pathscale pathscale commented Sep 9, 2026

Copy link
Copy Markdown
Owner

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:

<table aria-label="named table">
  <thead><tr><th scope="col">Crate</th></tr></thead>
  <tbody><tr><td>worktable</td></tr></tbody>
</table>
<section aria-label="a named section"><p>text</p></section>
<datalist id="t"><option value="u64"></option></datalist>
<pre>plain text in a pre</pre>
<div role="tooltip">tooltip text</div>

Before, through inspect_document:

role=table         name="named table"
role=row           name=""
role=cell          name=""            <- the <th>
role=row           name=""
role=cell          name=""            <- the <td>, text "worktable"
role=generic       name="a named section"
role=option        name=""            value="u64"
role=generic       name=""            <- the <pre>, its text nowhere
role=tooltip       name=""

After:

role=table         name="named table"
role=row           name="Crate"
role=columnheader  name="Crate"
role=row           name="worktable"
role=cell          name="worktable"
role=region        name="a named section"
role=text          name="text"
role=option        name="u64"
role=text          name="plain text in a pre"
role=tooltip       name="tooltip text"

What changed

A cell, a header and a row are named by their contents. The role list said
cell and row were deliberately absent because their content is a whole
subtree. That objection belongs to generic: 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 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 a columnheader or a rowheader, by its scope. Both cell
kinds mapped to cell, so nothing distinguished a column's title from a value
under it. blitz-dom's own accessibility tree already does this
(packages/blitz-dom/src/accessibility.rs), including the fallback to
columnheader when no scope is written; the two trees disagreed over the same
document and now agree.

A named <section> is a region. HTML-AAM maps a section with an
accessible name to region and one without to generic. Decided from
attributes, 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::Section for every <section>, and the HTML-AAM rule is the one a QA
suite 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, then value. <option value="u64"> is how a <datalist> is written. Empty contents no longer end the
name search either: the contents arm returned Some("") for any role on the
nameFrom-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: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. 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. LabelIndex reads its labels the
same 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 the
name. 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::TextRun with its content, so this tree disagreed with
both. 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 is
excluded. 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 through
inspect_document rather than the naming helpers, because a name that is right
inside 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 warnings and the headless
--no-default-features --features agent-control clippy are clean, as is
cargo 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 text
node is additive, so consumers pinned at ^0.3 pick these up without a repin.

Known gap, not addressed here

aria-labelledby is still not resolved into a name. A <section aria-labelledby="..."> gets the region role from this change and will report
an 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 it
whether that element already carries those words, and the owned semantic_role
allocated a String per ancestor to compare against a fixed list. The role is
either a &'static str or the role attribute's own text, so
semantic_role_ref hands both back borrowed.

test(agent): a text run is a new id in a tree a harness drives by id, so a
suite will press one by accident. It answers notInteractable, and the test
pins that down so the answer cannot become a panic in the control server.

meh 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant