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)`.