Commit 349bec0e99
349bec0e99f330fc1172ca9847c58f019e861f61
parent: eb8a0d30da
Unsigned
cmc <hello@cleberg.net> · 2026-07-20 23:29 UTC
fix(a11y): suppress disabled-control contrast findings on the rule
WCAG 1.4.3 exempts inactive components from contrast requirements, so
Inspect's Run button — disabled until a domain is typed — was reporting a
contrast failure that was never a real defect.
The first attempt typed a domain to enable the button. That worked for
the single-screen test but raised the keyboard, which then followed the
audit onto every later screen in the Dynamic Type sweep and reported nine
phantom hit-region findings per screen against the system emoji picker's
category buttons. Suppressing on the rule instead — drop contrast
findings whose element reports isEnabled == false — fixes it everywhere
with no UI manipulation.
Also documents that simulator keyboard state persists across runs, so a
dirty simulator inflates the burndown with system-UI findings. Erase and
re-run before believing anything that names system UI.
Layout: unified · split
Docs/ACCESSIBILITY.md
+13 −6
| @@ -137,12 +137,19 @@ pre-commit that blocks every commit. A hook routinely bypassed with |
| 137 | |
137 | |
| 138 | ## Notes |
138 | ## Notes |
| 139 | |
139 | |
| 140 | - **Disabled controls are a false positive.** WCAG 1.4.3 exempts inactive |
140 | - **Disabled controls are a false positive, and are suppressed.** WCAG 1.4.3 |
| 141 | components from contrast requirements, but the audit flags them anyway. The |
141 | exempts inactive components from contrast requirements, but the audit flags |
| 142 | Inspect screen's Run button is disabled until a domain is typed, and auditing |
142 | them anyway — Inspect's Run button is disabled until a domain is typed, and |
| 143 | the empty state reported a contrast failure that was never a real defect — |
143 | auditing the empty state reported a contrast failure that was never a real |
| 144 | which is why `testInspectScreen` types a domain before auditing. Watch for |
144 | defect. The harness now drops contrast findings whose element reports |
| 145 | this before "fixing" a contrast finding on a disabled control. |
145 | `isEnabled == false`. Suppressing on the rule beats driving the UI to enable |
| |
146 | the control: typing raises the keyboard, which then follows the audit onto |
| |
147 | later screens and flags the system emoji picker's category buttons. |
| |
148 | - **A dirty simulator inflates the burndown.** Keyboard state persists across |
| |
149 | runs, so a simulator left with the emoji picker open reports ~9 phantom |
| |
150 | hit-region findings per screen. If findings appear that name system UI |
| |
151 | ("Flags category", "Frequently Used category"), erase the simulator |
| |
152 | (`xcrun simctl erase <udid>`) and re-run before believing them. |
| 146 | - Audits retry up to three times. Slower machines can miss the audit's internal |
153 | - Audits retry up to three times. Slower machines can miss the audit's internal |
| 147 | deadline (`Audit failed to complete in time`, code `-56`), which is a tooling |
154 | deadline (`Audit failed to complete in time`, code `-56`), which is a tooling |
| 148 | timeout, not an app defect. A screen that still cannot be audited is reported |
155 | timeout, not an app defect. A screen that still cannot be audited is reported |
DomainDigUITests/AccessibilityAuditHarness.swift
+12
| @@ -85,6 +85,18 @@ enum AccessibilityAuditHarness { |
| 85 | timeout = nil |
85 | timeout = nil |
| 86 | do { |
86 | do { |
| 87 | try app.performAccessibilityAudit { issue in |
87 | try app.performAccessibilityAudit { issue in |
| |
88 | // WCAG 1.4.3 exempts inactive components from contrast |
| |
89 | // requirements, but the audit flags them anyway. Inspect's |
| |
90 | // Run button is disabled until a domain is typed, so the |
| |
91 | // empty state reported a contrast failure that was never a |
| |
92 | // real defect. Suppressing on the rule beats driving the UI |
| |
93 | // to enable the control: typing raises the keyboard, which |
| |
94 | // then follows the audit onto later screens and flags the |
| |
95 | // system emoji picker's category buttons. |
| |
96 | if issue.auditType.contains(.contrast), issue.element?.isEnabled == false { |
| |
97 | return true |
| |
98 | } |
| |
99 | |
| 88 | let isEnforced = !enforcedAuditTypes.intersection(issue.auditType).isEmpty |
100 | let isEnforced = !enforcedAuditTypes.intersection(issue.auditType).isEmpty |
| 89 | let marker = isEnforced ? "FAIL" : "report" |
101 | let marker = isEnforced ? "FAIL" : "report" |
| 90 | // Include the element so the burndown says *what* to fix, not |
102 | // Include the element so the burndown says *what* to fix, not |
DomainDigUITests/AccessibilityAuditTests.swift
+1 −14
| @@ -17,20 +17,7 @@ final class AccessibilityAuditTests: XCTestCase { |
| 17 | // MARK: Per-screen audits |
17 | // MARK: Per-screen audits |
| 18 | |
18 | |
| 19 | func testInspectScreen() throws { |
19 | func testInspectScreen() throws { |
| 20 | let app = AccessibilityAuditHarness.launch() |
20 | try auditRootTab("Inspect") |
| 21 | app.selectRootTab("Inspect") |
| |
| 22 | |
| |
| 23 | // Type a domain so the Run button is enabled. A disabled control has no |
| |
| 24 | // contrast requirement under WCAG 1.4.3, but the audit still flags it, |
| |
| 25 | // so auditing the empty state would report a false positive forever. |
| |
| 26 | let field = app.textFields.firstMatch |
| |
| 27 | if field.waitForExistence(timeout: 5) { |
| |
| 28 | field.tap() |
| |
| 29 | field.typeText("example.com") |
| |
| 30 | } |
| |
| 31 | |
| |
| 32 | let audited = try AccessibilityAuditHarness.audit(app, screen: "inspect", test: self) |
| |
| 33 | try XCTSkipUnless(audited, "Audit did not complete in time for Inspect") |
| |
| 34 | } |
21 | } |
| 35 | |
22 | |
| 36 | func testDashboardScreen() throws { |
23 | func testDashboardScreen() throws { |