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.
110110Dark mode reports 18 findings and light mode 21; the three extra are the section
111111headers above. Everything the app actually controls passes in both schemes.
112112
113## Findings are reported, not enforced
114
115The audit surfaces violations that exist today, so failing on all of them would
116block every unrelated change until the whole pass lands. Instead, findings are
117logged and attached to the result bundle tagged `[report]` or `[FAIL]`.
118
119Enforcement is the committed constant
120`AccessibilityAuditHarness.enforcedAuditTypes`. Widen it as each phase clears a
121category:
122
123| After phase | Enforce |
124| --- | --- |
125| 2 — semantic colors + light mode | `.contrast` |
126| 3 — Dynamic Type + reflow | `.textClipped`, `.dynamicType`, `.hitRegion` |
127| 4 — VoiceOver | `.elementDetection`, `.sufficientElementDescription`, `.trait` |
128
129A constant rather than a CI setting, for two reasons. Environment variables do
130not work: neither a plain `xcodebuild` env var nor a `TEST_RUNNER_`-prefixed
131build setting reaches the UI test process, so the toggle silently did nothing.
132And a committed value makes "when did contrast become enforced?" answerable with
133`git blame` instead of CI tribal knowledge.
113## Enforcement — the ratchet is engaged
114
115With phases 1–5 landed, `AccessibilityAuditHarness.enforcedAuditTypes` enforces
116**`.textClipped`, `.dynamicType`, `.hitRegion`, `.elementDetection`,
117`.sufficientElementDescription`, `.trait`** on the empty-state test suite. A
118named finding in any of these fails CI — regressions in five phases of work are
119now gated, not merely reported.
120
121Three deliberate carve-outs, each with its evidence:
122
1231. **`.contrast` stays report-only.** The two long-standing Settings findings
124 are rows scrolled under the translucent tab bar; their attribution flips
125 between a row name and nil run-to-run, so no suppression is narrow enough to
126 keep CI stable. The centralised palette in `Shared/Colors.xcassets` is the
127 actual guard against contrast regressions.
1282. **The seeded tests run `reportOnly`.** Bisecting the row/badge accessibility
129 modifiers showed the audit degrades on `children: .ignore` content — the
130 *correct* VoiceOver treatment for dense rows — emitting unattributed
131 contrast/dynamicType failures on rows that measure 6–7:1 and render
132 correctly. Their burndown still prints; it just doesn't gate.
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.
134145
135146## Why coverage is split between local and CI
136147
DomainDigUITests/AccessibilityAuditHarness.swift +80 −25
@@ -28,17 +28,23 @@ enum AccessibilityAuditHarness {
2828 /// reachable. `PurchaseService` honours this in `DEBUG` builds only.
2929 private static let forceProPlusArgument = "DOMAIN_DIG_FORCE_PRO_PLUS"
3030
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.
3233 ///
33 /// Empty until the accessibility pass starts landing. Suggested ratchet,
34 /// following the phases in issue #21:
35 ///
36 /// - after phase 2 (semantic colors + light mode): `.contrast`
37 /// - after phase 3 (Dynamic Type + reflow): `.textClipped`, `.dynamicType`,
38 /// `.hitRegion`
39 /// - after phase 4 (VoiceOver): `.elementDetection`,
40 /// `.sufficientElementDescription`, `.trait`
41 static let enforcedAuditTypes: XCUIAccessibilityAuditType = []
34 /// `.contrast` is deliberately absent: the two long-standing Settings
35 /// findings come from rows scrolled under the translucent tab bar, and their
36 /// attribution flips between a row name and nil run-to-run, so there is no
37 /// suppression narrow enough to keep CI stable. Contrast stays report-only,
38 /// with the palette centralised in `Shared/Colors.xcassets` as the actual
39 /// guard.
40 static let enforcedAuditTypes: XCUIAccessibilityAuditType = [
41 .textClipped,
42 .dynamicType,
43 .hitRegion,
44 .elementDetection,
45 .sufficientElementDescription,
46 .trait
47 ]
4248
4349 /// How many times to retry an audit that misses its internal deadline.
4450 private static let auditAttempts = 3
@@ -72,11 +78,18 @@ enum AccessibilityAuditHarness {
7278 /// Returns `false` if the audit could not complete, leaving the screen
7379 /// unaudited. Callers turn that into an `XCTSkip` — reporting a pass would
7480 /// 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.
7587 @discardableResult
7688 static func audit(
7789 _ app: XCUIApplication,
7890 screen: String,
79 test: XCTestCase
91 test: XCTestCase,
92 reportOnly: Bool = false
8093 ) throws -> Bool {
8194 var findings: [String] = []
8295 var timeout: Error?
@@ -94,26 +107,22 @@ enum AccessibilityAuditHarness {
94107 timeout = nil
95108 do {
96109 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"
111110 // Include the element so the burndown says *what* to fix, not
112111 // just that something is wrong.
113112 let element = issue.element.map { el -> String in
114113 let label = el.label.isEmpty ? el.identifier : el.label
115114 return label.isEmpty ? "\(el.elementType)" : "\"\(label)\""
116115 } ?? "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"
117126 findings.append("[\(marker)][\(name(for: issue.auditType))] \(issue.compactDescription) — \(element)")
118127 // true suppresses the finding, false reports it as a test failure.
119128 return !isEnforced
@@ -149,6 +158,52 @@ enum AccessibilityAuditHarness {
149158 return true
150159 }
151160
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
152207 /// `XCUIAccessibilityAuditType` is an option set whose description is just a
153208 /// raw bitmask, which makes the burndown list unreadable. Resolve it against
154209 /// 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 {
8989 func testSeededDashboard() throws {
9090 let app = AccessibilityAuditHarness.launch(seeded: true)
9191 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)
9393 try XCTSkipUnless(audited, "Audit did not complete in time for seeded Dashboard")
9494 }
9595
@@ -100,7 +100,7 @@ final class AccessibilityAuditTests: XCTestCase {
100100 let trackedDomains = app.buttons["Tracked Domains"]
101101 XCTAssertTrue(trackedDomains.waitForExistence(timeout: 5))
102102 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)
104104 try XCTSkipUnless(audited, "Audit did not complete in time for seeded Tracked Domains")
105105 }
106106
@@ -108,7 +108,7 @@ final class AccessibilityAuditTests: XCTestCase {
108108 func testSeededBatchResults() throws {
109109 let app = AccessibilityAuditHarness.launch(seeded: true)
110110 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)
112112 try XCTSkipUnless(audited, "Audit did not complete in time for seeded batch results")
113113 }
114114
@@ -123,12 +123,12 @@ final class AccessibilityAuditTests: XCTestCase {
123123 var unaudited: [String] = []
124124
125125 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) {
127127 unaudited.append("Dashboard")
128128 }
129129
130130 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) {
132132 unaudited.append("Inspect batch")
133133 }
134134
@@ -136,7 +136,7 @@ final class AccessibilityAuditTests: XCTestCase {
136136 let trackedDomains = app.buttons["Tracked Domains"]
137137 if trackedDomains.waitForExistence(timeout: 5) {
138138 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) {
140140 unaudited.append("Tracked Domains")
141141 }
142142 }