User-ruled 2026-07-29: the ledger builds now in base, pre-release (DESIGN/02 - Components - EchoLedger; DESIGN/10 - Live board announcements). Receipts drop inside BoardWriter's four disk primitives (atomic replace, folder move, removal, attachment copy) into a @TaskLocal ledger that BoardStore.performWrite binds for the bracket's duration - no call-site bookkeeping, and performWholesale deliberately binds nothing per 02's bracket exemption. Classification is a pure function of two snapshots: an item whose folder, index.md bytes, or attachment listing differs is an observed change; disk matching the receipt is app-mediated (receipt consumed), no receipt or mismatch is foreign. Byte-identical foreign overwrites classify app-mediated (unobservable, accepted); a foreign edit over a fresh app write classifies foreign. The announcer now consumes per-file facts on every reload origin - the WatchOrigin gate is gone (ReloadFacts.origin removed outright; nothing read it after the gate fell). Reconciling sweeps announce their receipt-less findings as foreign, closing both interim holes (debounce-window absorption, reconcile silence). The vanishing-focus rung gates on the ledger too: "deleted externally" would be a lie about an app-mediated delete, and the subject's own verdict decides. Divergence flagged: attachment imports hash the landed file right after FileManager.copyItem rather than during the copy (the bytes do not stream through the app); an unreadable read-back records nothing, the direction that biases toward foreign. 30 ledger tests added, announcer suite reworked to the ruling. 1638 green on both schemes. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
240 lines
13 KiB
Swift
240 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 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 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"
|
|
}
|