From f263008ffc58d65f238d016394c0476dedcd98f1 Mon Sep 17 00:00:00 2001 From: mpd Date: Mon, 24 Aug 2026 09:26:12 -0700 Subject: [PATCH 01/17] feat(a11y): add AxRole::Document and its role-support row Co-Authored-By: Claude Fable 5 --- crates/glass-core/src/accessibility.rs | 14 +++++++-- crates/glass-core/src/role_support.rs | 34 +++++++++++++++++++++ crates/glass-core/tests/role_support_doc.rs | 10 ++++++ docs/reference/a11y-roles.md | 2 ++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/glass-core/src/accessibility.rs b/crates/glass-core/src/accessibility.rs index f025bc80..e387c1ad 100644 --- a/crates/glass-core/src/accessibility.rs +++ b/crates/glass-core/src/accessibility.rs @@ -48,6 +48,10 @@ pub enum AxRole { Toolbar, StatusBar, Heading, + /// A web document or web area — the root of a subtree a web engine publishes (a + /// browser tab's page, a WebView's content). Its children are the page's own elements; + /// a `Document` with no children is disclosed by [`AxTree::document_guidance`]. + Document, Other, } @@ -55,7 +59,7 @@ impl AxRole { /// Every role except [`AxRole::Other`], which is the sink for unmapped native tokens /// rather than a mapping target. Used by the per-backend role-parity tests and by /// [`crate::role_support::ROLE_SUPPORT`]. - pub const ALL: [AxRole; 33] = [ + pub const ALL: [AxRole; 34] = [ AxRole::Application, AxRole::Window, AxRole::Dialog, @@ -89,6 +93,7 @@ impl AxRole { AxRole::Toolbar, AxRole::StatusBar, AxRole::Heading, + AxRole::Document, ]; /// Whether this role denotes an element a user acts on (clicks / types into) — @@ -153,6 +158,7 @@ impl AxRole { "toolbar" => Toolbar, "statusbar" => StatusBar, "heading" => Heading, + "document" => Document, "other" => Other, _ => return None, }) @@ -1350,7 +1356,8 @@ mod tests { | AxRole::Separator | AxRole::Toolbar | AxRole::StatusBar - | AxRole::Heading => {} + | AxRole::Heading + | AxRole::Document => {} // Deliberately excluded from `ALL`: the sink for unmapped native tokens, not a // mapping target. AxRole::Other => {} @@ -1692,7 +1699,7 @@ mod tests { #[test] fn every_role_parses_from_its_name() { use AxRole::*; - let pairs: [(&str, AxRole); 34] = [ + let pairs: [(&str, AxRole); 35] = [ ("application", Application), ("window", Window), ("dialog", Dialog), @@ -1726,6 +1733,7 @@ mod tests { ("toolbar", Toolbar), ("statusbar", StatusBar), ("heading", Heading), + ("document", Document), ("other", Other), ]; diff --git a/crates/glass-core/src/role_support.rs b/crates/glass-core/src/role_support.rs index 22c3a150..a2245fbd 100644 --- a/crates/glass-core/src/role_support.rs +++ b/crates/glass-core/src/role_support.rs @@ -622,6 +622,22 @@ pub const ROLE_SUPPORT: &[(AxRole, [RoleSupport; AxBackend::ALL.len()])] = { Mapped, ], ), + ( + R::Document, + [ + Mapped, + Gap { + unmapped: Some("Document"), + why: "UIA's Document control type is what a web document arrives as, and it \ + maps to TextArea because a stock text editor's edit surface reports the same \ + token (see glass-a11y-windows's document_maps_from_an_observed_token); \ + telling the two apart waits on a reading of both on one host", + }, + Mapped, + Mapped, + Mapped, + ], + ), ] }; @@ -876,4 +892,22 @@ mod tests { ); } } + + #[test] + fn document_row_declares_every_backend() { + for backend in AxBackend::ALL { + assert!( + support(AxRole::Document, backend).is_some(), + "{backend:?} has no Document cell" + ); + } + // Windows keeps UIA Document on TextArea until both readings exist on one host. + assert!(matches!( + support(AxRole::Document, AxBackend::Windows), + Some(RoleSupport::Gap { + unmapped: Some("Document"), + .. + }) + )); + } } diff --git a/crates/glass-core/tests/role_support_doc.rs b/crates/glass-core/tests/role_support_doc.rs index ef69d5c6..0eedfb48 100644 --- a/crates/glass-core/tests/role_support_doc.rs +++ b/crates/glass-core/tests/role_support_doc.rs @@ -46,3 +46,13 @@ fn crlf_normalization_works() { let extracted = normalized[start..end].trim(); assert_eq!(extracted, generated.trim()); } + +/// Prints the generated block so it can be pasted between the markers. Ignored: it is a +/// tool, not a check. +/// +/// `cargo test -p glass-core --test role_support_doc print_generated -- --ignored --nocapture` +#[test] +#[ignore = "prints the block for docs/reference/a11y-roles.md; run on demand"] +fn print_generated() { + print!("{}", glass_core::role_support::render_markdown()); +} diff --git a/docs/reference/a11y-roles.md b/docs/reference/a11y-roles.md index 0bf2e229..44037c35 100644 --- a/docs/reference/a11y-roles.md +++ b/docs/reference/a11y-roles.md @@ -96,6 +96,7 @@ now, and the outline only names the token of an element that has none. | `Toolbar` | yes | yes | yes | unmarked | yes | | `StatusBar` | yes | yes | unmarked | elsewhere | elsewhere | | `Heading` | yes | gap | yes | gap | yes | +| `Document` | yes | gap | yes | yes | yes | ### Why a cell is not `yes` @@ -146,4 +147,5 @@ now, and the outline only names the token of an element that has none. - `StatusBar` / iOS — elsewhere: the system status bar is outside the app tree - `Heading` / Windows (UIA) — gap: UIA marks a heading with the HeadingLevel property — an h1 arrives as Text carrying level 80051 — and the reader maps by control type alone, so it never sees it. Header and HeaderItem are a grid's column headers, a different concept the normalized set has no role for - `Heading` / Android — gap: AccessibilityNodeInfo's isHeading marks a heading, and neither reader carries it: the uiautomator dump has no such attribute and the service reader parses only class, text, description and bounds +- `Document` / Windows (UIA) — gap (`Document` arrives unmapped): UIA's Document control type is what a web document arrives as, and it maps to TextArea because a stock text editor's edit surface reports the same token (see glass-a11y-windows's document_maps_from_an_observed_token); telling the two apart waits on a reading of both on one host From 3a2c8b6dc22ae8628fb78e5ee069ba5e68b89c7a Mon Sep 17 00:00:00 2001 From: mpd Date: Mon, 24 Aug 2026 09:29:43 -0700 Subject: [PATCH 02/17] feat(a11y-linux): map AT-SPI DocumentWeb and DocumentFrame to Document Co-Authored-By: Claude Fable 5 --- crates/glass-a11y-linux/src/mapping.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/glass-a11y-linux/src/mapping.rs b/crates/glass-a11y-linux/src/mapping.rs index cb69cdf9..f1e5a3ef 100644 --- a/crates/glass-a11y-linux/src/mapping.rs +++ b/crates/glass-a11y-linux/src/mapping.rs @@ -50,6 +50,7 @@ pub(crate) fn map_role(role: Role) -> AxRole { Role::ToolBar => AxRole::Toolbar, Role::StatusBar => AxRole::StatusBar, Role::Heading => AxRole::Heading, + Role::DocumentWeb | Role::DocumentFrame => AxRole::Document, _ => AxRole::Other, } } @@ -108,6 +109,17 @@ mod tests { assert_eq!(map_role(Role::Calendar), AxRole::Other); } + #[test] + fn web_documents_map_to_document() { + // A browser's page root and an ARIA role=document region are both web documents; + // a text document (DocumentText) is a text area and stays one. + assert_eq!(map_role(Role::DocumentWeb), AxRole::Document); + assert_eq!(map_role(Role::DocumentFrame), AxRole::Document); + assert_eq!(map_role(Role::DocumentText), AxRole::TextArea); + // An embedded object (, ) is not a document. + assert_eq!(map_role(Role::Embedded), AxRole::Other); + } + #[test] fn states_map_to_flags() { let s = @@ -177,6 +189,7 @@ mod tests { (Role::ToolBar, AxRole::Toolbar), (Role::StatusBar, AxRole::StatusBar), (Role::Heading, AxRole::Heading), + (Role::DocumentWeb, AxRole::Document), ]; #[test] From 80483b0cfbac11cdcccb5a7cee00a89b682bdb59 Mon Sep 17 00:00:00 2001 From: mpd Date: Mon, 24 Aug 2026 09:30:15 -0700 Subject: [PATCH 03/17] test(a11y-windows): note the Document/TextArea trade the matrix defers Co-Authored-By: Claude Fable 5 --- crates/glass-a11y-windows/src/mapping.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/glass-a11y-windows/src/mapping.rs b/crates/glass-a11y-windows/src/mapping.rs index b834819e..6bd2b586 100644 --- a/crates/glass-a11y-windows/src/mapping.rs +++ b/crates/glass-a11y-windows/src/mapping.rs @@ -329,7 +329,9 @@ mod tests { #[test] fn document_maps_from_an_observed_token() { // Observed on a stock text editor — see the probe test in - // crates/glass-windows/tests/onbox.rs. + // crates/glass-windows/tests/onbox.rs. A web document arrives under the same control + // type; the role-support matrix records the Document cell as a gap until both are + // read on one host (tests/web_probe.rs). assert_eq!(map_role(50030, false), AxRole::TextArea); } From c9a65502eec4ed7e20ea6cf2f18ef97c27487758 Mon Sep 17 00:00:00 2001 From: mpd Date: Mon, 24 Aug 2026 09:31:24 -0700 Subject: [PATCH 04/17] feat(a11y): map AXWebArea to Document on macOS and iOS Co-Authored-By: Claude Fable 5 --- crates/glass-a11y-macos/src/mapping.rs | 7 +++++++ crates/glass-ios/src/axmap.rs | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/crates/glass-a11y-macos/src/mapping.rs b/crates/glass-a11y-macos/src/mapping.rs index bda1ece9..c777e9bb 100644 --- a/crates/glass-a11y-macos/src/mapping.rs +++ b/crates/glass-a11y-macos/src/mapping.rs @@ -40,6 +40,8 @@ pub const ROLE_TOKENS: &[(&str, AxRole)] = &[ ("AXSplitter", AxRole::Separator), ("AXHeading", AxRole::Heading), ("AXMenuButton", AxRole::Button), + // The root of a web engine's subtree (WebKit and Chromium both report it). + ("AXWebArea", AxRole::Document), ]; /// Subroles that decide a role, and the base roles that can carry one. @@ -231,6 +233,11 @@ mod tests { assert_eq!(map_role("", None), AxRole::Other); } + #[test] + fn a_web_area_is_a_document() { + assert_eq!(map_role("AXWebArea", None), AxRole::Document); + } + #[test] fn a_switch_is_a_togglebutton_whichever_base_role_carries_it() { // `AXToggle` is deliberately absent: AppKit documents it for on/off *buttons*, and no probe diff --git a/crates/glass-ios/src/axmap.rs b/crates/glass-ios/src/axmap.rs index cb2cdb2b..49eaf771 100644 --- a/crates/glass-ios/src/axmap.rs +++ b/crates/glass-ios/src/axmap.rs @@ -52,6 +52,8 @@ pub const ROLE_TOKENS: &[(&str, AxRole)] = &[ ("AXGroup", AxRole::Group), // A screen or section title. ("AXHeading", AxRole::Heading), + // The root of a web engine's subtree (WebKit and Chromium both report it). + ("AXWebArea", AxRole::Document), ]; /// Map an idb AX role string (e.g. `AXButton`) to a normalized [`AxRole`]. @@ -318,6 +320,11 @@ mod tests { assert_eq!(ax_role("AXWhatever"), AxRole::Other); } + #[test] + fn a_web_area_is_a_document() { + assert_eq!(ax_role("AXWebArea"), AxRole::Document); + } + #[test] fn an_empty_tree_maps_to_an_empty_window_rather_than_an_error() { // What an app reports for the second or so it takes to render. `IosA11y` relies on From faa33040c3928403c58ab91b5c5e0c38035b50a5 Mon Sep 17 00:00:00 2001 From: mpd Date: Mon, 24 Aug 2026 09:32:05 -0700 Subject: [PATCH 05/17] fix(android a11y): a WebView is a Document, not an opaque Group (glass#506) Co-Authored-By: Claude Fable 5 --- crates/glass-android/src/axmap.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/glass-android/src/axmap.rs b/crates/glass-android/src/axmap.rs index 2b269482..296928d5 100644 --- a/crates/glass-android/src/axmap.rs +++ b/crates/glass-android/src/axmap.rs @@ -43,7 +43,7 @@ pub const CLASS_TOKENS: &[(&str, AxRole)] = &[ ("RecyclerView", AxRole::List), ("ListView", AxRole::List), ("GridView", AxRole::List), - ("WebView", AxRole::Group), + ("WebView", AxRole::Document), // Containers the leaf-suffix rule below cannot catch, each observed in a real app's // tree: the AndroidX card container, the AppCompat linear layout (shipped under two // package names), the view that hosts a Compose hierarchy, and a swipe-paged container. @@ -629,6 +629,13 @@ mod tests { } } + #[test] + fn a_webview_is_a_document_not_a_group() { + // glass#506: as a Group, a WebView whose content the reader could not enter was + // indistinguishable from an empty container. + assert_eq!(class_to_role("android.webkit.WebView"), AxRole::Document); + } + #[test] fn class_tokens_have_no_duplicates() { for (i, (leaf, _)) in CLASS_TOKENS.iter().enumerate() { From fb4a59619afd8a35bf36d772c6b50dd7c691b535 Mon Sep 17 00:00:00 2001 From: mpd Date: Mon, 24 Aug 2026 09:38:45 -0700 Subject: [PATCH 06/17] feat(a11y): disclose a childless Document with the pixel path Co-Authored-By: Claude Fable 5 --- crates/glass-core/src/accessibility.rs | 136 +++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/crates/glass-core/src/accessibility.rs b/crates/glass-core/src/accessibility.rs index e387c1ad..ba9b2bc0 100644 --- a/crates/glass-core/src/accessibility.rs +++ b/crates/glass-core/src/accessibility.rs @@ -466,6 +466,20 @@ impl Truncation { } } +/// One line of [`AxTree::document_guidance`]. +fn document_notice(doc: &AxNode) -> String { + let bounds = match &doc.bounds { + Some(b) => format!("({},{} {}x{})", b.x, b.y, b.width, b.height), + None => "bounds unknown".to_string(), + }; + format!( + "… #{} Document {bounds} has no readable content: the web engine has not published \ + its accessibility tree, or the page is empty. Elements inside it cannot be addressed \ + by id. Drive it by pixels: glass_screenshot, then glass_click at x,y inside it.", + doc.id.0 + ) +} + /// Bookkeeping for a bounded pre-order walk. Every backend threads one of these through its /// traversal so the caps and the truncation record are computed one way rather than five. #[derive(Debug, Default)] @@ -757,6 +771,36 @@ impl AxTree { never will). Drive it by pixels instead: glass_screenshot, then glass_click at x,y.", ) } + + /// Every `Document` with no children, in pre-order. A web engine that has not published + /// its tree — or an empty page — arrives exactly like this, and the outline alone cannot + /// tell the two apart. + pub fn unpublished_documents(&self) -> Vec<&AxNode> { + fn walk<'a>(node: &'a AxNode, out: &mut Vec<&'a AxNode>) { + if node.role == AxRole::Document && node.children.is_empty() { + out.push(node); + } + for child in &node.children { + walk(child, out); + } + } + let mut out = Vec::new(); + walk(&self.root, &mut out); + out + } + + /// The disclosure for [`Self::unpublished_documents`]: one line per document, or `None` + /// when there is nothing to disclose. Same shape as [`Truncation::notice`] and + /// [`Self::empty_guidance`] — what is missing, then the pixel path — because a web page + /// the reader cannot enter fails the agent the same way a truncated tree does. + pub fn document_guidance(&self) -> Option { + let docs = self.unpublished_documents(); + if docs.is_empty() { + return None; + } + let lines: Vec = docs.iter().map(|d| document_notice(d)).collect(); + Some(lines.join("\n")) + } } /// Context the display backend supplies so the a11y reader can locate the right @@ -2393,6 +2437,98 @@ mod tests { assert!(sample_tree().empty_guidance().is_none()); } + fn document(name: &str, children: Vec) -> AxNode { + let mut d = leaf(AxRole::Document, name); + d.bounds = Some(AxRect { + x: 40, + y: 120, + width: 800, + height: 600, + }); + d.children = children; + d + } + + #[test] + fn a_childless_document_is_reported_with_its_id_and_bounds() { + let mut tree = AxTree::new(AxNode { + children: vec![leaf(AxRole::Button, "Back"), document("page", vec![])], + ..leaf(AxRole::Window, "App") + }); + tree.assign_ids(); + let found = tree.unpublished_documents(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].id, AxNodeId(2)); + let hint = tree + .document_guidance() + .expect("a childless Document yields guidance"); + assert!(hint.contains("#2 Document"), "{hint}"); + assert!( + hint.contains("(40,120 800x600)"), + "names the bounds: {hint}" + ); + assert!( + hint.contains("glass_screenshot"), + "names the pixel path: {hint}" + ); + } + + #[test] + fn a_populated_document_is_not_reported() { + let mut tree = AxTree::new(AxNode { + children: vec![document("page", vec![leaf(AxRole::Heading, "Hello")])], + ..leaf(AxRole::Window, "App") + }); + tree.assign_ids(); + assert!(tree.unpublished_documents().is_empty()); + assert!(tree.document_guidance().is_none()); + } + + #[test] + fn every_childless_document_is_reported_in_pre_order() { + // A populated document with an empty iframe inside it, and an empty one after it. + let mut tree = AxTree::new(AxNode { + children: vec![ + document( + "outer", + vec![leaf(AxRole::Heading, "H"), document("iframe", vec![])], + ), + document("second", vec![]), + ], + ..leaf(AxRole::Window, "App") + }); + tree.assign_ids(); + let ids: Vec = tree.unpublished_documents().iter().map(|n| n.id).collect(); + assert_eq!(ids, vec![AxNodeId(3), AxNodeId(4)]); + let hint = tree.document_guidance().unwrap(); + assert_eq!(hint.lines().count(), 2, "one line per document: {hint}"); + } + + #[test] + fn a_tree_without_documents_yields_no_document_guidance() { + assert!(sample_tree().document_guidance().is_none()); + // An empty tree is the empty_guidance case, not this one. + assert!( + AxTree::new(leaf(AxRole::Window, "App")) + .document_guidance() + .is_none() + ); + } + + #[test] + fn a_document_without_bounds_still_names_the_pixel_path() { + let mut d = document("page", vec![]); + d.bounds = None; + let mut tree = AxTree::new(AxNode { + children: vec![d], + ..leaf(AxRole::Window, "App") + }); + tree.assign_ids(); + let hint = tree.document_guidance().unwrap(); + assert!(hint.contains("bounds unknown"), "{hint}"); + assert!(hint.contains("glass_screenshot"), "{hint}"); + } + fn sample_tree() -> AxTree { let mut button = leaf(AxRole::Button, "Save"); button.bounds = Some(AxRect { From 79906659bddca721dea36e45d06f0e1366ee661e Mon Sep 17 00:00:00 2001 From: mpd Date: Mon, 24 Aug 2026 09:43:51 -0700 Subject: [PATCH 07/17] feat(mcp): surface the unpublished-Document guidance beside the truncation notice Co-Authored-By: Claude Fable 5 --- crates/glass-mcp/src/tools.rs | 64 +++++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/glass-mcp/src/tools.rs b/crates/glass-mcp/src/tools.rs index 38209f3a..2b0c8d24 100644 --- a/crates/glass-mcp/src/tools.rs +++ b/crates/glass-mcp/src/tools.rs @@ -289,13 +289,14 @@ fn a11y_truncation_steer(tree: &glass_core::AxTree) -> Option { }) } -/// Every disclosure a snapshot owes the agent: the elements it does not show, and what it turned -/// out to describe. One function, not three call-site lists, so `a11y_snapshot` and the -/// `return:"snapshot"` fold disclose identically. +/// Every disclosure a snapshot owes the agent: the elements it does not show, the web content +/// it could not enter, and what it turned out to describe. One function, not three call-site +/// lists, so `a11y_snapshot` and the `return:"snapshot"` fold disclose identically. fn a11y_steers(tree: &glass_core::AxTree) -> Vec { [ a11y_truncation_steer(tree), tree.unreadable_notice(), + tree.document_guidance(), tree.subject_notice(), ] .into_iter() @@ -816,6 +817,29 @@ pub(crate) mod testutil { t } + /// `fake_tree` with a childless `Document` child — the unpublished-web-content shape. + pub fn unpublished_document_tree() -> AxTree { + let mut t = fake_tree(); + t.root.children.push(AxNode { + id: AxNodeId(0), + role: AxRole::Document, + raw_role: "document web".into(), + name: Some("page".into()), + description: None, + value: None, + states: AxStates::default(), + bounds: Some(AxRect { + x: 0, + y: 40, + width: 100, + height: 60, + }), + children: vec![], + }); + t.assign_ids(); + t + } + pub fn glass_with_a11y(platform: FakePlatform, tree: AxTree) -> Glass { glass_with_a11y_outcome(platform, tree, SetOutcome::Ok) } @@ -1248,6 +1272,40 @@ mod tests { } } + #[test] + fn a11y_snapshot_discloses_an_unpublished_document_as_a_trusted_block() { + let mut g = glass_with_a11y(FakePlatform::new(100, 100), unpublished_document_tree()); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + let out = a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); + assert_envelope(&out, "glass_a11y_snapshot"); + assert_eq!( + out.0.len(), + 3, + "envelope + wrapped outline + document guidance" + ); + match (&out.0[1], &out.0[2]) { + (OutContent::Text(body), OutContent::Text(steer)) => { + assert!( + !body.contains("has no readable content"), + "guidance must not be inside the untrusted body: {body}" + ); + assert!(steer.contains("Document"), "{steer}"); + assert!(steer.contains("glass_screenshot"), "{steer}"); + } + other => panic!("unexpected blocks: {other:?}"), + } + } + #[test] fn a_snapshot_of_another_app_says_so_in_its_text() { let mut tree = empty_tree(); From b71d6a8405816a7f6f669dc7448351109afa45c1 Mon Sep 17 00:00:00 2001 From: mpd Date: Mon, 24 Aug 2026 09:49:41 -0700 Subject: [PATCH 08/17] docs: describe the Document role and its disclosure Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 ++++++ crates/glass-mcp/src/params.rs | 6 ++++-- crates/glass-mcp/src/server.rs | 6 ++++-- docs/reference/a11y-roles.md | 6 ++++++ docs/reference/tools.md | 6 ++++++ 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a2aa70e..e57dc6b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ internal refactors, CI, or test-only changes. ## [Unreleased] +### Added +- `Document` accessibility role: a browser page or embedded web view (AT-SPI `document web`/`document frame`, `AXWebArea`, Android `WebView`) now reads as a `Document` whose children are the page's elements. + +### Fixed +- A web view whose content the platform has not published is disclosed in the snapshot with its bounds and the pixel path, instead of arriving as an indistinguishable empty group (#506). + ## [1.5.0] - 2026-08-22 ### Added diff --git a/crates/glass-mcp/src/params.rs b/crates/glass-mcp/src/params.rs index c3e32368..e5bd9dcc 100644 --- a/crates/glass-mcp/src/params.rs +++ b/crates/glass-mcp/src/params.rs @@ -168,6 +168,8 @@ pub struct SetValueArgs { } /// Arguments for `glass_a11y_snapshot`. +/// Web content inside the app arrives under a `Document` element; a `Document` with no +/// children is disclosed with the pixel path. #[derive(Debug, Deserialize, JsonSchema)] pub struct A11ySnapshotArgs { /// Maximum number of elements to include. Omit for the default cap (protects the token @@ -333,7 +335,7 @@ pub struct WaitStableArgs { pub struct WaitForElementArgs { /// Substring of the element's accessible name (selector). pub name: Option, - /// Element role filter, e.g. "Button", "ProgressBar" (selector). + /// Element role filter, e.g. "Button", "ProgressBar", "Document" (selector). pub role: Option, /// What to wait for (default "appears"): appears|disappears|enabled|disabled| /// checked|unchecked|selected|unselected|expanded|collapsed|focused|visible|hidden. @@ -354,7 +356,7 @@ pub struct ScrollToElementArgs { /// Substring of the target element's accessible name (selector). `name` and/or /// `role` is required. pub name: Option, - /// Element role filter, e.g. "ListItem", "Button" (selector). + /// Element role filter, e.g. "ListItem", "Button", "Document" (selector). pub role: Option, /// Additionally require the matched element's `value` to contain this substring. /// Not a standalone selector — `name` and/or `role` is still required. diff --git a/crates/glass-mcp/src/server.rs b/crates/glass-mcp/src/server.rs index 67c794d1..63f12f9c 100644 --- a/crates/glass-mcp/src/server.rs +++ b/crates/glass-mcp/src/server.rs @@ -504,8 +504,10 @@ impl GlassServer { glass_scroll_to_element select on name, not description. Pass an #id to \ glass_click_element. Errors if the backend or app exposes no \ accessibility tree (e.g. a canvas/black-box app) — fall back to \ - glass_screenshot then. Optional max_nodes: raise the element cap, or 0 \ - to remove the element-count limit (default caps protect the token budget)." + glass_screenshot then. Web content arrives under a `Document` element, \ + and a childless `Document` is disclosed with the pixel path. Optional \ + max_nodes: raise the element cap, or 0 to remove the element-count limit \ + (default caps protect the token budget)." )] async fn glass_a11y_snapshot( &self, diff --git a/docs/reference/a11y-roles.md b/docs/reference/a11y-roles.md index 44037c35..5a965a13 100644 --- a/docs/reference/a11y-roles.md +++ b/docs/reference/a11y-roles.md @@ -60,6 +60,12 @@ window root sized to the app window, and the accessibility-service reader labels window's own root node. The outline does not name that node's widget class — the root has a role now, and the outline only names the token of an element that has none. +**A web document is a `Document`.** A browser page or an embedded web view arrives as one +`Document` element with the page's elements as its children; an `