Files
lanework/Kanban/UI/AccessibilityPhrases.swift
T
rzen 273c182ef4 Build the VoiceOver tree and actions
The board window's accessibility tree per DESIGN/10: lanes are containers
labeled "<title>, lane, N cards" (filter-aware count = renderedCards, the
badge's own collection); cards are one flattened element each — label =
title or the untitled placeholder, value = attachment count + "cut,
pending paste", selection via trait; face icon, stripe, and paperclip are
decorative and hidden. Masonry never leaks into traversal: slots carry
order-keyed accessibilitySortPriority, so a wide lane reads by card order,
not column-major. Lane titles carry the heading trait for the rotor.

VO-Space is the ⌘-click analogue routed through the existing
BoardStore.click funnel (SelectionGrammar stays the single answer for
toggle and container-boundary rules) — cards and lane headers both.
Context-menu rows double as custom accessibility actions, each calling
the same private method as its menu row so the surfaces cannot drift;
trash cards expose Delete and Reveal in Finder and never Open. The trash
column is pinned last via sort priority 0, its label/value re-routed
through the new AccessibilityPhrases seam; toggling trash visibility
posts a one-line announcement from the store seam (both command faces).
The invisible lane-resize drag strip leaves the tree — the stepper and
menu items are the accessible width path.

AccessibilityPhrases is the pure vocabulary seam (labels, values, plural
folding shared with TrashModel.phrase), pinned by its own test suite.
Both schemes build; 1466 unit tests green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 07:38:38 -04:00

125 lines
6.4 KiB
Swift

import Foundation
// MARK: - AccessibilityPhrases
/// **What the board's elements say** — every label and value VoiceOver reads off the board window,
/// composed by pure functions (10-accessibility.md ▸ The board through VoiceOver).
///
/// ### Why the strings live here and not at the modifiers
///
/// 10-accessibility.md states the board's tree as *sentences*: a lane container is
/// "⟨title⟩, lane, N cards", the header button is "New card in ⟨lane⟩", a card's value carries its
/// attachment count and, when it is cut-pending, "cut, pending paste". Those are rules about text —
/// which placeholder an untitled item wears, how a count folds its plural, what order two value
/// fragments join in — and a rule about text is only checkable if there is a function to ask. Split
/// out, the whole spoken vocabulary is pinned by `AccessibilityPhrasesTests` without a window, a
/// screen reader, or a running app; `Motion`, `TrashModel.purgePrompt` and `HistoryPhrase` are the
/// same shape for the same reason.
///
/// It is also the one place the board and the trash column can be made to *agree*: the lane's spoken
/// count and the trash's are one function, the untitled placeholder is one constant, and the
/// attachment phrase the card face used to spell inline is now the same string the flattened card
/// element carries in its value.
enum AccessibilityPhrases {
// MARK: - Shared vocabulary
/// The untitled placeholder — **the same word the face draws** (`LaneView.headerTitle`,
/// `CardFaceView.titleOrEditor`), because 10-accessibility.md asks for "label = title (or the
/// untitled placeholder)" and a spoken placeholder that differed from the visible one would make
/// a sighted user and a VoiceOver user describe different boards.
static let untitled = "Untitled"
/// An item's spoken name: its title, or the untitled placeholder. Total, so no caller branches.
static func displayTitle(_ title: String?) -> String {
guard let title, !title.isEmpty else { return untitled }
return title
}
/// "3 cards", "1 card" — the app's **one** plural folding for a card count, borrowed from
/// `TrashModel.phrase` rather than restated so a lane's spoken count and the trash column's
/// cannot drift apart.
static func cardCount(_ count: Int) -> String {
TrashModel.phrase(count)
}
// MARK: - Lanes
/// A lane container's label — "⟨title⟩, lane, N cards" (10-accessibility.md ▸ The board through
/// VoiceOver).
///
/// **The count is the caller's, and the caller passes the rendered one**: "the count reads the
/// search filter like the visible badge", so `LaneView` hands the very collection its badge
/// counts (`renderedCards`) and the two can no more disagree than the badge can disagree with
/// the masonry.
static func laneLabel(title: String?, cards count: Int) -> String {
"\(displayTitle(title)), lane, \(cardCount(count))"
}
/// The lane header's new-card button — "New card in ⟨lane⟩", the one labeled child
/// 10-accessibility.md gives the header.
static func newCardLabel(lane title: String?) -> String {
"New card in \(displayTitle(title))"
}
// MARK: - Cards
/// A card's label: its title, or the untitled placeholder. Named rather than inlined so the
/// card element and the lane's own title read through one function.
static func cardLabel(title: String?) -> String {
displayTitle(title)
}
/// "1 attachment", "4 attachments" — the paperclip chip's information, moved into the card
/// element's value where 10-accessibility.md puts it ("the flattened element carries the
/// attachment count in its value"). Plural-folded like every other count in the app; the chip
/// itself used to say "N attachments" unconditionally, which read wrong at one.
static func attachmentCount(_ count: Int) -> String {
"\(count) attachment\(count == 1 ? "" : "s")"
}
/// The deferred cut's spoken half — "cut items dim in place until paste moves them"
/// (04-interactions.md ▸ Clipboard), and **state is never colour-alone** (10-accessibility.md):
/// the dim is the sighted signal, this is the other one.
static let cutPending = "cut, pending paste"
/// A card element's value — the attachment count when it has files, the cut-pending phrase when
/// it is staged for paste, both when both, and **the empty string when neither**.
///
/// Empty rather than `nil` on purpose: the modifier that consumes it is unconditional, because a
/// `if` around `.accessibilityValue` would put the whole card face inside a `_ConditionalContent`
/// that flips identity — and therefore rebuilds the face, dropping its measured height and its
/// marquee registration — the moment an attachment lands or a cut is pasted. An empty AXValue
/// speaks as nothing, which is exactly what "no value" should sound like.
static func cardValue(attachments: Int, isCutPending: Bool) -> String {
var parts: [String] = []
if attachments > 0 { parts.append(attachmentCount(attachments)) }
if isCutPending { parts.append(cutPending) }
return parts.joined(separator: ", ")
}
// MARK: - The trash column
/// The trash container's label — stable, like the header's visible title (03-board-ui.md §
/// Trash: one word, never "Hide Trash"-style state in the name).
static let trashLabel = "Trash"
/// The trash container's value: its card count, filtered exactly as the lane labels' are — "the
/// shown trash's cards participate in the filter exactly like any other card" (03-board-ui.md §
/// Trash), so the column passes the collection its badge counts.
static func trashValue(cards count: Int) -> String {
cardCount(count)
}
/// What View ▸ Show Trash announces — "toggling visibility is announced"
/// (10-accessibility.md ▸ Trash lane). A whole container joining or leaving the board is a
/// layout change with no focus consequence and therefore nothing else to notice it by.
///
/// Phrased as the resulting *state* rather than as the action ("Trash shown", not "Showing
/// trash"), because the toolbar item and the menu checkmark both mean the same thing and a user
/// who mis-hit the toggle needs to know where the board ended up.
static func trashVisibility(shown: Bool) -> String {
shown ? "Trash shown" : "Trash hidden"
}
}