From 46a8aaa6f68a1a3b521cfda7428ffa83ebe01006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pobuta?= <52126292+michalpobuta@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:34:07 +0100 Subject: [PATCH 1/2] feat: add Accessibility tab to DevFlow Inspector - Add Accessibility tab to DevFlow Inspector with native screen reader tree view - Add IDevFlowConnectionProvider interface and DevFlowConnectionProvider service - Extend DevFlowInspectorTab enum with Profiling and Accessibility tabs - Add ValidTabs set to DevFlowInspector for tab routing validation - Register IDevFlowConnectionProvider in both MauiProgram and MacOSMauiProgram - Add AccessibilityAuditService for a11y audit logic - Add DevFlowAccessibilityTab.razor component - Extend DevFlowAgentClient with native a11y tree endpoint support - Extend DevFlowModels with NativeScreenReaderEntry and related types Co-Authored-By: Claude Sonnet 4.6 --- src/MauiSherpa.Core/Interfaces.cs | 12 + .../Models/DevFlow/DevFlowModels.cs | 222 +++ .../Services/AccessibilityAuditService.cs | 995 +++++++++++ .../Services/CopilotToolsService.cs | 152 +- .../Services/DevFlowAgentClient.cs | 43 + src/MauiSherpa.MacOS/MacOSMauiProgram.cs | 1 + src/MauiSherpa/MauiProgram.cs | 1 + .../Inspector/DevFlowAccessibilityTab.razor | 1479 +++++++++++++++++ .../Pages/Inspector/DevFlowInspector.razor | 8 +- .../Services/DevFlowConnectionProvider.cs | 16 + .../Services/DevFlowInspectorService.cs | 2 +- 11 files changed, 2924 insertions(+), 7 deletions(-) create mode 100644 src/MauiSherpa.Core/Services/AccessibilityAuditService.cs create mode 100644 src/MauiSherpa/Pages/Inspector/DevFlowAccessibilityTab.razor create mode 100644 src/MauiSherpa/Services/DevFlowConnectionProvider.cs diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index add83a1..3768f48 100644 --- a/src/MauiSherpa.Core/Interfaces.cs +++ b/src/MauiSherpa.Core/Interfaces.cs @@ -2364,6 +2364,18 @@ public record CopilotTool(Microsoft.Extensions.AI.AIFunction Function, bool IsRe public string Description => Function.Description ?? string.Empty; } +/// +/// Provides access to the currently active DevFlow agent connection (host/port). +/// Implemented in the platform project to avoid circular dependencies. +/// +public interface IDevFlowConnectionProvider +{ + bool IsConnected { get; } + string? Host { get; } + int Port { get; } + string? AppName { get; } +} + /// /// Service that provides Copilot SDK tool definitions for Apple Developer operations /// diff --git a/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs b/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs index 70604f5..7b2bc96 100644 --- a/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs +++ b/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs @@ -391,6 +391,15 @@ public class DevFlowElementInfo [JsonPropertyName("children")] public List? Children { get; set; } + + [JsonPropertyName("effectiveTextColor")] + public string? EffectiveTextColor { get; set; } + + [JsonPropertyName("effectiveBackgroundColor")] + public string? EffectiveBackgroundColor { get; set; } + + [JsonPropertyName("accessibility")] + public DevFlowNativeAccessibilityInfo? Accessibility { get; set; } } public class DevFlowBoundsInfo @@ -731,3 +740,216 @@ public class DevFlowSecureStorageEntry [JsonPropertyName("value")] public string? Value { get; set; } [JsonPropertyName("exists")] public bool Exists { get; set; } } + +// --- Native Accessibility Data (from agent /api/accessibility) --- + +public class DevFlowAccessibilityTree +{ + [JsonPropertyName("totalElements")] + public int TotalElements { get; set; } + + [JsonPropertyName("accessibilityElements")] + public List AccessibilityElements { get; set; } = new(); +} + +/// +/// Response from /api/a11y/native-tree — elements in the exact order +/// the platform screen reader (VoiceOver, TalkBack, Narrator) visits them. +/// +public class DevFlowNativeA11yTree +{ + [JsonPropertyName("platform")] + public string Platform { get; set; } = string.Empty; + + [JsonPropertyName("count")] + public int Count { get; set; } + + [JsonPropertyName("entries")] + public List Entries { get; set; } = new(); +} + +public class DevFlowNativeA11yEntry +{ + [JsonPropertyName("order")] + public int Order { get; set; } + + [JsonPropertyName("elementId")] + public string? ElementId { get; set; } + + [JsonPropertyName("label")] + public string? Label { get; set; } + + [JsonPropertyName("hint")] + public string? Hint { get; set; } + + [JsonPropertyName("value")] + public string? Value { get; set; } + + [JsonPropertyName("role")] + public string? Role { get; set; } + + [JsonPropertyName("traits")] + public List? Traits { get; set; } + + [JsonPropertyName("isHeading")] + public bool IsHeading { get; set; } + + [JsonPropertyName("windowBounds")] + public DevFlowBoundsInfo? WindowBounds { get; set; } + + [JsonPropertyName("nativeType")] + public string? NativeType { get; set; } +} + +public class DevFlowAccessibilityElement +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("automationId")] + public string? AutomationId { get; set; } + + [JsonPropertyName("text")] + public string? Text { get; set; } + + [JsonPropertyName("windowBounds")] + public DevFlowBoundsInfo? WindowBounds { get; set; } + + [JsonPropertyName("accessibility")] + public DevFlowNativeAccessibilityInfo? Accessibility { get; set; } +} + +public class DevFlowNativeAccessibilityInfo +{ + [JsonPropertyName("isAccessibilityElement")] + public bool IsAccessibilityElement { get; set; } + + [JsonPropertyName("label")] + public string? Label { get; set; } + + [JsonPropertyName("hint")] + public string? Hint { get; set; } + + [JsonPropertyName("value")] + public string? Value { get; set; } + + [JsonPropertyName("role")] + public string? Role { get; set; } + + [JsonPropertyName("traits")] + public List? Traits { get; set; } + + [JsonPropertyName("isEnabled")] + public bool IsEnabled { get; set; } = true; + + [JsonPropertyName("isFocusable")] + public bool IsFocusable { get; set; } + + [JsonPropertyName("isFocused")] + public bool IsFocused { get; set; } + + [JsonPropertyName("isHeading")] + public bool IsHeading { get; set; } + + [JsonPropertyName("order")] + public int? Order { get; set; } + + [JsonPropertyName("childCount")] + public int? ChildCount { get; set; } + + [JsonPropertyName("liveRegion")] + public string? LiveRegion { get; set; } +} + +// --- Accessibility Audit Models --- + +public enum AccessibilitySeverity +{ + Error, + Warning, + Info +} + +/// +/// A single accessibility issue found during an audit of the visual tree. +/// +public class AccessibilityIssue +{ + public string ElementId { get; set; } = string.Empty; + public string ElementType { get; set; } = string.Empty; + public string? ElementText { get; set; } + public string? AutomationId { get; set; } + public AccessibilitySeverity Severity { get; set; } + public string RuleId { get; set; } = string.Empty; + public string RuleName { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string Suggestion { get; set; } = string.Empty; + public string? XamlFix { get; set; } + public DevFlowBoundsInfo? WindowBounds { get; set; } +} + +/// +/// Represents how a screen reader would announce an element. +/// +public class ScreenReaderEntry +{ + public int Order { get; set; } + public string ElementId { get; set; } = string.Empty; + public string ElementType { get; set; } = string.Empty; + public string Role { get; set; } = string.Empty; + public string AnnouncedText { get; set; } = string.Empty; + public string? Hint { get; set; } + public string? HeadingLevel { get; set; } + public bool IsInteractive { get; set; } + public bool HasIssue { get; set; } + public DevFlowBoundsInfo? WindowBounds { get; set; } +} + +/// +/// Result of a WCAG color contrast check. +/// +public class ContrastCheckResult +{ + public string ElementId { get; set; } = string.Empty; + public string ElementType { get; set; } = string.Empty; + public string? ElementText { get; set; } + public string ForegroundColor { get; set; } = string.Empty; + public string BackgroundColor { get; set; } = string.Empty; + public double ContrastRatio { get; set; } + public bool PassesAA { get; set; } + public bool PassesAAA { get; set; } + public bool IsLargeText { get; set; } + public DevFlowBoundsInfo? WindowBounds { get; set; } +} + +/// +/// Accessibility score breakdown by category. +/// +public class AccessibilityScoreCategory +{ + public string Name { get; set; } = string.Empty; + public string Icon { get; set; } = string.Empty; + public int Passed { get; set; } + public int Total { get; set; } + public bool IsPass => Total == 0 || Passed == Total; +} + +/// +/// Summary of an accessibility audit run. +/// +public class AccessibilityAuditResult +{ + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; + public int TotalElements { get; set; } + public List Issues { get; set; } = new(); + public List ScreenReaderOrder { get; set; } = new(); + public List ContrastResults { get; set; } = new(); + public List ScoreCategories { get; set; } = new(); + public int Score { get; set; } + public int ErrorCount => Issues.Count(i => i.Severity == AccessibilitySeverity.Error); + public int WarningCount => Issues.Count(i => i.Severity == AccessibilitySeverity.Warning); + public int InfoCount => Issues.Count(i => i.Severity == AccessibilitySeverity.Info); +} diff --git a/src/MauiSherpa.Core/Services/AccessibilityAuditService.cs b/src/MauiSherpa.Core/Services/AccessibilityAuditService.cs new file mode 100644 index 0000000..6e207b6 --- /dev/null +++ b/src/MauiSherpa.Core/Services/AccessibilityAuditService.cs @@ -0,0 +1,995 @@ +using System.Globalization; +using MauiSherpa.Core.Models.DevFlow; + +namespace MauiSherpa.Core.Services; + +/// +/// Runs accessibility audit rules against a visual tree fetched from a DevFlow agent. +/// +public class AccessibilityAuditService +{ + private static readonly string[] ImageTypes = { "Image", "ImageButton" }; + private static readonly string[] ButtonTypes = { "Button", "ImageButton", "SwipeItem" }; + private static readonly string[] InputTypes = { "Entry", "Editor", "SearchBar", "DatePicker", "TimePicker", "Picker", "Stepper", "Slider", "Switch", "CheckBox", "RadioButton" }; + private static readonly string[] InteractiveTypes = ButtonTypes.Concat(InputTypes).ToArray(); + private static readonly string[] TextElementTypes = { "Label", "Button", "Entry", "Editor", "SearchBar", "Span", "RadioButton" }; + + private readonly List _rules; + + public AccessibilityAuditService() + { + _rules = new List + { + new MissingImageDescriptionRule(), + new MissingButtonLabelRule(), + new MissingInputLabelRule(), + new TouchTargetTooSmallRule(), + new DuplicateAutomationIdRule(), + new NotFocusableInteractiveRule(), + new MissingHeadingLevelRule(), + new LowContrastRule(), + }; + } + + // Properties needed per element type (only what can't be derived from tree or native a11y data) + private static readonly string[] ColorFontProps = { "FontSize", "TextColor", "BackgroundColor", "Background" }; + private static readonly HashSet TextElementSet = new(StringComparer.OrdinalIgnoreCase) + { "Label", "Span", "Button", "ImageButton", "Entry", "Editor", "SearchBar", "RadioButton" }; + private static readonly HashSet ImageElementSet = new(StringComparer.OrdinalIgnoreCase) + { "Image", "ImageButton" }; + private static readonly HashSet InteractiveElementSet = new(StringComparer.OrdinalIgnoreCase) + { "Button", "ImageButton", "SwipeItem", "Entry", "Editor", "SearchBar", "DatePicker", "TimePicker", + "Picker", "Stepper", "Slider", "Switch", "CheckBox", "RadioButton" }; + + /// + /// Run all audit rules against the visual tree, fetching additional properties as needed. + /// Uses native accessibility data embedded in the tree (element.Accessibility) for labels, + /// hints, and heading detection. Only fetches color/font/source properties via HTTP. + /// + public async Task AuditAsync( + List tree, + DevFlowAgentClient client, + CancellationToken ct = default) + { + var allElements = FlattenTree(tree); + + // Build native a11y lookup from tree data (agent populates element.Accessibility since v2) + var nativeA11yLookup = new Dictionary(StringComparer.Ordinal); + foreach (var el in allElements.Where(e => e.Accessibility != null)) + { + nativeA11yLookup[el.Id] = new DevFlowAccessibilityElement + { + Id = el.Id, + Type = el.Type, + AutomationId = el.AutomationId, + Text = el.Text, + WindowBounds = el.WindowBounds, + Accessibility = el.Accessibility, + }; + } + + // Fallback: if the connected agent is older and doesn't embed accessibility in the tree, + // fetch the dedicated endpoint instead + if (nativeA11yLookup.Count == 0) + { + try + { + using var a11yCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + a11yCts.CancelAfter(TimeSpan.FromSeconds(5)); + var a11yTree = await client.GetAccessibilityTreeAsync(ct: a11yCts.Token); + if (a11yTree?.AccessibilityElements != null) + foreach (var el in a11yTree.AccessibilityElements) + nativeA11yLookup[el.Id] = el; + } + catch { /* older agent without /api/accessibility support */ } + } + + // Only fetch properties that can't be derived from tree data or native a11y: + // - Source: images (to distinguish decorative/unloaded) + // - FontSize, TextColor, + // BackgroundColor, Background: text elements (for contrast + heading detection) + // - IsTabStop: interactive elements (rule A006) + var fetchTasks = allElements + .Where(e => e.IsVisible && (TextElementSet.Contains(e.Type) || ImageElementSet.Contains(e.Type) || InteractiveElementSet.Contains(e.Type))) + .Select(async element => + { + var propsToFetch = new List(); + if (TextElementSet.Contains(element.Type)) + propsToFetch.AddRange(ColorFontProps); + if (ImageElementSet.Contains(element.Type)) + propsToFetch.Add("Source"); + if (InteractiveElementSet.Contains(element.Type)) + propsToFetch.Add("IsTabStop"); + + var props = new Dictionary(StringComparer.OrdinalIgnoreCase); + var propTasks = propsToFetch.Distinct().Select(async prop => + { + var val = await client.GetPropertyAsync(element.Id, prop, ct); + return (prop, val); + }); + var results = await Task.WhenAll(propTasks); + foreach (var (prop, val) in results) + props[prop] = val; + return (Element: element, Properties: props); + }); + + var fetchedElements = (await Task.WhenAll(fetchTasks)).ToList(); + + // Merge: non-fetched elements get an empty props dict + var fetchedById = fetchedElements.ToDictionary(f => f.Element.Id, f => f.Properties); + var flatElements = allElements + .Select(e => (Element: e, Properties: fetchedById.TryGetValue(e.Id, out var p) ? p : new Dictionary(StringComparer.OrdinalIgnoreCase))) + .ToList(); + + // Use last-wins for duplicate IDs (can happen with multiple windows) + var elementLookup = new Dictionary Properties)>(); + foreach (var item in flatElements) + elementLookup[item.Element.Id] = item; + var context = new AuditContext(flatElements, elementLookup, nativeA11yLookup); + var issues = new List(); + + foreach (var rule in _rules) + { + issues.AddRange(rule.Evaluate(context)); + } + + using var nativeCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + nativeCts.CancelAfter(TimeSpan.FromSeconds(6)); + var nativeTree = await client.GetNativeA11yTreeAsync(ct: nativeCts.Token); + var screenReaderOrder = BuildScreenReaderOrderFromNative( + nativeTree?.Entries ?? new(), + elementLookup, + issues); + + // Build contrast results (with parent chain lookup for inherited backgrounds) + var contrastResults = BuildContrastResults(flatElements, elementLookup); + + // Calculate score + var (score, categories) = CalculateScore(flatElements, issues, contrastResults); + + return new AccessibilityAuditResult + { + Timestamp = DateTimeOffset.UtcNow, + TotalElements = allElements.Count, + Issues = issues.OrderBy(i => i.Severity).ThenBy(i => i.RuleId).ToList(), + ScreenReaderOrder = screenReaderOrder, + ContrastResults = contrastResults, + Score = score, + ScoreCategories = categories, + }; + } + + // --- Screen Reader Order (Native) --- + + /// + /// Builds screen reader order directly from the native platform accessibility tree. + /// Elements are in the exact order VoiceOver/TalkBack/Narrator visits them. + /// For elements matched back to a MAUI ID, marks them if they have issues. + /// + private static List BuildScreenReaderOrderFromNative( + List nativeEntries, + Dictionary Properties)> elementLookup, + List issues) + { + var issueElementIds = new HashSet(issues.Select(i => i.ElementId)); + var result = new List(); + + foreach (var entry in nativeEntries) + { + // Try to look up the corresponding MAUI element for richer data + var elementId = entry.ElementId ?? string.Empty; + elementLookup.TryGetValue(elementId, out var maui); + + result.Add(new ScreenReaderEntry + { + Order = entry.Order, + ElementId = elementId, + ElementType = maui.Element?.Type ?? entry.NativeType ?? "Native", + Role = entry.Role ?? "none", + AnnouncedText = entry.Label ?? maui.Element?.Text ?? "(no text)", + Hint = entry.Hint, + HeadingLevel = entry.IsHeading ? "Heading" : null, + IsInteractive = entry.Traits?.Any(t => t is "Button" or "Link" or "SearchField" or "Slider") == true, + HasIssue = !string.IsNullOrEmpty(elementId) && issueElementIds.Contains(elementId), + WindowBounds = maui.Element?.WindowBounds ?? entry.WindowBounds, + }); + } + + return result; + } + + /// + /// Enriches heuristic screen reader entries with native accessibility data (labels, roles, traits). + /// The heuristic path provides reliable bounds and ordering; native data provides accurate labels. + /// + private static void EnrichWithNativeData( + List entries, + Dictionary nativeLookup) + { + foreach (var entry in entries) + { + if (!nativeLookup.TryGetValue(entry.ElementId, out var native)) + continue; + + var a11y = native.Accessibility; + if (a11y == null) continue; + + // Override announced text with native label (what the screen reader actually says) + if (!string.IsNullOrEmpty(a11y.Label)) + entry.AnnouncedText = a11y.Label; + + // Override role with native role + if (!string.IsNullOrEmpty(a11y.Role)) + entry.Role = a11y.Role; + + // Override hint with native hint + if (!string.IsNullOrEmpty(a11y.Hint)) + entry.Hint = a11y.Hint; + + // Native heading info + if (a11y.IsHeading) + entry.HeadingLevel = "Heading"; + + // Native interactivity from traits + if (a11y.IsFocusable || (a11y.Traits?.Any(t => + t is "Button" or "Clickable" or "Link" or "Adjustable" or "SearchField") == true)) + entry.IsInteractive = true; + } + } + + // --- Screen Reader Order (Heuristic Fallback) --- + + private static List BuildScreenReaderOrder( + List<(DevFlowElementInfo Element, Dictionary Properties)> elements, + List issues) + { + var issueElementIds = new HashSet(issues.Select(i => i.ElementId)); + var order = new List(); + int index = 0; + + // Elements are already in visual tree order (DFS), which matches screen reader order + foreach (var (element, props) in elements) + { + // Skip layout containers that screen readers skip + if (IsLayoutContainer(element.Type) && !HasSemanticRole(element, props)) + continue; + + var role = GetScreenReaderRole(element, props); + var announced = GetAnnouncedText(element, props); + + // Skip elements with no announceable content and no role + if (string.IsNullOrWhiteSpace(announced) && role == "none") + continue; + + // Use native a11y data for hint and heading (more accurate than fetched props) + var hint = element.Accessibility?.Hint; + var isHeading = element.Accessibility?.IsHeading == true; + + order.Add(new ScreenReaderEntry + { + Order = ++index, + ElementId = element.Id, + ElementType = element.Type, + Role = role, + AnnouncedText = announced ?? "(no text)", + Hint = hint, + HeadingLevel = isHeading ? "Heading" : null, + IsInteractive = IsInteractive(element), + HasIssue = issueElementIds.Contains(element.Id), + WindowBounds = element.WindowBounds, + }); + } + + return order; + } + + private static bool IsLayoutContainer(string type) => + type is "StackLayout" or "VerticalStackLayout" or "HorizontalStackLayout" + or "Grid" or "FlexLayout" or "AbsoluteLayout" or "RelativeLayout" + or "ContentView" or "Frame" or "Border" or "ScrollView" + or "ContentPage" or "Shell" or "NavigationPage" or "TabbedPage" + or "FlyoutPage"; + + private static bool HasSemanticRole(DevFlowElementInfo element, Dictionary props) + { + if (!string.IsNullOrWhiteSpace(element.AutomationId)) return true; + if (props.TryGetValue("SemanticProperties.Description", out var desc) && !string.IsNullOrWhiteSpace(desc)) return true; + return false; + } + + private static string GetScreenReaderRole(DevFlowElementInfo element, Dictionary props) + { + var type = element.Type; + if (type is "Button" or "ImageButton" or "SwipeItem") return "Button"; + if (type is "Entry" or "Editor" or "SearchBar") return "Text Field"; + if (type is "Switch" or "CheckBox") return "Toggle"; + if (type is "Slider" or "Stepper") return "Adjustable"; + if (type is "Picker" or "DatePicker" or "TimePicker") return "Picker"; + if (type is "RadioButton") return "Radio Button"; + if (type is "Image" or "ImageButton") return "Image"; + if (type is "Label" or "Span") return "Text"; + if (type is "ActivityIndicator") return "Activity Indicator"; + if (type is "ProgressBar") return "Progress"; + if (type is "WebView") return "Web Content"; + if (element.Gestures?.Any(g => g.Contains("Tap", StringComparison.OrdinalIgnoreCase)) == true) return "Button"; + return "none"; + } + + private static string? GetAnnouncedText(DevFlowElementInfo element, Dictionary props) + { + // Native label is what the screen reader actually announces (combines SemanticProperties + platform) + if (!string.IsNullOrWhiteSpace(element.Accessibility?.Label)) return element.Accessibility!.Label; + if (!string.IsNullOrWhiteSpace(element.Text)) return element.Text; + if (!string.IsNullOrWhiteSpace(element.AutomationId)) return element.AutomationId; + if (element.NativeProperties != null) + { + if (element.NativeProperties.TryGetValue("AccessibilityLabel", out var iosLabel) && !string.IsNullOrWhiteSpace(iosLabel)) return iosLabel; + if (element.NativeProperties.TryGetValue("ContentDescription", out var androidDesc) && !string.IsNullOrWhiteSpace(androidDesc)) return androidDesc; + } + return null; + } + + // --- Color Contrast --- + + private static List BuildContrastResults( + List<(DevFlowElementInfo Element, Dictionary Properties)> elements, + Dictionary Properties)> elementLookup) + { + var results = new List(); + + foreach (var (element, props) in elements) + { + if (!IsType(element, TextElementTypes)) + continue; + + // Prefer native effective colors (populated by platform tree walker after theme/style resolution). + // Fall back to fetched MAUI properties for agents that predate this feature. + var fg = element.EffectiveTextColor + ?? (props.TryGetValue("TextColor", out var tc) ? tc : null); + if (string.IsNullOrWhiteSpace(fg)) + continue; + + // Resolve background: effective native color, then fetched props, then parent chain + var bg = element.EffectiveBackgroundColor + ?? ResolveBackground(element, props, elementLookup); + if (string.IsNullOrWhiteSpace(bg)) + continue; + + var fgRgb = ParseColor(fg); + var bgRgb = ParseColor(bg); + if (fgRgb == null || bgRgb == null) continue; + + var ratio = CalculateContrastRatio(fgRgb.Value, bgRgb.Value); + props.TryGetValue("FontSize", out var fontSizeStr); + var isLargeText = double.TryParse(fontSizeStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var fs) && fs >= 18; + + results.Add(new ContrastCheckResult + { + ElementId = element.Id, + ElementType = element.Type, + ElementText = element.Text, + ForegroundColor = fg, + BackgroundColor = bg, + ContrastRatio = Math.Round(ratio, 2), + PassesAA = isLargeText ? ratio >= 3.0 : ratio >= 4.5, + PassesAAA = isLargeText ? ratio >= 4.5 : ratio >= 7.0, + IsLargeText = isLargeText, + WindowBounds = element.WindowBounds, + }); + } + + return results; + } + + /// + /// Resolves the effective background color by walking up the parent chain. + /// In MAUI, Background (Brush) and BackgroundColor (Color) are separate properties. + /// Colors are often set via styles/ResourceDictionary on parent containers. + /// + private static string? ResolveBackground( + DevFlowElementInfo element, + Dictionary props, + Dictionary Properties)> elementLookup) + { + // First check the element itself + var selfBg = GetBgFromProps(props); + if (selfBg != null) return selfBg; + + // Walk up parent chain to find nearest ancestor with a background + var currentId = element.ParentId; + var depth = 0; + while (currentId != null && depth < 20) + { + if (elementLookup.TryGetValue(currentId, out var parent)) + { + var parentBg = GetBgFromProps(parent.Properties); + if (parentBg != null) return parentBg; + currentId = parent.Element.ParentId; + } + else break; + depth++; + } + + return null; + } + + /// + /// Checks both Background (Brush) and BackgroundColor (Color) properties. + /// + private static string? GetBgFromProps(Dictionary props) + { + // Check BackgroundColor first (more specific, Color type) + if (props.TryGetValue("BackgroundColor", out var bgColor) && !string.IsNullOrWhiteSpace(bgColor) && ParseColor(bgColor) != null) + return bgColor; + // Fall back to Background (Brush type) + if (props.TryGetValue("Background", out var bg) && !string.IsNullOrWhiteSpace(bg) && ParseColor(bg) != null) + return bg; + return null; + } + + internal static (double R, double G, double B)? ParseColor(string color) + { + if (string.IsNullOrWhiteSpace(color)) return null; + color = color.Trim(); + + // Handle "Color [A=1, R=0.2, G=0.3, B=0.4]" format from MAUI + if (color.StartsWith("Color [", StringComparison.OrdinalIgnoreCase) || color.StartsWith("[")) + { + var start = color.IndexOf('[') + 1; + var end = color.IndexOf(']'); + if (start > 0 && end > start) + { + var parts = color[start..end].Split(','); + double r = 0, g = 0, b = 0; + foreach (var part in parts) + { + var kv = part.Split('='); + if (kv.Length == 2) + { + var key = kv[0].Trim(); + if (double.TryParse(kv[1].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out var val)) + { + if (key == "R") r = val; + else if (key == "G") g = val; + else if (key == "B") b = val; + } + } + } + return (r, g, b); + } + } + + // Handle hex: #RRGGBB or #AARRGGBB + if (color.StartsWith('#')) + { + var hex = color[1..]; + if (hex.Length == 8) hex = hex[2..]; // skip alpha + if (hex.Length == 6 && + int.TryParse(hex[0..2], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) && + int.TryParse(hex[2..4], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) && + int.TryParse(hex[4..6], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b)) + { + return (r / 255.0, g / 255.0, b / 255.0); + } + } + + // Named colors (common ones) + return color.ToLowerInvariant() switch + { + "black" => (0, 0, 0), + "white" => (1, 1, 1), + "red" => (1, 0, 0), + "green" => (0, 0.502, 0), + "blue" => (0, 0, 1), + "yellow" => (1, 1, 0), + "gray" or "grey" => (0.502, 0.502, 0.502), + "transparent" => null, + _ => null, + }; + } + + internal static double CalculateContrastRatio((double R, double G, double B) fg, (double R, double G, double B) bg) + { + var l1 = RelativeLuminance(fg.R, fg.G, fg.B); + var l2 = RelativeLuminance(bg.R, bg.G, bg.B); + var lighter = Math.Max(l1, l2); + var darker = Math.Min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); + } + + private static double RelativeLuminance(double r, double g, double b) + { + static double Linearize(double c) => c <= 0.03928 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4); + return 0.2126 * Linearize(r) + 0.7152 * Linearize(g) + 0.0722 * Linearize(b); + } + + // --- Accessibility Score --- + + private static (int Score, List Categories) CalculateScore( + List<(DevFlowElementInfo Element, Dictionary Properties)> elements, + List issues, + List contrastResults) + { + var categories = new List(); + var issuesByRule = issues.ToLookup(i => i.RuleId); + + // Labels & Descriptions (A001, A002, A003) + var labelElements = elements.Count(e => + IsType(e.Element, ImageTypes) || IsType(e.Element, ButtonTypes) || IsType(e.Element, InputTypes)); + var labelIssues = issuesByRule["A001"].Count() + issuesByRule["A002"].Count() + issuesByRule["A003"].Count(); + categories.Add(new AccessibilityScoreCategory + { + Name = "Labels", + Icon = "fa-tag", + Total = Math.Max(1, labelElements), + Passed = Math.Max(0, labelElements - labelIssues), + }); + + // Touch Targets (A004) + var interactiveCount = elements.Count(e => IsInteractive(e.Element)); + var touchIssues = issuesByRule["A004"].Count(); + categories.Add(new AccessibilityScoreCategory + { + Name = "Touch Targets", + Icon = "fa-hand-pointer", + Total = Math.Max(1, interactiveCount), + Passed = Math.Max(0, interactiveCount - touchIssues), + }); + + // Contrast + var contrastTotal = contrastResults.Count; + var contrastPassed = contrastResults.Count(c => c.PassesAA); + if (contrastTotal > 0) + { + categories.Add(new AccessibilityScoreCategory + { + Name = "Contrast", + Icon = "fa-circle-half-stroke", + Total = contrastTotal, + Passed = contrastPassed, + }); + } + + // Keyboard / Focus (A006) + var focusIssues = issuesByRule["A006"].Count(); + categories.Add(new AccessibilityScoreCategory + { + Name = "Keyboard", + Icon = "fa-keyboard", + Total = Math.Max(1, interactiveCount), + Passed = Math.Max(0, interactiveCount - focusIssues), + }); + + // Headings (A007) + var headingCandidates = issuesByRule["A007"].Count(); + var headingsSet = elements.Count(e => e.Element.Accessibility?.IsHeading == true); + var headingTotal = headingCandidates + headingsSet; + if (headingTotal > 0) + { + categories.Add(new AccessibilityScoreCategory + { + Name = "Headings", + Icon = "fa-heading", + Total = headingTotal, + Passed = headingsSet, + }); + } + + // Unique IDs (A005) + var dupIssues = issuesByRule["A005"].Count(); + var totalWithId = elements.Count(e => !string.IsNullOrWhiteSpace(e.Element.AutomationId)); + if (totalWithId > 0) + { + categories.Add(new AccessibilityScoreCategory + { + Name = "Unique IDs", + Icon = "fa-fingerprint", + Total = totalWithId, + Passed = Math.Max(0, totalWithId - dupIssues), + }); + } + + // Calculate overall score as weighted average + var totalWeight = categories.Sum(c => c.Total); + var totalPassed = categories.Sum(c => c.Passed); + var score = totalWeight > 0 ? (int)Math.Round(100.0 * totalPassed / totalWeight) : 100; + + return (score, categories); + } + + // --- Helpers --- + + private static List FlattenTree(List tree) + { + var result = new List(); + foreach (var node in tree) + FlattenRecursive(node, result); + return result; + } + + private static void FlattenRecursive(DevFlowElementInfo node, List result) + { + result.Add(node); + if (node.Children != null) + { + foreach (var child in node.Children) + FlattenRecursive(child, result); + } + } + + internal static bool IsType(DevFlowElementInfo element, string[] types) + => types.Any(t => element.Type.Equals(t, StringComparison.OrdinalIgnoreCase)); + + internal static bool HasDescription(DevFlowElementInfo element, Dictionary props, AuditContext? context = null) + { + // Native label is the most reliable source (combines SemanticProperties + platform accessibility) + if (!string.IsNullOrWhiteSpace(element.Accessibility?.Label)) return true; + // Fallback for older agents without embedded accessibility data + if (context != null && !string.IsNullOrWhiteSpace(context.GetNativeLabel(element.Id))) return true; + + if (!string.IsNullOrWhiteSpace(element.AutomationId)) return true; + if (!string.IsNullOrWhiteSpace(element.Text)) return true; + if (element.NativeProperties != null) + { + if (element.NativeProperties.TryGetValue("accessibilityLabel", out var iosLabel) && !string.IsNullOrWhiteSpace(iosLabel)) return true; + if (element.NativeProperties.TryGetValue("contentDescription", out var androidDesc) && !string.IsNullOrWhiteSpace(androidDesc)) return true; + } + return false; + } + + internal static bool HasSemanticHint(DevFlowElementInfo element, Dictionary props) + { + // Native hint is set from SemanticProperties.Hint by the agent + if (!string.IsNullOrWhiteSpace(element.Accessibility?.Hint)) return true; + return props.TryGetValue("SemanticProperties.Hint", out var hint) && !string.IsNullOrWhiteSpace(hint); + } + + internal static bool IsInteractive(DevFlowElementInfo element) + => IsType(element, InteractiveTypes) + || (element.Gestures?.Any(g => g.Contains("Tap", StringComparison.OrdinalIgnoreCase)) == true); + + internal static AccessibilityIssue CreateIssue( + DevFlowElementInfo element, + AccessibilitySeverity severity, + string ruleId, + string ruleName, + string message, + string suggestion, + string? xamlFix = null) + { + return new AccessibilityIssue + { + ElementId = element.Id, + ElementType = element.Type, + ElementText = element.Text, + AutomationId = element.AutomationId, + Severity = severity, + RuleId = ruleId, + RuleName = ruleName, + Message = message, + Suggestion = suggestion, + XamlFix = xamlFix, + WindowBounds = element.WindowBounds, + }; + } +} + +/// +/// Context passed to each audit rule containing all elements and their fetched properties. +/// +public class AuditContext +{ + public List<(DevFlowElementInfo Element, Dictionary Properties)> Elements { get; } + public Dictionary Properties)> ElementLookup { get; } + public Dictionary NativeAccessibility { get; } + + public AuditContext( + List<(DevFlowElementInfo Element, Dictionary Properties)> elements, + Dictionary Properties)> elementLookup, + Dictionary? nativeAccessibility = null) + { + Elements = elements; + ElementLookup = elementLookup; + NativeAccessibility = nativeAccessibility ?? new(); + } + + /// + /// Gets the native accessibility label for an element, if available. + /// + public string? GetNativeLabel(string elementId) + { + return NativeAccessibility.TryGetValue(elementId, out var el) + ? el.Accessibility?.Label + : null; + } + + /// + /// Returns true if native data confirms this element is an accessibility element. + /// + public bool? IsNativeAccessibilityElement(string elementId) + { + return NativeAccessibility.TryGetValue(elementId, out var el) + ? el.Accessibility?.IsAccessibilityElement + : null; + } +} + +/// +/// Interface for an accessibility audit rule. +/// +public interface IAccessibilityRule +{ + IEnumerable Evaluate(AuditContext context); +} + +// --- Rule Implementations --- + +internal class MissingImageDescriptionRule : IAccessibilityRule +{ + private static readonly string[] Types = { "Image", "ImageButton" }; + + public IEnumerable Evaluate(AuditContext context) + { + foreach (var (element, props) in context.Elements) + { + if (!AccessibilityAuditService.IsType(element, Types)) + continue; + if (props.TryGetValue("Source", out var source) && string.IsNullOrWhiteSpace(source)) + continue; + if (!AccessibilityAuditService.HasDescription(element, props, context)) + { + yield return AccessibilityAuditService.CreateIssue( + element, + AccessibilitySeverity.Error, + "A001", + "Missing Image Description", + $"{element.Type} has no accessible description. Screen readers cannot describe this image.", + "Add SemanticProperties.Description to provide alt text, or set AutomationId for test identification.", + $"SemanticProperties.Description=\"[describe this {element.Type.ToLowerInvariant()}]\""); + } + } + } +} + +internal class MissingButtonLabelRule : IAccessibilityRule +{ + private static readonly string[] Types = { "Button", "ImageButton", "SwipeItem" }; + + public IEnumerable Evaluate(AuditContext context) + { + foreach (var (element, props) in context.Elements) + { + if (!AccessibilityAuditService.IsType(element, Types)) + continue; + if (!AccessibilityAuditService.HasDescription(element, props, context)) + { + yield return AccessibilityAuditService.CreateIssue( + element, + AccessibilitySeverity.Error, + "A002", + "Missing Button Label", + $"{element.Type} has no accessible label. Screen readers will announce it as an unlabeled button.", + "Set the Text property or add SemanticProperties.Description.", + $"SemanticProperties.Description=\"[button action]\""); + } + } + } +} + +internal class MissingInputLabelRule : IAccessibilityRule +{ + private static readonly string[] Types = { "Entry", "Editor", "SearchBar", "DatePicker", "TimePicker", "Picker" }; + + public IEnumerable Evaluate(AuditContext context) + { + foreach (var (element, props) in context.Elements) + { + if (!AccessibilityAuditService.IsType(element, Types)) + continue; + if (!AccessibilityAuditService.HasDescription(element, props, context) && !AccessibilityAuditService.HasSemanticHint(element, props)) + { + yield return AccessibilityAuditService.CreateIssue( + element, + AccessibilitySeverity.Error, + "A003", + "Missing Input Label", + $"{element.Type} has no accessible label or hint. Users won't know what to enter.", + "Add SemanticProperties.Description (label) and/or SemanticProperties.Hint (usage hint).", + $"SemanticProperties.Description=\"[field label]\"\nSemanticProperties.Hint=\"[e.g. Enter your email address]\""); + } + } + } +} + +internal class TouchTargetTooSmallRule : IAccessibilityRule +{ + private const double MinSize = 44; + + public IEnumerable Evaluate(AuditContext context) + { + foreach (var (element, props) in context.Elements) + { + if (!AccessibilityAuditService.IsInteractive(element)) + continue; + + var bounds = element.WindowBounds ?? element.Bounds; + if (bounds == null || bounds.Width <= 0 || bounds.Height <= 0) + continue; + + if (bounds.Width < MinSize || bounds.Height < MinSize) + { + var wFix = bounds.Width < MinSize ? $"MinimumWidthRequest=\"{MinSize}\"" : ""; + var hFix = bounds.Height < MinSize ? $"MinimumHeightRequest=\"{MinSize}\"" : ""; + var fix = string.Join(" ", new[] { wFix, hFix }.Where(s => s.Length > 0)); + + yield return AccessibilityAuditService.CreateIssue( + element, + AccessibilitySeverity.Warning, + "A004", + "Touch Target Too Small", + $"{element.Type} size is {bounds.Width:F0}x{bounds.Height:F0} dp. Minimum recommended is {MinSize}x{MinSize} dp.", + "Increase WidthRequest/HeightRequest or add Padding to meet the 44x44 dp minimum touch target.", + fix); + } + } + } +} + +internal class DuplicateAutomationIdRule : IAccessibilityRule +{ + public IEnumerable Evaluate(AuditContext context) + { + var grouped = context.Elements + .Where(e => !string.IsNullOrWhiteSpace(e.Element.AutomationId)) + .GroupBy(e => e.Element.AutomationId!) + .Where(g => g.Count() > 1); + + foreach (var group in grouped) + { + foreach (var (element, props) in group) + { + yield return AccessibilityAuditService.CreateIssue( + element, + AccessibilitySeverity.Error, + "A005", + "Duplicate AutomationId", + $"AutomationId \"{element.AutomationId}\" is used by {group.Count()} elements. AutomationIds must be unique.", + "Assign a unique AutomationId to each element for reliable accessibility and test identification.", + $"AutomationId=\"{element.AutomationId}_{element.Type.ToLowerInvariant()}\""); + } + } + } +} + +internal class NotFocusableInteractiveRule : IAccessibilityRule +{ + public IEnumerable Evaluate(AuditContext context) + { + foreach (var (element, props) in context.Elements) + { + if (!AccessibilityAuditService.IsInteractive(element)) + continue; + + if (props.TryGetValue("IsTabStop", out var isTabStop) + && bool.TryParse(isTabStop, out var val) + && !val) + { + yield return AccessibilityAuditService.CreateIssue( + element, + AccessibilitySeverity.Warning, + "A006", + "Not Keyboard Focusable", + $"{element.Type} has IsTabStop=false. Keyboard and switch-access users cannot reach it.", + "Set IsTabStop=\"true\" unless the element is intentionally unreachable.", + "IsTabStop=\"True\""); + } + } + } +} + +internal class MissingHeadingLevelRule : IAccessibilityRule +{ + private const double HeadingFontSizeThreshold = 20; + + public IEnumerable Evaluate(AuditContext context) + { + foreach (var (element, props) in context.Elements) + { + if (!element.Type.Equals("Label", StringComparison.OrdinalIgnoreCase)) + continue; + + if (!props.TryGetValue("FontSize", out var fontSizeStr) || !double.TryParse(fontSizeStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var fontSize)) + continue; + + if (fontSize < HeadingFontSizeThreshold) + continue; + + // Skip if native a11y already marks it as heading, or if SemanticProperties.HeadingLevel is set + if (element.Accessibility?.IsHeading == true) + continue; + + yield return AccessibilityAuditService.CreateIssue( + element, + AccessibilitySeverity.Info, + "A007", + "Missing Heading Level", + $"Label with FontSize={fontSize:F0} looks like a heading but has no SemanticProperties.HeadingLevel.", + "Add SemanticProperties.HeadingLevel (Level1-Level9) so screen readers can navigate by headings.", + "SemanticProperties.HeadingLevel=\"Level1\""); + } + } +} + +internal class LowContrastRule : IAccessibilityRule +{ + private static readonly string[] TextTypes = { "Label", "Button", "Entry", "Editor", "SearchBar", "Span", "RadioButton" }; + + public IEnumerable Evaluate(AuditContext context) + { + foreach (var (element, props) in context.Elements) + { + if (!AccessibilityAuditService.IsType(element, TextTypes)) + continue; + + if (!props.TryGetValue("TextColor", out var fg) || string.IsNullOrWhiteSpace(fg)) + continue; + + // Resolve background from parent chain (handles styles on containers) + var bg = ResolveBackgroundFromContext(element, props, context); + if (string.IsNullOrWhiteSpace(bg)) + continue; + + var fgRgb = AccessibilityAuditService.ParseColor(fg); + var bgRgb = AccessibilityAuditService.ParseColor(bg); + if (fgRgb == null || bgRgb == null) continue; + + var ratio = AccessibilityAuditService.CalculateContrastRatio(fgRgb.Value, bgRgb.Value); + + props.TryGetValue("FontSize", out var fontSizeStr); + var isLargeText = double.TryParse(fontSizeStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var fs) && fs >= 18; + var required = isLargeText ? 3.0 : 4.5; + + if (ratio < required) + { + yield return AccessibilityAuditService.CreateIssue( + element, + AccessibilitySeverity.Warning, + "A008", + "Low Color Contrast", + $"{element.Type} contrast ratio is {ratio:F1}:1 (requires {required:F1}:1 for WCAG AA{(isLargeText ? " large text" : "")}).", + "Change TextColor or Background to achieve sufficient contrast. Use a contrast checker to find accessible color pairs."); + } + } + } + + private static string? ResolveBackgroundFromContext(DevFlowElementInfo element, Dictionary props, AuditContext context) + { + var selfBg = GetBg(props); + if (selfBg != null) return selfBg; + + var currentId = element.ParentId; + var depth = 0; + while (currentId != null && depth < 20) + { + if (context.ElementLookup.TryGetValue(currentId, out var parent)) + { + var parentBg = GetBg(parent.Properties); + if (parentBg != null) return parentBg; + currentId = parent.Element.ParentId; + } + else break; + depth++; + } + + return null; + } + + private static string? GetBg(Dictionary props) + { + if (props.TryGetValue("BackgroundColor", out var bgColor) && !string.IsNullOrWhiteSpace(bgColor) && AccessibilityAuditService.ParseColor(bgColor) != null) + return bgColor; + if (props.TryGetValue("Background", out var bg) && !string.IsNullOrWhiteSpace(bg) && AccessibilityAuditService.ParseColor(bg) != null) + return bg; + return null; + } +} diff --git a/src/MauiSherpa.Core/Services/CopilotToolsService.cs b/src/MauiSherpa.Core/Services/CopilotToolsService.cs index 6b1ae67..82710a3 100644 --- a/src/MauiSherpa.Core/Services/CopilotToolsService.cs +++ b/src/MauiSherpa.Core/Services/CopilotToolsService.cs @@ -20,7 +20,8 @@ public class CopilotToolsService : ICopilotToolsService private readonly IProfilingArtifactAnalysisService _profilingArtifactAnalysisService; private readonly IProfilingContextService _profilingContextService; private readonly ILoggingService _logger; - + private readonly IDevFlowConnectionProvider _devFlow; + private readonly List _tools = new(); private readonly HashSet _readOnlyToolNames = new(); @@ -33,7 +34,8 @@ public CopilotToolsService( IProfilingArtifactLibraryService profilingArtifactLibraryService, IProfilingArtifactAnalysisService profilingArtifactAnalysisService, IProfilingContextService profilingContextService, - ILoggingService logger) + ILoggingService logger, + IDevFlowConnectionProvider devFlow) { _appleService = appleService; _identityState = identityState; @@ -44,7 +46,8 @@ public CopilotToolsService( _profilingArtifactAnalysisService = profilingArtifactAnalysisService; _profilingContextService = profilingContextService; _logger = logger; - + _devFlow = devFlow; + InitializeTools(); } @@ -152,9 +155,9 @@ private void InitializeTools() "List connected Android devices and running emulators."), isReadOnly: true); // Android System Images & Device Definitions - AddTool(AIFunctionFactory.Create(ListSystemImagesAsync, "list_system_images", + AddTool(AIFunctionFactory.Create(ListSystemImagesAsync, "list_system_images", "List available Android system images for creating emulators."), isReadOnly: true); - AddTool(AIFunctionFactory.Create(ListDeviceDefinitionsAsync, "list_device_definitions", + AddTool(AIFunctionFactory.Create(ListDeviceDefinitionsAsync, "list_device_definitions", "List available device definitions for creating emulators."), isReadOnly: true); // Profiling Tools @@ -168,6 +171,14 @@ private void InitializeTools() "Get a lightweight profiling snapshot for a running MAUI app using local status, network, and visual-tree summaries instead of raw trace uploads."), isReadOnly: true); AddTool(AIFunctionFactory.Create(AnalyzeProfilingArtifactAsync, "analyze_profiling_artifact", "Analyze a captured profiling artifact from Sherpa's artifact library and return a portable summary with hotspots, metrics, and insights."), isReadOnly: true); + + // DevFlow App Inspector Tools + AddTool(AIFunctionFactory.Create(GetConnectedAppInfoAsync, "get_connected_app_info", + "Get information about the currently connected MAUI app (via DevFlow agent). Returns app name, host, and connection status."), isReadOnly: true); + AddTool(AIFunctionFactory.Create(GetAppScreenshotAsync, "get_app_screenshot", + "Capture a screenshot of the currently connected MAUI app. Returns a base64-encoded PNG image (data URI) so you can visually analyze the UI."), isReadOnly: true); + AddTool(AIFunctionFactory.Create(AuditAppAccessibilityAsync, "audit_app_accessibility", + "Run a full accessibility audit on the currently connected MAUI app. Returns structured data: element tree summary, WCAG rule violations (missing labels, contrast issues, touch targets, etc.), and a list of all accessible elements with their properties. Use this to identify WCAG 2.1 compliance issues and suggest fixes."), isReadOnly: true); } private string? CheckIdentitySelected() @@ -1679,4 +1690,135 @@ private async Task AnalyzeProfilingArtifactAsync( } #endregion + + #region DevFlow App Inspector Tools + + private DevFlowAgentClient? GetActiveClient() + { + if (!_devFlow.IsConnected || string.IsNullOrEmpty(_devFlow.Host)) + return null; + return new DevFlowAgentClient(_devFlow.Host, _devFlow.Port); + } + + [Description("Get info about the currently connected MAUI app")] + private Task GetConnectedAppInfoAsync() + { + _logger.LogDebug("Tool: get_connected_app_info called"); + if (!_devFlow.IsConnected) + return Task.FromResult("No MAUI app is currently connected. Open DevFlow Inspector and connect to an app first."); + + return Task.FromResult(JsonSerializer.Serialize(new + { + connected = true, + appName = _devFlow.AppName, + host = _devFlow.Host, + port = _devFlow.Port, + }, new JsonSerializerOptions { WriteIndented = true })); + } + + [Description("Capture a screenshot of the connected MAUI app")] + private async Task GetAppScreenshotAsync() + { + _logger.LogDebug("Tool: get_app_screenshot called"); + var client = GetActiveClient(); + if (client == null) + return "No MAUI app is currently connected."; + + try + { + var bytes = await client.GetScreenshotAsync(); + if (bytes == null || bytes.Length == 0) + return "Failed to capture screenshot."; + return $"data:image/png;base64,{Convert.ToBase64String(bytes)}"; + } + catch (Exception ex) + { + return $"Screenshot failed: {ex.Message}"; + } + } + + [Description("Run a full WCAG accessibility audit on the connected MAUI app")] + private async Task AuditAppAccessibilityAsync() + { + _logger.LogDebug("Tool: audit_app_accessibility called"); + var client = GetActiveClient(); + if (client == null) + return "No MAUI app is currently connected."; + + try + { + // Fetch visual tree + var tree = await client.GetTreeAsync(); + + // Run rule-based audit + var auditService = new AccessibilityAuditService(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var result = await auditService.AuditAsync(tree, client, cts.Token); + + // Build compact element summary for Claude (avoid huge payloads) + var elements = result.ScreenReaderOrder.Select(e => new + { + order = e.Order, + type = e.ElementType, + role = e.Role, + label = e.AnnouncedText, + hint = e.Hint, + isInteractive = e.IsInteractive, + isHeading = e.HeadingLevel != null, + hasIssue = e.HasIssue, + bounds = e.WindowBounds != null + ? $"{e.WindowBounds.Width:F0}x{e.WindowBounds.Height:F0}" + : null, + }).ToList(); + + // Issues summary + var issues = result.Issues.Select(i => new + { + ruleId = i.RuleId, + rule = i.RuleName, + severity = i.Severity.ToString(), + element = i.ElementType, + text = i.ElementText, + automationId = i.AutomationId, + message = i.Message, + suggestion = i.Suggestion, + }).ToList(); + + // Contrast results + var contrast = result.ContrastResults.Select(c => new + { + element = c.ElementType, + text = c.ElementText, + ratio = Math.Round(c.ContrastRatio, 2), + fg = c.ForegroundColor, + bg = c.BackgroundColor, + passesAA = c.PassesAA, + passesAAA = c.PassesAAA, + }).ToList(); + + return JsonSerializer.Serialize(new + { + app = _devFlow.AppName, + score = result.Score, + totalElements = result.TotalElements, + accessibleElements = elements.Count, + issueCount = new + { + errors = result.ErrorCount, + warnings = result.WarningCount, + info = result.InfoCount, + }, + issues, + elements, + contrast, + note = "The 'elements' list shows what a screen reader would announce. The 'issues' list shows rule-based WCAG violations. Use your knowledge of WCAG 2.1 to identify additional issues beyond these rules.", + }, new JsonSerializerOptions { WriteIndented = true }); + } + catch (Exception ex) + { + return $"Audit failed: {ex.Message}"; + } + } + + #endregion } diff --git a/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs b/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs index 18a174e..2470b43 100644 --- a/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs +++ b/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs @@ -155,6 +155,49 @@ public async Task FillAsync(string elementId, string text, CancellationTok public async Task FocusAsync(string elementId, CancellationToken ct = default) => await PostActionAsync("/api/action/focus", new { elementId }, ct); + // --- Accessibility --- + + public async Task GetAccessibilityTreeAsync(int? window = null, CancellationToken ct = default) + { + var url = window != null ? $"/api/accessibility?window={window}" : "/api/accessibility"; + return await GetAsync(url, ct); + } + + /// + /// Returns the native accessibility tree in the exact order the platform screen reader visits elements. + /// Requires MauiDevFlow agent v3+ (the /api/a11y/native-tree endpoint). + /// + public async Task GetNativeA11yTreeAsync(int? window = null, CancellationToken ct = default) + { + var url = window != null ? $"/api/a11y/native-tree?window={window}" : "/api/a11y/native-tree"; + return await GetAsync(url, ct); + } + + /// + /// Highlights the element on the running device/emulator by drawing a native overlay border. + /// Pass null elementId to clear the current highlight. + /// + public async Task HighlightAsync( + string? elementId, + string? color = null, + int durationMs = 3000, + DevFlowBoundsInfo? fallbackBounds = null, + bool scrollIntoView = true, + CancellationToken ct = default) + { + await PostActionAsync("/api/action/highlight", new + { + elementId, + color, + durationMs, + scrollIntoView, + x = fallbackBounds?.X, + y = fallbackBounds?.Y, + width = fallbackBounds?.Width, + height = fallbackBounds?.Height, + }, ct); + } + // --- Hit Test --- public async Task HitTestAsync(double x, double y, int? window = null, CancellationToken ct = default) diff --git a/src/MauiSherpa.MacOS/MacOSMauiProgram.cs b/src/MauiSherpa.MacOS/MacOSMauiProgram.cs index 42424c9..7df47ce 100644 --- a/src/MauiSherpa.MacOS/MacOSMauiProgram.cs +++ b/src/MauiSherpa.MacOS/MacOSMauiProgram.cs @@ -105,6 +105,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/MauiSherpa/MauiProgram.cs b/src/MauiSherpa/MauiProgram.cs index 2908864..053fe14 100644 --- a/src/MauiSherpa/MauiProgram.cs +++ b/src/MauiSherpa/MauiProgram.cs @@ -125,6 +125,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/MauiSherpa/Pages/Inspector/DevFlowAccessibilityTab.razor b/src/MauiSherpa/Pages/Inspector/DevFlowAccessibilityTab.razor new file mode 100644 index 0000000..5636fe7 --- /dev/null +++ b/src/MauiSherpa/Pages/Inspector/DevFlowAccessibilityTab.razor @@ -0,0 +1,1479 @@ +@using MauiSherpa.Core.Models.DevFlow +@using MauiSherpa.Core.Services +@inject IJSRuntime JS +@implements IDisposable + +
+
+ +
+
+ + + +
+
+ +
+
+ + + @if (auditResult != null && !isAuditing) + { +
+
+ @auditResult.Score + / 100 +
+
+ @foreach (var cat in auditResult.ScoreCategories) + { +
+ + @cat.Name + @cat.Passed/@cat.Total +
+ } +
+
+ @auditResult.ErrorCount + @auditResult.WarningCount + @auditResult.InfoCount + @auditResult.TotalElements elements + @if (previousScore != null) + { + var delta = auditResult.Score - previousScore.Value; + @if (delta != 0) + { + + @Math.Abs(delta) + + } + } +
+
+ } + +
+ @if (isAuditing && auditResult == null) + { +
+ + Scanning visual tree for accessibility issues... + @if (auditProgress != null) + { + @auditProgress + } +
+ } + else if (auditResult == null) + { +
+ + Click Audit to scan the running app for accessibility issues. + Checks for missing labels, small touch targets, color contrast, keyboard accessibility, and more. +
+ } + else if (activeView == "audit") + { + +
+
+
+ + + +
+
+ @if (FilteredIssues.Any()) + { + @foreach (var group in FilteredIssues.GroupBy(i => i.RuleId).OrderBy(g => g.First().Severity).ThenBy(g => g.Key)) + { + var first = group.First(); + var isExpanded = expandedRules.Contains(first.RuleId); +
+
+ @(isExpanded ? "\u25BC" : "\u25B6") + + + + @first.RuleName + @group.Count() +
+ @if (isExpanded) + { +
+ @foreach (var issue in group) + { + var isSelected = selectedIssue?.ElementId == issue.ElementId && selectedIssue?.RuleId == issue.RuleId; +
+
+ @issue.ElementType + @if (!string.IsNullOrWhiteSpace(issue.ElementText)) + { + "@Truncate(issue.ElementText, 30)" + } + @if (!string.IsNullOrWhiteSpace(issue.AutomationId)) + { + #@issue.AutomationId + } +
+
@issue.Message
+
+ } +
+ } +
+ } + } + else if (auditResult!.Issues.Count == 0) + { +
+ + No accessibility issues found! +
+ } + else + { +
No issues match the current filter.
+ } +
+
+ + @if (selectedIssue != null) + { +
+
+ + + + @selectedIssue.RuleName + @selectedIssue.RuleId + +
+
+ Element + @selectedIssue.ElementType @(!string.IsNullOrWhiteSpace(selectedIssue.AutomationId) ? $"#{selectedIssue.AutomationId}" : "") +
+
@selectedIssue.Message
+
+ + @selectedIssue.Suggestion +
+ @if (!string.IsNullOrWhiteSpace(selectedIssue.XamlFix)) + { +
+
+ XAML Fix + +
+
@selectedIssue.XamlFix
+
+ } +
+ } +
+ } + else if (activeView == "screenreader") + { + + @if (auditResult!.ScreenReaderOrder.Count == 0) + { +
No accessible elements found in the visual tree.
+ } + else + { + var srEntries = auditResult.ScreenReaderOrder; + var current = srEntries[_srCurrentIndex]; +
+ +
+ + @(_srCurrentIndex + 1) / @srEntries.Count + +
+ + +
+
+ @if (!string.IsNullOrEmpty(current.AnnouncedText)) + { + "@current.AnnouncedText" + } + else + { + (no label) + } +
+ @if (!string.IsNullOrEmpty(current.Role)) + { + @current.Role + } + @if (!string.IsNullOrEmpty(current.Hint)) + { +
@current.Hint
+ } + @if (current.HeadingLevel != null) + { + @current.HeadingLevel + } + @if (current.IsInteractive) + { + Interactive + } + @if (current.HasIssue) + { + Has Issue + } +
+ + +
+
+ Type + @current.ElementType +
+ @if (current.WindowBounds != null) + { +
+ Bounds + @($"{current.WindowBounds.X:F0}, {current.WindowBounds.Y:F0} - {current.WindowBounds.Width:F0}x{current.WindowBounds.Height:F0}") +
+ } +
+ + +
+ @foreach (var entry in srEntries) + { + var isActive = entry.Order == _srCurrentIndex; +
+ @(entry.Order + 1) + @(!string.IsNullOrEmpty(entry.AnnouncedText) ? entry.AnnouncedText : "(no label)") + @if (!string.IsNullOrEmpty(entry.Role)) + { + @entry.Role + } +
+ } +
+
+ } + } + else if (activeView == "contrast") + { + +
+
+ + +
+ @if (FilteredContrastResults.Any()) + { +
+ @FilteredContrastResults.Count(c => c.PassesAA) AA Pass + @FilteredContrastResults.Count(c => !c.PassesAA) AA Fail + @FilteredContrastResults.Count(c => c.PassesAAA) AAA Pass +
+ + @foreach (var cr in FilteredContrastResults.OrderBy(c => c.ContrastRatio)) + { + var isSelected = selectedContrastId == cr.ElementId; +
+
+ + on + +
+
+ @(cr.ContrastRatio):1 + + AA @(cr.PassesAA ? "\u2713" : "\u2717") + AAA @(cr.PassesAAA ? "\u2713" : "\u2717") + @if (cr.IsLargeText) { Large } + +
+
+ @cr.ElementType + @if (!string.IsNullOrWhiteSpace(cr.ElementText)) + { + "@Truncate(cr.ElementText, 20)" + } +
+
+ } + } + else if (auditResult!.ContrastResults.Any()) + { +
No contrast results match the current filter.
+ } + else + { +
No text elements with both TextColor and Background detected. Contrast checks require explicit color values.
+ } +
+ } +
+
+
+ +@code { + [Parameter] public DevFlowAgentClient Client { get; set; } = null!; + + private readonly AccessibilityAuditService _auditService = new(); + private AccessibilityAuditResult? auditResult; + private AccessibilityIssue? selectedIssue; + private ScreenReaderEntry? selectedSrEntry; + private string? selectedContrastId; + private bool isAuditing; + private string? auditProgress; + private string? screenshotData; + private string severityFilter = ""; + private string searchQuery = ""; + private string activeView = "audit"; + private HashSet expandedRules = new(); + private DevFlowBoundsInfo? highlightBounds; + private bool copiedFix; + private int? previousScore; + private bool showHiddenElements; + private CancellationTokenSource? _auditCts; + + // Screenshot pixel dimensions for overlay calculation + private double screenshotPixelWidth; + private double screenshotPixelHeight; + + // Screen reader navigator state + private int _srCurrentIndex; + private ElementReference _srNavigatorRef; + // Window logical dimensions (from tree root) for proper overlay positioning + private double _windowLogicalWidth; + private double _windowLogicalHeight; + + private static bool IsElementVisible(DevFlowBoundsInfo? bounds) => + bounds is { Width: > 0, Height: > 0 }; + + private IEnumerable FilteredIssues + { + get + { + if (auditResult == null) return Enumerable.Empty(); + var issues = auditResult.Issues.AsEnumerable(); + if (!showHiddenElements) + issues = issues.Where(i => IsElementVisible(i.WindowBounds)); + if (!string.IsNullOrEmpty(severityFilter) && Enum.TryParse(severityFilter, out var sev)) + issues = issues.Where(i => i.Severity == sev); + if (!string.IsNullOrWhiteSpace(searchQuery)) + issues = issues.Where(i => + i.ElementType.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) + || (i.ElementText?.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) == true) + || (i.AutomationId?.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) == true) + || i.Message.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) + || i.RuleName.Contains(searchQuery, StringComparison.OrdinalIgnoreCase)); + return issues; + } + } + + private IEnumerable FilteredContrastResults + { + get + { + if (auditResult == null) return Enumerable.Empty(); + var results = auditResult.ContrastResults.AsEnumerable(); + if (!showHiddenElements) + results = results.Where(c => IsElementVisible(c.WindowBounds)); + return results; + } + } + + protected override async Task OnInitializedAsync() + { + await RefreshScreenshot(); + } + + private Task RunAudit() => RunAuditInternal(silent: false); + + private async Task RunAuditInternal(bool silent) + { + if (isAuditing) return; + isAuditing = true; + previousScore = auditResult?.Score; + + if (!silent) + { + auditResult = null; + selectedIssue = null; + selectedSrEntry = null; + selectedContrastId = null; + highlightBounds = null; + copiedFix = false; + auditProgress = "Fetching visual tree..."; + StateHasChanged(); + } + + _auditCts?.Cancel(); + _auditCts = new CancellationTokenSource(); + var ct = _auditCts.Token; + + try + { + var screenshotTask = RefreshScreenshot(); + + var tree = await Client.GetTreeAsync(ct: ct); + + if (!silent) + { + auditProgress = "Analyzing elements..."; + StateHasChanged(); + } + + // Compute window logical dimensions from tree root for overlay positioning + var rootBounds = tree.FirstOrDefault()?.WindowBounds; + if (rootBounds != null && rootBounds.Width > 0 && rootBounds.Height > 0) + { + _windowLogicalWidth = rootBounds.Width; + _windowLogicalHeight = rootBounds.Height; + } + + var newResult = await _auditService.AuditAsync(tree, Client, ct); + _srCurrentIndex = 0; + + auditResult = newResult; + expandedRules = new HashSet(auditResult.Issues.Select(i => i.RuleId).Distinct()); + + await screenshotTask; + } + catch (Exception ex) + { + if (!silent) + { + auditResult = new AccessibilityAuditResult + { + Issues = new List + { + new() + { + Severity = AccessibilitySeverity.Error, + RuleId = "ERR", + RuleName = "Audit Error", + Message = $"Failed to run audit: {ex.Message}", + Suggestion = "Check that the DevFlow agent is running and accessible.", + ElementType = "System", + } + } + }; + } + } + finally + { + isAuditing = false; + auditProgress = null; + StateHasChanged(); + } + } + + // --- Screenshot --- + + private async Task RefreshScreenshot() + { + try + { + var bytes = await Client.GetScreenshotAsync(); + if (bytes != null) + { + screenshotData = Convert.ToBase64String(bytes); + if (bytes.Length > 24) + { + screenshotPixelWidth = (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19]; + screenshotPixelHeight = (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23]; + } + } + } + catch { } + StateHasChanged(); + } + + // --- Selection --- + + private async Task SelectIssue(AccessibilityIssue issue) + { + selectedIssue = issue; + highlightBounds = issue.WindowBounds; + copiedFix = false; + if (IsElementVisible(issue.WindowBounds)) + await HighlightElementOnDevice(issue.ElementId, issue.WindowBounds); + } + + private async Task SelectSrEntry(ScreenReaderEntry entry) + { + selectedSrEntry = selectedSrEntry?.ElementId == entry.ElementId ? null : entry; + highlightBounds = selectedSrEntry?.WindowBounds; + await HighlightElementOnDevice(selectedSrEntry?.ElementId, selectedSrEntry?.WindowBounds); + } + + private async Task SelectContrast(ContrastCheckResult cr) + { + selectedContrastId = selectedContrastId == cr.ElementId ? null : cr.ElementId; + highlightBounds = selectedContrastId != null ? cr.WindowBounds : null; + if (selectedContrastId == null || IsElementVisible(cr.WindowBounds)) + await HighlightElementOnDevice(selectedContrastId != null ? cr.ElementId : null, selectedContrastId != null ? cr.WindowBounds : null); + } + + private async Task HighlightElementOnDevice(string? elementId, DevFlowBoundsInfo? fallbackBounds = null) + { + try + { + await Client.HighlightAsync(elementId, color: null, durationMs: 3000, + fallbackBounds: fallbackBounds, ct: default); + } + catch { /* ignore — highlight is best-effort */ } + } + + private void ToggleRule(string ruleId) + { + if (!expandedRules.Remove(ruleId)) + expandedRules.Add(ruleId); + } + + // --- Copy Fix --- + + private async Task CopyFix(string xamlFix) + { + try + { + await JS.InvokeVoidAsync("navigator.clipboard.writeText", xamlFix); + copiedFix = true; + StateHasChanged(); + _ = Task.Delay(2000).ContinueWith(_ => InvokeAsync(() => { copiedFix = false; StateHasChanged(); })); + } + catch { } + } + + // --- Overlay --- + + private string GetOverlayStyle(DevFlowBoundsInfo bounds) + { + if (screenshotPixelWidth <= 0 || screenshotPixelHeight <= 0) + return "display:none"; + + var scale = screenshotPixelWidth / Math.Max(1, bounds.X + bounds.Width + 100); + var left = (bounds.X / screenshotPixelWidth * scale) * 100; + var top = (bounds.Y / screenshotPixelHeight * scale) * 100; + var width = (bounds.Width / screenshotPixelWidth * scale) * 100; + var height = (bounds.Height / screenshotPixelHeight * scale) * 100; + + left = Math.Max(0, Math.Min(100, left)); + top = Math.Max(0, Math.Min(100, top)); + width = Math.Max(0.5, Math.Min(100 - left, width)); + height = Math.Max(0.5, Math.Min(100 - top, height)); + + return $"left:{left:F2}%;top:{top:F2}%;width:{width:F2}%;height:{height:F2}%"; + } + + private string GetBadgeStyle(DevFlowBoundsInfo bounds) + { + if (screenshotPixelWidth <= 0 || screenshotPixelHeight <= 0) + return "display:none"; + + var scale = screenshotPixelWidth / Math.Max(1, bounds.X + bounds.Width + 100); + var left = (bounds.X / screenshotPixelWidth * scale) * 100; + var top = (bounds.Y / screenshotPixelHeight * scale) * 100; + + return $"left:{left:F2}%;top:{top:F2}%"; + } + + // --- Helpers --- + + private static string GetScoreClass(int score) => score switch + { + >= 90 => "score-excellent", + >= 70 => "score-good", + >= 50 => "score-fair", + _ => "score-poor" + }; + + private static string GetSeverityIcon(AccessibilitySeverity severity) => severity switch + { + AccessibilitySeverity.Error => "fa-circle-xmark", + AccessibilitySeverity.Warning => "fa-triangle-exclamation", + AccessibilitySeverity.Info => "fa-circle-info", + _ => "fa-circle-question" + }; + + private static string Truncate(string? text, int maxLength) + { + if (string.IsNullOrEmpty(text)) return ""; + return text.Length <= maxLength ? text : text[..maxLength] + "\u2026"; + } + + // --- Screen Reader Navigator --- + + private List SrEntries => auditResult?.ScreenReaderOrder ?? new(); + + private async Task SrNext() + { + if (_srCurrentIndex < SrEntries.Count - 1) + { + _srCurrentIndex++; + await UpdateSrHighlight(); + } + } + + private async Task SrPrevious() + { + if (_srCurrentIndex > 0) + { + _srCurrentIndex--; + await UpdateSrHighlight(); + } + } + + private async Task SrGoTo(int index) + { + if (index >= 0 && index < SrEntries.Count) + { + _srCurrentIndex = index; + await UpdateSrHighlight(); + } + } + + private async Task HandleSrKeyDown(KeyboardEventArgs e) + { + switch (e.Key) + { + case "ArrowRight": await SrNext(); break; + case "ArrowLeft": await SrPrevious(); break; + case "Home": await SrGoTo(0); break; + case "End": await SrGoTo(SrEntries.Count - 1); break; + } + } + + private async Task UpdateSrHighlight() + { + if (_srCurrentIndex >= 0 && _srCurrentIndex < SrEntries.Count) + { + var entry = SrEntries[_srCurrentIndex]; + highlightBounds = entry.WindowBounds; + StateHasChanged(); + try { await JS.InvokeVoidAsync("eval", "requestAnimationFrame(()=>document.querySelector('.sr-list-item.active')?.scrollIntoView({block:'nearest',behavior:'instant'}))"); } catch { } + await HighlightElementOnDevice(entry.ElementId, entry.WindowBounds); + } + else + { + highlightBounds = null; + await HighlightElementOnDevice(null); + } + } + + /// + /// Positions overlay using window logical dimensions (from tree root). + /// WindowBounds are in logical points; this converts to percentages correctly. + /// + private string GetLogicalOverlayStyle(DevFlowBoundsInfo bounds) + { + if (_windowLogicalWidth <= 0 || _windowLogicalHeight <= 0) + return "display:none"; + + var left = (bounds.X / _windowLogicalWidth) * 100; + var top = (bounds.Y / _windowLogicalHeight) * 100; + var width = (bounds.Width / _windowLogicalWidth) * 100; + var height = (bounds.Height / _windowLogicalHeight) * 100; + + left = Math.Max(0, Math.Min(100, left)); + top = Math.Max(0, Math.Min(100, top)); + width = Math.Max(0.5, Math.Min(100 - left, width)); + height = Math.Max(0.5, Math.Min(100 - top, height)); + + return $"left:{left:F2}%;top:{top:F2}%;width:{width:F2}%;height:{height:F2}%"; + } + + private string GetLogicalBadgeStyle(DevFlowBoundsInfo bounds) + { + if (_windowLogicalWidth <= 0 || _windowLogicalHeight <= 0) + return "display:none"; + + var left = (bounds.X / _windowLogicalWidth) * 100; + var top = (bounds.Y / _windowLogicalHeight) * 100; + + return $"left:{left:F2}%;top:{top:F2}%"; + } + + public void Dispose() + { + _auditCts?.Cancel(); + _auditCts?.Dispose(); + } +} + + diff --git a/src/MauiSherpa/Pages/Inspector/DevFlowInspector.razor b/src/MauiSherpa/Pages/Inspector/DevFlowInspector.razor index 4768a0e..e3081bc 100644 --- a/src/MauiSherpa/Pages/Inspector/DevFlowInspector.razor +++ b/src/MauiSherpa/Pages/Inspector/DevFlowInspector.razor @@ -41,6 +41,9 @@ + @@ -77,6 +80,9 @@ case "platform": break; + case "accessibility": + + break; } } @@ -99,7 +105,7 @@ private string activeTab = "tree"; private static readonly HashSet ValidTabs = new(StringComparer.OrdinalIgnoreCase) { - "tree", "network", "profiling", "webview", "logs", "platform" + "tree", "network", "profiling", "webview", "logs", "platform", "accessibility" }; private bool isConnected; private bool isReconnecting; diff --git a/src/MauiSherpa/Services/DevFlowConnectionProvider.cs b/src/MauiSherpa/Services/DevFlowConnectionProvider.cs new file mode 100644 index 0000000..2828b76 --- /dev/null +++ b/src/MauiSherpa/Services/DevFlowConnectionProvider.cs @@ -0,0 +1,16 @@ +using MauiSherpa.Core.Interfaces; + +namespace MauiSherpa.Services; + +public class DevFlowConnectionProvider : IDevFlowConnectionProvider +{ + private readonly DevFlowInspectorService _inspector; + + public DevFlowConnectionProvider(DevFlowInspectorService inspector) + => _inspector = inspector; + + public bool IsConnected => _inspector.IsOpen && !string.IsNullOrEmpty(_inspector.ActiveHost); + public string? Host => _inspector.ActiveHost; + public int Port => _inspector.ActivePort; + public string? AppName => _inspector.ActiveAppName; +} diff --git a/src/MauiSherpa/Services/DevFlowInspectorService.cs b/src/MauiSherpa/Services/DevFlowInspectorService.cs index 2634711..a01811f 100644 --- a/src/MauiSherpa/Services/DevFlowInspectorService.cs +++ b/src/MauiSherpa/Services/DevFlowInspectorService.cs @@ -1,6 +1,6 @@ namespace MauiSherpa.Services; -public enum DevFlowInspectorTab { Tree, Network, Profiling, WebView, Logs } +public enum DevFlowInspectorTab { Tree, Network, Profiling, WebView, Logs, Accessibility } public class DevFlowInspectorService { From 55af3571b31b25a85c65059797bf456639cdacd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pobuta?= <52126292+michalpobuta@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:06:43 +0100 Subject: [PATCH 2/2] fix: update CopilotToolsServiceTests to pass IDevFlowConnectionProvider mock CopilotToolsService constructor was extended with a required IDevFlowConnectionProvider parameter in this PR, but the test was not updated, causing CS7036 build failure. Co-Authored-By: Claude Sonnet 4.6 --- .../MauiSherpa.Core.Tests/Services/CopilotToolsServiceTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/MauiSherpa.Core.Tests/Services/CopilotToolsServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/CopilotToolsServiceTests.cs index ca834aa..669210f 100644 --- a/tests/MauiSherpa.Core.Tests/Services/CopilotToolsServiceTests.cs +++ b/tests/MauiSherpa.Core.Tests/Services/CopilotToolsServiceTests.cs @@ -19,7 +19,8 @@ public void Constructor_RegistersProfilingToolsAsReadOnly() new Mock().Object, new Mock().Object, new Mock().Object, - new Mock().Object); + new Mock().Object, + new Mock().Object); sut.GetTool("get_profiling_catalog").Should().NotBeNull(); sut.GetTool("list_profiling_targets").Should().NotBeNull();