# 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.

This is the **accessibility** gate. The golden-path pass — the flows a user performs, the fail-fast launch, and the performance budgets — is `EndToEndVerification.md`, beside it, sharing this document's prerequisites and the same fixture mechanism.

## 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. That is the `standard` fixture variant; the bare flag means it, and the other two shapes (`large`, `malformed`) belong to the end-to-end pass. 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.
