summaryrefslogtreecommitdiff
path: root/Docs/ACCESSIBILITY.md
blob: 6e82055c17924e26bedaaf3b20443d6a25a5592b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
# Accessibility Audit

`DomainDigUITests` runs Apple's `performAccessibilityAudit()` across every
primary screen. The audit checks contrast, hit-region size, clipped text at
large Dynamic Type, element descriptions, trait correctness, and Dynamic Type
support — the same ground the accessibility pass tracked in
[issue #21](https://github.com/krazywarez/domain-dig/issues/21) covers.

## The colour palette

Semantic colours live in `Shared/Colors.xcassets`, which is inside the `Shared`
file-system-synchronized group and therefore reaches the app, the widget, and
the share extension automatically. `AccentColor` stays in
`DomainDig/Assets.xcassets` because it is the system-wide tint resolved via
`ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME`.

Use the generated asset symbols — `Color(.statusCritical)`, `Color(.appSurface)`
— never a literal. `ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS`
is on, so these are compile-time checked; a typo will not build.

Every value clears WCAG AA (4.5:1) as text on its page, on its card, **and on
its own 16% badge tint** — the way `AppStatusBadgeView` actually draws it. The
worst of those three is shown:

| Role | Light | Dark | Worst light | Worst dark |
| --- | --- | --- | --- | --- |
| `StatusInfo` / `AccentColor` | `#0000FF` | `#4DA3FF` | 6.76 | 6.47 |
| `StatusPositive` | `#008035` | `#30D158` | 4.54 | 7.62 |
| `StatusWarning` | `#AD5100` | `#FF9F0A` | 4.59 | 7.76 |
| `StatusCritical` | `#CC0700` | `#FF6961` | 4.68 | 6.12 |
| `StatusNeutral` | `#5A5A5F` | `#A1A1A6` | 5.84 | 6.76 |

Each status foreground has a matching `…Surface` colour for the fill behind it,
paired through `AppStatusTone`.

### Contrast alone is not a palette

The first version of this palette maximised contrast and produced mud. Requiring
every foreground to clear 4.5:1 against *its own 16% tint* — the harshest
surface it ever sits on — pushed each colour ~20% darker than the common case
needed. `#7A5600` is not amber, it is olive; `#146C2E` is not green so much as
bottle-dark. Contrast passed and the UI was still hard to read, because hue
identity is what tells "warning" from "critical" at a glance.

Two fixes:

1. **Decouple the fill from the foreground.** `AppStatusTone` carries a
   `foreground` and a `surface` that are authored independently, so the
   foreground no longer has to survive a wash of itself. Every status foreground
   is now fully saturated (`S = 1.0`).
2. **Warning is orange, not yellow.** Yellow cannot stay yellow at a lightness
   low enough to clear 4.5:1 on white — it *becomes* olive. That is
   colorimetric, not a tuning problem. Orange holds its identity when darkened,
   so warning is `#AD5100` in light and `#FF9F0A` in dark.

When adding a colour, search for the most saturated value that passes, not the
darkest. The darkest is always easy and always wrong.
| `AppTextSecondary` | `#5A5A5F` | `#A1A1A6` | 6.15 | 7.50 |

`AppTextSecondary` replaces `.secondary` for body text. iOS's own `secondaryLabel`
is only **3.29:1** on a light card — below AA — which never showed while the app
was locked to dark, where the same colour reads 6.32:1. Unlocking light mode
exposed it across 191 sites.

High Contrast variants push further in the same direction. Surfaces
(`AppBackground`, `AppSurface`, `AppSurfaceElevated`, `AppSeparator`) carry no
meaning, so they get Any/Dark and, where useful, High Contrast — but no status
semantics.

Why custom values instead of the system palette: **every** system colour fails
in light mode. Measured on white — systemYellow 1.51:1, systemOrange 2.20:1,
systemGreen 2.22:1, systemCyan 2.54:1, systemRed 3.55:1. All of them pass in
dark mode, which is why the dark-locked app looked fine and why unlocking light
mode is impossible without this work.

### The accent has two roles, and they conflict

An accent used as **text on a dark background** must be light. The same accent
used as a **fill behind a white label** must be dark. One value cannot do both:
`#4DA3FF` reads at 8.00:1 as text on black, but only 2.63:1 behind white text.

So there are two colours:

- `StatusInfo` / `AccentColor` — the accent as *foreground*: text, icons,
  bordered-button labels, tab bar.
- `AccentFill` — the accent as a *filled background* behind a label, used by
  `.borderedProminent`. Stays dark in both schemes so a white label clears AA
  (8.59:1 light, 7.56:1 dark).

`AppOnAccent` is the label colour for a solid accent fill and flips by scheme —
white on the light accent, black on the dark one.

## Appearance

`AppAppearance` (System / Light / Dark) is stored in `@AppStorage` and applied in
**exactly one place** — the `WindowGroup` in `DomainDigApp`. Keep it that way. The
app previously carried 16 separate `.preferredColorScheme(.dark)` calls scattered
through view bodies, which is how light mode became unreachable without anyone
noticing; re-applying per view is what let the lock spread.

Users override it under Settings → Display.

### Known light-mode findings

| Finding | Cause | Action |
| --- | --- | --- |
| 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 |
| 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 |

Dark mode reports 18 findings and light mode 21; the three extra are the section
headers above. Everything the app actually controls passes in both schemes.

## Enforcement — the ratchet is engaged

With phases 1–5 landed, `AccessibilityAuditHarness.enforcedAuditTypes` enforces
**`.textClipped`, `.dynamicType`, `.hitRegion`, `.elementDetection`,
`.sufficientElementDescription`, `.trait`** on the empty-state test suite. A
named finding in any of these fails CI — regressions in five phases of work are
now gated, not merely reported.

Three deliberate carve-outs, each with its evidence:

1. **`.contrast` stays report-only.** The two long-standing Settings findings
   are rows scrolled under the translucent tab bar; their attribution flips
   between a row name and nil run-to-run, so no suppression is narrow enough to
   keep CI stable. The centralised palette in `Shared/Colors.xcassets` is the
   actual guard against contrast regressions.
2. **The seeded tests run `reportOnly`.** Bisecting the row/badge accessibility
   modifiers showed the audit degrades on `children: .ignore` content — the
   *correct* VoiceOver treatment for dense rows — emitting unattributed
   contrast/dynamicType failures on rows that measure 6–7:1 and render
   correctly. Their burndown still prints; it just doesn't gate.
3. **Characterised noise is suppressed narrowly and always logged** with a
   `[noise: reason]` marker — disabled controls (WCAG 1.4.3 exempt), "nearly
   passed" near-misses, system field placeholders (clipped at any length —
   proven by shortening them to no effect), and unattributed
   clipped/dynamic-type artifacts. Nothing disappears silently; see
   `noiseReason(for:)` for each rule's provenance.

