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" } }