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 } /// What the pre-snapshot loading surface says (02-architecture.md § Launch and window lifecycle; /// `BoardLoadingView`). The surface draws a bare system spinner and no text — "no skeleton lanes, /// the motion language animates real data only" — so this is the *only* description a VoiceOver /// user gets of a board window that is still walking its tree, and a spinner with nothing to say /// would leave that window silent. static let boardLoading = "Loading board" /// **What the decision surface is called** (01-storage-format.md § Malformed input, settled /// 2026-07-31; `BoardDecisionSurface`) — its heading *and* the group label a VoiceOver user hears /// on entering it, which are one string here for the banner row's reason exactly: the sentence /// heard and the sentence read are one, or they are two descriptions of one thing waiting to /// disagree. /// /// It says the board did not open and stops there. What is wrong is the sections' to say — the /// surface exists precisely because there is usually more than one answer to that. static let decisionSurfaceLabel = "Lanework couldn't open this board" /// The surface's own subtitle: how much is wrong, and what to do about it. The count is the /// number of affected *files*, which is what the rows below it are. static func decisionSurfaceSummary(defects count: Int) -> String { let subject = count == 1 ? "One file needs" : "\(count) files need" return "\(subject) a decision before this board can open." } /// One affected file, as one utterance: **path then reason**, in that order, because the path is /// what identifies the row and the reason is what the user is deciding about /// (01: "lists the affected files (path + specifics …)"). /// /// The reason is the loader's own sentence, unrewritten — the same specifics the row shows — for /// `BannerCenter.causePhrase`'s reason: fail-fast's diagnostics are specific in a way a /// re-phrasing would not be, and the alternative to showing one is a shrug. static func decisionRowLabel(path: String, reason: String) -> String { "\(path): \(reason)" } /// "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 counts, 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 collections its badge counts. /// /// **Both kinds are named when both are there** (lanes rejoined the trash 2026-07-29): the /// container's rows are cards *and* opaque lane units, and a value that spoke only the cards /// would understate what the VoiceOver cursor is about to walk into. A trash holding no lane rows /// — overwhelmingly the common case — reads exactly as it always did. /// /// It stops at counting, deliberately: the *freight* phrasing ("2 lanes containing 9 more /// cards") belongs to the purge confirmations, where the number is what the user is about to /// lose (`TrashModel.subject(for:)`). static func trashValue(cards: Int, lanes: Int = 0) -> String { guard lanes > 0 else { return cardCount(cards) } return "\(cardCount(cards)), \(laneCount(lanes))" } /// A trashed lane row — **one flattened opaque element**, "⟨title⟩, deleted lane, N cards" /// (10-accessibility.md ▸ Trash lane, lanes rejoined 2026-07-29). /// /// "Deleted lane" and not "lane" is the whole of what the label has to carry beyond a live lane /// container's: the row is in the trash, it is not a container, and its N is the freight it took /// with it rather than a filtered count of children VoiceOver could enter (`TrashedLane`). static func trashedLaneLabel(title: String?, cards count: Int) -> String { "\(displayTitle(title)), deleted lane, \(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" } /// What View ▸ Zoom In / Zoom Out / Actual Size announces — "a zoom change announces its new /// level" (10-accessibility.md ▸ Text scaling). /// /// `trashVisibility`'s rule exactly, and for its reason: the resulting *state* rather than the /// action, because three commands and two toolbar buttons all land on one ladder and what a user /// needs to hear is which rung they are on — not that something moved. It is also the only signal /// there is: nothing gains or loses focus, no element's label or value changes, and the whole /// effect is a redraw a VoiceOver user cannot see. static func zoomLevel(_ percent: String) -> String { "Zoom \(percent)" } // MARK: - Live board announcements /// "3 lanes", "1 lane" — `cardCount`'s twin, and the second half of the digest's plural folding. /// Borrowed from `TrashModel.lanePhrase` for `cardCount`'s reason: since lanes rejoined the trash /// (2026-07-29) the purge confirmations count lanes too, and one folding means a spoken count and /// a confirmed one cannot drift. static func laneCount(_ count: Int) -> String { TrashModel.lanePhrase(count) } /// The digest's opening — and, on its own, the whole sentence for a change no bucket counts. /// /// Named for the sentence rather than overloading `boardChanged(_:)`: a constant and a function /// sharing a base name would make `"\(boardChanged)"` an ambiguity a reader has to resolve by /// hand, and string interpolation accepts either. static let boardChangedSubject = "Board changed" /// **One polite digest per reload debounce** — 10-accessibility.md ▸ Live board announcements, /// whose own example sentence this reproduces: "Board changed: 2 cards edited, 1 card added". /// /// Three phrasing rules, all pinned by `AccessibilityPhrasesTests`: /// /// - **Zero categories are omitted**, never spoken as "0 cards moved". A digest is a summary, /// and a summary that lists what did *not* happen is a list of noise with the news buried in /// it. /// - **Counts fold their plural** (`cardCount`, `laneCount`), like every other count in the app. /// - **The order is fixed**: cards before lanes, and within each kind edited, added, moved, /// deleted. Cards lead because they are what a board is mostly made of and what 10's example /// leads with; within a kind the order runs from the change that leaves the board's shape /// alone to the one that takes something out of it, so the sentence ends on the fragment most /// likely to need acting on. /// /// `nil` — silence — when nothing differs at all, which is the ordinary outcome of a reload that /// re-read an unchanged tree. A change that no bucket counts (a renamed board, an edited board /// description) still says *something*: "Board changed", bare. Announcing nothing there would be /// the lie 10's principle names — "silence about a mutating board is a lie to a VoiceOver user". static func boardChanged(_ diff: BoardDiff) -> String? { var fragments: [String] = [] appendFragments(of: diff.cards, counting: cardCount, to: &fragments) appendFragments(of: diff.lanes, counting: laneCount, to: &fragments) guard !fragments.isEmpty else { return diff.boardChanged ? boardChangedSubject : nil } return "\(boardChangedSubject): \(fragments.joined(separator: ", "))" } /// One kind's fragments, in the fixed category order. `counting` is the kind's plural folding, /// passed in so the two kinds are one piece of code rather than two that could drift. private static func appendFragments( of changes: BoardDiff.Changes, counting count: (Int) -> String, to fragments: inout [String] ) { if !changes.edited.isEmpty { fragments.append("\(count(changes.edited.count)) edited") } if !changes.added.isEmpty { fragments.append("\(count(changes.added.count)) added") } if !changes.moved.isEmpty { fragments.append("\(count(changes.moved.count)) moved") } if !changes.deleted.isEmpty { fragments.append("\(count(changes.deleted.count)) deleted") } } /// **A vanishing focus is called out specifically** (10 ▸ Live board announcements) — the /// design's own two sentences, "Card 'Fix login' was deleted externally" and "Lane 'Doing' was /// deleted externally, with 5 cards". /// /// The lane form's trailing clause is what makes the substitution honest: when the lane went, it /// took cards with it, and naming the lane *without* the count would hide the larger half of /// what happened. It is omitted at zero — an empty lane vanishing has no second clause to add, /// and "with 0 cards" would be an odd way to say "and nothing else". /// /// "Externally" and not "by an agent" or "on disk": the sentence is only ever composed for a /// vanishing the `EchoLedger` did not vouch for (`BoardStore.vanishingIsForeign`), and which /// outside writer did it — an editor, an agent, `git` in a terminal — is exactly what the store /// cannot know. static func vanishedFocus(_ vanished: BoardAnnouncer.VanishedFocus) -> String { switch vanished { case let .card(title): "Card '\(displayTitle(title))' was deleted externally" case let .lane(title, cards): cards == 0 ? "Lane '\(displayTitle(title))' was deleted externally" : "Lane '\(displayTitle(title))' was deleted externally, with \(cardCount(cards))" } } // MARK: - The comments pane /// The pane's container label — **"Comments, N"** (10-accessibility.md ▸ Comments: "the pane is a /// labeled container ('Comments, N')"). /// /// The count is the *thread's*, not the rendered rows' — there is no filter over a thread — and it /// is the same number the visible header shows (`CommentsHeader.title(count:)`), which is the same /// discipline the lane label keeps with its badge. static func commentsContainerLabel(count: Int) -> String { "Comments, \(count)" } /// "3 comments", "1 comment" — the pane's plural folding, beside `cardCount` and `laneCount`. static func commentCount(_ count: Int) -> String { "\(count) comment\(count == 1 ? "" : "s")" } /// **One comment as a single flattened element's label** (10 ▸ Comments: "each comment is **one /// flattened element** — author, date, edited state, body"). /// /// The author line is the label and the body is the value (`commentValue`), which is the same split /// the card element makes: the label is what the element *is*, the value is what it currently /// holds. A comment with no author line at all — no name, no date — falls back to the word /// "Comment", because an unlabeled element is an audit failure and "unattributed" is the absence of /// a name rather than a name to speak (`CommentAuthorLine`). static func commentLabel(authorLine: String?) -> String { guard let authorLine, !authorLine.isEmpty else { return commentSubject } return authorLine } /// What a comment with nothing to attribute is called. Named rather than inlined because both the /// label fallback and the empty pane's hint read it. static let commentSubject = "Comment" /// A comment element's **value**: its body, with the attachment count appended when it has files. /// /// The body is spoken as it is rather than summarized: 10 puts "body" in the flattened element, and /// a comment is short by nature — the thing a thread is *for* is the text, and paraphrasing it /// would be the app deciding what a VoiceOver user may hear of somebody's comment. Empty for a /// comment with neither, which speaks as nothing (`cardValue`'s rule). static func commentValue(body: String, attachments: Int) -> String { var parts: [String] = [] let text = body.trimmingCharacters(in: .whitespacesAndNewlines) if !text.isEmpty { parts.append(text) } if attachments > 0 { parts.append(attachmentCount(attachments)) } return parts.joined(separator: ", ") } /// The three custom actions a comment element carries — its context menu's rows, which is what 10 /// asks for ("with its context-menu rows (Edit / Delete / Reveal in Finder) riding as custom /// actions per the cut"). /// /// Constants rather than literals at the modifier because the menu row and the custom action must /// be the *same* string: a user who has learned the pointer inventory should hear the same three /// words from the rotor. static let commentEditAction = "Edit" static let commentDeleteAction = "Delete" static let commentRevealAction = "Reveal in Finder" /// The composer, labeled — "the composer is a labeled text field (⌘↩ posts)" (10 ▸ Comments). static let commentComposerLabel = "Add a comment" /// The sort control's label. Its *value* is the direction's own word /// (`CommentSortDirection.controlLabel`), which is also its help text — one string, three readers. static let commentSortLabel = "Sort" /// The paperclip on either authoring surface. static let commentAttachFilesLabel = "Attach Files" // MARK: - Foreign comment changes /// **What a foreign comment change says out loud** — path-shaped, naming the card /// (10-accessibility.md ▸ Comments: "Foreign comment arrivals announce path-shaped ('New comment on /// '⟨card⟩'') — the window-scoped read never blocks the announcement, which composes from the path /// alone"). /// /// ### Precedence, not concatenation /// /// One polite sentence per reload is the whole ladder's rule (`BoardAnnouncer`), so three /// simultaneous kinds of change pick one: **arrivals lead** — they are the news 10 names and the /// only kind that adds something to read — then edits, then deletions. A thread that gained one /// comment and lost another says the gain; the loss is visible in the pane and has no reader /// waiting on it. /// /// ### The verbs are 06's family, and the plurals are built on them /// /// 10 names one sentence and 01 names the family the other two come from ("a changed path under /// `…/comments//` composes 'Comment on ⟨card title⟩' / 'Edit comment on…' / 'Delete comment /// on…'"). The singular forms are those verbs verbatim; the plural forms fold a count in front, /// like every other count in the app, rather than repeating the sentence N times. static func commentsChanged(_ changes: CommentThreadChanges, onCard title: String?) -> String? { let card = displayTitle(title) if let phrase = commentFragment( count: changes.arrived.count, singular: "New comment", plural: { "\($0) new comments" }, onCard: card ) { return phrase } if let phrase = commentFragment( count: changes.edited.count, singular: "Edit comment", plural: { "\($0) comments edited" }, onCard: card ) { return phrase } return commentFragment( count: changes.deleted.count, singular: "Delete comment", plural: { "\($0) comments deleted" }, onCard: card ) } /// One bucket's sentence, or `nil` at zero — the shape all three share, written once so the three /// cannot drift in punctuation or in where the card's name sits. private static func commentFragment( count: Int, singular: String, plural: (Int) -> String, onCard card: String ) -> String? { guard count > 0 else { return nil } return "\(count == 1 ? singular : plural(count)) on '\(card)'" } // MARK: - The banner strip /// What VoiceOver says before a banner's headline. "Status" rather than "Info" because that is /// the word the platform uses for a non-alarming state announcement. /// /// Moved here from `BannerStripView` when the strip's rows started being *announced* as well as /// read: 10 makes the live-reload-resilience banner "an accessibility element … announced when /// it appears and when it clears", and the row's label and its announcement must be the same /// sentence or a user would hear the condition described two different ways. static func bannerTonePrefix(_ tone: BannerTone) -> String { switch tone { case .error: "Error" case .warning: "Warning" case .info: "Status" } } /// A banner row as one spoken element — tone first, because a VoiceOver user must hear *that* /// this is an error before hearing what the error is, and colour cannot carry that. static func bannerLabel(tone: BannerTone, headline: String) -> String { "\(bannerTonePrefix(tone)): \(headline)" } /// The read-only lock clearing. Stated as the regained capability rather than as the cause's /// disappearance ("the volume came back") because the causes are three and the consequence is /// one, and the consequence is what the user was waiting on. static let readOnlyLockCleared = "The board is editable again" /// Reload breakage clearing — the board is reading its files again, which is a smaller claim /// than the lock's and is deliberately phrased as one. static let reloadBreakageCleared = "The board is loading again" /// The unreadable repository clearing (06-history-undo.md ▸ Rules, ruled 2026-07-31: "the banner /// clears when a later open or reload finds the repo readable"). /// /// Stated as the regained capability, `readOnlyLockCleared`'s rule: what the user was waiting on /// is history advancing again, not libgit2 changing its mind about a folder. It stays the /// smaller claim of the two — nothing about editing the board was ever blocked by this /// condition, which is precisely what its own row says. static let repositoryUnreadableCleared = "History is recording again" }