74 lines
21 KiB
Markdown
74 lines
21 KiB
Markdown
# Accessibility: text that scales, controls that are named and large enough, every gesture with an alternative, and an audit that fails the build
|
|
|
|
**Status: MANDATORY for every app and every UI framework.** An app is used by people who see large text, no screen, little contrast, one finger, a switch, a voice or a keyboard. The app is built for them from the first view and checked by a test that fails, not by a look at the end. A UI framework (a split view, a tree, an editor) has a platform-neutral core and a surface for each renderer; the core says what is said and what can be done, and every surface is required to do it.
|
|
|
|
Each rule below carries its basis: **documented** (Apple documentation or the Human Interface Guidelines, named so it can be read), **observed** (found by building and running it, with the number), or **design** (a choice, with the reason). Everything marked documented was read in the Cupertino corpus: the Human Interface Guidelines pages Accessibility and VoiceOver, the article Performing accessibility testing, the documentation of `XCUIApplication.performAccessibilityAudit(for:_:)` and `XCUIAccessibilityAuditType`, and the SwiftUI accessibility modifiers.
|
|
|
|
## Why
|
|
|
|
- **Accessibility added at the end is a list of findings.** The first audit of an iPad app that had been built carefully, in every other respect, reported 300 findings on five screens (small tap targets, text that did not follow the system's size, secondary text that was too faint, controls named by the default name of their symbol). Each was cheap when the view was written and expensive afterwards.
|
|
- **A name that is wrong is worse than no name.** The buttons that expand and collapse every row of an outline were announced as "Enter Full Screen" and "Exit Full Screen", the default descriptions of the arrow symbols they drew. A person using VoiceOver was told to do something the button did not do.
|
|
- **A gesture with no alternative is a locked door.** Dragging a divider to resize panes and dragging a header to rearrange them cannot be done with VoiceOver, Switch Control or the keyboard. Where a gesture is the only way, the feature is not there for those people.
|
|
- **A framework that leaves it to each surface gets it right once.** A split-view framework with a SwiftUI surface, a layer surface and an AppKit/UIKit surface had each surface decide its own names and sizes, and the surfaces disagreed (a 16 point control on two, a 28 point divider on one). The words and the sizes belong in the core, once, with a test.
|
|
|
|
## Rules
|
|
|
|
1. **Text follows the system's text size, through text styles.** Every piece of text in a view is a text style (`.body`, `.footnote`, `.caption` and the others) or a font made with `relativeTo:`, never `.font(.system(size:))` alone. The Human Interface Guidelines say to support larger text sizes, at least to 200 percent, and to use the system's Dynamic Type; the default and the least sizes are 17 and 11 points on iOS and 13 and 10 on macOS. A size that is not a text style may be used where the system has no text size setting (macOS), in a branch that says so with a comment `a11y: fixed size` on the same line (acceptance check 1). *Basis: documented (HIG, Accessibility; Typography); observed (an iPad app whose fonts were `.system(size:)`, even with `@ScaledMetric` for the size, was reported by the audit for 69 elements as "Dynamic Type font sizes are partially unsupported", though its text did grow at the largest accessibility size; moving the fonts to text styles left 5).*
|
|
2. **An app's own zoom composes with Dynamic Type, never replaces it.** An app that lets the person zoom its text (Command-plus) applies the zoom on a touch screen as a number of steps of Dynamic Type, added to the system's size (`DynamicTypeSize.allCases` is ordered, so the shift is index arithmetic, clamped at both ends), applied once at the outermost window (an environment flag stops an inner window shifting it again). On the Mac, which has no Dynamic Type, the zoom multiplies the point size. Metrics that are not text (icon sizes, heights, spacing) use `@ScaledMetric(relativeTo:)`, so they grow with the text. *Basis: documented (SwiftUI `ScaledMetric`, `DynamicTypeSize`, `dynamicTypeSize(_:)`); design (the zoom as a shift, so that one setting does not fight the other).*
|
|
3. **Every control has a name that says what it does.** A control with no text on it (an icon button) is built as `Button("Close All", systemImage: "...") { }` with `.labelStyle(.iconOnly)`, or has `.accessibilityLabel`. The name starts with a verb, does not say "button", and is the same name on every surface that shows the control (a core owns it, rule 11). A symbol's default description is never the name. A hint says what the control does when that is not clear from the name. A list or an outline is named too: on the Mac a SwiftUI `List` is an `NSOutlineView`, and the audit reported each as an element with no description until it had `.accessibilityLabel("Files")` or `("Outline")`; a text field whose only text is a placeholder has no label either, and gets one. A column drawn beside text (a ruler of line numbers) is not an element of its own and is hidden. *Basis: documented (HIG, VoiceOver: alternative labels for all key elements; Voice Control users say the name to tap the control); observed (the "Enter Full Screen" case above; on the Mac, two outlines, a search field and a ruler reported with no description).*
|
|
4. **A tap target is at least 28 by 28 points on iOS and 20 by 20 on macOS, whatever is drawn in it.** The glyph may be 16 points; the target is the rule. A modifier gives an icon the target without changing the drawing (`.frame(minWidth: 28, minHeight: 28).contentShape(Rectangle())`). A bar a finger drags (a divider) is hit across 28 points on a touch screen and 16 with a pointer. The default size of a control on iOS is 44 by 44, and the padding around a bezeled element is about 12 points. *Basis: documented (HIG, Accessibility: control sizes, iOS default 44 by 44 and least 28 by 28, macOS 28 and 20); observed (118 "Hit area is too small" findings on five screens: 16 point header controls, 16 point disclosure chevrons, and 16 by 15 point icon buttons; none after the targets were raised).*
|
|
5. **Every gesture has an alternative that needs no gesture.** A drag to rearrange is a set of named actions on the element (`.accessibilityActions`, a custom action in UIKit, `NSAccessibilityCustomAction` in AppKit): "Swap with Source", "Maximize pane", "Close pane". A drag to resize is an adjustable element: `.accessibilityAdjustableAction` in SwiftUI, `accessibilityIncrement()` and `accessibilityDecrement()` in UIKit, `accessibilityPerformIncrement()` in AppKit, each moving the value by a fixed step and the value read as a percentage. On the Mac a bare element with an adjustable action has no role (the audit reports "Unknown role"), so there the divider is represented as a slider (`accessibilityRepresentation` with a `Slider` stepped by the same step); on iOS the representation made the audit report a slider with nothing drawn, so iOS keeps the adjustable action. The actions and the steps are defined once in the core and tested there. *Basis: documented (HIG, Accessibility: provide alternatives to gestures; VoiceOver; SwiftUI `accessibilityAdjustableAction`, `accessibilityActions`, `accessibilityRepresentation`); observed (the Mac audit's "Unknown role" for the bare element, and the iOS findings with no element for the slider).*
|
|
6. **Put the name and the actions on the element a person reaches, not on a container that holds controls.** A pane is reached by its title bar: one element named by the title, valued by its position and state ("2 of 3, focused"), with the actions of rule 5. Wrapping the whole pane in `.accessibilityElement(children: .contain)` with actions made the audit count every control and every line of text inside it as a small tap target (93 more findings on one app), and took the description of the pane's content from the host that owns it. Decoration (a symbol beside a title, a large number in an empty pane, a status symbol beside a word that says the same) is hidden with `.accessibilityHidden(true)`. *Basis: observed; documented for hiding decoration (HIG, VoiceOver: hide decorative elements, describe meaningful images).*
|
|
7. **Contrast is at least 4.5 to 1 for text up to 17 points, and 3 to 1 for text of 18 points or bold, in Light, in Dark and with Increase Contrast.** Secondary text is not the system's `.secondary` on a tinted or translucent surface: it is the primary color at a strength that keeps the ratio on the lightest and darkest panes the app draws, and stronger when Increase Contrast is on (a `ShapeStyle` that resolves from the environment). A state shown by a color (good, wrong, to watch) has a darker variant in Light that keeps the ratio, and is never shown by color alone: it has a symbol and a word. Contrast is a number and is tested as one: the WCAG formula over the colors' own components, against the extreme backgrounds, in Light and Dark. *Basis: documented (HIG, Accessibility: color and contrast; Dark Mode and Increase Contrast; not color alone); observed (the audit reported 59 contrast findings, mostly secondary text; a text color of 72 percent primary, 88 with Increase Contrast, computed at 4.5 to 1 or better on backgrounds from white to a mid gray in Light and from black to a dark gray in Dark, removed all but a few).*
|
|
8. **A change a person cannot see is announced.** When the layout changes (a pane is maximized, restored or closed), the app posts a short sentence (`AccessibilityNotification.Announcement` in SwiftUI, `UIAccessibility.post(notification: .announcement, argument:)` in UIKit, `NSAccessibility.post(element:notification:userInfo:)` with `.announcementRequested` in AppKit). *Basis: documented (HIG, VoiceOver: announce changes; SwiftUI `AccessibilityNotification`).*
|
|
9. **Reduce Motion turns springs and slides into a snap.** A view that animates its layout reads the setting (`accessibilityReduceMotion` in SwiftUI, `UIAccessibility.isReduceMotionEnabled`, `NSWorkspace.accessibilityDisplayShouldReduceMotion`) and does not animate while it is on; a host that chose no animation keeps it off whatever the system says. *Basis: documented (HIG, Accessibility: Reduce Motion; the settings list of Performing accessibility testing).*
|
|
10. **UI drawn on layers is exposed element by element.** A surface that draws panes, dividers and glyphs on `CALayer`s has no views to expose, so it builds accessibility elements (`UIAccessibilityElement` with the frame in container space, `NSAccessibilityElement` with the frame in parent space) from the same descriptions after every layout change, with activate, increment and decrement wired to the same operations as a tap and a drag. *Basis: documented (UIKit and AppKit accessibility element APIs); observed (the elements' labels and frames are read back in a headless test).*
|
|
11. **The core describes, the surfaces realize, and the protocol makes a surface say so.** A UI framework with a platform-neutral core puts in the core, with no platform framework: the words of every control (label and hint), the minimum target size, the description of each element (label, value, actions), the step of an adjustable element, and the announcement sentences. It defines a protocol each surface must adopt that declares which duties it fulfils (an option set: element per pane, control labels, adjustable dividers, actions, announcements, reduced motion), and a test per surface requires the full set, so a new surface cannot be added without saying, and a duty cannot be dropped without a failing test. Platform setters that differ between AppKit and UIKit (label, value, hint, custom actions, announcement, Reduce Motion) live once in the cross-platform layer (AppUIKit, `ui/appuikit.md`). *Basis: design; documented for the underlying APIs.*
|
|
12. **The audit is a UI test that fails.** Every screen and state of an app is audited in a UI test with `app.performAccessibilityAudit()` (iOS 17, macOS 14, Xcode 16.3), at least: the first screen, a document open, a selection made, each sheet, each tab of a phone, portrait and landscape, and Dark. The handler lets a finding pass only if it is on a ledger of known findings kept in the test target, each entry with the audit type, the element's label, the screens it is allowed on and the reason, and the reason says when the cause is not found. A finding not on the ledger fails the test, so nothing new gets in, and a fix is the deletion of its line. An entry may also name the kinds of element it covers (a disabled container, the Touch Bar the system adds on the Mac), and a finding with no element is let pass only by an entry with no label. A disabled control is let pass for contrast (inactive components are exempt in WCAG 1.4.3). Each audited screen is kept as a picture, and **a finding is checked against the picture before it is believed or listed**: measure the pixels (the text color against its background, with the WCAG formula) and record a finding the pixels contradict as a false positive with the measured ratio (the audit reported "Contrast failed" for titles that measure 13.9 to 1 and labels that measure 17.1 to 1). The audit enlarges the text while it checks; an app whose layout adapts at accessibility sizes (rule 14) changes while it is audited, so findings arrive without an element, and those are listed on their own, narrowly, with the reason. The macOS SDK has no member for the text-clipped and Dynamic Type checks, so a ledger shared with the Mac names them by their raw values (131072 and 65536, observed). The Mac app is audited too (`performAccessibilityAudit` is available from macOS 14); it found, on a window that had passed on the iPad, the divider with no role, two outlines and a search field with no description, and a ruler. *Basis: documented (`performAccessibilityAudit`; Performing accessibility testing: automate with audits in UI tests); design (the ledger); observed (the Mac findings and the pixel measurements).*
|
|
13. **What the audit cannot do is done by hand and said so.** VoiceOver, Voice Control, Switch Control and Assistive Access need a physical device; the Simulator does not run them. The audit is run at the default text size and in Dark; it does not read a screen as VoiceOver does and does not judge whether a name is a good one. An app that has not been used with VoiceOver on a device says "audited, not used with VoiceOver", never "accessible". *Basis: documented (Performing accessibility testing: the settings and the tools that need a device).*
|
|
14. **Layout at accessibility text sizes is part of the work.** At the largest accessibility size (AX5) a window of several panes cannot show them side by side. The arrangement changes (one pane at a time, a list instead of a row, a scroll view), and no information is carried only by a string that is cut off. A title that is cut is wrapped or reached another way. The decision is a pure function of the width and the text size (`usesPages(isCompactWidth:textSize:)`) and is tested over every size. *Basis: documented (HIG, Accessibility: support larger text; avoid truncation); observed (an iPad app at AX XXXL showed pane titles cut to one letter and the outline's rows to "r..." in the side-by-side arrangement; after the window took the phone's arrangement at accessibility sizes, the iPad showed the file list as one full-width page with readable text; looked at for the first screen only).*
|
|
|
|
## How to test it
|
|
|
|
- **The descriptions in the core.** Build a layout of three panes and require: each visible pane described once in reading order with its position and its focus; its actions are maximize or restore, close (only when the host allows closing) and a swap with each other pane; performing a swap action exchanges the two panes; a maximized pane is the only one described and has a restore action and no swaps; each divider's label names its two sides and its value is the percentage; an increment moves it by one step and a decrement back; a step never leaves 0 to 1 for any start; a side holding several panes is named by its count; every control role has a label, a hint, and a minimum target of at least 28.
|
|
- **Each surface.** Require that its protocol value is the full set; read back from the surface what the core described: labels and values of the elements (SwiftUI: through the model; layer view: the elements' labels, frames and handlers; native views: the labels and values on the views), that incrementing a divider element changes the ratio, that activating the close element closes the pane, that the close target is at least 28 and fits the header. Run on macOS and on an iOS simulator, because the AppKit and UIKit branches are different code (a branch that was never compiled for iOS hid a build failure).
|
|
- **The cross-platform setters.** Read back what was set (label, value, hint), the names and order of custom actions, that each runs its handler and that setting none clears them. Note: an `NSTextField`'s accessibility value is its text and ignores a value set on it, so state that must be spoken goes into the label there.
|
|
- **Dynamic Type.** The shift is tested as a pure function: zero changes nothing, steps move along the ordered sizes, a shift past either end stops at the end, up and down by the same steps returns the size. The zoom's step offset is tested from the normal size to both ends with no gap.
|
|
- **Contrast as arithmetic.** Compute the WCAG ratio from the style's own numbers against the lightest and darkest backgrounds, in Light and Dark, and with Increase Contrast; require 4.5.
|
|
- **Reduce Motion.** Switch the model's setting and require that animation is off and restored, and that a host's choice of no animation wins.
|
|
- **The audit.** The UI tests of the iPad (landscape, portrait, Dark) and iPhone (each page, each sheet) run it with the ledger.
|
|
- **By eye, at the largest size.** Set the simulator's text size (`xcrun simctl ui <device> content_size accessibility-extra-extra-extra-large`), launch, and look; set it back (`content_size large`) when done.
|
|
|
|
## Acceptance check
|
|
|
|
Run from the repository root. `SOURCES` are the Swift sources of the views, `UITESTS` the UI test folders. Every command must print nothing, except where it says otherwise:
|
|
|
|
```sh
|
|
# 1. no fixed point size of a font without the marker that says why (the Mac has no Dynamic Type)
|
|
grep -rnE 'font\(\.system\(size:|\.system\(size:' "$SOURCES" --include='*.swift' | grep -v 'a11y: fixed size'
|
|
|
|
# 2. no icon-only button named by its symbol: a Button whose label is just an Image must carry accessibilityLabel in the lines after it (or be a Button(title, systemImage:))
|
|
find "$SOURCES" -name '*.swift' -exec perl -0ne 'while (/(Button *\{[^}]*\} *label: *\{ *Image\(systemName[^\n]*\n(?:[^\n]*\n){0,6})/g) { print "$ARGV: $1\n" unless $1 =~ /accessibilityLabel/ }' {} +
|
|
|
|
# 3. every app target that has UI tests has an audit in them (this must print the files that have one)
|
|
grep -rl 'performAccessibilityAudit' "$UITESTS" --include='*.swift'
|
|
|
|
# 4. the audit's handler is never a bare "return true": a ledger decides
|
|
grep -rnE 'performAccessibilityAudit *\{[^}]*return true *\}' "$UITESTS" --include='*.swift'
|
|
|
|
# 5. the core of a UI framework imports no platform framework and declares the duties (this must print the protocol)
|
|
grep -rn 'protocol .*AccessibilityRendering' "$CORE" --include='*.swift'
|
|
grep -rnE '^import (SwiftUI|UIKit|AppKit)' "$CORE" --include='*.swift'
|
|
|
|
# 6. no color given to text from the bright system palette without a contrast test beside it
|
|
grep -rnE 'foregroundStyle\(\.(red|orange|green|yellow)\)|foregroundStyle\(Color\.(red|orange|green|yellow)\)' "$SOURCES" --include='*.swift'
|
|
|
|
# 7. the test suites pass on macOS and on an iOS simulator (the two build different branches)
|
|
swift test
|
|
xcodebuild test -scheme "<package scheme>" -destination 'platform=iOS Simulator,id=<id>'
|
|
```
|
|
|
|
Promote commands 1, 2, 4 and 6 to a build-failing script with a self-test (a fixture that breaks each must fail it; a fixture that obeys all must pass it), and the tests above to the project's test suite. The check passes when the commands print as stated, the tests pass on both platforms, the audit tests pass with their ledger, and the ledger's entries each have a reason. The claim "accessible" is made only with rule 13 stated: audited at the default size and in Dark (the Mac in its own appearance), not audited at the largest accessibility size, and not used with VoiceOver on a device, until each has been done. Rule 14 is established only for the screens that have been looked at at AX5 (the first screen of the iPad app); the others are not.
|
|
|
|
Cross-references: `help.md` (the help and About are screens the audit covers; the spoken form of the version), `ui/appuikit.md` (the cross-platform setters for label, value, hint, actions, announcement and Reduce Motion), `ui/three-renderers.md` (the three surfaces a core serves, each required to fulfil the duties), `ui/swiftui-views.md` and `ui/uikit-views.md` (the views the rules apply to), `testing.md` and `core/testing-discipline.md` (the tests), `core/oracle-first.md` (the core's descriptions and the contrast arithmetic are the answer keys the surfaces are held to), `core/proof-discipline.md` (the ledger is the named frontier: each entry is a measured finding with its cause found or not), `core/no-shortcuts-first-principles.md` (a finding is fixed or listed with its reason, never silenced).
|