From 77aad83917f6a221ff3fe2db95ea3cf870c8870f Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 18:14:27 +0700 Subject: [PATCH 01/13] fix(agent): a header cell is a header, not a cell `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 `` 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 `` case. --- crates/tauri-runtime-blitz/src/agent.rs | 88 ++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/crates/tauri-runtime-blitz/src/agent.rs b/crates/tauri-runtime-blitz/src/agent.rs index 785784d..d2f6e6b 100644 --- a/crates/tauri-runtime-blitz/src/agent.rs +++ b/crates/tauri-runtime-blitz/src/agent.rs @@ -508,7 +508,22 @@ pub(crate) fn semantic_role(element: &blitz_dom::ElementData) -> String { "li" => "listitem", "table" => "table", "tr" => "row", - "td" | "th" => "cell", + "td" => "cell", + // A header cell is not a cell. + // + // HTML-AAM maps `` to `columnheader` or `rowheader`, and blitz-dom's + // own accessibility tree already does exactly this, so the two trees + // disagreed about the same document. What a header is for is saying + // which column or row the values under it belong to, and a check that + // wants "the Version column" has nothing to ask for while every header + // is spelled the same as the data beneath it. + // + // `scope` decides. Without one this is a column header, which is the + // common case (a `` row) and what blitz-dom falls back to. + "th" => match element_attr(element, "scope") { + Some("row") | Some("rowgroup") => "rowheader", + _ => "columnheader", + }, "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => "heading", "input" => match element_attr(element, "type").unwrap_or("text") { "checkbox" => "checkbox", @@ -1853,3 +1868,74 @@ mod tests { ); } } + +/// What the semantic tree says about one small document. +/// +/// Every test here reads the tree through `inspect_document`, which is the +/// entry point a headless QA host calls, rather than through the naming +/// helpers directly. A role or a name that is right inside the crate and wrong +/// by the time it reaches the socket is the defect these were written for. +#[cfg(all(test, feature = "agent-control", unix))] +mod semantic_tests { + use super::*; + use blitz_dom::DocumentConfig; + + /// One document, reproducing every naming and role defect this module + /// covers. Kept whole rather than split per test so a fix that repairs one + /// case by breaking another is caught by the next assertion down. + const REPRO: &str = r#" + + +
Crate
worktable
+

text

+ +
plain text in a pre
+
tooltip text
"#; + + fn tree(html: &str) -> Vec { + let mut document = ScriptDocument::from_html(html, DocumentConfig::default()); + document.inner_mut().resolve(0.0); + match inspect_document(&mut document, None, 0, 1) { + DebugResponse::AgentSnapshot(snapshot) => snapshot.nodes, + other => panic!("inspection did not answer with a tree: {other:?}"), + } + } + + fn roles<'a>(nodes: &'a [SemanticNode], role: &str) -> Vec<&'a SemanticNode> { + nodes.iter().filter(|node| node.role == role).collect() + } + + fn names(nodes: &[SemanticNode], role: &str) -> Vec { + roles(nodes, role) + .into_iter() + .map(|node| node.name.clone()) + .collect() + } + + #[test] + fn a_header_cell_is_a_header() { + let nodes = tree(REPRO); + assert_eq!( + roles(&nodes, "columnheader").len(), + 1, + "a `` is a column header, not an ordinary cell" + ); + assert_eq!( + roles(&nodes, "cell").len(), + 1, + "only the `` is a cell" + ); + } + + #[test] + fn a_row_scoped_header_is_a_row_header() { + let nodes = tree( + r#"
Crateworktable
"#, + ); + assert_eq!( + roles(&nodes, "rowheader").len(), + 1, + "`scope=\"row\"` makes a header describe its row, which is what blitz-dom reports" + ); + } +} From e7f68baf9d1c547b0373d24fd984c21e735c0e74 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 18:16:18 +0700 Subject: [PATCH 02/13] fix(agent): name a cell, a header and a row from their contents 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. `worktable`, 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. --- crates/tauri-runtime-blitz/src/agent.rs | 125 +++++++++++++++--------- 1 file changed, 80 insertions(+), 45 deletions(-) diff --git a/crates/tauri-runtime-blitz/src/agent.rs b/crates/tauri-runtime-blitz/src/agent.rs index d2f6e6b..c9cd8c7 100644 --- a/crates/tauri-runtime-blitz/src/agent.rs +++ b/crates/tauri-runtime-blitz/src/agent.rs @@ -701,6 +701,60 @@ fn name_text(node: &blitz_dom::Node, document: &blitz_dom::BaseDocument) -> Stri out } +/// Whether a role takes its accessible name from its own subtree when the +/// author wrote no explicit one. +/// +/// ARIA's *nameFrom: author, contents*, and nothing else. The list is closed on +/// purpose: a role that is not on it is named only by what the author declared, +/// because a container's text content is its whole subtree and naming those +/// would give every wrapper on a page a name made of the page. +/// +/// `alert` and `status` are here because they are the roles an application uses +/// to say something happened -- a refusal, a saved confirmation -- and what they +/// say is their content. Without them a live region arrives anonymous, so "the +/// reason is shown" is not a question a suite can ask, and every validation +/// outcome has to be approximated by something else that moved. +/// +/// `menuitem`, `tab` and `treeitem` are the menu, tab and tree equivalents of +/// `option`. Leaving them out made every dropdown item in the fleet anonymous: +/// a `` came back with an empty +/// name, so nothing was announced and no check could name the option it meant +/// to press. +/// +/// The table roles are here because ARIA gives all five of them +/// *nameFrom: contents*, and their absence is why whole tables of crate names, +/// versions and column types were unreadable: the cells were in the tree and +/// every one of them was anonymous, which reads from outside as a table that is +/// not in the tree at all. A row's name being the run of its cells is not an +/// accident of that rule, it is the rule: it is what a screen reader announces +/// when the caret enters the row. +/// +/// `semantic_role` returns a `role` attribute verbatim, so an author who writes +/// one of these opts into the naming this list provides. +#[cfg(all(feature = "agent-control", unix))] +pub(crate) fn names_from_contents(role: &str) -> bool { + matches!( + role, + "button" + | "link" + | "heading" + | "option" + | "alert" + | "status" + | "menuitem" + | "menuitemcheckbox" + | "menuitemradio" + | "tab" + | "treeitem" + | "cell" + | "gridcell" + | "columnheader" + | "rowheader" + | "row" + ) +} + +#[cfg(all(feature = "agent-control", unix))] pub(crate) fn semantic_name( element: &blitz_dom::ElementData, node: &blitz_dom::Node, @@ -722,46 +776,8 @@ pub(crate) fn semantic_name( .or_else(|| element_attr(element, "alt").map(std::borrow::Cow::Borrowed)) .or_else(|| element_attr(element, "title").map(std::borrow::Cow::Borrowed)) // Named by their own content. - // - // `alert` and `status` are here because they are the roles an - // application uses to say something happened -- a refusal, a saved - // confirmation -- and what they say is their content. Without them a - // live region arrives anonymous, so "the reason is shown" is not a - // question that can be asked, and every validation outcome in a suite - // has to be approximated by something else that moved. - // - // Deliberately not `generic`. A wrapper's text content is its entire - // subtree, so naming those would give every container on the page a - // name made of the whole page. .or_else(|| { - matches!( - role, - "button" - | "link" - | "heading" - | "option" - | "alert" - | "status" - // The menu, tab and tree equivalents of `option`. ARIA names all - // of these from their own content, and leaving them out - // made every dropdown item in the fleet anonymous: a - // `` came - // back with an empty name, so a screen reader announced - // nothing and no check could name the option it meant to - // press. `semantic_role` returns the `role` attribute - // verbatim, so an author who writes one of these opts out - // of the native naming this list is meant to provide. - // - // Still deliberately absent: `cell` and `row`. Their - // content is a whole subtree, which is the same objection - // the comment above raises against `generic`. - | "menuitem" - | "menuitemcheckbox" - | "menuitemradio" - | "tab" - | "treeitem" - ) - .then(|| std::borrow::Cow::Owned(name_text(node, document))) + names_from_contents(role).then(|| std::borrow::Cow::Owned(name_text(node, document))) }) // A placeholder is the last resort a browser falls back to, and it is // the only thing naming a great many search and filter fields. Last, so @@ -1920,18 +1936,37 @@ mod semantic_tests { 1, "a `` is a column header, not an ordinary cell" ); + assert_eq!(roles(&nodes, "cell").len(), 1, "only the `` is a cell"); + } + + #[test] + fn a_cell_is_named_by_what_it_holds() { + let nodes = tree(REPRO); assert_eq!( - roles(&nodes, "cell").len(), - 1, - "only the `` is a cell" + names(&nodes, "cell"), + vec!["worktable".to_string()], + "a data cell's text is its accessible name, so a table of values is readable" + ); + assert_eq!( + names(&nodes, "columnheader"), + vec!["Crate".to_string()], + "a header cell is named by its content too" ); } #[test] - fn a_row_scoped_header_is_a_row_header() { - let nodes = tree( - r#"
Crateworktable
"#, + fn a_row_is_named_by_its_cells() { + let nodes = tree(REPRO); + assert_eq!( + names(&nodes, "row"), + vec!["Crate".to_string(), "worktable".to_string()], + "a row is named from its contents, which is what makes a table row addressable" ); + } + + #[test] + fn a_row_scoped_header_is_a_row_header() { + let nodes = tree(r#"
Crateworktable
"#); assert_eq!( roles(&nodes, "rowheader").len(), 1, From b887eb004119a2e966695f2f84c3592dfe7dcb74 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 18:17:06 +0700 Subject: [PATCH 03/13] fix(agent): a named section is a region `semantic_role` had no `section` arm, so `
` arrived as `generic` with a name on it. HTML-AAM maps a `
` 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 `
` as a synonym for `
`. 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 `
`; 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. --- crates/tauri-runtime-blitz/src/agent.rs | 44 +++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/crates/tauri-runtime-blitz/src/agent.rs b/crates/tauri-runtime-blitz/src/agent.rs index c9cd8c7..f3efa32 100644 --- a/crates/tauri-runtime-blitz/src/agent.rs +++ b/crates/tauri-runtime-blitz/src/agent.rs @@ -503,6 +503,30 @@ pub(crate) fn semantic_role(element: &blitz_dom::ElementData) -> String { "img" => "img", "nav" => "navigation", "main" => "main", + // A named section is a landmark; an unnamed one is nothing. + // + // HTML-AAM: `
` maps to `region` when it has an accessible + // name, and to `generic` otherwise. Both halves matter. A named section + // is how a page says "this part is the connection settings", and it + // arrived indistinguishable from the `
`s around it; an unnamed one + // is a wrapper, and promoting those would put a landmark around every + // block on a page that reaches for `
` as a synonym for `
`. + // + // Attributes only, because the name has not been computed yet at this + // point and computing it here would walk the section's whole subtree for + // every element in the document. That is the same set an accessible name + // can come from for a container: `aria-labelledby` is included so an + // author who names a section that way still gets the landmark, even + // though `semantic_name` does not yet resolve that reference. + "section" + if ["aria-label", "aria-labelledby", "title"] + .iter() + .any(|name| { + element_attr(element, name).is_some_and(|value| !value.trim().is_empty()) + }) => + { + "region" + } "form" => "form", "ul" | "ol" => "list", "li" => "listitem", @@ -1973,4 +1997,24 @@ mod semantic_tests { "`scope=\"row\"` makes a header describe its row, which is what blitz-dom reports" ); } + + #[test] + fn a_named_section_is_a_region() { + let nodes = tree(REPRO); + assert_eq!( + names(&nodes, "region"), + vec!["a named section".to_string()], + "a `
` with an accessible name is a landmark, not a wrapper" + ); + } + + #[test] + fn an_unnamed_section_is_not_a_region() { + let nodes = tree("

text

"); + assert!( + roles(&nodes, "region").is_empty(), + "HTML-AAM gives an unnamed section no landmark role, so a page of \ + plain sections does not grow a landmark per wrapper" + ); + } } From fc63a32049bedfabc671d7199d106a3640cdcd32 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 18:17:45 +0700 Subject: [PATCH 04/13] fix(agent): name a tooltip by what it says `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. --- crates/tauri-runtime-blitz/src/agent.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/tauri-runtime-blitz/src/agent.rs b/crates/tauri-runtime-blitz/src/agent.rs index f3efa32..dd079e9 100644 --- a/crates/tauri-runtime-blitz/src/agent.rs +++ b/crates/tauri-runtime-blitz/src/agent.rs @@ -765,6 +765,11 @@ pub(crate) fn names_from_contents(role: &str) -> bool { | "option" | "alert" | "status" + // The same class as `alert` and `status`: a tooltip exists to say + // one thing, and what it says is its content. Anonymous, it is a + // node reporting that some explanation is on screen without + // reporting the explanation. + | "tooltip" | "menuitem" | "menuitemcheckbox" | "menuitemradio" @@ -2008,6 +2013,16 @@ mod semantic_tests { ); } + #[test] + fn a_tooltip_says_what_it_says() { + let nodes = tree(REPRO); + assert_eq!( + names(&nodes, "tooltip"), + vec!["tooltip text".to_string()], + "a tooltip is named by its contents, and its contents are the whole point of it" + ); + } + #[test] fn an_unnamed_section_is_not_a_region() { let nodes = tree("

text

"); From 664794849b6db35a2214db1b552e9941ba0ea707 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 18:18:59 +0700 Subject: [PATCH 05/13] fix(agent): an option with no text is named by its label, then its value `