Skip to content

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

Merged
pathscale merged 8 commits into
masterfrom
fix/semantic-tree-tables-and-text
Sep 23, 2026
Merged

pathscale merged 8 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 6 commits September 23, 2026 11:04
The published 0.5.1 predates endpoint-libs 3.2 and still imports the framed_json
that 3.2 removed, so it cannot compile against the endpoint-libs this tree now
resolves. A caret alone kept selecting it, because 0.5.3 is not published yet.

The path dependency keeps the version requirement beside it, so this goes back
to a plain registry dep by deleting one path key once 0.5.3 is released.

Resolved endpoint-libs moves 3.0.0 to 3.2.1; the requirement was already a caret
and the lock was simply stale.
The runtime is becoming ours rather than a thin layer over Tauri, so it takes a
name of its own instead of one that describes what it wraps. Docs call it IRB.

Crate only. The repository, its GitHub URLs and the tauri dependencies keep
their names, so this is a one-line change for a consumer: the package it asks
for is izumo, and the module path is izumo rather than tauri_runtime_blitz.
The only tokio here this crate actually chooses. The rest of its tokio, the
watch sender and the oneshot pair, are the types blitz-control-protocol names in
its public API, so they cannot move until that crate does.

An RwLock only ever written is what an async mutex is, and the bound that
usually blocks the substitution, RwLock<T> being Sync only when T is, is
satisfied trivially here because the guarded type is (). new is const, so it
still serves as a static.
The watch sender and the oneshot pair were here only to feed
blitz-control-protocol's public API, and that API no longer speaks either: the
bridge returns an Arc<Once<DebugResponse>> and the event stream is an
Arc<Latest<DebugEvent>>. Converting the call sites was the whole change, and
with it the dependency is gone from the manifest.

cargo tree -e normal -i tokio prints nothing for a headless build. What remains
in a windowed one is tauri's own, which is not ours to remove without the fork.

subscribe is gone with the watch: a Latest reader is a clone of the Arc, because
the reading position lives in the reader rather than in a handle the slot hands
out.

The paint hook uses set_now, the synchronous setter, since a window event
handler is not async.

control_interface_is_absent_until_explicitly_enabled still fails, as it did
before this change and on the commit before it. It starts a real control server
and asserts on deep profiling; the failure predates this work.
The event loop answered a bridged request with `let _ = response.send(result)`.
`Once::send` is an async fn, so that built a future and dropped it unpolled: the
reply was never stored and the socket task waited until the client gave up.
Every request that needs the window, `inspect` and `act` among them, hung for
the client's whole timeout, while `initialize` (answered off the event loop)
kept working, which is what made it read as a transport bug.

`fill` is the synchronous half and is what a non-async caller should use. The
test bridge made the same mistake and gets the same fix.
The dependency named a sibling checkout by path. CI checks out this repository
alone, so the path does not exist there and the build cannot start, and
publishing needs the version on crates.io regardless. ^0.5.3 resolves once
ps-observability releases it, which is the order these land in.
@pathscale
pathscale force-pushed the fix/semantic-tree-tables-and-text branch from f028975 to e57801f Compare September 23, 2026 04:08
meh added 2 commits September 23, 2026 18:45
ps-anyrender, ps-blitz-dom, blitz-control-protocol and nagoya were required at
a patch floor. No lockfile is tracked, so the newest release resolves anyway;
the floor only had to be chased on every release.
The crate was renamed but every job still asked cargo for -p tauri-runtime-blitz,
which no longer exists, so cargo fell back to treating the flags as applying to
a package outside the workspace and refused.
@pathscale
pathscale merged commit 2f604ef into master Sep 23, 2026
3 checks passed
@pathscale
pathscale deleted the fix/semantic-tree-tables-and-text branch September 23, 2026 11:52
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