Commit 7315ae3da6
Unsigned
Layout: unified · split
Docs/ACCESSIBILITY_VERIFICATION_RESULTS.md added +292
| @@ -0,0 +1,292 @@ | ||
| 1 | # Accessibility Verification — Results (issue #21, Phase 6) | |
| 2 | ||
| 3 | Execution of `ACCESSIBILITY_VERIFICATION.md` against the iOS Simulator. The | |
| 4 | runbook was written for a human on a physical device; a meaningful fraction is | |
| 5 | beyond a simulator. Every item below is sorted into one of three tiers and | |
| 6 | treated accordingly: | |
| 7 | ||
| 8 | - **Tier 1 — executed here.** Appearance, Increase Contrast, Dynamic Type | |
| 9 | (including a middle-band sweep), the audit suite, accessibility **metadata** | |
| 10 | (labels/values/traits, now permanent XCUITest assertions), and the | |
| 11 | Liquid-Glass-vs-classic cross-runtime comparison. | |
| 12 | - **Tier 2 — attempted, reported honestly.** Differentiate Without Color, | |
| 13 | Reduce Motion, Reduce Transparency. One of the three turned out to be fully | |
| 14 | toggleable and is now **verified**; the mechanism is documented for the rest. | |
| 15 | - **Tier 3 — requires a physical device.** VoiceOver speech, the rotor, | |
| 16 | announcements, Voice Control, Screen Curtain. Not attempted, not faked. | |
| 17 | ||
| 18 | **"Verified" vs "verified by construction."** A ✅ **Pass** means the behaviour | |
| 19 | was *observed* (a screenshot, an assertion, or a finding-count delta). *Verified | |
| 20 | by construction* (⚙️) means the code compiles and the pattern is right but the | |
| 21 | runtime behaviour was not observed here — it is **not** counted as a pass. | |
| 22 | ||
| 23 | ## Environment | |
| 24 | ||
| 25 | | Role | Simulator | Runtime | Design language | | |
| 26 | | --- | --- | --- | --- | | |
| 27 | | Floor | iPhone 16 | iOS 18.6 | Classic chrome | | |
| 28 | | Liquid Glass (mid) | iPhone 17 | iOS 26.5 | Liquid Glass | | |
| 29 | | Current | iPhone 17e | iOS 27.0 | Liquid Glass | | |
| 30 | ||
| 31 | Deployment target is 17.6; no 17.6/17.5 runtime is usable (the app cannot | |
| 32 | install below the floor), so 18.6 is the practical floor, per the runbook. | |
| 33 | ||
| 34 | Seed data via `DOMAIN_DIG_SEED_FIXTURES` + `DOMAIN_DIG_FORCE_PRO_PLUS` (DEBUG, | |
| 35 | in-memory, never persisted). **Note on tooling:** `simctl launch` with the seed | |
| 36 | argument did *not* populate the fixtures, and `simctl ui appearance` did not | |
| 37 | propagate to a headless-booted simulator. Both were worked around by driving | |
| 38 | everything through **XCUITest** (which seeds reliably) and switching appearance | |
| 39 | through the app's own Settings → Display picker. This is why the screenshots and | |
| 40 | metadata checks are committed as tests rather than shell scripts — see | |
| 41 | `DomainDigUITests/AccessibilityScreenshotTests.swift` and | |
| 42 | `AccessibilityMetadataTests.swift`. | |
| 43 | ||
| 44 | --- | |
| 45 | ||
| 46 | ## Headline outcomes | |
| 47 | ||
| 48 | 1. **A real enforced failure was found and fixed.** `Scripts/audit-a11y.sh | |
| 49 | current` (iOS 27.0) failed an **enforced** `.dynamicType` finding on the | |
| 50 | Settings `Section("Services")` header — a system-rendered header the app sets | |
| 51 | no font on, present only on 27.0 (18.6 floor and the 26.x runtime CI uses are | |
| 52 | clean). Resolved with a narrow, proven `noiseReason(for:)` carve-out. Delta: | |
| 53 | `current` went **FAIL → SUCCEEDED**, the finding still printed as | |
| 54 | `[noise: …]`. See §5 and the fix note below. | |
| 55 | 2. **The highest-value metadata checks are now permanent tests.** The dense-row | |
| 56 | label/value contracts and the icon-only control labels are asserted | |
| 57 | mechanically in `AccessibilityMetadataTests` (green on 18.6 and 27.0), | |
| 58 | shrinking the manual runbook. | |
| 59 | 3. **Differentiate Without Color is fully verifiable in the simulator** via the | |
| 60 | *global* `com.apple.Accessibility` defaults domain — the runbook and task | |
| 61 | both assumed this might not be reachable. It is. Captured proof: | |
| 62 | `lg-dashboard-light-differentiate.png`. | |
| 63 | 4. **The middle-band Dynamic Type sweep found no third bug** exclusive to that | |
| 64 | band (negative result), but is retained as regression insurance for a band | |
| 65 | that historically shipped two. | |
| 66 | ||
| 67 | --- | |
| 68 | ||
| 69 | ## 1. Baseline visual — Light, Dark, System `[P1][P2]` | |
| 70 | ||
| 71 | Evidence: `classic-dashboard-{light,dark}.png`, `lg-dashboard-{light,dark}.png`, | |
| 72 | `classic-batch-{light,dark}.png`, `lg-batch-{light,dark}.png`. Appearance driven | |
| 73 | through Settings → Display. | |
| 74 | ||
| 75 | | Item | Result | Notes | | |
| 76 | | --- | --- | --- | | |
| 77 | | System follows device | ⚙️ By construction | `simctl ui appearance` does not propagate headlessly; Light/Dark set via the in-app picker instead, which drives the same single `@AppStorage` path. | | |
| 78 | | Light / Dark overrides hold | ✅ Pass | Both captured on both runtimes; the picker override renders correctly. | | |
| 79 | | Accent blue everywhere, no cyan | ✅ Pass | Tab-bar selection, `•All`, links, selected quick-filter chip all blue. No cyan observed. | | |
| 80 | | Warning reads orange, not olive | ✅ Pass | Dashboard "Warning" tile and batch "Warning" badge are clearly orange in both schemes. | | |
| 81 | | Selected tile is blue-tinted, not lavender | ✅ Pass | "Total Domains" tile is a soft blue surface in light and dark. | | |
| 82 | | Prominent buttons: white label on blue fill | ⚙️ By construction | Run/Run Batch are disabled in the seeded state (no typed domain), so the enabled `.borderedProminent` fill was not captured; palette values are audited. | | |
| 83 | | Secondary text legible in Light | ✅ Pass | "QUICK FILTERS", timestamps, "No recent portfolio changes" read clearly on the light card (this is the `AppTextSecondary` fix). | | |
| 84 | | Badges pair icon + text + colour | ✅ Pass | Batch badges show lock/triangle/octagon/x + word + colour. | | |
| 85 | | Repeat on floor runtime | ✅ Pass | Classic-chrome (18.6) captures match; see cross-runtime §8. | | |
| 86 | ||
| 87 | ## 2. Dynamic Type & reflow — up to Accessibility 5 `[P3]` | |
| 88 | ||
| 89 | Evidence: `*-dashboard-axxxl.png`, `*-batch-axxxl.png`, `*-watchlist-axxxl.png` | |
| 90 | (both runtimes), plus the automated `…AccessibilityXXXL` and new | |
| 91 | `…AccessibilityL` audit sweeps. | |
| 92 | ||
| 93 | | Item | Result | Notes | | |
| 94 | | --- | --- | --- | | |
| 95 | | Body text + tile numbers scale | ✅ Pass | Dashboard "4 / 2 / 1" and all labels scale at AXXXL. | | |
| 96 | | No clipped headings (wrap, not "…") | ✅ Pass | "Batch Results", "Total Domains" wrap onto multiple lines; audit reports no *named* `textClipped` on empty-state headings. | | |
| 97 | | No card needs horizontal scrolling | ✅ Pass | Cards reflow vertically at AXXXL; no hidden horizontal gesture. | | |
| 98 | | Dense rows readable at AX5 | ⚠️ Known-deferred | Watchlist/batch rows become very tall and wrap; readable, no horizontal badge overlap. This is the deferred `ViewThatFits` case, **not** a P3 regression (see `lg-watchlist-axxxl.png`). | | |
| 99 | | Tap targets ≥ 44×44 | ⚙️ By construction | `AppLayout.minimumTapTarget` floor is enforced in code and the audit reports no named `hitRegion` findings; not separately measured here. | | |
| 100 | | Bold Text | ❌ Not executed | Not exposed by `simctl`; same class as the Tier-2 settings. Requires device or Settings-app automation. | | |
| 101 | | Widget clamps at AX1 | ❌ Not executed | Widgets do not render in the audit simulator or these captures — Tier 3-adjacent (needs Home Screen). | | |
| 102 | | Repeat spot-checks on floor | ✅ Pass | Classic AXXXL captures match. | | |
| 103 | ||
| 104 | **Middle-band sweep (added).** `testSeededScreensAtIntermediateAccessibilitySize` | |
| 105 | audits the seeded screens at `AccessibilityL`. Result: the `textClipped` findings | |
| 106 | it surfaced on the Risk badges (`Risk 12 Low`, `Risk 41 Medium`) are **also | |
| 107 | present at the default size** and absent at XXXL, so they are pre-existing | |
| 108 | reportOnly seeded-row findings, **not** a middle-band-exclusive third bug. No new | |
| 109 | gap found. The sweep is retained as regression insurance for a band that | |
| 110 | historically shipped two escaped bugs. | |
| 111 | ||
| 112 | ## 3. VoiceOver `[P4]` — Tier 3, requires a physical device | |
| 113 | ||
| 114 | iOS VoiceOver **speech** does not run in the Simulator; macOS VoiceOver reading | |
| 115 | the simulator window is not equivalent and is not accepted as evidence. What the | |
| 116 | simulator *can* assert is the underlying **metadata**, which is now covered by | |
| 117 | `AccessibilityMetadataTests` (green on 18.6 and 27.0): | |
| 118 | ||
| 119 | | Runbook item | Metadata coverage | Result | | |
| 120 | | --- | --- | --- | | |
| 121 | | §3a Dashboard refresh → "Refresh all tracked domains" | asserted | ✅ Pass | | |
| 122 | | §3a Watchlist add → "Add domain"; filter → "Filter and sort" | asserted | ✅ Pass | | |
| 123 | | §3a Workflows create → "Create workflow" | asserted | ✅ Pass | | |
| 124 | | §3a History filter → "Filter" | ⚙️ By construction | Menu is gated behind non-empty history; not seedable. Label exists at `HistoryView.swift:109`. | | |
| 125 | | §3a Inspect clear/actions/export, Timeline grouping, Workflow export/re-run/shared | ⚙️ By construction | Reachable only after a live lookup / on populated workflow runs; labels verified in source (see `ACCESSIBILITY.md` map). | | |
| 126 | | §3d Watchlist row: label = domain, value = availability | asserted | ✅ Pass (`healthy.example`→"Registered"; long domain→"Unknown") | | |
| 127 | | §3d Batch row: label = domain, value = "status, availability" | asserted | ✅ Pass (`broken.example`→"Critical, Registered"; `unreachable.example`→"Failed, Unknown") | | |
| 128 | | §3c Badge reads as one word | asserted (folded into row value) | ✅ Pass | | |
| 129 | | §3b Save/Pin selected-state; §3b audit checklist; picker selected | ⚙️ By construction | All live behind a completed live inspection, seeded audits, or a multi-step gated flow — non-deterministic in CI. Traits verified in source (`ContentView.swift:565–567, 1372–1374`; `AuditViews.swift:264–265`; `WorkflowsView.swift:647`). | | |
| 130 | | §3e speech style, §3f announcements, §3g widget speech, §3h Screen Curtain | ❌ Requires device | VoiceOver speech / rotor / announcements — Tier 3. | | |
| 131 | ||
| 132 | **Rotor / custom-content ordering:** not observable from XCUITest at all — | |
| 133 | `.accessibilityCustomContent` does not surface as a queryable element property. | |
| 134 | Verified by construction (the `WatchlistRowAccessibility` / | |
| 135 | `BatchRowAccessibility` modifiers) and deferred to the device pass. | |
| 136 | ||
| 137 | ## 4. Voice Control (WCAG 2.5.3) `[P4]` — Tier 3, requires a physical device | |
| 138 | ||
| 139 | Voice Control does not run in the Simulator. Label-in-name is partially | |
| 140 | *inferable* — every asserted `accessibilityLabel` in §3 preserves the control's | |
| 141 | visible text — but "say the printed word and it activates" must be confirmed on | |
| 142 | hardware. | |
| 143 | ||
| 144 | | Item | Result | | |
| 145 | | --- | --- | | |
| 146 | | "Tap Run / Track / Note / Compare / Cancel / Save" by printed word | ❌ Requires device | | |
| 147 | | "Show numbers" overlays on icon-only controls | ❌ Requires device | | |
| 148 | | No control reachable only by a differing name | ⚙️ By construction (labels preserve visible text) | | |
| 149 | ||
| 150 | ## 5. Colour & contrast settings `[P1][P2][P5]` | |
| 151 | ||
| 152 | ### 5a. Increase Contrast — Tier 1 | |
| 153 | ||
| 154 | `simctl ui <udid> increase_contrast enabled` works. The palette carries HC | |
| 155 | variants; the audit's `.contrast` category stays report-only by design (see | |
| 156 | `ACCESSIBILITY.md`). | |
| 157 | ||
| 158 | | Item | Result | Notes | | |
| 159 | | --- | --- | --- | | |
| 160 | | Status/accent shift to HC variants, nothing unreadable | ⚙️ By construction | HC toggles via `simctl`, but the effect is a colour-value swap not reliably distinguishable in a downscaled screenshot; palette HC variants are defined and audited. | | |
| 161 | | Marginal Settings headers clear | ⚙️ By construction | The documented light 21→18 contrast measurement; unchanged this pass. | | |
| 162 | ||
| 163 | ### 5b. Differentiate Without Color — **Tier 2, VERIFIED** ✅ | |
| 164 | ||
| 165 | The runbook and task both flagged this as possibly un-toggleable in a simulator. | |
| 166 | It **is** toggleable: `simctl ui` does not expose it, and a `defaults write` to | |
| 167 | the app's *own* (sandboxed) container does not reach it — but a write to the | |
| 168 | **global** `com.apple.Accessibility` domain does, and XCUITest-launched apps read | |
| 169 | it via `UIAccessibility`: | |
| 170 | ||
| 171 | ```sh | |
| 172 | xcrun simctl spawn <udid> defaults write com.apple.Accessibility DifferentiateWithoutColor -bool true | |
| 173 | ``` | |
| 174 | ||
| 175 | Captured proof — `lg-dashboard-light-differentiate.png` vs `lg-dashboard-light.png`: | |
| 176 | ||
| 177 | | Item | Result | Observed | | |
| 178 | | --- | --- | --- | | |
| 179 | | Summary tiles gain per-filter symbols | ✅ Pass | Dot → grid (All), checkmark (Healthy), triangle (Warning), octagon (Critical), refresh (Changed), wifi-slash (Unreachable). | | |
| 180 | | Selected quick-filter chip gains checkmark + border | ✅ Pass | "All" chip shows ✓ and a border; selection no longer fill-colour only. | | |
| 181 | | Inspect data-row warning/failure symbol | ⚙️ By construction | `LabeledValueRow` is behind a live lookup; the Dashboard payoff above exercises the same `accessibilityDifferentiateWithoutColor` path. | | |
| 182 | | Turning it off removes the extras | ✅ Pass | The default set of screenshots (setting off) shows plain dots / no chip checkmark. | | |
| 183 | | Widget uses symbols regardless | ⚙️ By construction | Widget does not render in these captures. | | |
| 184 | ||
| 185 | ### 5c. Smart Invert — ❌ Not executed | |
| 186 | ||
| 187 | Not exposed by `simctl`; not in the global-domain set that worked for DWC. | |
| 188 | Requires the Settings app / device. | |
| 189 | ||
| 190 | ## 6. Motion & transparency `[P5]` — Tier 2 | |
| 191 | ||
| 192 | Both settings **can be written** to the global `com.apple.Accessibility` domain | |
| 193 | (`ReduceMotionEnabled`, `ReduceTransparencyEnabled`) — the same mechanism proven | |
| 194 | to reach the app for DWC. But their *effects* are not screenshot-capturable: | |
| 195 | ||
| 196 | | Item | Result | Notes | | |
| 197 | | --- | --- | --- | | |
| 198 | | §6a Reduce Motion: copy-check swap, section expand, timeline scroll, list reorder become instant | ⚙️ By construction | Effect is animation *timing*; a still frame cannot show "instant vs animated". Toggle mechanism confirmed; five sites guarded via `accessibilityReduceMotion` in code. | | |
| 199 | | §6b Reduce Transparency: Data Management toast is opaque | ⚙️ By construction | The only translucency swap is a **transient** toast behind a multi-step clear; not captured. `accessibilityReduceTransparency` swap verified in source. | | |
| 200 | | §6b iOS 26+ system chrome still reads acceptably | ✅ Pass | Liquid Glass nav/tab bars legible in all captures (app cannot declare that translucency itself). | | |
| 201 | ||
| 202 | **Net:** the Tier-2 toggle method (global accessibility defaults + XCUITest | |
| 203 | launch) is now known to work — DWC is fully verified with it. Motion and | |
| 204 | Transparency remain verified-by-construction because their effects are timing / | |
| 205 | transient, but the manual device pass for them is now optional rather than | |
| 206 | blocked: the same `defaults write` unblocks a scripted check with a screen | |
| 207 | recording. | |
| 208 | ||
| 209 | ## 7. iPad — Full Keyboard Access & split layout `[verification]` | |
| 210 | ||
| 211 | | Item | Result | | |
| 212 | | --- | --- | | |
| 213 | | Tab focus order, focus ring, sidebar/detail reachability, tab→detail update | ❌ Requires device | Full Keyboard Access is not exposed by `simctl`; keyboard-focus traversal is a hardware/Settings behaviour. The `NavigationSplitView` layout itself renders (regular width) but focus order was not exercised. | | |
| 214 | ||
| 215 | ## 8. Cross-runtime sign-off `[cross-runtime]` | |
| 216 | ||
| 217 | Captured the same seeded screens on **classic chrome (18.6)** and **Liquid Glass | |
| 218 | (26.5)** in Light, Dark, and AXXXL. The audit was run on **18.6 and 27.0**. | |
| 219 | ||
| 220 | - The semantic palette resolves correctly on both design languages: warning | |
| 221 | orange, critical red, positive green, blue accent, blue-tinted selected tile. | |
| 222 | No Liquid-Glass-only palette regression observed. | |
| 223 | - The only structural difference is expected: Liquid Glass renders translucent, | |
| 224 | rounded nav/tab chrome; classic renders flatter, opaque chrome. Legibility | |
| 225 | holds in both. | |
| 226 | - **Audit coverage is genuinely not nested:** 18.6 passed clean; 27.0 surfaced | |
| 227 | the extra `Section` header `.dynamicType` finding that 18.6/26.x do not (now | |
| 228 | carved out). This confirms the repo's rationale for a per-runtime local run. | |
| 229 | ||
| 230 | --- | |
| 231 | ||
| 232 | ## Filled sign-off | |
| 233 | ||
| 234 | | Pass | 26+/27 (Liquid Glass) | Floor (classic 18.6) | Notes | | |
| 235 | | --- | --- | --- | --- | | |
| 236 | | 1 Baseline visual | ✅ | ✅ | Light/Dark captured both; System by-construction | | |
| 237 | | 2 Dynamic Type / reflow | ✅ | ✅ | AXXXL + middle-band L; dense rows known-deferred | | |
| 238 | | 3 VoiceOver | metadata ✅ / speech ❌ device | metadata ✅ | Speech/rotor Tier 3 | | |
| 239 | | 4 Voice Control | ❌ device | ❌ device | Labels preserve visible text (by construction) | | |
| 240 | | 5 Colour & contrast | 5b DWC ✅ / 5a,5c ⚙️/❌ | 5b DWC ✅ | DWC verified via global defaults | | |
| 241 | | 6 Motion & transparency | ⚙️ | ⚙️ | Effects not screenshot-capturable | | |
| 242 | | 7 iPad keyboard | n/a | ❌ device | FKA not in simulator | | |
| 243 | ||
| 244 | ## Tier 3 — the residual physical-device pass | |
| 245 | ||
| 246 | The human pass now shrinks to exactly these, all requiring hardware: | |
| 247 | ||
| 248 | - **VoiceOver:** §3a controls only reachable after a live lookup (Inspect | |
| 249 | clear/actions/export, Timeline grouping, Workflow export/re-run/shared); | |
| 250 | §3b Save/Pin/audit-checklist/picker selected-state *spoken*; §3c one-word | |
| 251 | badge *spoken*; §3d More Content rotor order; §3e technical-string speech; | |
| 252 | §3f completion announcements; §3g widget speech; §3h Screen Curtain journey. | |
| 253 | - **Voice Control:** §4 in full. | |
| 254 | - **Bold Text** (§2), **Smart Invert** (§5c), and the **iPad Full Keyboard | |
| 255 | Access** pass (§7) — Settings toggles not exposed to `simctl` and not in the | |
| 256 | global accessibility domain. | |
| 257 | - **Widget** at AX sizes and under VoiceOver (§2, §3g) — needs the Home Screen. | |
| 258 | - **Reduce Motion / Reduce Transparency** *effect* confirmation (§6) — optional; | |
| 259 | the toggle is now scriptable, but observing instant-animation / opaque-toast | |
| 260 | needs a screen recording. | |
| 261 | ||
| 262 | ## Changes made this pass | |
| 263 | ||
| 264 | | Change | File | Evidence | | |
| 265 | | --- | --- | --- | | |
| 266 | | Fixed enforced 27.0 Settings `.dynamicType` failure | `DomainDigUITests/AccessibilityAuditHarness.swift` | `audit-a11y.sh current` FAIL → SUCCEEDED; finding prints as `[noise: iOS-rendered Settings section header …]` | | |
| 267 | | New metadata assertions (icon labels, dense-row label/value) | `DomainDigUITests/AccessibilityMetadataTests.swift` | 4 tests green on 18.6 + 27.0 | | |
| 268 | | Middle-band Dynamic Type sweep (`AccessibilityL`) | `DomainDigUITests/AccessibilityAuditTests.swift` | Passes; negative result recorded above | | |
| 269 | | Screenshot-capture utility (best-effort, non-gating) | `DomainDigUITests/AccessibilityScreenshotTests.swift` | 15 screenshots in `Docs/a11y-screenshots/` | | |
| 270 | ||
| 271 | ### On the suppression (not a ratchet weakening) | |
| 272 | ||
| 273 | The Settings finding is on a plain `Section("Services")` (`ContentView.swift` | |
| 274 | ~2768) whose font the app never sets — the scaling is UIKit's system header. It | |
| 275 | appears **only** on iOS 27.0 (the 18.6 floor and the 26.x runtime CI runs are | |
| 276 | clean; `ACCESSIBILITY.md` already records this asymmetry as "dynamicType finding | |
| 277 | 18.6 missed"). The carve-out is scoped to `.dynamicType` on the exact Settings | |
| 278 | section-header titles, so a real regression on app-controlled text still | |
| 279 | enforces — matching the existing "system field placeholder" and system-header | |
| 280 | contrast carve-outs. The proof lives inline in `noiseReason(for:)`. | |
| 281 | ||
| 282 | ## Screenshot index (`Docs/a11y-screenshots/`) | |
| 283 | ||
| 284 | | File | Runtime | Screen | Config | | |
| 285 | | --- | --- | --- | --- | | |
| 286 | | `classic-dashboard-light.png` / `-dark.png` | 18.6 | Dashboard | Light / Dark | | |
| 287 | | `classic-batch-light.png` / `-dark.png` | 18.6 | Batch results | Light / Dark | | |
| 288 | | `classic-{dashboard,watchlist,batch}-axxxl.png` | 18.6 | — | AccessibilityXXXL | | |
| 289 | | `lg-dashboard-light.png` / `-dark.png` | 26.5 | Dashboard | Light / Dark | | |
| 290 | | `lg-batch-light.png` / `-dark.png` | 26.5 | Batch results | Light / Dark | | |
| 291 | | `lg-{dashboard,watchlist,batch}-axxxl.png` | 26.5 | — | AccessibilityXXXL | | |
| 292 | | `lg-dashboard-light-differentiate.png` | 26.5 | Dashboard | Light + Differentiate Without Color | | |
Docs/a11y-screenshots/classic-batch-axxxl.png added
Binary file not shown.
Docs/a11y-screenshots/classic-batch-dark.png added
Binary file not shown.
Docs/a11y-screenshots/classic-batch-light.png added
Binary file not shown.
Docs/a11y-screenshots/classic-dashboard-axxxl.png added
Binary file not shown.
Docs/a11y-screenshots/classic-dashboard-dark.png added
Binary file not shown.
Docs/a11y-screenshots/classic-dashboard-light.png added
Binary file not shown.
Docs/a11y-screenshots/classic-watchlist-axxxl.png added
Binary file not shown.
Docs/a11y-screenshots/lg-batch-axxxl.png added
Binary file not shown.
Docs/a11y-screenshots/lg-batch-dark.png added
Binary file not shown.
Docs/a11y-screenshots/lg-batch-light.png added
Binary file not shown.
Docs/a11y-screenshots/lg-dashboard-axxxl.png added
Binary file not shown.
Docs/a11y-screenshots/lg-dashboard-dark.png added
Binary file not shown.
Docs/a11y-screenshots/lg-dashboard-light-differentiate.png added
Binary file not shown.
Docs/a11y-screenshots/lg-dashboard-light.png added
Binary file not shown.
Docs/a11y-screenshots/lg-watchlist-axxxl.png added
Binary file not shown.
DomainDigUITests/AccessibilityAuditHarness.swift +32
| @@ -201,9 +201,41 @@ enum AccessibilityAuditHarness { | ||
| 201 | 201 | return "unattributed, audit artifact on ignored/link content" |
| 202 | 202 | } |
| 203 | 203 | |
| 204 | // iOS-27-only Settings `Section` header dynamicType finding. On iOS 27.0 | |
| 205 | // (and only there) the audit reports "font sizes partially unsupported" | |
| 206 | // against a Settings section header — the same system-rendered headers | |
| 207 | // already carved out for the contrast near-miss above. Proof it is a | |
| 208 | // system-chrome artifact, not an app defect: | |
| 209 | // • Each is a plain `Section("Services")` etc. (ContentView.swift ~2768); | |
| 210 | // the app sets no font, so the scaling is UIKit's `.footnote` header. | |
| 211 | // • Version-specific: absent on the iOS 18.6 floor and on the 26.x | |
| 212 | // runtime CI runs (both audit clean); it surfaces only under 27.0. | |
| 213 | // ACCESSIBILITY.md's coverage table records the same asymmetry | |
| 214 | // ("dynamicType finding 18.6 missed"). | |
| 215 | // • Attribution is unstable run-to-run across the header set | |
| 216 | // (Tier/Preferences/Services), exactly like the documented contrast | |
| 217 | // flip — so it lands on whichever header the traversal reaches first. | |
| 218 | // Overriding every Section header with a custom scaling `Text` to chase | |
| 219 | // this was rejected for the contrast case (ACCESSIBILITY.md) for trading | |
| 220 | // platform convention for nothing; the same holds here. Scoped to | |
| 221 | // dynamicType on the exact Settings header titles so a real regression | |
| 222 | // on app-controlled text still enforces. | |
| 223 | if issue.auditType.contains(.dynamicType), | |
| 224 | let label = issue.element?.label, | |
| 225 | settingsSectionHeaders.contains(label) { | |
| 226 | return "iOS-rendered Settings section header, app sets no font (27.0-only)" | |
| 227 | } | |
| 228 | ||
| 204 | 229 | return nil |
| 205 | 230 | } |
| 206 | 231 | |
| 232 | /// The Settings screen's `Section(_:)` header titles. UIKit renders these; | |
| 233 | /// the app passes only a string literal. Used to scope the section-header | |
| 234 | /// dynamicType carve-out narrowly (see `noiseReason`). | |
| 235 | private static let settingsSectionHeaders: Set<String> = [ | |
| 236 | "Tier", "Preferences", "Services", "Data", "About" | |
| 237 | ] | |
| 238 | ||
| 207 | 239 | /// `XCUIAccessibilityAuditType` is an option set whose description is just a |
| 208 | 240 | /// raw bitmask, which makes the burndown list unreadable. Resolve it against |
| 209 | 241 | /// the named members rather than hard-coding bit positions, so this keeps |
DomainDigUITests/AccessibilityAuditTests.swift +42
| @@ -112,6 +112,48 @@ final class AccessibilityAuditTests: XCTestCase { | ||
| 112 | 112 | try XCTSkipUnless(audited, "Audit did not complete in time for seeded batch results") |
| 113 | 113 | } |
| 114 | 114 | |
| 115 | /// The seeded screens at an **intermediate** accessibility size. | |
| 116 | /// | |
| 117 | /// The default/`AccessibilityXXXL` pair brackets the range but skips the | |
| 118 | /// middle band, and two production layout bugs lived exactly there — a | |
| 119 | /// bordered button letter-wrapping vertically at a merely-large size, which | |
| 120 | /// neither endpoint reproduced. `AccessibilityL` samples that band across the | |
| 121 | /// dense seeded screens so a regression in it cannot slip between the two | |
| 122 | /// existing test points. Reports rather than gates, matching the other | |
| 123 | /// seeded audits. | |
| 124 | func testSeededScreensAtIntermediateAccessibilitySize() throws { | |
| 125 | let app = AccessibilityAuditHarness.launch( | |
| 126 | contentSizeCategory: "UICTContentSizeCategoryAccessibilityL", | |
| 127 | seeded: true | |
| 128 | ) | |
| 129 | ||
| 130 | var unaudited: [String] = [] | |
| 131 | ||
| 132 | app.selectRootTab("Dashboard") | |
| 133 | if try !AccessibilityAuditHarness.audit(app, screen: "seeded-dashboard-accessibilityL", test: self, reportOnly: true) { | |
| 134 | unaudited.append("Dashboard") | |
| 135 | } | |
| 136 | ||
| 137 | app.selectRootTab("Inspect") | |
| 138 | if try !AccessibilityAuditHarness.audit(app, screen: "seeded-batch-accessibilityL", test: self, reportOnly: true) { | |
| 139 | unaudited.append("Inspect batch") | |
| 140 | } | |
| 141 | ||
| 142 | app.selectRootTab("Settings") | |
| 143 | let trackedDomains = app.buttons["Tracked Domains"] | |
| 144 | if trackedDomains.waitForExistence(timeout: 5) { | |
| 145 | trackedDomains.tap() | |
| 146 | if try !AccessibilityAuditHarness.audit(app, screen: "seeded-tracked-domains-accessibilityL", test: self, reportOnly: true) { | |
| 147 | unaudited.append("Tracked Domains") | |
| 148 | } | |
| 149 | } | |
| 150 | ||
| 151 | try XCTSkipUnless( | |
| 152 | unaudited.isEmpty, | |
| 153 | "Audit did not complete in time for: \(unaudited.joined(separator: ", "))" | |
| 154 | ) | |
| 155 | } | |
| 156 | ||
| 115 | 157 | /// The seeded screens again at the largest accessibility size — the case the |
| 116 | 158 | /// deferred ViewThatFits work exists for. |
| 117 | 159 | func testSeededScreensAtLargestAccessibilitySize() throws { |
DomainDigUITests/AccessibilityMetadataTests.swift added +160
| @@ -0,0 +1,160 @@ | ||
| 1 | import XCTest | |
| 2 | ||
| 3 | /// Mechanical assertions for the accessibility **metadata** the manual runbook | |
| 4 | /// (issue #21, Phase 6) checks by hand: icon-only control labels, dense-row | |
| 5 | /// label/value pairs, and toggle selected-state. | |
| 6 | /// | |
| 7 | /// `performAccessibilityAudit` (see `AccessibilityAuditTests`) validates | |
| 8 | /// contrast, hit-region, clipping, and trait *correctness*, but it does not | |
| 9 | /// assert that a specific control carries a specific spoken label — that a | |
| 10 | /// refresh button says "Refresh all tracked domains" rather than "arrow | |
| 11 | /// clockwise". Those strings were one-time manual VoiceOver checks; this file | |
| 12 | /// converts the ones reachable without a live network lookup into permanent | |
| 13 | /// regression coverage, so a relabel or a lost `.accessibilityValue` fails CI. | |
| 14 | /// | |
| 15 | /// What is deliberately **not** here, and why: | |
| 16 | /// - Inspect toolbar Clear/Actions/Export, the bookmark (Save) toggle, and the | |
| 17 | /// Timeline grouping control only appear after a completed lookup, which needs | |
| 18 | /// the network — non-deterministic in CI. They stay in the manual pass. | |
| 19 | /// - VoiceOver speech, the More Content rotor, and custom-content ordering are | |
| 20 | /// not observable from XCUITest at all (the rotor is a VoiceOver feature, not | |
| 21 | /// an element property). `.accessibilityCustomContent` does not surface as a | |
| 22 | /// queryable value here, so the row assertions cover label + value only. | |
| 23 | @MainActor | |
| 24 | final class AccessibilityMetadataTests: XCTestCase { | |
| 25 | override func setUp() { | |
| 26 | continueAfterFailure = true | |
| 27 | } | |
| 28 | ||
| 29 | // MARK: Icon-only control labels (runbook §3a) | |
| 30 | ||
| 31 | /// Every icon-only control reachable from the seeded launch state must | |
| 32 | /// announce a purpose, never a raw SF Symbol name. | |
| 33 | func testIconOnlyControlLabels() { | |
| 34 | let app = AccessibilityAuditHarness.launch(seeded: true) | |
| 35 | ||
| 36 | // Dashboard refresh. | |
| 37 | app.selectRootTab("Dashboard") | |
| 38 | XCTAssertTrue( | |
| 39 | app.buttons["Refresh all tracked domains"].waitForExistence(timeout: 5), | |
| 40 | "Dashboard refresh lost its 'Refresh all tracked domains' label" | |
| 41 | ) | |
| 42 | ||
| 43 | // Watchlist (Tracked Domains) add + filter. | |
| 44 | openTrackedDomains(app) | |
| 45 | XCTAssertTrue( | |
| 46 | app.buttons["Add domain"].waitForExistence(timeout: 5), | |
| 47 | "Watchlist add-domain lost its 'Add domain' label" | |
| 48 | ) | |
| 49 | XCTAssertTrue( | |
| 50 | app.buttons["Filter and sort"].exists, | |
| 51 | "Watchlist filter lost its 'Filter and sort' label" | |
| 52 | ) | |
| 53 | ||
| 54 | // History's "Filter" menu (HistoryView.swift:109) is gated behind a | |
| 55 | // non-empty history, which the seed fixtures do not populate, so it is | |
| 56 | // not reachable here — it stays a verified-by-construction item in the | |
| 57 | // results matrix rather than a flaky assertion. | |
| 58 | } | |
| 59 | ||
| 60 | /// Workflows is Pro-gated; the seed harness forces Pro so its create button | |
| 61 | /// is reachable. | |
| 62 | func testWorkflowsCreateLabel() { | |
| 63 | let app = AccessibilityAuditHarness.launch(seeded: true) | |
| 64 | app.selectRootTab("Settings") | |
| 65 | let workflows = app.buttons["Workflows"] | |
| 66 | XCTAssertTrue(workflows.waitForExistence(timeout: 5), "Settings no longer offers Workflows") | |
| 67 | workflows.tap() | |
| 68 | XCTAssertTrue( | |
| 69 | app.buttons["Create workflow"].waitForExistence(timeout: 5), | |
| 70 | "Workflows create lost its 'Create workflow' label" | |
| 71 | ) | |
| 72 | } | |
| 73 | ||
| 74 | // MARK: Dense rows — label is the domain, value is the status (runbook §3d) | |
| 75 | ||
| 76 | /// The watchlist's dense rows collapse to a single VoiceOver element whose | |
| 77 | /// label is the domain and whose value is availability. The badge title is | |
| 78 | /// folded into that value (children: .ignore), which is the §3c "one word" | |
| 79 | /// contract. | |
| 80 | func testWatchlistRowLabelAndValue() { | |
| 81 | let app = AccessibilityAuditHarness.launch(seeded: true) | |
| 82 | openTrackedDomains(app) | |
| 83 | ||
| 84 | assertElement(in: app, label: "healthy.example", value: "Registered") | |
| 85 | // The stress-length fixture with no known availability. | |
| 86 | assertElement( | |
| 87 | in: app, | |
| 88 | label: "very-long-subdomain.observability.internal.staging.example", | |
| 89 | value: "Unknown" | |
| 90 | ) | |
| 91 | } | |
| 92 | ||
| 93 | /// Batch result rows: domain as label, "<status>, <availability>" as value — | |
| 94 | /// including the failed lookup, whose badge reads "Failed". | |
| 95 | func testBatchRowLabelAndValue() { | |
| 96 | let app = AccessibilityAuditHarness.launch(seeded: true) | |
| 97 | app.selectRootTab("Inspect") | |
| 98 | ||
| 99 | assertElement(in: app, label: "broken.example", value: "Critical, Registered") | |
| 100 | assertElement(in: app, label: "unreachable.example", value: "Failed, Unknown") | |
| 101 | } | |
| 102 | ||
| 103 | // Toggle selected-state (runbook §3b) is intentionally not asserted here. | |
| 104 | // The bookmark ("Save domain") and Pin ("Pin domain") toggles both live in | |
| 105 | // the Inspect result's Domain section, reachable only after a completed live | |
| 106 | // lookup — non-deterministic in CI. The watchlist's own pin is a swipe/menu | |
| 107 | // action that carries no `.isSelected` trait, and the Audit checklist and | |
| 108 | // picker rows need seeded audits / a multi-step gated flow the fixtures do | |
| 109 | // not provide. These remain in the manual pass; the results matrix records | |
| 110 | // each as verified-by-construction with its source line. | |
| 111 | ||
| 112 | // MARK: Helpers | |
| 113 | ||
| 114 | private func openTrackedDomains(_ app: XCUIApplication) { | |
| 115 | app.selectRootTab("Settings") | |
| 116 | let trackedDomains = app.buttons["Tracked Domains"] | |
| 117 | XCTAssertTrue(trackedDomains.waitForExistence(timeout: 5), "Settings no longer offers Tracked Domains") | |
| 118 | trackedDomains.tap() | |
| 119 | } | |
| 120 | ||
| 121 | /// A `children: .ignore` row can surface as a button, cell, or other-element | |
| 122 | /// depending on its container; match on label across the likely types. | |
| 123 | private func firstElement(in app: XCUIApplication, label: String) -> XCUIElement? { | |
| 124 | let predicate = NSPredicate(format: "label == %@", label) | |
| 125 | for query in [app.buttons, app.cells, app.otherElements, app.staticTexts] { | |
| 126 | let match = query.matching(predicate).firstMatch | |
| 127 | if match.exists { return match } | |
| 128 | } | |
| 129 | return nil | |
| 130 | } | |
| 131 | ||
| 132 | private func assertElement( | |
| 133 | in app: XCUIApplication, | |
| 134 | label: String, | |
| 135 | value: String, | |
| 136 | file: StaticString = #filePath, | |
| 137 | line: UInt = #line | |
| 138 | ) { | |
| 139 | // Wait for the row to appear at all. | |
| 140 | let predicate = NSPredicate(format: "label == %@", label) | |
| 141 | let anyMatch = app.descendants(matching: .any).matching(predicate).firstMatch | |
| 142 | XCTAssertTrue( | |
| 143 | anyMatch.waitForExistence(timeout: 8), | |
| 144 | "No accessibility element labelled \(label)", | |
| 145 | file: file, | |
| 146 | line: line | |
| 147 | ) | |
| 148 | guard let element = firstElement(in: app, label: label) else { | |
| 149 | XCTFail("Element \(label) exists but not as a queryable button/cell/other", file: file, line: line) | |
| 150 | return | |
| 151 | } | |
| 152 | XCTAssertEqual( | |
| 153 | element.value as? String, | |
| 154 | value, | |
| 155 | "Element \(label) reported value \(String(describing: element.value)); expected \(value)", | |
| 156 | file: file, | |
| 157 | line: line | |
| 158 | ) | |
| 159 | } | |
| 160 | } | |
DomainDigUITests/AccessibilityScreenshotTests.swift added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | import XCTest | |
| 2 | ||
| 3 | /// Captures the Phase-6 visual-pass evidence as result-bundle attachments: | |
| 4 | /// the seeded dense screens in Light and Dark, and again at an accessibility | |
| 5 | /// text size. Driven from XCUITest (not `simctl`) for two reasons proven during | |
| 6 | /// this pass: the seed fixtures only populate under the test harness's launch, | |
| 7 | /// and `xcrun simctl ui appearance` does not propagate to a headless-booted | |
| 8 | /// simulator — so appearance is switched through the app's own Settings → | |
| 9 | /// Display picker, exercising the real code path. | |
| 10 | /// | |
| 11 | /// **This is a capture utility, not a pass/fail test.** Every step is | |
| 12 | /// best-effort and never asserts: a control it cannot reach on a given runtime | |
| 13 | /// simply yields no screenshot, so adding it to the audit suite can never gate | |
| 14 | /// CI. Run with an explicit `-resultBundlePath` and export the attachments. | |
| 15 | @MainActor | |
| 16 | final class AccessibilityScreenshotTests: XCTestCase { | |
| 17 | override func setUp() { | |
| 18 | continueAfterFailure = true | |
| 19 | } | |
| 20 | ||
| 21 | /// Dashboard, Watchlist, and batch results in Light then Dark. | |
| 22 | func testLightAndDarkScreens() { | |
| 23 | let app = AccessibilityAuditHarness.launch(seeded: true) | |
| 24 | ||
| 25 | for appearance in ["Light", "Dark"] { | |
| 26 | guard setAppearance(app, to: appearance) else { continue } | |
| 27 | captureSeededScreens(app, suffix: appearance.lowercased()) | |
| 28 | } | |
| 29 | } | |
| 30 | ||
| 31 | /// The same seeded screens at the largest accessibility text size, where | |
| 32 | /// reflow and clipping surface. Launched fresh with the content-size arg. | |
| 33 | func testAccessibilityTextSizeScreens() { | |
| 34 | let app = AccessibilityAuditHarness.launch( | |
| 35 | contentSizeCategory: "UICTContentSizeCategoryAccessibilityXXXL", | |
| 36 | seeded: true | |
| 37 | ) | |
| 38 | captureSeededScreens(app, suffix: "axxxl") | |
| 39 | } | |
| 40 | ||
| 41 | // MARK: Capture (best-effort) | |
| 42 | ||
| 43 | private func captureSeededScreens(_ app: XCUIApplication, suffix: String) { | |
| 44 | app.selectRootTab("Dashboard") | |
| 45 | _ = app.buttons["Refresh all tracked domains"].waitForExistence(timeout: 5) | |
| 46 | attach(app, name: "dashboard-\(suffix)") | |
| 47 | ||
| 48 | app.selectRootTab("Settings") | |
| 49 | let trackedDomains = app.buttons["Tracked Domains"] | |
| 50 | if trackedDomains.waitForExistence(timeout: 5) { | |
| 51 | trackedDomains.tap() | |
| 52 | _ = element(app, labelled: "healthy.example").waitForExistence(timeout: 5) | |
| 53 | attach(app, name: "watchlist-\(suffix)") | |
| 54 | } | |
| 55 | ||
| 56 | app.selectRootTab("Inspect") | |
| 57 | _ = element(app, labelled: "broken.example").waitForExistence(timeout: 5) | |
| 58 | attach(app, name: "batch-\(suffix)") | |
| 59 | } | |
| 60 | ||
| 61 | private func element(_ app: XCUIApplication, labelled label: String) -> XCUIElement { | |
| 62 | app.descendants(matching: .any) | |
| 63 | .matching(NSPredicate(format: "label == %@", label)) | |
| 64 | .firstMatch | |
| 65 | } | |
| 66 | ||
| 67 | private func attach(_ app: XCUIApplication, name: String) { | |
| 68 | let attachment = XCTAttachment(screenshot: app.screenshot()) | |
| 69 | attachment.name = name | |
| 70 | attachment.lifetime = .keepAlways | |
| 71 | add(attachment) | |
| 72 | } | |
| 73 | ||
| 74 | // MARK: Appearance (best-effort; returns whether it switched) | |
| 75 | ||
| 76 | /// Drives Settings → Display → Appearance to the given option. The `Picker` | |
| 77 | /// renders as an inline `.menu` whose trigger is labelled | |
| 78 | /// "Appearance, <current value>". Returns `false` (rather than failing) if | |
| 79 | /// any step is unreachable on this runtime. | |
| 80 | private func setAppearance(_ app: XCUIApplication, to option: String) -> Bool { | |
| 81 | // A prior capture may have left the Settings tab on a pushed view | |
| 82 | // (Tracked Domains). Re-selecting the active tab pops it back to root. | |
| 83 | app.selectRootTab("Settings") | |
| 84 | let display = app.buttons["Display"] | |
| 85 | if !display.waitForExistence(timeout: 2) { | |
| 86 | app.selectRootTab("Settings") | |
| 87 | } | |
| 88 | guard display.waitForExistence(timeout: 5) else { return false } | |
| 89 | display.tap() | |
| 90 | ||
| 91 | let trigger = app.buttons | |
| 92 | .matching(NSPredicate(format: "label BEGINSWITH %@", "Appearance")) | |
| 93 | .firstMatch | |
| 94 | guard trigger.waitForExistence(timeout: 5) else { return false } | |
| 95 | trigger.tap() | |
| 96 | ||
| 97 | // The popped menu exposes System / Light / Dark as buttons (or menu | |
| 98 | // items on some runtimes). | |
| 99 | let asButton = app.buttons[option] | |
| 100 | let choice = asButton.waitForExistence(timeout: 3) ? asButton : app.menuItems[option] | |
| 101 | guard choice.waitForExistence(timeout: 3) else { return false } | |
| 102 | choice.tap() | |
| 103 | return true | |
| 104 | } | |
| 105 | } | |