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 {