# 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 the surfaces the design names — all of them automatically since 2026-08-07, when the **board settings sheet** retired and its controls rehomed into the board popover's Git tab (03-board-ui.md ▸ Board settings sheet, marked retired). The by-hand pass below is now about that tab, not about a surface the suite cannot reach. **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 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 |
| `testCardWindowCommentsPane` | Card window, comments pane over a seeded thread | The pane is shown by default; the test waits on "Comments, 3" |
| `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, an attachment and a three-comment thread (one unattributed, one edited), 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.

### The popover's Git tab, by hand

The automated audit opens the popover on its **Info** tab, and switching tabs is a click the suite doesn't make yet — so the git surface's own keyboard and VoiceOver behaviour is a by-hand pass, once per release, with a git-mode board open. (This section was the **board settings sheet's** until 2026-08-07; the sheet retired and its controls came here, so the checks moved with them rather than being dropped.)

- [ ] **One door.** File ▸ Board Info (⌘I) ▸ **Git**. There is no second configuration surface and no Board ▸ Board Settings… row — both retired with the sheet.
- [ ] **Navigable by heading.** With VoiceOver on, the rotor's heading list holds "Commit Identity" on a git-mode board.
- [ ] **Tab reaches every control** with VoiceOver off and Full Keyboard Access on — the branch menu, the New Branch… field and Create when revealed, both identity fields.
- [ ] **⌘Z inside a field is the field's**, not the board's: type into Commit Identity ▸ Name, press ⌘Z, and the *typing* reverts — no tree checkout, no board step consumed (06-history-undo.md ▸ Undo routing).
- [ ] **Escape steps outward one layer per press**: in a dirty New Branch… field it clears the field; in an empty one it closes the reveal; again, it dismisses the popover.
- [ ] **The read-only lock disables in place**: the popover stays open and its controls grey out, with the banner naming why.

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

> 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 **in card order** — down the first column, then down the next (the 2026-07-31 column-major masonry made drawn order and card order agree within a column). The failure mode to look for is a geometry-sorted reading-order sweep — left-to-right, then down — that *interleaves* the columns: 1, 4, 7, 2, 5, 8 …. **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.2b The comments pane

The pane ships with the comments feature; 10-accessibility.md ▸ Comments is the whole of what it owes. Open the fixture board's rich card ("Write the smoke script") — its thread carries an ordinary comment, an unattributed one, and an edited one on purpose.

- [ ] **The pane is a labeled container**: "Comments, 3", with the count matching the visible header ("Comments · 3"). It stays a container with zero comments — a comment-less card shows the empty thread and the composer, and reads "Comments, 0".
- [ ] **Each comment is one flattened element**: the author line as label ("⟨name⟩ · ⟨date⟩", ending "· edited" on the edited one, and the date alone on the unattributed one), the body as value with "N attachments" appended when it has files. The rendered body's paragraphs, links and code are not separately focusable — the row is one stop.
- [ ] **The three custom actions** (VO-⌘-Space, or the Actions rotor) are **Edit / Delete / Reveal in Finder**, the same three words the right-click menu carries. Edit opens the inline session — and while it is open the row stops being flattened, so the editor and its Save/Cancel buttons are reachable.
- [ ] **A comment's attachment chips stay reachable** beside the flattened element, with the sidebar's Quick Look behaviour.
- [ ] **The composer is a labeled text field** ("Add a comment"), and its paperclip reads "Attach Files". ⌘↩ posts from inside it.
- [ ] **The sort control is Tab-reachable** beside the count, labeled "Sort" with the direction as its value ("Oldest First" / "Newest First").
- [ ] **A foreign comment announces path-shaped.** With the card window open, add a comment folder to that card's `comments/` from a terminal: expect **"New comment on 'Write the smoke script'"**, once. Edit one of its files: **"Edit comment on '…'"**. Remove one: **"Delete comment on '…'"**. Then post, edit and delete comments *in the app* with VoiceOver running and expect total silence — the app never narrates its own writes.
- [ ] **⌘F follows focus.** Click into a comment's text and press ⌘F: the pane's find bar appears (not the body's). Type a word that occurs in two different comments and press ⌘G — the highlight moves *across* rows and the thread scrolls to it; ⇧⌘G steps back; at the last hit ⌘G wraps to the first. Done takes the bar down and the highlighting with it. Click into the composer and press ⌘F: the standard AppKit find bar appears over the composer alone. Click into the body and press ⌘F: the body's find bar, unchanged.
- [ ] **Full Keyboard Access reaches the pane's controls** with VoiceOver off: Tab must reach the sort control, the composer, its paperclip, the Comment button, and the find bar's field and chevrons when it is up.

### 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 collapse chevron, 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.
