# Accessibility verification The whole of DESIGN/10-accessibility.md § Verification, in one place: the automated audit suite, the manual VoiceOver smoke script, and the m11 checklist that the four implementation cards each left behind. Run it top to bottom. It lives beside `AccessibilityAuditTests.swift` because the design says it should — "a manual VoiceOver smoke script lives with the test plan" — and because the automated and manual halves are one pass, not two. ## Contents 1. [Before you start](#before-you-start) 2. [Part 1 — run the audit suite](#part-1--run-the-audit-suite) 3. [Part 2 — the VoiceOver smoke script](#part-2--the-voiceover-smoke-script) 4. [Part 3 — the m11 checklist](#part-3--the-m11-checklist) - [3.1 The VoiceOver tree and its actions](#31-the-voiceover-tree-and-its-actions) - [3.2 Live announcements](#32-live-announcements) - [3.3 Text scaling, visual accommodations, Full Keyboard Access](#33-text-scaling-visual-accommodations-full-keyboard-access) - [3.4 Runtime contrast and palette ink](#34-runtime-contrast-and-palette-ink) 5. [Recording the result](#recording-the-result) ## Before you start Run the whole pass **per release**. Part 2 is the canonical "does the board actually work blind" check; nothing else in this repository substitutes for it. You need a real, unlocked display and a real keyboard — none of this survives a headless or screen-shared session. Grant the test runner Accessibility control under System Settings ▸ Privacy & Security ▸ Accessibility before Part 1, or every audit fails on its first click for a reason that has nothing to do with accessibility. VoiceOver's modifier is written **VO** throughout and is Control-Option by default. Turn VoiceOver on with ⌘F5. Have a scratch board to hand for Part 2 — a new one from File ▸ New Board… ▸ Basic is ideal, because the script creates and destroys everything it touches. ## Part 1 — run the audit suite `KanbanUITests/AccessibilityAuditTests.swift` runs Xcode's accessibility audit over all eight surfaces the design names. **Violations are test failures, not warnings**, and nothing is waived: the audits pass no issue handler at all. ``` xcodebuild test -project Kanban.xcodeproj -scheme Kanban \ -destination 'platform=macOS,arch=arm64' \ -only-testing:KanbanUITests/AccessibilityAuditTests ``` The eight surfaces, and how each test gets there: | Test | Surface | Navigation | | --- | --- | --- | | `testBoardWindowWithTrashHidden` | Board window, default state | The fixture board opens at launch | | `testBoardWindowWithTrashShown` | Board window, trash column shown | View ▸ Show Trash | | `testCardWindowPreviewMode` | Card window, Preview | ↓ then → to select, Board ▸ Open Card | | `testCardWindowEditMode` | Card window, Edit | …then View ▸ Edit Body | | `testCardWindowRawSourceMode` | Card window, raw source outlet | …then View ▸ Raw Source | | `testWelcomeWindow` | Welcome, with a recents row | Window ▸ Welcome to Lanework | | `testTemplateChooser` | Template chooser | File ▸ New Board… | | `testBoardInfoPopover` | Board popover | File ▸ Board Info | Every test launches the app with `--ui-test-fixture-board`, which makes the app build a known board inside its own container and open it — three lanes ("To Do", "Doing", "Done"), six cards, one card with a rich Markdown body and an attachment, one card already in the trash. The board and the registry both live in a scratch directory that is wiped on every launch, so an audit run never touches your real boards or your recents list. See `Kanban/App/UITestLaunch.swift` for why the board cannot simply be handed to the app on the command line (the sandbox). If a test fails, read the issue's `compactDescription` and fix the app. Adding a waiver is a design change and needs an entry on the Redesign board first. ## Part 2 — the VoiceOver smoke script Nine steps, in one sitting, on a scratch board. Expected speech is quoted from `Kanban/UI/AccessibilityPhrases.swift`; where VoiceOver adds its own words (role names, "selected", "button") they are shown in [brackets]. Start with VoiceOver on (⌘F5), the scratch board frontmatter, and the trash hidden. **0. Land on the board.** Press VO-→ until the VoiceOver cursor reaches the lane strip. > Each lane reads **"To Do, lane, 2 cards"** — title, the word "lane", the plural-folded count. The count is the *filtered* count, the same number the visible badge shows. Interact with a lane (VO-⇧-↓) to reach its cards; each card is **one** element reading just its title — no separate icon, stripe, or chip stops. **1. Create a lane.** ⇧⌘N (File ▸ New Lane). > The new lane arrives with its title editor focused. Type `Later` and press Return. The lane now reads **"Later, lane, 0 cards"**. **2. Create a card.** With the new lane still active, ⌘N (File ▸ New Card). Alternatively VO-→ to the lane header's one child button, which reads **"New card in Later" [button]**, and VO-Space it. > The card placeholder appears with its editor focused. Type `Smoke test` and press Return. VO-→ onto it: **"Smoke test"**. No value is spoken — it has no attachments and is not cut. **3. Rename it.** With the card selected, open its context menu with VO-⇧-M and choose **Rename** — or use Board ▸ Rename. > The menu offers exactly **Open**, **Rename**, **Style…**, the quick-style row, **Delete**. Rename puts the caret in the title. Type `Smoke test, renamed` and press Return; the element now reads the new title. **4. Cut and paste it into another lane.** ⌘X, then arrow to a card in a different lane, then ⌘V. > On ⌘X the card's value gains **"cut, pending paste"** — the dim is the sighted signal, this is the other one, and hearing it is the point of the step. Move with plain arrows: the VoiceOver cursor and the app's selection are independent, so arrow to the destination and confirm the selection moved by listening for **[selected]** on the target. ⌘V lands the card **after** the anchor card; VO-→ over the destination lane and confirm the card is now in it, its "cut, pending paste" gone, and the source lane's spoken count has dropped by one. **5. Let an external edit land.** In a terminal, edit the board on disk — for example, append a line to a card's `index.md` body, or `mkdir` a new lane folder with an `index.md`. Wait for the reload debounce (about a second). > One polite, non-interrupting sentence, and only one: **"Board changed: 1 card edited"** — or whatever the digest counts, in the fixed order cards-before-lanes and edited/added/moved/deleted within each. A change no bucket counts (renaming the board) says just **"Board changed"**. Your own edits in the app must stay **silent**; if you hear a digest after clicking around in the app, that is a bug. > > Now delete, from the terminal, the very card the VoiceOver cursor is on. Expect **"Card 'Smoke test, renamed' was deleted externally"**, and focus recovering to that card's lane. Delete a whole lane the cursor is inside and expect **"Lane 'Later' was deleted externally, with 3 cards"** — the *lane* named, not a card — with focus landing on the lane that now occupies its position. **6. Delete a card.** Select a card and press ⌘⌫ (File ▸ Delete). > **No confirmation** — a board delete is recoverable. The card leaves the lane and the lane's spoken count drops. **7. Restore it from the trash.** View ▸ Show Trash, then arrow to the trashed card, ⌘X, arrow to a live lane, ⌘V. > Toggling the column announces its resulting state: **"Trash shown"** (and **"Trash hidden"** on the way back — the state, never the action). The column is the **last** container and reads **"Trash"** with its card count as its value. Its cards are ordinary card elements; VO-⇧-M on one offers exactly **Delete** and **Reveal in Finder** — **there is no Open**, and finding one is a defect. ⌘X/⌘V moves the card back onto the board; the trash's spoken count drops. **8. Empty the trash.** Delete another card, show the trash, then ⇧⌘⌫ (File ▸ Empty Trash…). > The alert always appears and is fully readable: **"Permanently delete 1 card?"** with **Delete** and **Cancel**. Confirm, and the column's value returns to **"0 cards"**. **9. Check the rotor.** Press VO-U and choose the Headings rotor, or press VO-⌘-H repeatedly. > Lane titles are headings, so heading navigation jumps lane to lane. On a one-dimensional board that *is* structural navigation. The script passes when every quoted sentence was heard, nothing was announced that shouldn't have been, and you never needed the pointer. ## Part 3 — the m11 checklist The four implementation cards' manual items, consolidated. Each line is a claim the automated suite cannot make. ### 3.1 The VoiceOver tree and its actions - [ ] **Traversal order is `order`, never geometry.** Widen a lane (⌥⌘→, or the header context menu's Increase Width) until its cards lay out in two or more interior masonry columns, then walk it with VO-→. The cards must read **top-to-bottom in card order**, not column-major. **This is the riskiest bet in the whole milestone** — it rests on `accessibilitySortPriority` being honoured on slots inside a custom `Layout` (`LaneView`'s masonry), which is not a documented guarantee. If it has regressed, this is where it shows. - [ ] **Lanes read in lane order, and the trash reads last** — including with the trash shown, a lane lifted by an in-flight resize, and a right-to-left system language. - [ ] **Lane containers**: "⟨title⟩, lane, N cards", with the count matching the visible badge. Type a query into the search field and confirm the *spoken* count drops with the badge — filtered-out cards leave the tree and the layout together. - [ ] **Cards are one flattened element**: title as label; attachment count and "cut, pending paste" as value, in that order when both apply; face icon, edge stripe, and the paperclip chip never separately focusable. - [ ] **VO-Space toggles selection, it does not replace it.** On a card and on a lane header alike, VO-Space is the ⌘-click analogue: pressing it on a second card must leave the first selected. Moving the VoiceOver cursor alone must never change the selection (Finder-style independence). - [ ] **Selection is a trait, not just a ring** — VoiceOver says "selected"; the ring alone is not enough. - [ ] **⌘↩ opens the card window** with VoiceOver running, and arrows and ⇧-arrows drive selection exactly as they do without it. - [ ] **Context menus via VO-⇧-M** carry the full inventory, and the custom accessibility actions (VO-⌘-Space, or the Actions rotor) carry the same rows: card ▸ Open / Rename / Delete; lane ▸ Rename / Increase Width / Decrease Width / Delete; **trash card ▸ Delete / Reveal in Finder and never Open**. - [ ] **The lane-resize drag strip is out of the tree.** The accessible width path is the stepper and the menu items; an invisible focusable strip between lanes is a defect. - [ ] **Rotor headings** jump lane to lane (also checked in Part 2, step 9). ### 3.2 Live announcements - [ ] **Foreign edits announce; app-mediated echoes never do.** Exercised in Part 2, step 5 — but also click around the app for a minute with VoiceOver on and confirm total silence. - [ ] **One sentence per reload debounce**, never per file. Touch five files in one terminal command and expect a single digest, not five. - [ ] **Vanishing focus is named specifically**, and lane-vanish walks up then sideways: focus lands on the lane now occupying the vanished lane's position — the next lane by order, else the previous — and on the board container only when no lanes remain. **Never into the trash**, even with the trash shown. - [ ] **The banner strip is announced when it appears and when it clears.** Make the board unwritable (`chmod a-w` the board folder, or move it aside while it is open) and expect the error sentence; restore it and expect **"The board is editable again"**. A load breakage clearing says **"The board is loading again"**. - [ ] **The banner row's spoken label is the banner's own sentence** — tone first, so a VoiceOver user hears *that* it is an error before hearing what it is: "Error: ⟨headline⟩". The strip's container reads as "Board status". - [ ] **Announcements never interrupt.** Start VoiceOver reading a long card body (VO-A), then trigger a foreign edit; the digest must wait its turn rather than cutting the reading off. ### 3.3 Text scaling, visual accommodations, Full Keyboard Access - [ ] **Largest system text size**: System Settings ▸ Accessibility ▸ Display ▸ Text size, at maximum. Card faces, lane headers, masonry spacing, the trash hatch, the toolbar search field and both window minimum sizes all grow with it; nothing clips, nothing overlaps. - [ ] **No horizontal scroll at any text size.** The board still scrolls vertically only; lane widths are the user's choice and the strip never gains a horizontal scroller. Check at three sizes across two window widths. - [ ] **Increase Contrast** (Accessibility ▸ Display ▸ Increase contrast): strokes gain a flat point, borderless card and lane plates gain a resting separator hairline, faded accents go to full alpha, and the selection ring and plate edge both strengthen. Hierarchy must be preserved — everything strengthening by the same amount is the intent, not a bug. - [ ] **Reduce Transparency**: the transient search bar's glass goes solid, and so do the alpha washes that composite over a user-chosen board background — the trash plate, the hatched trash header, the drag shadow. Look for any remaining see-through surface. - [ ] **Reduce Motion**: movement animations go instant, appear/disappear transitions go crossfade — uniformly. Named cases to walk: reflow-on-drag, search animate-out, the drag replica's lift and settle, the lane-resize rubber band, the trash column's appear/disappear, and the store's reload seam (the largest animated surface in the app). The drag replica's 1:1 tracking and the selection marquee correctly have no reduced variant. - [ ] **Full Keyboard Access** (Keyboard ▸ Keyboard navigation, VoiceOver **off**): the board is **one** tab stop, not one per card, and its focus ring becomes visible under FKA when it is hidden without. Tab must reach every lane's new-card button, the toolbar, the search field, the board popover's controls, the card window, and welcome. - [ ] **The template chooser is arrow-navigable** under FKA: tiles take focus, arrows move between them and clamp at the ends, Space picks, Return is still the sheet's default action, and focus follows the selection one way only. - [ ] **The style editor's grids** are arrow-navigable, every well is Tab-reachable and labeled by name (palette color, symbol name; leading wells "None" / "Default"), the current value is stated by trait, and a batch selection's mixed state reads as "mixed". - [ ] **State is never colour-alone**, everywhere: selection is ring plus trait, cut-pending is dim plus stated value, the trash header is hatched plus labeled. ### 3.4 Runtime contrast and palette ink The only board text that sits on a user-chosen colour is the lane header (title, icon, badge, rename field, + button) and the trash header. Everything else has its own plate. Check both, in **both appearances**, switching System Settings ▸ Appearance between Light and Dark without restarting the app — the ink must flip live. - [ ] **Palette wells, both appearances.** Set the board background to **smokey-ocean** and view it in **Light Mode**; set it to **chalk** and view it in **Dark Mode**. These are the two that the pre-m11 code got wrong. Lane header text must stay legible in both; if it disappears into the background, the ink selection has regressed. - [ ] **obsidian in Light Mode** — the specific case that measured 1.0:1 before this milestone. It must now read white-on-black, not black-on-black. - [ ] **Hand-written hex.** Enter `#00000080` (the alpha dead zone) as the board background. The colour composites over the *window* background of the active appearance, so it resolves differently in Light and Dark — the text must be legible in both, and must change when you switch appearance with the popover still open. - [ ] **A mid-grey hex** such as `#7F7F7F` cannot clear 4.5:1 against either label colour. The app must paint the **better** of the two rather than override the user's colour — legible-ish, never inverted, never refused. - [ ] **Increase Contrast on top of a hex background**: the ink recomputes against the Increase Contrast label colours, not the default ones. - [ ] **Card plates**: confirm the card face's `.background.secondary` plate is opaque enough that a saturated board background does not bleed through and drag the card's own text below AA. This one is a judgement call by eye — hold a dark card title against `#FFCC00` and against `obsidian`. - [ ] **Menus, popovers, and drag replicas stay native** — they deliberately do not follow the board's ink, and should look like ordinary system chrome even over a strongly-coloured board. ## Recording the result File anything that fails on the Redesign board, with the checklist line it came from. A failing line in Part 1 is a build defect; a failing line in Part 2 or 3 is either a build defect or a design gap, and which one it is belongs in the issue.