# Accessibility

The stance is committed in 00-vision.md: **accessibility is a requirement of "native to the bone," not an afterthought.** This document is the design iteration that stance promised — VoiceOver correctness on the custom board, text scaling, and the system visual accommodations. The keyboard-first contract (04-interactions.md) is the floor this builds on.

## Principles

- **The keyboard path is the accessibility path.** Every function's drag-free guarantee is its keyboard/menu path (04-interactions.md's contract: every board function has a menu item — the menu home is the keyboard path, with or without a default chord), and VoiceOver rides those same paths. Bespoke accessibility-only UI is a last resort — if a function needs a drag-free alternative, it belongs in the context menu or menu bar, where *all* users get it. Concretely: **card movement is the clipboard** (⌘X, select target, ⌘V — 04's paste-lands-after-anchor/appends-to-lane rules make cut/paste a complete move story), not a parallel set of "Move to lane" accessibility actions.
- **Logical order over visual geometry.** Assistive traversal follows the board's semantic order (lane `order`, then card `order`), never rendering artifacts like masonry column position.
- **Announce what the files do.** A live board that changes under external writers must say so — silence about a mutating board is a lie to a VoiceOver user.

## The board through VoiceOver

- **Tree shape**: window → lanes (accessibility containers, in lane `order`) → cards (leaf elements, in card `order`). A lane container is labeled "⟨title⟩, lane, N cards" — the count reads the search filter like the visible badge (04-interactions.md). The lane header's collapse chevron is a labeled child ("Collapse ⟨lane⟩"). A card is **one flattened element**: label = title (or the untitled placeholder), value carries the attachment count and the comment count when present (comments joined 2026-08-09, card e729e30a — 03-board-ui.md § Card face), selected state via trait. Face icon and chips are decorative — folded into the element, never separately focusable: the flattened element carries both counts in its value, the accessible attachment surface is the card window's keyboard-native section (below), and the accessible comment surface is the card window's comments column ("Comments, N" — Comments below); the face itself has no media presentation (03-board-ui.md's no-carousel resettlement).
- **Logical order, not masonry position** (decided): within a wide lane, VoiceOver reads cards by `order` — the interior grid columns are presentation only. This deliberately diverges from on-screen geometry (narrowed by the 2026-07-31 column-major masonry: walking down one column now *is* consecutive `order`; the divergence that remains is a geometry-sorted reading-order sweep — left-to-right, then down — which interleaves the columns); the spatial arrow-key model (04-interactions.md) remains available alongside, since board keyboard navigation keeps working with VoiceOver running.
- **VO cursor and app selection are independent** (Finder-style): moving the VoiceOver cursor never mutates selection. VO-Space on any selectable element — card or lane header — toggles its selection (the ⌘-click analogue — a toggle, never plain click's replace, 04-interactions.md ▸ Selection; ruled 2026-07-29: a replace would silently wipe a multi-element selection, and one uniform VO-Space rule means the user never has to know the element kind to predict Space); ⌘↩ opens the card window; arrow keys and ⇧-arrows drive selection exactly as without VoiceOver. Selection state is always readable from the element (trait), and cut cards expose their dimmed pending state in the value ("cut, pending paste").
- **Actions come from the context menu.** Context menus are the single inventory of per-item actions (Open, Rename, Delete, width stepper, …), reachable the standard way (VO-⇧-M); where SwiftUI additionally surfaces menu items as custom accessibility actions, that's free improvement, not a separate design surface. **The custom-action cut** (confirmed 2026-07-29): every plain button row of an item's context menu becomes a custom action; rows that open their own accessible surface (Style…'s popover) and non-action controls (the quick-style swatch picker) stay menu-only — the context menu remains the full inventory either way. Each action must call the same method as its menu row, so the two surfaces cannot drift.
- **Rotor**: lane titles are headings, so the headings rotor jumps lane-to-lane — on a one-dimensional board that *is* structural navigation; no custom rotors unless practice shows the need. **The title doubling is accepted** (ruled 2026-07-29): entering a lane reads the container label ("Doing, lane, 3 cards") and then the heading ("Doing, heading") — two elements, two purposes: the label gives boundary-crossing context, the heading feeds the rotor. This is the platform-standard landmark-plus-heading pattern; collapsing it would cost the on-entry announcement, the more valuable half.
- **Trash lane**: when shown (View ▸ Show Trash), it is the last container, labeled as Trash with its count; toggling visibility is announced — "Trash shown" / "Trash hidden" (confirmed 2026-07-29: resulting state, not the action, so a mis-hit tells the user where the board ended up and the toolbar toggle reads like the menu checkmark; the count stays on the container label, which VO reads on arrival, rather than duplicated into the announcement). Its cards are ordinary card elements (resettled 2026-07-28 — the materialized trash: no special states beyond the container) exposing Delete and Reveal in Finder via the context menu; there is no Open (03-board-ui.md's no-editing-in-the-trash), and restore is drag-out or the ⌘X/⌘V keyboard path; keyboard reachability follows 04-interactions.md ▸ The trash. A trashed **lane** (rejoined 2026-07-29) is one flattened opaque element — "⟨title⟩, deleted lane, N cards" — never a container: its cards are not in the tree (the opaque unit, 03 ▸ Trash), its actions are the same Delete / Reveal in Finder, and its restore paths are the same drag-out or ⌘X/⌘V.
- **Search**: filtered-out cards leave layout and the accessibility tree together — the filter is the single source of truth for "what's on the board" (04-interactions.md), and the tree is one of its readers. Lane counts announced reflect the filter.

## Moving without dragging

- **Cards**: clipboard. ⌘X the selection, move selection to the destination (arrows), ⌘V — between lanes, within a lane (paste lands after the anchor card), and across boards (04-interactions.md's staged clipboard). This is the committed drag-free move story; it needs no VoiceOver-specific machinery because selection and paste targeting are already keyboard-native.
- **Lanes**: the defect this doc originally named (lanes had no keyboard-move path) is closed by the keyboard map — with a lane selected, ⌘←/⌘→ move it (Board ▸ Move Left / Move Right, 04-interactions.md); cards gain ⌥⌘↑/⌥⌘↓ within-lane sorting, and cross lanes drag-free via cut/paste (04-interactions.md's clipboard rules). Lanes carry the clipboard too (resettled, 04-interactions.md ▸ Clipboard), so cross-board lane copy/move — once drag-only, the contract's last gap — is ⌘C/⌘X, then ⌘V with the destination board frontmost.
- **Lane resize**: the header context menu's width stepper (03-board-ui.md) — and its keyboard face, the Increase/Decrease Lane Width menu items (⌥⌘→/⌥⌘←, 11-command-nexus.md) — is the accessible path; edge drag is enhancement only.
- **Attachments**: the card window's sidebar items expose Open / Reveal in Finder / Remove via context menu, and the section is keyboard-navigable outright (arrows, Space-QuickLook, Return, ⌫ — 05-card-window.md); adding files drag-free is File ▸ Add Attachment… (⇧⌘A, 11-command-nexus.md) alongside Finder-drop.
- **Comments** (05-card-window.md ▸ The comments column): the pane is a labeled container ("Comments, N"); each comment is **one flattened element** — author, date, edited state, body — with its context-menu rows (Edit / Delete / Reveal in Finder) riding as custom actions per the cut; the composer is a labeled text field (⌘↩ posts) and the header's sort control is Tab-reachable. Foreign comment arrivals announce path-shaped ("New comment on '⟨card⟩'") — the window-scoped read never blocks the announcement, which composes from the path alone. **Edits and deletes speak the same family** (pinned 2026-07-31): "Edit comment on '⟨card⟩'" / "Delete comment on '⟨card⟩'" — 06-history-undo.md's path-shaped verb family verbatim, arrivals leading by precedence, plurals folding ("3 new comments on 'X'"); the exact strings live in AccessibilityPhrases, one home.

## Live board announcements

- **Foreign changes announce, app-mediated echoes never do.** The auto-committer already classifies every observed change as app-mediated or foreign (the EchoLedger — 02-architecture.md ▸ Components) and synthesizes diff summaries for commit messages (06-history-undo.md); announcements reuse that summarizer — one polite (non-interrupting) digest per reload debounce ("Board changed: 2 cards edited, 1 card added"), never per-file chatter. **The announcer consumes the ledger's per-file facts on every reload origin, reconciling included** (ruled 2026-07-29 — the EchoLedger builds pre-release in base, not with Pro's committer): files changed during a blind window (sleep, deactivation) carry no receipts, so they classify foreign and announce — the launch-catch-up doctrine ("the app never vouches for changes it didn't witness") applied to speech; a reconciling reload that reveals external changes is never silent. On no-git boards the same classifier runs without the committer — announcements don't depend on git mode. **The digest covers the trash only while the trash lane is shown** (ruled 2026-07-29): with View ▸ Show Trash on, trash cards are ordinary elements of the visible board (▸ Trash lane above), so a foreign purge, restore, or Empty Trash joins the digest like any lane's churn — a user working in the shown trash must hear it emptied under them; while hidden, trash churn stays silent, matching "no layout side effects from a foreign edit". **A shown trashed-lane row's held count speaks when it changes** (ruled 2026-07-31): foreign churn *inside* a trashed lane's subtree is invisible to the snapshot, but the row's count is part of its visible face — a sighted user sees the number move, so the digest says it ("Deleted lane 'Doing' now holds 6 cards", plural-folded as usual), sourced from the disk-side held count. The opacity doctrine holds: the digest narrates the row's visible value, never the interior.
- **A vanishing focus is called out specifically.** If the selected or VO-focused card disappears in a reload (deleted externally, or gone with its deleted lane), the announcement names it ("Card 'Fix login' was deleted externally") and focus recovers to the card's lane (mirroring selection's reload-survival rules, 02-architecture.md). **Naming and recovery are independent axes** (ruled 2026-07-29): when the head of a multi-selection vanishes but co-selected cards survive, the announcement still names the vanished head — the thing under the cursor was deleted, and that is what the rule exists to say — while the focus move is vetoed by the survivors (02's re-resolution rule: the head re-anchors within the surviving selection; a reload never edits a selection the user still partly holds). Vanished non-head members stay unnamed and fall to the digest's counts. **When the lane itself vanished, recovery walks up then sideways** (settled): focus lands on the lane now occupying the vanished lane's position — the next lane by `order`, else the previous one — and on the board container only when no lanes remain (the ⌫-successor pattern, 04-interactions.md, applied to external change; never into the trash, which stays hidden — no layout side effects from a foreign edit). The announcement then names the *lane*, not the card ("Lane 'Doing' was deleted externally, with 5 cards") — the implied-events-don't-steal-the-subject discipline of 06-history-undo.md's composer, applied to speech.
- **Bracketed operations announce once, at completion** ("Pulled 3 commits", "Switched to branch 'redesign'") — never their internal churn (02-architecture.md's bracketing). The live-reload-resilience banner (02-architecture.md) is an accessibility element and is announced when it appears and when it clears — including the read-only lock after a failed bracketed reload. **Banner transitions are origin-independent** (confirmed 2026-07-29): "app-mediated echoes never announce" governs the change digest — never narrate the user's own edits — but a banner appearing or clearing is surface liveness, visible to a sighted user regardless of cause, so it speaks on any reload origin (an app write whose reload clears a breakage is exactly a moment the user should hear "cleared"). Precedence ladder: raised condition > bracket completion > cleared condition > vanished focus > digest.

## Card window, welcome, template chooser, popover

- **Card window**: standard controls, standard labels. The attributes sidebar is a labeled container of labeled sections; attachment rows are elements labeled by filename; the Details section's unknown-key rows read as static text ("⟨key⟩, ⟨value⟩"); the bottom actions are ordinary buttons. **Preview renders to the accessibility tree as structured text** — headings navigable by rotor, lists and tables read as such; task-list checkboxes are real accessible checkboxes, toggleable without the pointer (05-card-window.md's live checkboxes); body images use Markdown alt text when present, else the filename. Edit and the raw-source outlet are ordinary accessible text editors; the Preview/Edit toggle (⌘E) announces its state.
- **Welcome window**: recents rows are elements labeled "⟨name⟩, ⟨location⟩, N lanes, M cards" (name, icon, and counts all registry-cached — 02-architecture.md; the row never reads a board's files); row actions (Open / Reveal in Finder / Forget) via context menu; unavailable rows say so ("unavailable — board not found").
- **Template chooser**: templates are elements labeled by title; the mini per-lane previews are decorative and hidden from the tree.
- **Board popover**: labeled controls throughout; the ahead/behind indicator's information — counts, queued pushes, last error — must be readable as text, never conveyed by color or shape alone.
- **Board settings sheet** — *retired 2026-08-07* (03 ▸ Board settings sheet; the 2026-07-31 popover/sheet split reversed). Its bar carries over to the popover's Git tab, which now hosts what it held: headers VoiceOver can navigate by (the Commit Identity block keeps its heading trait); labeled controls throughout; inline probe/verify outcomes announced from the focused surface; every control Tab-reachable under Full Keyboard Access. The original objection stands as the standing test — Tab-walking two dozen controls in an *untitled, unsectioned* surface fails this bar — and the tab strip plus per-block headings are what answer it now that the controls are back in the popover.
- **Style editor** (card sidebar section, board popover, Style… popover — 03-board-ui.md ▸ Styling ▸ Controls): grids are arrow-navigable, every well 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", never conveyed by highlight alone.

## Text scaling & visual accommodations

- **Full relative scaling** (decided): relative text styles everywhere, no fixed point sizes. Card face, lane header, and masonry metrics derive from font metrics, so layout survives the largest system text sizes; the no-horizontal-scroll invariant is untouched (lane count is the user's choice; lanes scroll vertically), and 03-board-ui.md's graceful-truncation rules apply at every scale.
- **Board zoom is that scaling's user-facing control** (settled 2026-08-02; behavior in 03-board-ui.md ▸ Layout, rows in 11-command-nexus.md). macOS ships no system text-size setting, so the commitment above had nothing to move it — the point size the whole board derives from is read once and never changes. View ▸ Zoom In / Zoom Out / Actual Size supply the multiplier: one ladder, one effective body size, and every em multiple and every text style scaling off it together. This is an accessibility feature before it is a convenience one, which is why it is a first-class menu command with a chord rather than a setting buried in a pane, and why **the strip's own truncation rules are the acceptance test** — 03's graceful-truncation promise "at every scale" is only checkable now that a scale exists. **A zoom change announces its new level** ("Zoom 125%") through the ordinary announcement path: it is chrome, not information — nothing about the board's meaning changes — so no label, value, or trait anywhere else moves with it.
- **Contrast is pinned to WCAG AA.** Lane and card colors render as edge accents (03-board-ui.md's top-edge band / left-edge stripe), so text never sits on them — they are supplementary decoration, never the sole carrier of information, and carry no text-contrast obligation. The ≥ 4.5:1 automatic-contrast rule binds where text does sit on a user-chosen color: the **board** background (palette pairs verified at design time; arbitrary hex computes its text color at runtime against that threshold). The 2026-08-06 color-combo reversal (03 ▸ Styling ▸ Controls) changes none of this: a panel-picked color is stored as the palette name when it lands on one, else as hex, and either spelling renders through the same runtime ink seam — in-app picking gained the freedom hand-editing always had, and the ink math was already waiting for it. No warning surface exists or is owed; the app's answer to a low-contrast pick is to choose readable ink, not to argue. An `#RRGGBBAA` background with alpha computes against the color **composited over its effective backdrop** in the active appearance (the board's over the window background; light and dark resolve differently), recomputed on appearance change. Palette names and hex share one ink-selection code path — the palette's AA claim is pinned by a computed-contrast test over all 12 backgrounds in both appearances (ratified 2026-07-29). **The AA obligation binds the primary label tier** (ruled 2026-07-29): the ink seam moves the whole label hierarchy with the primary, and subordinate tiers (.secondary, .quaternary) inherit the system vocabulary's own contrast posture, which sits below 4.5:1 on any background including the system's — the platform-standard reading; the strict path for users who need more is Increase Contrast, which raises accents and washes to full alpha (▸ Visual accommodations). Increase Contrast strengthens borders and the selection indicator.
- **State is never color-alone**: selection is a ring plus trait, cut-pending is dim plus stated value, the trash header is hatched plus labeled — all already patterned; kept as a rule.
- **Reduce Motion is a per-voice rule, not a feature list** (settled): movement animations go **instant**, appear/disappear transitions go **crossfade**, uniformly — every animated surface derives its reduced variant from its voice, the store's reload seam included (the largest animated surface in the app), so new surfaces never need individual rulings. The named cases — reflow-on-drag, search animate-out, the drag replica's lift and settle transitions (its 1:1 tracking never animates, like the selection marquee, which needs no variant — 03-board-ui.md ▸ Motion), the lane-resize rubber-band feedback (03-board-ui.md ▸ Lane), trash animations — are applications of the rule, not the rule itself. **Reduce Transparency**: glass underlays go solid, wherever they appear.
- **Full Keyboard Access** (independent of VoiceOver): the board is one tab stop with arrow-key navigation within; every control — lane buttons, popover, card window, welcome — is Tab-reachable. **"Every control" is literal and includes banner-row buttons** (ruled 2026-07-29): a Dismiss or Cancel on a banner must be a Tab stop — FKA serves sighted keyboard-only users, to whom VO custom actions are invisible, and Cancel on an in-progress operation is exactly the control that cannot require a pointer. This coexists with the VoiceOver presentation (one combined row-sentence with Dismiss/Cancel as custom actions): the AX combine and the FKA focus loop are independent surfaces; the implementation may uncombine conditionally under FKA if the focus system requires it.

## Verification

- **Automated audits are test failures**: Xcode's accessibility audit (`performAccessibilityAudit`) runs in UI tests over every surface — board (trash shown and hidden), card window (Preview, Edit, raw source), welcome, template chooser, board popover. *(Amended 2026-08-07: the board settings sheet retired and its controls rehomed into the popover's Git tab, so the inventory is one surface shorter; auditing that tab specifically — the popover opens on Info — is an open card, tracked with the manual pass in `KanbanUITests/AccessibilityVerification.md`.)* **Pro surfaces audit through a fixture-gated tier override** (ruled 2026-08-06): the audit's every-surface claim reaches tier-gated UI (the settings sheet's Pro sections, remote/credentials, the card History section in its present state) via a launch flag honored **only when `UITestLaunch.isFixtureLaunch` is also set** — the fixture flag already redirects registry and app state to a scratch container and builds a throwaway board, so the override grants Pro on a disposable sandbox and never over real boards; a bare tier flag in the shipping binary would be a subscription bypass, and this one is not (a Terminal user gains a Pro-looking toy, not a working subscription). The weighed alternatives lose on the audit's own terms: a permanent manual-checklist carve-out would asterisk the every-surface claim, and a debug-build-only override would leave release builds unauditable. Surfaces whose reachable-state audit is genuinely partial pin what the free fixture can reach (the disabled settings row, the absent History section) *and* audit the full surface under the override — both states are shipping states, both audit.
- **A manual VoiceOver smoke script** lives with the test plan: create lane → create card → rename → cut/paste to another lane → external edit lands (announcement heard) → delete → restore from the trash (⌘X/⌘V) → Empty Trash. Run per release; it is the canonical "does the board actually work blind" check.

## Changes from Kanban

- All new ground — the pathfinder never had an accessibility pass. Nothing carries over.

## Open questions

None currently — the lane-move gap and the Add Attachment menu path are both closed by the keyboard map (04-interactions.md's contract, inventoried in 11-command-nexus.md).
