From cee72cabb95d59bc3d93efa696933175fc09c132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Fri, 17 Apr 2026 08:33:13 +0200 Subject: [PATCH] cdp: improve AX tree visibility and label resolution Prunes hidden subtrees from the accessibility tree and implements accessible name resolution via labels. Adds the `labels` property to labellable HTML elements. --- src/browser/StyleManager.zig | 27 +++ .../webapi/css/CSSStyleDeclaration.zig | 14 ++ src/browser/webapi/element/html/Button.zig | 5 + src/browser/webapi/element/html/Input.zig | 8 + src/browser/webapi/element/html/Label.zig | 54 +++++ src/browser/webapi/element/html/Meter.zig | 7 + src/browser/webapi/element/html/Output.zig | 7 + src/browser/webapi/element/html/Progress.zig | 7 + src/browser/webapi/element/html/Select.zig | 5 + src/browser/webapi/element/html/TextArea.zig | 5 + src/cdp/AXNode.zig | 204 ++++++++++++++++-- src/cdp/CDP.zig | 4 + 12 files changed, 331 insertions(+), 16 deletions(-) diff --git a/src/browser/StyleManager.zig b/src/browser/StyleManager.zig index 404a11ed..25c01c6a 100644 --- a/src/browser/StyleManager.zig +++ b/src/browser/StyleManager.zig @@ -231,6 +231,33 @@ pub fn isHidden(self: *StyleManager, el: *Element, cache: ?*VisibilityCache, opt return false; } +/// Computed display:none for a single element (own property, no ancestor walk). +/// Also honors the HTML `hidden` attribute, matching the UA stylesheet rule +/// `[hidden] { display: none }`. +pub fn hasDisplayNone(self: *StyleManager, el: *Element) bool { + self.rebuildIfDirty() catch return false; + if (el.hasAttributeSafe(comptime .wrap("hidden"))) return true; + return self.isElementHidden(el, .{}); +} + +/// Computed visibility:hidden for an element, walking ancestors since `visibility` +/// inherits by default. Only considers visibility-level hidden (not display:none) +/// — display:none on an ancestor means the element isn't rendered, but its +/// computed `visibility` still reflects inherited visibility chain. +pub fn hasVisibilityHiddenInherited(self: *StyleManager, el: *Element) bool { + self.rebuildIfDirty() catch return false; + var current: ?*Element = el; + while (current) |elem| { + const combined = self.isElementHidden(elem, .{ .check_visibility = true }); + if (combined) { + const display_only = self.isElementHidden(elem, .{}); + if (!display_only) return true; + } + current = elem.parentElement(); + } + return false; +} + /// Check if a single element (not ancestors) is hidden. fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOptions) bool { // Track best match per property (value + priority) diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index 8644a75d..63eb9db3 100644 --- a/src/browser/webapi/css/CSSStyleDeclaration.zig +++ b/src/browser/webapi/css/CSSStyleDeclaration.zig @@ -78,6 +78,20 @@ pub fn item(self: *const CSSStyleDeclaration, index: u32) []const u8 { pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const u8, page: *Page) []const u8 { const normalized = normalizePropertyName(property_name, &page.buf); const wrapped = String.wrap(normalized); + + // Computed styles must reflect stylesheet rules, not just the element's + // inline `style=` attribute. Limited to display/visibility — what aria + // tree builders (Playwright ariaSnapshot) consult on every element. + if (self._is_computed) { + if (self._element) |element| { + if (wrapped.eql(comptime .wrap("display"))) { + if (page._style_manager.hasDisplayNone(element)) return "none"; + } else if (wrapped.eql(comptime .wrap("visibility"))) { + if (page._style_manager.hasVisibilityHiddenInherited(element)) return "hidden"; + } + } + } + const prop = self.findProperty(wrapped) orelse { // Only return default values for computed styles if (self._is_computed) { diff --git a/src/browser/webapi/element/html/Button.zig b/src/browser/webapi/element/html/Button.zig index 89bb3575..85549d29 100644 --- a/src/browser/webapi/element/html/Button.zig +++ b/src/browser/webapi/element/html/Button.zig @@ -110,6 +110,10 @@ pub fn getForm(self: *Button, page: *Page) ?*Form { return null; } +pub fn getLabels(self: *Button, page: *Page) !js.Array { + return @import("Label.zig").getControlLabels(self.asElement(), page); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Button); @@ -125,6 +129,7 @@ pub const JsApi = struct { pub const form = bridge.accessor(Button.getForm, null, .{}); pub const value = bridge.accessor(Button.getValue, Button.setValue, .{}); pub const @"type" = bridge.accessor(Button.getType, Button.setType, .{}); + pub const labels = bridge.accessor(Button.getLabels, null, .{}); }; pub const Build = struct { diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index 2c8eb2f2..082cd56a 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -498,6 +498,13 @@ pub fn setSelectionRange( try self.dispatchSelectionChangeEvent(page); } +pub fn getLabels(self: *Input, page: *Page) !js.Array { + if (self._input_type == .hidden) { + return page.js.local.?.newArray(0); + } + return @import("Label.zig").getControlLabels(self.asElement(), page); +} + pub fn getForm(self: *Input, page: *Page) ?*Form { const element = self.asElement(); @@ -897,6 +904,7 @@ pub const JsApi = struct { pub const size = bridge.accessor(Input.getSize, Input.setSize, .{}); pub const src = bridge.accessor(Input.getSrc, Input.setSrc, .{}); pub const form = bridge.accessor(Input.getForm, null, .{}); + pub const labels = bridge.accessor(Input.getLabels, null, .{}); pub const indeterminate = bridge.accessor(Input.getIndeterminate, Input.setIndeterminate, .{}); pub const placeholder = bridge.accessor(Input.getPlaceholder, Input.setPlaceholder, .{}); pub const min = bridge.accessor(Input.getMin, Input.setMin, .{}); diff --git a/src/browser/webapi/element/html/Label.zig b/src/browser/webapi/element/html/Label.zig index 1069f2c6..227a9c3d 100644 --- a/src/browser/webapi/element/html/Label.zig +++ b/src/browser/webapi/element/html/Label.zig @@ -1,3 +1,4 @@ +const std = @import("std"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); @@ -51,6 +52,59 @@ fn isLabelable(el: *Element) bool { }; } +/// First ancestor `