Enforcement is a committed constant rather than a CI setting, for two reasons.
Environment variables do not work: neither a plain `xcodebuild` env var nor a
`TEST_RUNNER_`-prefixed build setting reaches the UI test process, so the toggle
silently did nothing. And a committed value makes "when did clipping become
enforced?" answerable with `git blame` instead of CI tribal knowledge.

## Why coverage is split between local and CI

**Audit coverage is not nested across OS versions.** Each runtime reports
findings the others miss, in *both* directions. Measured on this project:

| Screen | iOS 18.6 | iOS 27.0 |
| --- | --- | --- |
| Tracked Domains | 2 (text clipped) | **6** (+ contrast ×3, element detection) |
| Settings | 2 contrast | **`dynamicType`** finding 18.6 missed |
| Dashboard @ `AccessibilityXXXL` | **hit region** + 2 clipped | 1 clipped only |

Neither runtime is a superset, so the oldest supported OS needs its own run.
This also rules out committing per-screen baseline counts as a regression guard:
no single number is correct on both.

The catch is that **GitHub's `macos-26` image ships only iOS 26.x simulator
runtimes.** It cannot test the 17.6 floor at all. A two-job CI matrix was tried
and produced two near-identical 26.x runs at double the macOS minutes.

So the work is split by what each side can uniquely do:

