The board speaks when files change under the user, per DESIGN/10 § Live board announcements. BoardDiff is the pure snapshot summarizer (identity sets for cards/lanes added/edited/moved/deleted — ids, not tallies, so pro-m1's semantic commit engine can build on it; edited = rendered content only, moved beats edited, implied events don't steal the subject). BoardAnnouncer is the decision seam: focusOutcome computes the vanishing-focus sentence and the walk-up-then-sideways recovery (next lane by order, else previous, board container only when none remain, never the trash); speech(for:) is the one-sentence precedence ladder — raised condition > bracket completion > cleared condition > vanished focus > digest — foreign-only for the last two rungs, so app-mediated echoes stay silent. BoardStore.land assembles ReloadFacts and posts exactly one sentence per reload through the injectable announce outlet (AccessibilityAnnouncer, medium priority, never interrupting). Selection recovery layers on top of ItemReferenceSet re-resolution — survivors veto, the emptied selection lands on the vanished item's lane and re-arms ⌘N's active-lane memory. performWholesale(announcing:) arms a completion phrase consumed by the closing reload — nil on every base bracket today; pro-m1 fills git phrasings. Locks raised outside the reload path (vanished root, unwritable location) announce through the same ladder, and the banner strip is a labeled "Board status" container whose row labels are the announced sentences (AccessibilityPhrases.bannerLabel — one string for eye and ear). Announcements classify at reload granularity (WatchOrigin) as a deliberate interim: DESIGN/02's EchoLedger (per-file classification, the announcer's specified input, git-free) was scheduled with the auto-committer that the edition split moved to pro-m1 — filed on the Redesign board for a ruling. 1533 unit tests green, both schemes build. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
239 lines
13 KiB
Swift
239 lines
13 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"
|
|
}
|
|
|
|
// MARK: - Live board announcements
|
|
|
|
/// "3 lanes", "1 lane" — `cardCount`'s twin, and the second half of the digest's plural folding.
|
|
/// Spelled here rather than borrowed from `TrashModel.phrase` because that one is a cards-only
|
|
/// container's phrase by design ("Lanes are never trashed").
|
|
static func laneCount(_ count: Int) -> String {
|
|
"\(count) lane\(count == 1 ? "" : "s")"
|
|
}
|
|
|
|
/// 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 announcement fires only on a foreign
|
|
/// reload (`BoardAnnouncer.speech(for:)`), 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 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"
|
|
}
|