Skip to content

Commit 1266799

Browse files
committed
Add accessibility rules, collectors, and tests
Introduce a full accessibility audit subsystem: add AccessibilityRulesPlugin and register it in PluginHost; expand AccessibilityAuditContext with ComputedStyles, DuplicateIds and DocumentLanguage; add ComputedStyleInfo type. Implement collectors (ComputedStyleCollector, DuplicateIdCollector, DocumentLanguageCollector) and ContrastCalculator for WCAG luminance/contrast logic. Add a set of accessibility rules (color contrast, duplicate id, empty button/link, heading hierarchy, missing lang/landmark, unlabeled form control) and wire pre-fetching into Page.RunAccessibilityAuditAsync. Update CdpJsonContext with DOM/CSS CDP types used by collectors. Remove previous builtin rule registration from BrowserContext. Add comprehensive unit & integration tests for rules and contrast calculations.
1 parent 11bb1b1 commit 1266799

30 files changed

Lines changed: 1806 additions & 14 deletions

src/Motus.Abstractions/Types/AccessibilityAuditContext.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ namespace Motus.Abstractions;
55
/// </summary>
66
/// <param name="AllNodes">All walkable nodes in the accessibility tree, in depth-first order.</param>
77
/// <param name="Page">The page being audited, for computed-style queries.</param>
8+
/// <param name="ComputedStyles">Pre-fetched computed styles keyed by BackendDOMNodeId.</param>
9+
/// <param name="DuplicateIds">Set of HTML id values that appear more than once in the document.</param>
10+
/// <param name="DocumentLanguage">The lang attribute value of the document element, or null if absent.</param>
811
public sealed record AccessibilityAuditContext(
912
IReadOnlyList<AccessibilityNode> AllNodes,
10-
IPage Page);
13+
IPage Page,
14+
IReadOnlyDictionary<long, ComputedStyleInfo>? ComputedStyles = null,
15+
IReadOnlySet<string>? DuplicateIds = null,
16+
string? DocumentLanguage = null);
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace Motus.Abstractions;
2+
3+
/// <summary>
4+
/// Pre-fetched computed style properties for an element, used by accessibility rules
5+
/// that need CSS information (e.g., color contrast checks).
6+
/// </summary>
7+
public sealed record ComputedStyleInfo(
8+
string? Color,
9+
string? BackgroundColor,
10+
string? FontSize,
11+
string? FontWeight);
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using Motus.Abstractions;
2+
3+
namespace Motus;
4+
5+
/// <summary>
6+
/// Built-in plugin that registers all WCAG 2.1 Level A and AA accessibility rules.
7+
/// Registered as a built-in in PluginHost (not via [MotusPlugin]) because
8+
/// internal types are not visible to user assemblies through the source generator.
9+
/// </summary>
10+
internal sealed class AccessibilityRulesPlugin : IPlugin
11+
{
12+
public string PluginId => "motus.accessibility-rules";
13+
public string Name => "Accessibility Rules";
14+
public string Version => "1.0.0";
15+
public string? Author => "Motus";
16+
public string? Description => "Built-in WCAG 2.1 Level A and AA accessibility rules.";
17+
18+
public Task OnLoadedAsync(IPluginContext context)
19+
{
20+
context.RegisterAccessibilityRule(new AltTextAccessibilityRule());
21+
context.RegisterAccessibilityRule(new UnlabeledFormControlRule());
22+
context.RegisterAccessibilityRule(new MissingLandmarkRule());
23+
context.RegisterAccessibilityRule(new HeadingHierarchyRule());
24+
context.RegisterAccessibilityRule(new EmptyButtonRule());
25+
context.RegisterAccessibilityRule(new EmptyLinkRule());
26+
context.RegisterAccessibilityRule(new ColorContrastRule());
27+
context.RegisterAccessibilityRule(new DuplicateIdRule());
28+
context.RegisterAccessibilityRule(new MissingDocumentLanguageRule());
29+
return Task.CompletedTask;
30+
}
31+
32+
public Task OnUnloadedAsync() => Task.CompletedTask;
33+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
using Motus.Abstractions;
2+
3+
namespace Motus;
4+
5+
/// <summary>
6+
/// Pre-fetches computed CSS styles for accessibility nodes that need contrast checking.
7+
/// Uses CSS.getComputedStyleForNode via CDP to resolve color and background-color.
8+
/// </summary>
9+
internal static class ComputedStyleCollector
10+
{
11+
private static readonly HashSet<string> TextBearingRoles = new(StringComparer.OrdinalIgnoreCase)
12+
{
13+
"heading", "text", "paragraph", "label", "link", "button",
14+
"menuitem", "menuitemcheckbox", "menuitemradio", "tab",
15+
"treeitem", "option", "cell", "columnheader", "rowheader",
16+
"gridcell", "listitem", "caption"
17+
};
18+
19+
internal static async Task<Dictionary<long, ComputedStyleInfo>> CollectAsync(
20+
IMotusSession session,
21+
IReadOnlyList<AccessibilityNode> nodes,
22+
CancellationToken ct)
23+
{
24+
var result = new Dictionary<long, ComputedStyleInfo>();
25+
26+
if ((session.Capabilities & MotusCapabilities.AccessibilityTree) == 0)
27+
return result;
28+
29+
// Gather text-bearing nodes with BackendDOMNodeIds
30+
var candidates = new List<(long backendNodeId, AccessibilityNode node)>();
31+
foreach (var node in nodes)
32+
{
33+
if (node.BackendDOMNodeId.HasValue &&
34+
!string.IsNullOrWhiteSpace(node.Name) &&
35+
node.Role is not null &&
36+
TextBearingRoles.Contains(node.Role))
37+
{
38+
candidates.Add((node.BackendDOMNodeId.Value, node));
39+
}
40+
}
41+
42+
if (candidates.Count == 0)
43+
return result;
44+
45+
try
46+
{
47+
// Enable CSS domain
48+
await session.SendAsync(
49+
"CSS.enable",
50+
CdpJsonContext.Default.CssEnableResult,
51+
ct).ConfigureAwait(false);
52+
53+
// Push backend node IDs to get DOM node IDs
54+
var backendIds = candidates.Select(c => (int)c.backendNodeId).ToArray();
55+
var pushResult = await session.SendAsync(
56+
"DOM.pushNodesByBackendIds",
57+
new DomPushNodesByBackendIdsParams(backendIds),
58+
CdpJsonContext.Default.DomPushNodesByBackendIdsParams,
59+
CdpJsonContext.Default.DomPushNodesByBackendIdsResult,
60+
ct).ConfigureAwait(false);
61+
62+
for (int i = 0; i < candidates.Count && i < pushResult.NodeIds.Length; i++)
63+
{
64+
var domNodeId = pushResult.NodeIds[i];
65+
if (domNodeId <= 0)
66+
continue;
67+
68+
try
69+
{
70+
var styleResult = await session.SendAsync(
71+
"CSS.getComputedStyleForNode",
72+
new CssGetComputedStyleForNodeParams(domNodeId),
73+
CdpJsonContext.Default.CssGetComputedStyleForNodeParams,
74+
CdpJsonContext.Default.CssGetComputedStyleForNodeResult,
75+
ct).ConfigureAwait(false);
76+
77+
string? color = null, bgColor = null, fontSize = null, fontWeight = null;
78+
foreach (var prop in styleResult.ComputedStyle)
79+
{
80+
switch (prop.Name)
81+
{
82+
case "color": color = prop.Value; break;
83+
case "background-color": bgColor = prop.Value; break;
84+
case "font-size": fontSize = prop.Value; break;
85+
case "font-weight": fontWeight = prop.Value; break;
86+
}
87+
}
88+
89+
result[candidates[i].backendNodeId] = new ComputedStyleInfo(color, bgColor, fontSize, fontWeight);
90+
}
91+
catch
92+
{
93+
// Skip nodes whose styles can't be resolved
94+
}
95+
}
96+
}
97+
catch
98+
{
99+
// CSS domain not available or DOM push failed; return partial results
100+
}
101+
102+
return result;
103+
}
104+
105+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
using System.Globalization;
2+
3+
namespace Motus;
4+
5+
/// <summary>
6+
/// Implements WCAG 2.1 relative luminance and contrast ratio calculations.
7+
/// </summary>
8+
internal static class ContrastCalculator
9+
{
10+
/// <summary>
11+
/// WCAG AA minimum contrast ratio for normal text.
12+
/// </summary>
13+
internal const double NormalTextThreshold = 4.5;
14+
15+
/// <summary>
16+
/// WCAG AA minimum contrast ratio for large text (>= 18pt or bold >= 14pt).
17+
/// </summary>
18+
internal const double LargeTextThreshold = 3.0;
19+
20+
/// <summary>
21+
/// Computes the WCAG contrast ratio between two colors.
22+
/// Returns a value >= 1.0 where 1.0 means no contrast and 21.0 is maximum.
23+
/// </summary>
24+
internal static double ContrastRatio(double luminance1, double luminance2)
25+
{
26+
var lighter = Math.Max(luminance1, luminance2);
27+
var darker = Math.Min(luminance1, luminance2);
28+
return (lighter + 0.05) / (darker + 0.05);
29+
}
30+
31+
/// <summary>
32+
/// Computes relative luminance per WCAG 2.1 definition.
33+
/// Input is an sRGB color with components in [0, 255].
34+
/// </summary>
35+
internal static double RelativeLuminance(int r, int g, int b)
36+
{
37+
var rLin = Linearize(r / 255.0);
38+
var gLin = Linearize(g / 255.0);
39+
var bLin = Linearize(b / 255.0);
40+
return 0.2126 * rLin + 0.7152 * gLin + 0.0722 * bLin;
41+
}
42+
43+
/// <summary>
44+
/// Converts an sRGB component (0..1) to linear light.
45+
/// </summary>
46+
internal static double Linearize(double c) =>
47+
c <= 0.04045 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4);
48+
49+
/// <summary>
50+
/// Determines whether text is "large" per WCAG criteria.
51+
/// Large text is >= 18pt (24px) or bold >= 14pt (18.66px).
52+
/// </summary>
53+
internal static bool IsLargeText(string? fontSize, string? fontWeight)
54+
{
55+
var px = ParsePx(fontSize);
56+
if (px is null)
57+
return false;
58+
59+
var isBold = IsBold(fontWeight);
60+
61+
// 18pt = 24px, 14pt = 18.66px
62+
return px.Value >= 24.0 || (isBold && px.Value >= 18.66);
63+
}
64+
65+
/// <summary>
66+
/// Parses a CSS color string (rgb/rgba format from computed styles) into RGB components.
67+
/// Returns false if the format is not recognized.
68+
/// </summary>
69+
internal static bool TryParseColor(string? color, out int r, out int g, out int b)
70+
{
71+
r = g = b = 0;
72+
if (string.IsNullOrWhiteSpace(color))
73+
return false;
74+
75+
var span = color.AsSpan().Trim();
76+
77+
// Handle "rgb(r, g, b)" and "rgba(r, g, b, a)"
78+
if (span.StartsWith("rgb"))
79+
{
80+
var open = span.IndexOf('(');
81+
var close = span.IndexOf(')');
82+
if (open < 0 || close < 0 || close <= open + 1)
83+
return false;
84+
85+
var inner = span.Slice(open + 1, close - open - 1);
86+
return ParseRgbComponents(inner, out r, out g, out b);
87+
}
88+
89+
// Handle "#RRGGBB" and "#RGB"
90+
if (span.Length > 0 && span[0] == '#')
91+
{
92+
return TryParseHexColor(span.Slice(1), out r, out g, out b);
93+
}
94+
95+
return false;
96+
}
97+
98+
private static bool ParseRgbComponents(ReadOnlySpan<char> inner, out int r, out int g, out int b)
99+
{
100+
r = g = b = 0;
101+
102+
// Split by comma or space (modern CSS allows both)
103+
Span<Range> parts = stackalloc Range[5];
104+
int count;
105+
106+
if (inner.Contains(','))
107+
{
108+
count = inner.Split(parts, ',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
109+
}
110+
else
111+
{
112+
count = inner.Split(parts, ' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
113+
}
114+
115+
if (count < 3)
116+
return false;
117+
118+
return int.TryParse(inner[parts[0]], NumberStyles.Integer, CultureInfo.InvariantCulture, out r) &&
119+
int.TryParse(inner[parts[1]], NumberStyles.Integer, CultureInfo.InvariantCulture, out g) &&
120+
int.TryParse(inner[parts[2]], NumberStyles.Integer, CultureInfo.InvariantCulture, out b);
121+
}
122+
123+
private static bool TryParseHexColor(ReadOnlySpan<char> hex, out int r, out int g, out int b)
124+
{
125+
r = g = b = 0;
126+
127+
if (hex.Length == 6 || hex.Length == 8)
128+
{
129+
return int.TryParse(hex.Slice(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out r) &&
130+
int.TryParse(hex.Slice(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out g) &&
131+
int.TryParse(hex.Slice(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out b);
132+
}
133+
134+
if (hex.Length == 3 || hex.Length == 4)
135+
{
136+
if (!int.TryParse(hex.Slice(0, 1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out r) ||
137+
!int.TryParse(hex.Slice(1, 1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out g) ||
138+
!int.TryParse(hex.Slice(2, 1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out b))
139+
return false;
140+
141+
r = r * 16 + r;
142+
g = g * 16 + g;
143+
b = b * 16 + b;
144+
return true;
145+
}
146+
147+
return false;
148+
}
149+
150+
private static double? ParsePx(string? fontSize)
151+
{
152+
if (string.IsNullOrWhiteSpace(fontSize))
153+
return null;
154+
155+
var span = fontSize.AsSpan().Trim();
156+
if (span.EndsWith("px", StringComparison.OrdinalIgnoreCase))
157+
span = span.Slice(0, span.Length - 2);
158+
159+
return double.TryParse(span, NumberStyles.Float, CultureInfo.InvariantCulture, out var val)
160+
? val
161+
: null;
162+
}
163+
164+
private static bool IsBold(string? fontWeight)
165+
{
166+
if (string.IsNullOrWhiteSpace(fontWeight))
167+
return false;
168+
169+
if (string.Equals(fontWeight, "bold", StringComparison.OrdinalIgnoreCase) ||
170+
string.Equals(fontWeight, "bolder", StringComparison.OrdinalIgnoreCase))
171+
return true;
172+
173+
return int.TryParse(fontWeight, NumberStyles.Integer, CultureInfo.InvariantCulture, out var weight) &&
174+
weight >= 700;
175+
}
176+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace Motus;
2+
3+
/// <summary>
4+
/// Pre-fetches the document's lang attribute via Runtime.evaluate.
5+
/// </summary>
6+
internal static class DocumentLanguageCollector
7+
{
8+
internal static async Task<string?> CollectAsync(IMotusSession session, CancellationToken ct)
9+
{
10+
try
11+
{
12+
var result = await session.SendAsync(
13+
"Runtime.evaluate",
14+
new RuntimeEvaluateParams("document.documentElement.lang || ''", ReturnByValue: true),
15+
CdpJsonContext.Default.RuntimeEvaluateParams,
16+
CdpJsonContext.Default.RuntimeEvaluateResult,
17+
ct).ConfigureAwait(false);
18+
19+
var lang = result.Result.Value?.ToString();
20+
return string.IsNullOrWhiteSpace(lang) ? null : lang;
21+
}
22+
catch
23+
{
24+
return null;
25+
}
26+
}
27+
}

0 commit comments

Comments
 (0)