| | Runtime | Uniquely provides |
| --- | --- | --- |
| **CI** (`.github/workflows/build.yml`) | newest available | A clean checkout of the merge result — catches a file that was never committed, which a local run cannot. Matters here because `DomainDig.xcodeproj` is hand-edited and uses file-system-synchronized groups, where a whole missing folder still builds locally. |
| **Local** (`Scripts/audit-a11y.sh`) | oldest supported + newest | Real floor coverage, on a machine that actually has an 18.x runtime installed. |

Together they cover both ends; neither duplicates the other.

## Running it

```sh
./Scripts/audit-a11y.sh            # floor + current
./Scripts/audit-a11y.sh floor      # oldest supported only (~85s)
./Scripts/audit-a11y.sh current    # newest installed only
```

The script reads the deployment target from the project rather than hard-coding
it, and selects the oldest installed runtime **at or above** it — a runtime
below the deployment target is useless, because the app cannot install there.
If the nearest installed runtime is a major version above the target, it says
so rather than implying floor coverage it does not have.

### Pre-push hook

```sh
git config core.hooksPath .githooks
```

Runs the floor audit before a push, and only when Swift, asset, or project files
changed. Bypass with `git push --no-verify`.

Pre-push rather than pre-commit deliberately: the suite takes ~85s, and at
pre-commit that blocks every commit. A hook routinely bypassed with
`--no-verify` is worse than no hook, because it trains you to ignore it.

## Layout gotchas found the hard way

- **`Label` clips its own title.** Every empty-state heading reported as clipped
  text. `.fixedSize` applied to the `Label` does not reach the `Text` inside it,
  so the fix is to split it into an `HStack { Image; Text }` and put the modifier
  on the `Text`. Changing the font design did **not** help — that hypothesis was
  tested and discarded.
- **Splitting a `Label` exposes its icon to VoiceOver.** `Label` folds the image
  into the title's accessibility element; an `HStack` does not, so the icon
  starts announcing its raw SF Symbol name ("checklist.unchecked"). Decorative
  icons split out of a `Label` need `.accessibilityHidden(true)`.
- **Placeholder text is always reported as clipped.** Search prompts and
  `TextField` placeholders are flagged regardless of length — shortening
  "Search portfolio" to "Search" changed nothing. Treat `textClipped` findings on
  a `searchField` or `textField` element as noise rather than shortening useful
  prompts to chase them.
- **`AppLayout.minimumTapTarget` is the floor for every control.** `@ScaledMetric`
  scales *down* below the default text size as well as up, so a scaled dimension
  needs `max(scaled, AppLayout.minimumTapTarget)` or it drops under 44pt for
  users who prefer smaller text.

## VoiceOver conventions

- **Dense rows use combine-for-summary, custom-content-for-detail.**
  `BatchResultRowView` and `WatchlistRowView` each hold 8–9 text elements.
  Reading them inline makes a long sweep unnavigable, so each row is one element:
  `.accessibilityElement(children: .ignore)` + domain label + status value, with
  the rest on `.accessibilityCustomContent(...)`. `.high` importance is spoken
  inline; everything else reaches the More Content rotor on a vertical swipe.
  Rows with only 3–4 elements (the portfolio activity/attention/expiry rows) are
  left to `NavigationLink`'s automatic combine — custom content is for the dense
  case, per WWDC21-10121.
- **The custom-content chain must live in a `ViewModifier`.** Inlined onto a row
  body, six `.accessibilityCustomContent` calls plus the visual layout blow the
  Swift type-checker's budget ("unable to type-check in reasonable time").
  `BatchRowAccessibility` / `WatchlistRowAccessibility` exist for that reason.
