How to check whether the app is usable with VoiceOver, and how the CI ratchet keeps it from getting worse.
Background: ticket 32072 — "Please add support for screenreaders. The tabs at the bottom, as well as buttons, need to be labeled so that screenreaders can read them."
scripts/a11y_audit.py is a static audit. Python 3.8+, stdlib only, no Xcode,
runs in about a second over the whole repo.
scripts/a11y_audit.py # full report
scripts/a11y_audit.py --summary # counts per rule
scripts/a11y_audit.py --rule A11Y004 # one rule (repeatable)
scripts/a11y_audit.py --list-rules # what it checks and how to fix each
scripts/a11y_audit.py --json out.json # machine-readable
scripts/a11y_audit.py --self-test # verify the rules themselves still workScope it while you work on one screen:
scripts/a11y_audit.py --path "DashWallet/Sources/UI/Payments"| Rule | Sev | What it catches |
|---|---|---|
| A11Y001 | P0 | UITabBarItem(title: nil) with no accessibilityLabel |
| A11Y002 | P1 | Icon-only UIBarButtonItem with no label |
| A11Y003 | P1 | UIButton that only ever gets setImage, no title, no label |
| A11Y004 | P1 | SwiftUI Button whose label is only an image/shape |
| A11Y005 | P1 | Toggle with an empty label |
| A11Y006 | P1 | Custom back button replacing UIKit's automatically labeled one |
| A11Y007 | P2 | .system(size:) with no relativeTo: — ignores Dynamic Type |
| A11Y008 | P0 | extension Font shadowing an Apple text style |
| A11Y009 | P1 | accessibilityLabel with a bare, unlocalized string |
| A11Y010 | P1 | accessibilityLabel key missing from en.lproj |
| A11Y011 | P1 | Image-only control in a xib/storyboard with no label |
| A11Y012 | P1 | .onTapGesture on a view with no accessibility treatment |
All three SwiftUI button forms are inspected — the trailing-closure
Button(action:) { … }, the two-closure Button { … } label: { … }, and
Button(action:label:) — so an icon-only label cannot hide in the form it was
written with.
What it does not check — these still need a human with VoiceOver on a device:
- whether a label reads well (an accurate label can still be a bad one)
- reading order, focus movement, and whether a modal traps focus
- whether state changes are announced (
UIAccessibility.post) — the app has zero such announcements today, so nothing tells a blind user that a copy succeeded, a PIN was wrong, or a payment went through - custom controls that need an
.adjustabletrait (sliders, steppers) - anything that only exists at runtime
A quick VoiceOver spot-check can also mislead you: an unlabeled button does not go silent, it announces the image asset name — "icon copy outline", "diagonal-up-down". It sounds like something is there.
.github/workflows/accessibility.yml runs on every PR that touches app code.
It fails only on findings that are not in scripts/a11y_baseline.json. The
existing debt is recorded there, so the check can be adopted today without
fixing everything first — but a PR cannot add a new unlabeled control.
On a pull request both the baseline and a11y_config.json are read from the
base branch, not from the PR checkout. Otherwise a PR could introduce a
regression and, in the same commit, either baseline its fingerprint or disable
the rule that catches it — and the check would pass on its own weakened gate.
A consequence worth knowing: a config change takes effect only once it is
merged. If a PR genuinely needs its own config to pass — say it adds a wrapper
component and lists it in labeled_wrappers — either land the config change
first, or add the a11y-config-change label to have the PR checked with its own
config.
New P2 findings (Dynamic Type) are printed as advisory and do not fail the
build. Change that with --fail-on all once the P2 debt is paid down.
Baseline entries are keyed by a hash of the code snippet, not by line number, so moving code around or reindenting a file does not create false failures. They are matched as a multiset: two identical unlabeled buttons in one file share a fingerprint, so the baseline records the count and a third copy still fails.
-
Fix it. Almost always one line —
--list-rulesprints the fix for each rule. -
Or suppress it deliberately, on the offending line or the one above:
// a11y-ignore: A11Y004 decorative chevron, the row itself carries the label
The rule id and a reason are both required.
-
Never regenerate the baseline to silence a new finding. That is what the baseline is for, and it is the one thing that makes the ratchet useless.
scripts/a11y_config.json exists so the rules bend to the codebase rather than
the other way round. Every field is optional:
| Field | Use it for |
|---|---|
labeled_wrappers |
our own components that set an accessibility label internally — a view built from one counts as already announced |
speaks_for_itself |
components whose API requires a title, so they always read |
disabled_rules |
a rule that does not apply to this project at all |
advisory_rules |
a rule that should report but never fail CI |
exclude_paths |
path prefixes to skip (generated code, debug-only screens) |
allowed_font_shadows |
Font tokens we knowingly keep despite shadowing an Apple text style |
Prefer this over scattering a11y-ignore comments: a wrapper listed once covers
every call site, and the claim is reviewable in one place. DashStepper and
SegmentedControl are listed today because both genuinely set their own labels.
Note on custom fonts and icons. The audit does not flag either. There are no
bundled font files in the repo and no Font.custom / UIFont(name:) call sites
— our "custom fonts" are tokens over the system face, and the 159 dw_font
helper calls are never flagged because dw_font(forTextStyle:) scales
correctly. Custom icons are only ever reported when they are the entire label
of a tappable control, which is exactly the case a screen reader cannot resolve.
A11Y008 is not about having our own tokens either: it fires only where a token's
name collides with one of Apple's, and allowed_font_shadows turns it off.
scripts/a11y_audit.py --write-baselineThis banks the progress so the fixed entry can never come back unnoticed. The
--check output tells you when there is progress worth banking.
From the full audit (see ticket 32072), the defects concentrate in a handful of shared components rather than being spread across screens. Fixing these repairs an estimated 60–70 screens:
| Component | Defect |
|---|---|
UI/Views/Navigation/BaseNavigationController.swift |
replaces UIKit's automatically labeled system Back button with an unlabeled image button |
UI/SwiftUI Components/NavigationBar.swift |
back/close/plus/info built from a bare Icon — the DashUIKit original does set the label; this copy dropped it |
UI/SwiftUI Components/MenuItem.swift |
Toggle(isOn:) { } with an empty label — every settings switch |
UI/SwiftUI Components/DashAmount.swift |
currency symbol is an image, so amounts never say "Dash" |
UI/Views/SharedViews/Keyboard/NumberKeyboardButton.swift |
keys are plain UIViews with no button trait; the delete key is an image attachment |
UI/SwiftUI Components/Font+DWStyle.swift is the Dynamic Type root cause: it
declares an extension Font whose members shadow Apple's body, headline,
largeTitle, title2, title3, callout, footnote and caption2. It
compiles silently and wins inside the app module, so .font(.body) — which
looks entirely correct — stops scaling. Any Dynamic Type work starts there.
UI/SwiftUI Components/DashStepper.swift is the reference: it sets a label, a
value, a hint, and custom actions. Copy its shape.
Button(action: onClose) {
Icon(name: .custom("icon_close"))
}
.accessibilityLabel(Text(NSLocalizedString("Close", comment: "Accessibility")))- A control with visible text needs nothing — VoiceOver reads the title.
- An icon-only control always needs
.accessibilityLabel. accessibilityIdentifieris for XCUITest and is never spoken. It is not a substitute for a label.- A composite row (icon + title + value + chevron) should be one element:
.accessibilityElement(children: .combine), or.ignoreplus an explicit label. - Every label is a user-visible string: wrap it in
NSLocalizedStringand let it reach Transifex, or it ships English-only across 43 locales.