Commit 2ca5ddf53e

2ca5ddf53e72379169ab3eb26468a3baa76b0b38

parent: 7315ae3da6

Unsigned

cmc <hello@cleberg.net> · 2026-07-24 00:38 UTC

docs cleanup

Layout: unified · split

Docs/ACCESSIBILITY_VERIFICATION.md deleted −346
@@ -1,346 +0,0 @@
1# Accessibility Verification Checklist (issue #21, Phase 6)
2
3Manual verification for the accessibility work in phases 1–5. Everything here is
4what the automated audit (`Scripts/audit-a11y.sh`) **cannot** check: VoiceOver
5speech, the rotor, custom-content ordering, announcements, Voice Control,
6motion/transparency/colour settings, keyboard focus order, and the two system
7design languages. A green audit is necessary, not sufficient — this is what makes
8it sufficient.
9
10Each item traces to the phase that introduced it (e.g. `[P4]`) so a failure points
11straight at the code. Check the box only when the **Expected** line is literally
12true on the device.
13
14---
15
16## 0. Setup
17
18### Devices / runtimes
19
20The app supports **iOS 17.6+** and renders under two system design languages:
21classic chrome (17.6–25) and Liquid Glass (26+). Materials, surfaces, and
22contrast resolve differently between them, so visual passes need both.
23
24- [ ] Primary device on **iOS 26+** (Liquid Glass).
25- [ ] A device or simulator on the **floor** (oldest available ≥ 17.6; 18.x is
26 the practical minimum since no 17.6 runtime ships). A simulator is fine for
27 the visual and Dynamic Type passes; VoiceOver/Voice Control are best on
28 hardware.
29
30### Seed data — required, or half the checklist is untestable
31
32Several items only render with tracked domains and a completed lookup. The audit
33simulator has none, which is exactly why the dense rows and widget are unverified
34so far. Before starting:
35
36- [ ] Inspect tab → run a lookup on a **live** domain (e.g. `cleberg.net`), let
37 all sections load.
38- [ ] Run a lookup on a domain with a **weak/expiring or missing** cert and
39 missing SPF/DMARC, so warning/critical tones and badges actually appear.
40- [ ] Track **at least 4** domains with mixed health (one healthy, one warning,
41 one critical, one unreachable) so Dashboard tiles, dense watchlist rows, and
42 the widget all have content.
43- [ ] Run a **Bulk** lookup on ~5 domains so `BatchResultRowView` renders.
44- [ ] Add the **Domain Portfolio** widget to the Home Screen in all three sizes
45 (small, medium, large).
46- [ ] Force the Pro tier if needed so gated screens (Workflows, Scheduled
47 Reports, Compare) are reachable.
48
49### Settings map (paths used throughout)
50
51| Setting | Path |
52| --- | --- |
53| VoiceOver | Settings → Accessibility → VoiceOver |
54| Screen Curtain | VoiceOver on → triple-tap with 3 fingers |
55| Voice Control | Settings → Accessibility → Voice Control |
56| Larger Text / Dynamic Type | Settings → Accessibility → Display & Text Size → Larger Text |
57| Bold Text | Settings → Accessibility → Display & Text Size → Bold Text |
58| Increase Contrast | Settings → Accessibility → Display & Text Size → Increase Contrast |
59| Differentiate Without Color | Settings → Accessibility → Display & Text Size → Differentiate Without Color |
60| Smart Invert | Settings → Accessibility → Display & Text Size → Smart Invert |
61| Reduce Motion | Settings → Accessibility → Motion → Reduce Motion |
62| Reduce Transparency | Settings → Accessibility → Display & Text Size → Reduce Transparency |
63| Full Keyboard Access (iPad) | Settings → Accessibility → Keyboards → Full Keyboard Access |
64| App appearance override | In-app: Settings tab → Display → Appearance |
65
66> Ordering note: the passes are grouped so each iOS setting is toggled **once**.
67> Do them top to bottom to avoid thrashing Settings.
68
69---
70
71## 1. Baseline visual — Light, Dark, System `[P1][P2]`
72
73No assistive tech on. Toggle appearance via **in-app Settings → Display →
74Appearance**, then confirm the system setting is also honoured.
75
76- [ ] **System** appearance follows the device; flipping the device Light/Dark
77 flips the app.
78- [ ] **Light** and **Dark** overrides hold regardless of the device setting.
79- [ ] Accent is **blue** everywhere — tab bar selection, links, section titles'
80 "info" accents, the Insights icons, the selected quick-filter chip. No
81 leftover **cyan**. `[P1]`
82- [ ] Warning tone reads as **orange**, not olive/brown — check a "Warning" or
83 "Expiring" badge and the Dashboard "Warning" tile. `[P1 rebalance]`
84- [ ] The selected Dashboard summary tile is a soft **blue-tinted** surface, not
85 lavender/violet. `[P2 wash fix]`
86- [ ] Prominent buttons (**Run**, **Run Batch**, **Scan**) show a **white label
87 on a blue fill** that is comfortably readable in both schemes. `[P1 AccentFill]`
88- [ ] Secondary/detail text (row labels, timestamps, "Monitoring off") is legible
89 in **Light** — not washed-out grey. `[P2 AppTextSecondary]`
90- [ ] Status badges pair an **icon + text + colour** (e.g. lock + "Valid"),
91 never colour alone.
92- [ ] Repeat the whole list on the **floor runtime**. Note any Liquid-Glass-only
93 difference. `[cross-runtime]`
94
95---
96
97## 2. Dynamic Type & reflow — up to Accessibility 5 `[P3]`
98
99Larger Text → drag to **maximum** (AX5). Walk every primary screen: Inspect
100(with results), Dashboard, Audit, History, Settings, Watchlist, a tracked-domain
101detail, Workflows.
102
103- [ ] All body text **scales up** (it already did pre-P3; confirm nothing is
104 pinned). The Dashboard tile numbers scale too. `[P3 fixed .system(size:)]`
105- [ ] **No clipped headings.** Empty-state titles ("No Portfolio Yet", "No Audits
106 Yet", "No History Yet", "No Tracked Domains", "No Batch Results Yet") **wrap
107 onto multiple lines** rather than truncating with "…". `[P3 Label→HStack]`
108- [ ] **No card requires horizontal scrolling.** Inspect result cards, the risk
109 card, and detail rows **wrap vertically**; there is no hidden left-right
110 gesture to reach content. `[P3 CardView reflow]`
111- [ ] Dense rows (`BatchResultRowView`, `WatchlistRowView`) remain **readable** —
112 text may be tall but must not overlap the trailing badge or clip. If it
113 does, that is the deferred `ViewThatFits` work, not a P3 regression — log it.
114 `[deferred]`
115- [ ] Every tappable control is at least **44×44pt** at default size. Spot-check
116 the **copy buttons** on data rows (the most-repeated control), the
117 **collapsible section headers**, and **Run**. `[P3 tap targets]`
118- [ ] Turn on **Bold Text**; confirm no layout breaks and contrast holds.
119- [ ] **Widget**: at AX sizes the widget content stays readable and is **not
120 truncated into nonsense** — it clamps at Accessibility 1 by design. `[P3 widget clamp]`
121- [ ] Repeat the clipping/reflow spot-checks on the **floor runtime**.
122
123---
124
125## 3. VoiceOver `[P4]`
126
127Enable VoiceOver. Learn the gestures if needed: swipe right = next element, swipe
128up/down on the **rotor** set to "More Content" = reveal extra fields, two-finger
129swipe up = read from top.
130
131### 3a. Icon-only controls announce a purpose, not a symbol name
132
133Focus each and confirm the spoken label. **None** should say a raw symbol name
134("arrow clockwise", "square and arrow up", "bolt circle").
135
136- [ ] Dashboard toolbar refresh → **"Refresh all tracked domains"**.
137- [ ] Inspect toolbar (after a lookup): clear → **"Clear results"**; actions menu
138 → **"Actions"**; export menu → **"Export"**.
139- [ ] Watchlist: add → **"Add domain"**; filter → **"Filter and sort"**.
140- [ ] History filter → **"Filter"**.
141- [ ] Timeline grouping → **"Group timeline"**.
142- [ ] Workflows: create → **"Create workflow"**; a run summary's export →
143 **"Export summary"**; re-run → **"Re-run workflow"**; a shared workflow's
144 person icon → **"Shared"**.
145
146### 3b. Toggles announce their state
147
148- [ ] Inspect **Save** (bookmark): label **"Save domain"**, value **"Not saved"**;
149 activate → value becomes **"Saved"** and the element reports **selected**.
150- [ ] Domain section **Pin**: label **"Pin domain"**, value toggles
151 **"Pinned"/"Not pinned"** and reports **selected** when pinned.
152- [ ] Audit checklist item: reports **selected** when complete, and a **hint**
153 ("Marks complete" / "Marks incomplete").
154- [ ] Audit area picker and Workflow domain picker rows report **selected** when
155 chosen.
156
157### 3c. Badges and headings
158
159- [ ] A status badge reads as **one word** — "Critical", "Valid", "Secure" — not
160 "icon, Critical". `[P4 badge combine]`
161- [ ] Set the rotor to **Headings**. Section titles ("Summary", "Risk", "DNS",
162 etc.) and the collapsible Inspect section headers are reachable as headings
163 and let you **jump between sections**. `[P4 .isHeader]`
164- [ ] A collapsible Inspect header announces **"Expanded"/"Collapsed"** as its
165 value with a hint, and toggling it updates the value. `[P4]`
166- [ ] Confirm the header's **trailing controls** (Track / Pin / Note on the Domain
167 section) are still **individually focusable** — they were deliberately *not*
168 merged into the header. `[P4 no combine on trailing]`
169
170### 3d. Dense rows — combine + More Content rotor (the flagship)
171
172On the **Watchlist** and a **Bulk** result list, with the rotor on **More Content**:
173
174- [ ] Each row is **one VoiceOver stop**, not eight. `[P4]`
175- [ ] The row's **label is the domain**; its **value is the status** (e.g.
176 "Registered" / "Critical, Registered").
177- [ ] Swiping up/down on More Content reveals the extra fields **in order**:
178 - Watchlist row: **Certificate** (spoken first, high importance),
179 Monitoring, Updated, Pinned.
180 - Batch row: **Risk** (high importance), IP address, Checked, Source,
181 Impact/Status.
182- [ ] Risk and Certificate are spoken **without** needing the rotor (high
183 importance); the rest wait for the swipe.
184
185### 3e. Speech for technical strings
186
187- [ ] Focus a **DNS record value** (Inspect → DNS section) and an SSL **Cipher
188 Suite** (Web section). Punctuation (`;`, `~`, `_`, `-`) is **spoken**, and
189 the string reads character/token-sensibly rather than as garbled prose.
190 `[P4 speechStyle .technical]`
191- [ ] Focus a plain prose value (e.g. Issuer common name) and confirm it is **not**
192 spelled out awkwardly — only technical rows get the treatment.
193
194### 3f. Completion announcements
195
196- [ ] Run a **single lookup**. On completion VoiceOver speaks **"Lookup complete
197 for `<domain>`. `<availability>`."** without you moving focus. `[P4]`
198- [ ] Run a **sweep / Check All**. On completion it speaks **"Sweep complete. N
199 domains, X changed, Y warnings."** `[P4]`
200- [ ] The announcements do **not** fire per-domain during a long sweep (would
201 flood the queue) — only once at the end.
202
203### 3g. Widget under VoiceOver
204
205- [ ] A medium/large widget domain row reads as one phrase, e.g. **"example.com,
206 critical, certificate expires in 12 days"** (or "pinned", or "certificate
207 expired"). `[P4]`
208- [ ] The small widget's count pills read **"N healthy"**, **"N warning"**,
209 **"N critical"** — not a bare number. `[P4]`
210
211### 3h. Full walkthrough with Screen Curtain
212
213Turn on Screen Curtain (triple-tap, 3 fingers — screen goes black). Complete the
214core journey **without looking**:
215
216- [ ] Inspect a domain → hear the sections → **Save** it → open **Watchlist** →
217 open its **detail** → back out. Every step is discoverable and every control
218 announces a purpose and state. Log anything that leaves you stuck.
219
220---
221
222## 4. Voice Control — label-in-name (WCAG 2.5.3) `[P4]`
223
224Enable Voice Control. Say the **printed** text of controls. Every visible-text
225control must respond to its visible name (this is why labels preserve visible
226text rather than replacing it).
227
228- [ ] "Tap **Run**" runs the lookup (not broken by a relabel).
229- [ ] "Tap **Track**", "Tap **Note**", "Tap **Compare**", "Tap **Cancel**",
230 "Tap **Save**" each work by their printed word.
231- [ ] Say **"Show numbers"**; confirm the icon-only controls get numbered overlays
232 and are operable (they have labels, so they also respond to "Show names").
233- [ ] No control is reachable *only* by a name that differs from its visible text.
234
235---
236
237## 5. Colour & contrast settings `[P1][P2][P5]`
238
239### 5a. Increase Contrast
240
241- [ ] Enable. Status colours and the accent shift to their **high-contrast**
242 variants; nothing becomes unreadable in either scheme. `[P1 HC variants]`
243- [ ] The Settings section headers that were marginal now clear — this is the one
244 the audit already measured (light 21 → 18). `[P2]`
245
246### 5b. Differentiate Without Color — the P5 payoff
247
248Enable. This is the pass that validates most of Phase 5.
249
250- [ ] **Dashboard summary tiles**: the small dot becomes a **per-filter symbol**
251 (grid, checkmark, triangle, octagon, refresh, clock, wifi-slash). `[P5]`
252- [ ] **Selected quick-filter chip** gains a **checkmark + border** — selection no
253 longer depends on fill colour alone. `[P5]`
254- [ ] **Inspect data rows** with a warning/failure value show a **leading symbol**
255 (triangle / octagon) before the value. `[P5 LabeledValueRow]`
256- [ ] Turning the setting **off** removes those extras (no permanent clutter). `[P5]`
257- [ ] **Widget** status already uses symbols regardless of this setting — confirm
258 each row shows checkmark/triangle/octagon, not a bare dot. `[P5]`
259
260### 5c. Smart Invert
261
262- [ ] Enable. UI inverts sensibly; images/icons that should stay un-inverted do.
263 Text stays legible. Note anything that inverts wrongly. `[verification]`
264
265---
266
267## 6. Motion & transparency `[P5]`
268
269### 6a. Reduce Motion
270
271Enable. Confirm each animated transition becomes an **instant** state change (no
272slide/fade):
273
274- [ ] Copy button check-mark swap (tap a copy button) — flips instantly. `[P5]`
275- [ ] Collapsible Inspect section expand/collapse — no ease animation. `[P5]`
276- [ ] Timeline scroll-to-section — jumps, no scroll animation. `[P5]`
277- [ ] Watchlist list reorder (change sort/filter) — no reflow animation. `[P5]`
278
279### 6b. Reduce Transparency
280
281- [ ] Enable. Trigger the **Data Management** success toast (Settings → Data
282 Management → perform a clear). Its pill background is an **opaque surface**,
283 not a blur. `[P5]`
284- [ ] On **iOS 26+**, confirm system-composited chrome (nav/tab bars) still reads
285 acceptably — the app can't declare that translucency itself. `[cross-runtime]`
286
287---
288
289## 7. iPad — Full Keyboard Access & split layout `[verification]`
290
291On iPad (regular width, so `RootTabView` shows the `NavigationSplitView`
292sidebar + detail). Enable Full Keyboard Access; attach or use the software
293alternative.
294
295- [ ] **Tab** moves focus in a **logical order** — sidebar → detail, top → bottom,
296 no traps.
297- [ ] The blue **focus ring** is visible on every focusable control.
298- [ ] Sidebar tab selection and detail controls are all reachable and operable by
299 keyboard.
300- [ ] Switching tabs via keyboard updates the detail pane correctly.
301
302---
303
304## 8. Cross-runtime sign-off `[cross-runtime]`
305
306- [ ] Sections 1, 2, 5, 6 re-checked on the **floor runtime** (classic chrome).
307- [ ] Sections 1, 2, 5, 6 checked on **iOS 26+** (Liquid Glass).
308- [ ] Any behaviour that differs between the two is logged below with the runtime
309 noted.
310
311---
312
313## Sign-off
314
315| Pass | 26+ (Liquid Glass) | Floor (classic) | Notes |
316| --- | --- | --- | --- |
317| 1 Baseline visual | | | |
318| 2 Dynamic Type / reflow | | | |
319| 3 VoiceOver | n/a-runtime | | do once on hardware |
320| 4 Voice Control | n/a-runtime | | do once on hardware |
321| 5 Colour & contrast | | | |
322| 6 Motion & transparency | | | |
323| 7 iPad keyboard | n/a | | iPad only |
324
325## Defect log
326
327Record failures here; each becomes an issue or a fix commit.
328
329| # | Pass / item | Device / runtime | Observed | Expected | Traces to |
330| --- | --- | --- | --- | --- | --- |
331| | | | | | |
332
333---
334
335## Known-deferred (not defects — expected gaps)
336
337- **`ViewThatFits` dense-row reflow** was not implemented (unverifiable in the
338 audit sim). If §2 shows dense rows overlapping/truncating at AX5 with real data,
339 that is this gap surfacing — file it against the deferred item, don't treat it
340 as a P3 regression.
341- **Live Activity / Dynamic Island** custom accessibility was intentionally not
342 added (ActivityKit exposes the labelled `ProgressView` already). Sanity-check a
343 running sweep's Live Activity reads acceptably, but a finding here is
344 enhancement, not regression.
345- **Localization** — all strings are English literals by design for now; a11y
346 strings were written `LocalizedStringKey`-compatible for a future catalog.
Docs/ACCESSIBILITY_VERIFICATION_RESULTS.md deleted −292
@@ -1,292 +0,0 @@
1# Accessibility Verification — Results (issue #21, Phase 6)
2
3Execution of `ACCESSIBILITY_VERIFICATION.md` against the iOS Simulator. The
4runbook was written for a human on a physical device; a meaningful fraction is
5beyond a simulator. Every item below is sorted into one of three tiers and
6treated 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
19was *observed* (a screenshot, an assertion, or a finding-count delta). *Verified
20by construction* (⚙️) means the code compiles and the pattern is right but the
21runtime 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
31Deployment target is 17.6; no 17.6/17.5 runtime is usable (the app cannot
32install below the floor), so 18.6 is the practical floor, per the runbook.
33
34Seed data via `DOMAIN_DIG_SEED_FIXTURES` + `DOMAIN_DIG_FORCE_PRO_PLUS` (DEBUG,
35in-memory, never persisted). **Note on tooling:** `simctl launch` with the seed
36argument did *not* populate the fixtures, and `simctl ui appearance` did not
37propagate to a headless-booted simulator. Both were worked around by driving
38everything through **XCUITest** (which seeds reliably) and switching appearance
39through the app's own Settings → Display picker. This is why the screenshots and
40metadata 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
481. **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.
552. **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.
593. **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`.
634. **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
71Evidence: `classic-dashboard-{light,dark}.png`, `lg-dashboard-{light,dark}.png`,
72`classic-batch-{light,dark}.png`, `lg-batch-{light,dark}.png`. Appearance driven
73through 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
89Evidence: `*-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`
105audits the seeded screens at `AccessibilityL`. Result: the `textClipped` findings
106it surfaced on the Risk badges (`Risk 12 Low`, `Risk 41 Medium`) are **also
107present at the default size** and absent at XXXL, so they are pre-existing
108reportOnly seeded-row findings, **not** a middle-band-exclusive third bug. No new
109gap found. The sweep is retained as regression insurance for a band that
110historically shipped two escaped bugs.
111
112## 3. VoiceOver `[P4]` — Tier 3, requires a physical device
113
114iOS VoiceOver **speech** does not run in the Simulator; macOS VoiceOver reading
115the simulator window is not equivalent and is not accepted as evidence. What the
116simulator *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.
134Verified 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
139Voice Control does not run in the Simulator. Label-in-name is partially
140*inferable* — every asserted `accessibilityLabel` in §3 preserves the control's
141visible text — but "say the printed word and it activates" must be confirmed on
142hardware.
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
155variants; 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
165The runbook and task both flagged this as possibly un-toggleable in a simulator.
166It **is** toggleable: `simctl ui` does not expose it, and a `defaults write` to
167the 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
169it via `UIAccessibility`:
170
171```sh
172xcrun simctl spawn <udid> defaults write com.apple.Accessibility DifferentiateWithoutColor -bool true
173```
174
175Captured 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
187Not exposed by `simctl`; not in the global-domain set that worked for DWC.
188Requires the Settings app / device.
189
190## 6. Motion & transparency `[P5]` — Tier 2
191
192Both settings **can be written** to the global `com.apple.Accessibility` domain
193(`ReduceMotionEnabled`, `ReduceTransparencyEnabled`) — the same mechanism proven
194to 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
203launch) is now known to work — DWC is fully verified with it. Motion and
204Transparency remain verified-by-construction because their effects are timing /
205transient, but the manual device pass for them is now optional rather than
206blocked: the same `defaults write` unblocks a scripted check with a screen
207recording.
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
217Captured 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
246The 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
273The 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
275appears **only** on iOS 27.0 (the 18.6 floor and the 26.x runtime CI runs are
276clean; `ACCESSIBILITY.md` already records this asymmetry as "dynamicType finding
27718.6 missed"). The carve-out is scoped to `.dynamicType` on the exact Settings
278section-header titles, so a real regression on app-controlled text still
279enforces — matching the existing "system field placeholder" and system-header
280contrast 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 deleted

Binary file not shown.

Docs/a11y-screenshots/classic-batch-dark.png deleted

Binary file not shown.

Docs/a11y-screenshots/classic-batch-light.png deleted

Binary file not shown.

Docs/a11y-screenshots/classic-dashboard-axxxl.png deleted

Binary file not shown.

Docs/a11y-screenshots/classic-dashboard-dark.png deleted

Binary file not shown.

Docs/a11y-screenshots/classic-dashboard-light.png deleted

Binary file not shown.

Docs/a11y-screenshots/classic-watchlist-axxxl.png deleted

Binary file not shown.

Docs/a11y-screenshots/lg-batch-axxxl.png deleted

Binary file not shown.

Docs/a11y-screenshots/lg-batch-dark.png deleted

Binary file not shown.

Docs/a11y-screenshots/lg-batch-light.png deleted

Binary file not shown.

Docs/a11y-screenshots/lg-dashboard-axxxl.png deleted

Binary file not shown.

Docs/a11y-screenshots/lg-dashboard-dark.png deleted

Binary file not shown.

Docs/a11y-screenshots/lg-dashboard-light-differentiate.png deleted

Binary file not shown.

Docs/a11y-screenshots/lg-dashboard-light.png deleted

Binary file not shown.

Docs/a11y-screenshots/lg-watchlist-axxxl.png deleted

Binary file not shown.

SECURITY.md +3 −3
@@ -4,8 +4,8 @@
4 4
5|Version|Supported| 5|Version|Supported|
6|-------|---------| 6|-------|---------|
7| 1.x | ✅ Yes | 7| 4.x | ✅ Yes |
8| < 1.0 | ❌ No | 8| < 4.0 | ❌ No |
9 9
10--- 10---
11 11
@@ -14,7 +14,7 @@
14If you discover a security vulnerability, **do not open a public issue**. 14If you discover a security vulnerability, **do not open a public issue**.
15Instead: 15Instead:
16 16
171. **Email** your report to [security@cleberg.net](mailto:security@cleberg.net). 171. **Email** your report to [security@zerolabs.sh](mailto:security@zerolabs.sh).
18 Include: 18 Include:
19 - A detailed description of the vulnerability 19 - A detailed description of the vulnerability
20 - Steps to reproduce 20 - Steps to reproduce