- **Splitting a `Label` exposes its icon; combining a header swallows its
  trailing controls.** Two opposite traps. A decorative icon pulled out of a
  `Label` needs `.accessibilityHidden(true)`. A header built as a `Button` must
  *not* get `.accessibilityElement(children: .combine)` if its label contains
  other controls (`CollapsibleSectionView`'s `trailing()` holds Track/Pin) —
  combine would merge them into the header and make them unreachable.
- **Label-in-name (WCAG 2.5.3).** Every `accessibilityLabel` added to a control
  with visible text keeps that text, so Voice Control still works. Free-form
  labels are used only where the control is genuinely icon-only.
- **Technical strings** get `speechStyle: .technical` on `InfoRowViewData`, which
  applies `.speechAlwaysIncludesPunctuation()` and
  `.accessibilityTextContentType(.sourceCode)`. Set today on DNS record values
  and cipher suites; extend it wherever the view model emits a fingerprint,
  serial, or record string.

## Color independence, motion, transparency

- **Status is never colour-only.** In-app badges already pair a symbol with the
  colour. The widget status dot is now an SF Symbol
  (`checkmark.circle.fill` / `exclamationmark.triangle.fill` /
  `exclamationmark.octagon.fill`) — the same vocabulary as the badges, so a
  status reads consistently across surfaces and survives greyscale.
- **`accessibilityDifferentiateWithoutColor`** adds redundant shape only when the
  user asks for it, avoiding clutter otherwise: the Dashboard summary-card dot
  becomes a per-filter symbol, the selected quick-filter chip gains a checkmark
  and border (selection was fill-colour only), and `LabeledValueRow` prefixes a
  warning/failure symbol.
- **`accessibilityReduceMotion`** guards all five animation sites via
  `withAnimation(reduceMotion ? nil : …)` / `.animation(reduceMotion ? nil : …)`:
  `AppCopyButton`'s check cross-fade, `CollapsibleSectionView`'s expand/collapse,
  `TimelineDiffView`'s scroll, and `WatchlistView`'s list reorder.
- **`accessibilityReduceTransparency`** swaps the single `.thinMaterial` for an
  opaque `AppSurfaceElevated` capsule.

These cannot be verified by `simctl`, which toggles only Increase Contrast — the
other three settings live in the simulator's Settings app. They are correct by
construction and build-clean; their runtime behaviour is part of the Phase 6
manual pass. `SweepActivityController` was dropped from the motion list: it is
pure ActivityKit lifecycle with no animation to guard.

### What the automated audit cannot check

`performAccessibilityAudit()` validates descriptions, traits, contrast, hit
regions, and clipping. It does **not** exercise VoiceOver speech, the More
Content rotor, custom-content ordering, or announcements. Those are verified by
construction and a manual VoiceOver pass (Phase 6), not by the suite. A green
audit is necessary, not sufficient, for the row and speech work.

Additionally, the dense rows (`BatchResultRowView`, `WatchlistRowView`) and the
widget never render in the audit — the test simulator has no tracked domains or
batch results. Their treatment is unverified by the suite for the same reason the
Phase 3 `ViewThatFits` work was deferred: absence of findings is absence of data.

## Notes

- **Disabled controls are a false positive, and are suppressed.** WCAG 1.4.3
  exempts inactive components from contrast requirements, but the audit flags
  them anyway — Inspect's Run button is disabled until a domain is typed, and
  auditing the empty state reported a contrast failure that was never a real
  defect. The harness now drops contrast findings whose element reports
  `isEnabled == false`. Suppressing on the rule beats driving the UI to enable
  the control: typing raises the keyboard, which then follows the audit onto
  later screens and flags the system emoji picker's category buttons.
- **A dirty simulator inflates the burndown.** Keyboard state persists across
  runs, so a simulator left with the emoji picker open reports ~9 phantom
  hit-region findings per screen. If findings appear that name system UI
  ("Flags category", "Frequently Used category"), erase the simulator
  (`xcrun simctl erase <udid>`) and re-run before believing them.
- Audits retry up to three times. Slower machines can miss the audit's internal
  deadline (`Audit failed to complete in time`, code `-56`), which is a tooling
  timeout, not an app defect. A screen that still cannot be audited is reported
  as an `XCTSkip`, never a pass — skips are visually distinct in CI, so an
  unaudited screen stays visible instead of being silently counted as clean.
- The suite launches with `DOMAIN_DIG_FORCE_PRO_PLUS` so Pro-gated screens are
  reachable. `PurchaseService` honours that argument in `DEBUG` builds only.
- Everything used is available at the iOS 17.6 deployment floor;
  `performAccessibilityAudit` is `ios(17.0)`.