krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.3: Docs/ACCESSIBILITY.txt · raw
1# Accessibility Audit
2
3`DomainDigUITests` runs Apple's `performAccessibilityAudit()` across every
4primary screen. The audit checks contrast, hit-region size, clipped text at
5large Dynamic Type, element descriptions, trait correctness, and Dynamic Type
6support — the same ground the accessibility pass tracked in
7[issue #21](https://github.com/krazywarez/domain-dig/issues/21) covers.
8
9## The colour palette
10
11Semantic colours live in `Shared/Colors.xcassets`, which is inside the `Shared`
12file-system-synchronized group and therefore reaches the app, the widget, and
13the share extension automatically. `AccentColor` stays in
14`DomainDig/Assets.xcassets` because it is the system-wide tint resolved via
15`ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME`.
16
17Use the generated asset symbols — `Color(.statusCritical)`, `Color(.appSurface)`
18— never a literal. `ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS`
19is on, so these are compile-time checked; a typo will not build.
20
21Every value clears WCAG AA (4.5:1) as text on its page, on its card, **and on
22its own 16% badge tint** — the way `AppStatusBadgeView` actually draws it. The
23worst of those three is shown:
24
25| Role | Light | Dark | Worst light | Worst dark |
26| --- | --- | --- | --- | --- |
27| `StatusInfo` / `AccentColor` | `#0000FF` | `#4DA3FF` | 6.76 | 6.47 |
28| `StatusPositive` | `#008035` | `#30D158` | 4.54 | 7.62 |
29| `StatusWarning` | `#AD5100` | `#FF9F0A` | 4.59 | 7.76 |
30| `StatusCritical` | `#CC0700` | `#FF6961` | 4.68 | 6.12 |
31| `StatusNeutral` | `#5A5A5F` | `#A1A1A6` | 5.84 | 6.76 |
32
33Each status foreground has a matching `…Surface` colour for the fill behind it,
34paired through `AppStatusTone`.
35
36### Contrast alone is not a palette
37
38The first version of this palette maximised contrast and produced mud. Requiring
39every foreground to clear 4.5:1 against *its own 16% tint* — the harshest
40surface it ever sits on — pushed each colour ~20% darker than the common case
41needed. `#7A5600` is not amber, it is olive; `#146C2E` is not green so much as
42bottle-dark. Contrast passed and the UI was still hard to read, because hue
43identity is what tells "warning" from "critical" at a glance.
44
45Two fixes:
46
471. **Decouple the fill from the foreground.** `AppStatusTone` carries a
48 `foreground` and a `surface` that are authored independently, so the
49 foreground no longer has to survive a wash of itself. Every status foreground
50 is now fully saturated (`S = 1.0`).
512. **Warning is orange, not yellow.** Yellow cannot stay yellow at a lightness
52 low enough to clear 4.5:1 on white — it *becomes* olive. That is
53 colorimetric, not a tuning problem. Orange holds its identity when darkened,
54 so warning is `#AD5100` in light and `#FF9F0A` in dark.
55
56When adding a colour, search for the most saturated value that passes, not the
57darkest. The darkest is always easy and always wrong.
58| `AppTextSecondary` | `#5A5A5F` | `#A1A1A6` | 6.15 | 7.50 |
59
60`AppTextSecondary` replaces `.secondary` for body text. iOS's own `secondaryLabel`
61is only **3.29:1** on a light card — below AA — which never showed while the app
62was locked to dark, where the same colour reads 6.32:1. Unlocking light mode
63exposed it across 191 sites.
64
65High Contrast variants push further in the same direction. Surfaces
66(`AppBackground`, `AppSurface`, `AppSurfaceElevated`, `AppSeparator`) carry no
67meaning, so they get Any/Dark and, where useful, High Contrast — but no status
68semantics.
69
70Why custom values instead of the system palette: **every** system colour fails
71in light mode. Measured on white — systemYellow 1.51:1, systemOrange 2.20:1,
72systemGreen 2.22:1, systemCyan 2.54:1, systemRed 3.55:1. All of them pass in
73dark mode, which is why the dark-locked app looked fine and why unlocking light
74mode is impossible without this work.
75
76### The accent has two roles, and they conflict
77
78An accent used as **text on a dark background** must be light. The same accent
79used as a **fill behind a white label** must be dark. One value cannot do both:
80`#4DA3FF` reads at 8.00:1 as text on black, but only 2.63:1 behind white text.
81
82So there are two colours:
83
84- `StatusInfo` / `AccentColor` — the accent as *foreground*: text, icons,
85 bordered-button labels, tab bar.
86- `AccentFill` — the accent as a *filled background* behind a label, used by
87 `.borderedProminent`. Stays dark in both schemes so a white label clears AA
88 (8.59:1 light, 7.56:1 dark).
89
90`AppOnAccent` is the label colour for a solid accent fill and flips by scheme —
91white on the light accent, black on the dark one.
92
93## Appearance
94
95`AppAppearance` (System / Light / Dark) is stored in `@AppStorage` and applied in
96**exactly one place** — the `WindowGroup` in `DomainDigApp`. Keep it that way. The
97app previously carried 16 separate `.preferredColorScheme(.dark)` calls scattered
98through view bodies, which is how light mode became unreachable without anyone
99noticing; re-applying per view is what let the lock spread.
100
101Users override it under Settings → Display.
102
103### Known light-mode findings
104
105| Finding | Cause | Action |
106| --- | --- | --- |
107| 2× `contrast failed` on Settings | The last rows of a section sit under the translucent tab bar, so the audit measures text against a blended background. Present in dark mode too, since phase 0. | None — standard iOS scroll-under behaviour |
108| 3× `contrast nearly passed` on Settings | iOS-rendered `Section` headers (`TIER`, `PREFERENCES`, `SERVICES`) use the system's grey. | Not fixed. Overriding system header styling across every section to gain ~0.3:1 on decorative labels trades platform convention for very little |
109
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.
112
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 the test run — regressions in five phases of
119work are still gated locally and at the pre-push hook, 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.
145
146## Why the floor runtime needs its own run
147
148**Audit coverage is not nested across OS versions.** Each runtime reports
149findings the others miss, in *both* directions. Measured on this project:
150
151| Screen | iOS 18.6 | iOS 27.0 |
152| --- | --- | --- |
153| Tracked Domains | 2 (text clipped) | **6** (+ contrast ×3, element detection) |
154| Settings | 2 contrast | **`dynamicType`** finding 18.6 missed |
155| Dashboard @ `AccessibilityXXXL` | **hit region** + 2 clipped | 1 clipped only |
156
157Neither runtime is a superset, so the oldest supported OS needs its own run.
158This also rules out committing per-screen baseline counts as a regression guard:
159no single number is correct on both.
160
161The audit therefore runs locally, across both ends, via
162`Scripts/audit-a11y.sh` — on a machine that actually has an 18.x runtime
163installed. It is wired to the pre-push hook in `.githooks/`.
164
165There is no CI job. The GitHub Actions workflow that used to run this suite on
166the newest runtime has been removed, and with it the one check that no local run
167can reproduce: **a clean checkout of the merge result.** That mattered here
168because `DomainDig.xcodeproj` is hand-edited and uses file-system-synchronized
169groups, where a whole missing folder still builds fine locally and breaks only
170for someone else. A local hook runs against the working tree, so it cannot catch
171a file that was never committed. Nothing covers that now — verify a fresh clone
172by hand before a release.
173
174## Running it
175
176```sh
177./Scripts/audit-a11y.sh # floor + current
178./Scripts/audit-a11y.sh floor # oldest supported only (~85s)
179./Scripts/audit-a11y.sh current # newest installed only
180```
181
182The script reads the deployment target from the project rather than hard-coding
183it, and selects the oldest installed runtime **at or above** it — a runtime
184below the deployment target is useless, because the app cannot install there.
185If the nearest installed runtime is a major version above the target, it says
186so rather than implying floor coverage it does not have.
187
188### Pre-push hook
189
190```sh
191git config core.hooksPath .githooks
192```
193
194Runs the floor audit before a push, and only when Swift, asset, or project files
195changed. Bypass with `git push --no-verify`.
196
197Pre-push rather than pre-commit deliberately: the suite takes ~85s, and at
198pre-commit that blocks every commit. A hook routinely bypassed with
199`--no-verify` is worse than no hook, because it trains you to ignore it.
200
201## Layout gotchas found the hard way
202
203- **`Label` clips its own title.** Every empty-state heading reported as clipped
204 text. `.fixedSize` applied to the `Label` does not reach the `Text` inside it,
205 so the fix is to split it into an `HStack { Image; Text }` and put the modifier
206 on the `Text`. Changing the font design did **not** help — that hypothesis was
207 tested and discarded.
208- **Splitting a `Label` exposes its icon to VoiceOver.** `Label` folds the image
209 into the title's accessibility element; an `HStack` does not, so the icon
210 starts announcing its raw SF Symbol name ("checklist.unchecked"). Decorative
211 icons split out of a `Label` need `.accessibilityHidden(true)`.
212- **Placeholder text is always reported as clipped.** Search prompts and
213 `TextField` placeholders are flagged regardless of length — shortening
214 "Search portfolio" to "Search" changed nothing. Treat `textClipped` findings on
215 a `searchField` or `textField` element as noise rather than shortening useful
216 prompts to chase them.
217- **`AppLayout.minimumTapTarget` is the floor for every control.** `@ScaledMetric`
218 scales *down* below the default text size as well as up, so a scaled dimension
219 needs `max(scaled, AppLayout.minimumTapTarget)` or it drops under 44pt for
220 users who prefer smaller text.
221
222## VoiceOver conventions
223
224- **Dense rows use combine-for-summary, custom-content-for-detail.**
225 `BatchResultRowView` and `WatchlistRowView` each hold 8–9 text elements.
226 Reading them inline makes a long sweep unnavigable, so each row is one element:
227 `.accessibilityElement(children: .ignore)` + domain label + status value, with
228 the rest on `.accessibilityCustomContent(...)`. `.high` importance is spoken
229 inline; everything else reaches the More Content rotor on a vertical swipe.
230 Rows with only 3–4 elements (the portfolio activity/attention/expiry rows) are
231 left to `NavigationLink`'s automatic combine — custom content is for the dense
232 case, per WWDC21-10121.
233- **The custom-content chain must live in a `ViewModifier`.** Inlined onto a row
234 body, six `.accessibilityCustomContent` calls plus the visual layout blow the
235 Swift type-checker's budget ("unable to type-check in reasonable time").
236 `BatchRowAccessibility` / `WatchlistRowAccessibility` exist for that reason.
237- **Splitting a `Label` exposes its icon; combining a header swallows its
238 trailing controls.** Two opposite traps. A decorative icon pulled out of a
239 `Label` needs `.accessibilityHidden(true)`. A header built as a `Button` must
240 *not* get `.accessibilityElement(children: .combine)` if its label contains
241 other controls (`CollapsibleSectionView`'s `trailing()` holds Track/Pin) —
242 combine would merge them into the header and make them unreachable.
243- **Label-in-name (WCAG 2.5.3).** Every `accessibilityLabel` added to a control
244 with visible text keeps that text, so Voice Control still works. Free-form
245 labels are used only where the control is genuinely icon-only.
246- **Technical strings** get `speechStyle: .technical` on `InfoRowViewData`, which
247 applies `.speechAlwaysIncludesPunctuation()` and
248 `.accessibilityTextContentType(.sourceCode)`. Set today on DNS record values
249 and cipher suites; extend it wherever the view model emits a fingerprint,
250 serial, or record string.
251
252## Color independence, motion, transparency
253
254- **Status is never colour-only.** In-app badges already pair a symbol with the
255 colour. The widget status dot is now an SF Symbol
256 (`checkmark.circle.fill` / `exclamationmark.triangle.fill` /
257 `exclamationmark.octagon.fill`) — the same vocabulary as the badges, so a
258 status reads consistently across surfaces and survives greyscale.
259- **`accessibilityDifferentiateWithoutColor`** adds redundant shape only when the
260 user asks for it, avoiding clutter otherwise: the Dashboard summary-card dot
261 becomes a per-filter symbol, the selected quick-filter chip gains a checkmark
262 and border (selection was fill-colour only), and `LabeledValueRow` prefixes a
263 warning/failure symbol.
264- **`accessibilityReduceMotion`** guards all five animation sites via
265 `withAnimation(reduceMotion ? nil : …)` / `.animation(reduceMotion ? nil : …)`:
266 `AppCopyButton`'s check cross-fade, `CollapsibleSectionView`'s expand/collapse,
267 `TimelineDiffView`'s scroll, and `WatchlistView`'s list reorder.
268- **`accessibilityReduceTransparency`** swaps the single `.thinMaterial` for an
269 opaque `AppSurfaceElevated` capsule.
270
271These cannot be verified by `simctl`, which toggles only Increase Contrast — the
272other three settings live in the simulator's Settings app. They are correct by
273construction and build-clean; their runtime behaviour is part of the Phase 6
274manual pass. `SweepActivityController` was dropped from the motion list: it is
275pure ActivityKit lifecycle with no animation to guard.
276
277### What the automated audit cannot check
278
279`performAccessibilityAudit()` validates descriptions, traits, contrast, hit
280regions, and clipping. It does **not** exercise VoiceOver speech, the More
281Content rotor, custom-content ordering, or announcements. Those are verified by
282construction and a manual VoiceOver pass (Phase 6), not by the suite. A green
283audit is necessary, not sufficient, for the row and speech work.
284
285Additionally, the dense rows (`BatchResultRowView`, `WatchlistRowView`) and the
286widget never render in the audit — the test simulator has no tracked domains or
287batch results. Their treatment is unverified by the suite for the same reason the
288Phase 3 `ViewThatFits` work was deferred: absence of findings is absence of data.
289
290## Notes
291
292- **Disabled controls are a false positive, and are suppressed.** WCAG 1.4.3
293 exempts inactive components from contrast requirements, but the audit flags
294 them anyway — Inspect's Run button is disabled until a domain is typed, and
295 auditing the empty state reported a contrast failure that was never a real
296 defect. The harness now drops contrast findings whose element reports
297 `isEnabled == false`. Suppressing on the rule beats driving the UI to enable
298 the control: typing raises the keyboard, which then follows the audit onto
299 later screens and flags the system emoji picker's category buttons.
300- **A dirty simulator inflates the burndown.** Keyboard state persists across
301 runs, so a simulator left with the emoji picker open reports ~9 phantom
302 hit-region findings per screen. If findings appear that name system UI
303 ("Flags category", "Frequently Used category"), erase the simulator
304 (`xcrun simctl erase <udid>`) and re-run before believing them.
305- Audits retry up to three times. Slower machines can miss the audit's internal
306 deadline (`Audit failed to complete in time`, code `-56`), which is a tooling
307 timeout, not an app defect. A screen that still cannot be audited is reported
308 as an `XCTSkip`, never a pass — skips are visually distinct in CI, so an
309 unaudited screen stays visible instead of being silently counted as clean.
310- The suite launches with `DOMAIN_DIG_FORCE_PRO_PLUS` so Pro-gated screens are
311 reachable. `PurchaseService` honours that argument in `DEBUG` builds only.
312- Everything used is available at the iOS 17.6 deployment floor;
313 `performAccessibilityAudit` is `ios(17.0)`.