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 | 138 | ## Notes |
| 139 | 139 | |
| 140 | | - **Disabled controls are a false positive.** WCAG 1.4.3 exempts inactive |
| 141 | | components from contrast requirements, but the audit flags them anyway. The |
| 142 | | Inspect screen's Run button is disabled until a domain is typed, and auditing |
| 143 | | the empty state reported a contrast failure that was never a real defect — |
| 144 | | which is why `testInspectScreen` types a domain before auditing. Watch for |
| 145 | | this before "fixing" a contrast finding on a disabled control. |
| 140 | - **Disabled controls are a false positive, and are suppressed.** WCAG 1.4.3 |
| 141 | exempts inactive components from contrast requirements, but the audit flags |
| 142 | them anyway — Inspect's Run button is disabled until a domain is typed, and |
| 143 | auditing the empty state reported a contrast failure that was never a real |
| 144 | defect. The harness now drops contrast findings whose element reports |
| 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 | 153 | - Audits retry up to three times. Slower machines can miss the audit's internal |
| 147 | 154 | deadline (`Audit failed to complete in time`, code `-56`), which is a tooling |
| 148 | 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 | 85 | timeout = nil |
| 86 | 86 | do { |
| 87 | 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 | 100 | let isEnforced = !enforcedAuditTypes.intersection(issue.auditType).isEmpty |
| 89 | 101 | let marker = isEnforced ? "FAIL" : "report" |
| 90 | 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 | 17 | // MARK: Per-screen audits |
| 18 | 18 | |
| 19 | 19 | func testInspectScreen() throws { |
| 20 | | let app = AccessibilityAuditHarness.launch() |
| 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") |
| 20 | try auditRootTab("Inspect") |
| 34 | 21 | } |
| 35 | 22 | |
| 36 | 23 | func testDashboardScreen() throws { |