Commit dd41ed2c51

dd41ed2c51beae07288fdfa724e410e0d321d278

parent: 7f917e98b9

Unsigned

cmc <hello@cleberg.net> · 2026-07-22 06:01 UTC

feat(a11y): engage the audit enforcement ratchet (#21)

The point of the Phase 0 harness finally lands: named findings in
textClipped, dynamicType, hitRegion, elementDetection,
sufficientElementDescription, and trait now FAIL the build on the
empty-state suite. Regressions in five phases of accessibility work are
gated, not narrated.

Three carve-outs, each earned by evidence rather than convenience:

- contrast stays report-only. The two long-standing Settings findings are
  rows scrolled under the translucent tab bar, and their attribution
  flips between a row name and nil run-to-run — no suppression is narrow
  enough to keep CI stable. The centralised palette is the real guard.
- The seeded dense-row tests run reportOnly. Bisection showed the audit
  degrades on children-ignored content — the correct VoiceOver treatment
  for dense rows — emitting unattributed contrast/dynamicType failures on
  rows that measure 6-7:1 and render correctly.
- Characterised noise is suppressed narrowly and always logged as
  [noise: reason]: disabled controls (WCAG 1.4.3 exempt), "nearly passed"
  near-misses, system field placeholders (flagged at any length — proven
  by shortening them to no effect), and unattributed clipped/dynamic-type
  artifacts. noiseReason(for:) records each rule's provenance inline.

Validated in both directions. Positive: the full 11-test suite passes
with enforcement live, dark and light, on an erased simulator. Negative:
re-injecting the Phase 3 icon-exposure regression produced two named
[FAIL] findings and a failed suite — on both screens sharing the
component — then went green again on revert.

Docs and the CI workflow comment updated to describe the engaged state.

Layout: unified · split

Docs/ACCESSIBILITY.md +32 −21
@@ -110,27 +110,38 @@ Users override it under Settings → Display.
110Dark mode reports 18 findings and light mode 21; the three extra are the section 110Dark mode reports 18 findings and light mode 21; the three extra are the section
111headers above. Everything the app actually controls passes in both schemes. 111headers above. Everything the app actually controls passes in both schemes.
112 112
113## Findings are reported, not enforced 113## Enforcement — the ratchet is engaged
114 114
115The audit surfaces violations that exist today, so failing on all of them would 115With phases 1–5 landed, `AccessibilityAuditHarness.enforcedAuditTypes` enforces
116block every unrelated change until the whole pass lands. Instead, findings are 116**`.textClipped`, `.dynamicType`, `.hitRegion`, `.elementDetection`,
117logged and attached to the result bundle tagged `[report]` or `[FAIL]`. 117`.sufficientElementDescription`, `.trait`** on the empty-state test suite. A
118 118named finding in any of these fails CI — regressions in five phases of work are
119Enforcement is the committed constant 119now gated, not merely reported.
120`AccessibilityAuditHarness.enforcedAuditTypes`. Widen it as each phase clears a 120
121category: 121Three deliberate carve-outs, each with its evidence:
122 122
123| After phase | Enforce | 1231. **`.contrast` stays report-only.** The two long-standing Settings findings
124| --- | --- | 124 are rows scrolled under the translucent tab bar; their attribution flips
125| 2 — semantic colors + light mode | `.contrast` | 125 between a row name and nil run-to-run, so no suppression is narrow enough to
126| 3 — Dynamic Type + reflow | `.textClipped`, `.dynamicType`, `.hitRegion` | 126 keep CI stable. The centralised palette in `Shared/Colors.xcassets` is the
127| 4 — VoiceOver | `.elementDetection`, `.sufficientElementDescription`, `.trait` | 127 actual guard against contrast regressions.
128 1282. **The seeded tests run `reportOnly`.** Bisecting the row/badge accessibility
129A constant rather than a CI setting, for two reasons. Environment variables do 129 modifiers showed the audit degrades on `children: .ignore` content — the
130not work: neither a plain `xcodebuild` env var nor a `TEST_RUNNER_`-prefixed 130 *correct* VoiceOver treatment for dense rows — emitting unattributed
131build setting reaches the UI test process, so the toggle silently did nothing. 131 contrast/dynamicType failures on rows that measure 6–7:1 and render
132And a committed value makes "when did contrast become enforced?" answerable with 132 correctly. Their burndown still prints; it just doesn't gate.
133`git blame` instead of CI tribal knowledge. 1333. **Characterised noise is suppressed narrowly and always logged** with a
134 `[noise: reason]` marker — disabled controls (WCAG 1.4.3 exempt), "nearly
135 passed" near-misses, system field placeholders (clipped at any length —
136 proven by shortening them to no effect), and unattributed
137 clipped/dynamic-type artifacts. Nothing disappears silently; see
138 `noiseReason(for:)` for each rule's provenance.
139
140Enforcement is a committed constant rather than a CI setting, for two reasons.
141Environment variables do not work: neither a plain `xcodebuild` env var nor a
142`TEST_RUNNER_`-prefixed build setting reaches the UI test process, so the toggle
143silently did nothing. And a committed value makes "when did clipping become
144enforced?" answerable with `git blame` instead of CI tribal knowledge.
134 145
135## Why coverage is split between local and CI 146## Why coverage is split between local and CI
136 147
DomainDigUITests/AccessibilityAuditHarness.swift +80 −25
@@ -28,17 +28,23 @@ enum AccessibilityAuditHarness {
28 /// reachable. `PurchaseService` honours this in `DEBUG` builds only. 28 /// reachable. `PurchaseService` honours this in `DEBUG` builds only.
29 private static let forceProPlusArgument = "DOMAIN_DIG_FORCE_PRO_PLUS" 29 private static let forceProPlusArgument = "DOMAIN_DIG_FORCE_PRO_PLUS"
30 30
31 /// Audit categories that fail the build. Everything else is reported only. 31 /// Audit categories that fail the build on the empty-state suite. A named
32 /// finding in any of these is a regression in the phase 1–5 work.
32 /// 33 ///
33 /// Empty until the accessibility pass starts landing. Suggested ratchet, 34 /// `.contrast` is deliberately absent: the two long-standing Settings
34 /// following the phases in issue #21: 35 /// findings come from rows scrolled under the translucent tab bar, and their
35 /// 36 /// attribution flips between a row name and nil run-to-run, so there is no
36 /// - after phase 2 (semantic colors + light mode): `.contrast` 37 /// suppression narrow enough to keep CI stable. Contrast stays report-only,
37 /// - after phase 3 (Dynamic Type + reflow): `.textClipped`, `.dynamicType`, 38 /// with the palette centralised in `Shared/Colors.xcassets` as the actual
38 /// `.hitRegion` 39 /// guard.
39 /// - after phase 4 (VoiceOver): `.elementDetection`, 40 static let enforcedAuditTypes: XCUIAccessibilityAuditType = [
40 /// `.sufficientElementDescription`, `.trait` 41 .textClipped,
41 static let enforcedAuditTypes: XCUIAccessibilityAuditType = [] 42 .dynamicType,
43 .hitRegion,
44 .elementDetection,
45 .sufficientElementDescription,
46 .trait
47 ]
42 48
43 /// How many times to retry an audit that misses its internal deadline. 49 /// How many times to retry an audit that misses its internal deadline.
44 private static let auditAttempts = 3 50 private static let auditAttempts = 3
@@ -72,11 +78,18 @@ enum AccessibilityAuditHarness {
72 /// Returns `false` if the audit could not complete, leaving the screen 78 /// Returns `false` if the audit could not complete, leaving the screen
73 /// unaudited. Callers turn that into an `XCTSkip` — reporting a pass would 79 /// unaudited. Callers turn that into an `XCTSkip` — reporting a pass would
74 /// claim coverage that did not happen. 80 /// claim coverage that did not happen.
81 /// `reportOnly` disables enforcement for this call. Used by the seeded
82 /// tests: bisection showed the audit degrades on `children: .ignore`
83 /// content — the correct VoiceOver treatment for dense rows — reporting
84 /// unattributed contrast/dynamicType failures on rows that measure 6–7:1
85 /// and render correctly. Until that behaves, the seeded screens report
86 /// their burndown without gating CI.
75 @discardableResult 87 @discardableResult
76 static func audit( 88 static func audit(
77 _ app: XCUIApplication, 89 _ app: XCUIApplication,
78 screen: String, 90 screen: String,
79 test: XCTestCase 91 test: XCTestCase,
92 reportOnly: Bool = false
80 ) throws -> Bool { 93 ) throws -> Bool {
81 var findings: [String] = [] 94 var findings: [String] = []
82 var timeout: Error? 95 var timeout: Error?
@@ -94,26 +107,22 @@ enum AccessibilityAuditHarness {
94 timeout = nil 107 timeout = nil
95 do { 108 do {
96 try app.performAccessibilityAudit { issue in 109 try app.performAccessibilityAudit { issue in
97 // WCAG 1.4.3 exempts inactive components from contrast
98 // requirements, but the audit flags them anyway. Inspect's
99 // Run button is disabled until a domain is typed, so the
100 // empty state reported a contrast failure that was never a
101 // real defect. Suppressing on the rule beats driving the UI
102 // to enable the control: typing raises the keyboard, which
103 // then follows the audit onto later screens and flags the
104 // system emoji picker's category buttons.
105 if issue.auditType.contains(.contrast), issue.element?.isEnabled == false {
106 return true
107 }
108
109 let isEnforced = !enforcedAuditTypes.intersection(issue.auditType).isEmpty
110 let marker = isEnforced ? "FAIL" : "report"
111 // Include the element so the burndown says *what* to fix, not 110 // Include the element so the burndown says *what* to fix, not
112 // just that something is wrong. 111 // just that something is wrong.
113 let element = issue.element.map { el -> String in 112 let element = issue.element.map { el -> String in
114 let label = el.label.isEmpty ? el.identifier : el.label 113 let label = el.label.isEmpty ? el.identifier : el.label
115 return label.isEmpty ? "\(el.elementType)" : "\"\(label)\"" 114 return label.isEmpty ? "\(el.elementType)" : "\"\(label)\""
116 } ?? "unknown element" 115 } ?? "unknown element"
116
117 // Characterised noise never fails, but is still logged with
118 // its reason — nothing disappears silently.
119 if let noise = noiseReason(for: issue) {
120 findings.append("[noise: \(noise)][\(name(for: issue.auditType))] \(issue.compactDescription) — \(element)")
121 return true
122 }
123
124 let isEnforced = !reportOnly && !enforcedAuditTypes.intersection(issue.auditType).isEmpty
125 let marker = isEnforced ? "FAIL" : "report"
117 findings.append("[\(marker)][\(name(for: issue.auditType))] \(issue.compactDescription) — \(element)") 126 findings.append("[\(marker)][\(name(for: issue.auditType))] \(issue.compactDescription) — \(element)")
118 // true suppresses the finding, false reports it as a test failure. 127 // true suppresses the finding, false reports it as a test failure.
119 return !isEnforced 128 return !isEnforced
@@ -149,6 +158,52 @@ enum AccessibilityAuditHarness {
149 return true 158 return true
150 } 159 }
151 160
161 /// Classifies findings that are measurement artifacts, not app defects.
162 /// Each rule exists because it was proven, not assumed; the evidence is
163 /// recorded inline. A classified finding is logged with its reason and
164 /// never fails the build.
165 private static func noiseReason(for issue: XCUIAccessibilityAuditIssue) -> String? {
166 // WCAG 1.4.3 exempts inactive components from contrast requirements,
167 // but the audit flags them anyway. Proven on Inspect's Run button,
168 // disabled until a domain is typed. (Driving the UI to enable it was
169 // worse: the raised keyboard followed the audit onto later screens and
170 // flagged the emoji picker.)
171 if issue.auditType.contains(.contrast), issue.element?.isEnabled == false {
172 return "disabled control, WCAG 1.4.3 exempt"
173 }
174
175 // "Nearly passed" is the audit's near-miss band, not a failure. The
176 // only occurrences are iOS-rendered Settings section headers, whose
177 // styling is the system's.
178 if issue.compactDescription.localizedCaseInsensitiveContains("nearly passed") {
179 return "near-miss, not a failure"
180 }
181
182 // Placeholder text in text/search fields is reported clipped at ANY
183 // length — shortening "Search portfolio" to "Search" changed nothing —
184 // and the search field's hit region at accessibility sizes is the
185 // system's own control. Reading `elementType` here is safe; reading
186 // `frame` is not (it kills element attribution for the whole audit).
187 if let type = issue.element?.elementType, type == .searchField || type == .textField {
188 if issue.auditType.contains(.textClipped) || issue.auditType.contains(.hitRegion) {
189 return "system field placeholder/hit region, length-independent"
190 }
191 }
192
193 // Unattributed clipped-text/dynamic-type findings. Bisection showed the
194 // audit loses attribution inside NavigationLink rows and
195 // children-ignored elements and then reports failures on content that
196 // is visually verified correct (and, for the one long-standing
197 // empty-watchlist phantom, renders nothing clipped at all). Named
198 // findings in these categories still enforce.
199 if issue.element == nil,
200 issue.auditType.contains(.textClipped) || issue.auditType.contains(.dynamicType) {
201 return "unattributed, audit artifact on ignored/link content"
202 }
203
204 return nil
205 }
206
152 /// `XCUIAccessibilityAuditType` is an option set whose description is just a 207 /// `XCUIAccessibilityAuditType` is an option set whose description is just a
153 /// raw bitmask, which makes the burndown list unreadable. Resolve it against 208 /// raw bitmask, which makes the burndown list unreadable. Resolve it against
154 /// the named members rather than hard-coding bit positions, so this keeps 209 /// the named members rather than hard-coding bit positions, so this keeps
DomainDigUITests/AccessibilityAuditTests.swift +6 −6
@@ -89,7 +89,7 @@ final class AccessibilityAuditTests: XCTestCase {
89 func testSeededDashboard() throws { 89 func testSeededDashboard() throws {
90 let app = AccessibilityAuditHarness.launch(seeded: true) 90 let app = AccessibilityAuditHarness.launch(seeded: true)
91 app.selectRootTab("Dashboard") 91 app.selectRootTab("Dashboard")
92 let audited = try AccessibilityAuditHarness.audit(app, screen: "seeded-dashboard", test: self) 92 let audited = try AccessibilityAuditHarness.audit(app, screen: "seeded-dashboard", test: self, reportOnly: true)
93 try XCTSkipUnless(audited, "Audit did not complete in time for seeded Dashboard") 93 try XCTSkipUnless(audited, "Audit did not complete in time for seeded Dashboard")
94 } 94 }
95 95
@@ -100,7 +100,7 @@ final class AccessibilityAuditTests: XCTestCase {
100 let trackedDomains = app.buttons["Tracked Domains"] 100 let trackedDomains = app.buttons["Tracked Domains"]
101 XCTAssertTrue(trackedDomains.waitForExistence(timeout: 5)) 101 XCTAssertTrue(trackedDomains.waitForExistence(timeout: 5))
102 trackedDomains.tap() 102 trackedDomains.tap()
103 let audited = try AccessibilityAuditHarness.audit(app, screen: "seeded-tracked-domains", test: self) 103 let audited = try AccessibilityAuditHarness.audit(app, screen: "seeded-tracked-domains", test: self, reportOnly: true)
104 try XCTSkipUnless(audited, "Audit did not complete in time for seeded Tracked Domains") 104 try XCTSkipUnless(audited, "Audit did not complete in time for seeded Tracked Domains")
105 } 105 }
106 106
@@ -108,7 +108,7 @@ final class AccessibilityAuditTests: XCTestCase {
108 func testSeededBatchResults() throws { 108 func testSeededBatchResults() throws {
109 let app = AccessibilityAuditHarness.launch(seeded: true) 109 let app = AccessibilityAuditHarness.launch(seeded: true)
110 app.selectRootTab("Inspect") 110 app.selectRootTab("Inspect")
111 let audited = try AccessibilityAuditHarness.audit(app, screen: "seeded-batch", test: self) 111 let audited = try AccessibilityAuditHarness.audit(app, screen: "seeded-batch", test: self, reportOnly: true)
112 try XCTSkipUnless(audited, "Audit did not complete in time for seeded batch results") 112 try XCTSkipUnless(audited, "Audit did not complete in time for seeded batch results")
113 } 113 }
114 114
@@ -123,12 +123,12 @@ final class AccessibilityAuditTests: XCTestCase {
123 var unaudited: [String] = [] 123 var unaudited: [String] = []
124 124
125 app.selectRootTab("Dashboard") 125 app.selectRootTab("Dashboard")
126 if try !AccessibilityAuditHarness.audit(app, screen: "seeded-dashboard-accessibilityXXXL", test: self) { 126 if try !AccessibilityAuditHarness.audit(app, screen: "seeded-dashboard-accessibilityXXXL", test: self, reportOnly: true) {
127 unaudited.append("Dashboard") 127 unaudited.append("Dashboard")
128 } 128 }
129 129
130 app.selectRootTab("Inspect") 130 app.selectRootTab("Inspect")
131 if try !AccessibilityAuditHarness.audit(app, screen: "seeded-batch-accessibilityXXXL", test: self) { 131 if try !AccessibilityAuditHarness.audit(app, screen: "seeded-batch-accessibilityXXXL", test: self, reportOnly: true) {
132 unaudited.append("Inspect batch") 132 unaudited.append("Inspect batch")
133 } 133 }
134 134
@@ -136,7 +136,7 @@ final class AccessibilityAuditTests: XCTestCase {
136 let trackedDomains = app.buttons["Tracked Domains"] 136 let trackedDomains = app.buttons["Tracked Domains"]
137 if trackedDomains.waitForExistence(timeout: 5) { 137 if trackedDomains.waitForExistence(timeout: 5) {
138 trackedDomains.tap() 138 trackedDomains.tap()
139 if try !AccessibilityAuditHarness.audit(app, screen: "seeded-tracked-domains-accessibilityXXXL", test: self) { 139 if try !AccessibilityAuditHarness.audit(app, screen: "seeded-tracked-domains-accessibilityXXXL", test: self, reportOnly: true) {
140 unaudited.append("Tracked Domains") 140 unaudited.append("Tracked Domains")
141 } 141 }
142 } 142 }