Comments, phase 3 — search, the thread find, announcements, and a11y

Board search reaches comment bodies through a search-owned transient
index: the first live-query keystroke sweeps comments/*/index.md
off-actor (.draft and comments/.trash excluded), keystrokes re-filter
in memory, the index discards on clear — the snapshot stays O(cards).
⌘F routes by focus: the comments pane gets an app-owned find bar
spanning the whole rendered thread (next/prev cross rows with
wraparound); body and composer keep NSTextFinder; Find Next/Previous
graduate from FutureCommands. Foreign comment changes speak
path-shaped beside the announcer's ladder ("New comment on 'X'",
plural folds), narrowed by EchoLedger receipts consumed through
CommentPath.classify — and that read fixed a latent footprint bug
where a comment receipt resolved against the card's attachment
listing, read .absent, and classified the user's own write as
foreign. The pane completes its a11y story: flattened comment
elements with Edit/Delete/Reveal custom actions (un-flattening
during inline edit), phrase-table vocabulary, labeled composer and
sort control, and an audit over the open pane on a comment-seeded
fixture (runnable only where automation permission exists).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-30 21:30:22 -04:00
parent fe3ffac48e
commit 9588f7b1f0
31 changed files with 3036 additions and 73 deletions
+28
View File
@@ -265,6 +265,34 @@ public enum BoardAnnouncer {
return AccessibilityPhrases.boardChanged(facts.diff)
}
// MARK: - A card window's thread
/// **What a card window's landed thread re-read says out loud** the ladder's sibling for the one
/// kind of change the board's snapshot cannot see (10-accessibility.md Comments;
/// 01-storage-format.md Enhanced schema's path shape).
///
/// ### Why it is beside the ladder rather than a sixth rung on it
///
/// `speech(for:)` answers "what does *this board reload* say", and its rationing one sentence per
/// reload debounce is about the board window's own churn. A thread change is not in that reload's
/// picture at all: comments are outside the snapshot, so the facts arrive from the card window's
/// own re-read, on a different window, about a different element. Folding it into the ladder would
/// mean either the board reload waiting on a window-scoped disk read (which 01 rules out) or the
/// ladder silently dropping a comment sentence whenever a banner also moved.
///
/// What it *does* share is everything that matters: the same doctrine (foreign only the narrowing
/// is the caller's, through `CommentThreadChanges.excluding(_:)`), the same rationing (one optional
/// sentence, chosen by precedence), the same vocabulary (`AccessibilityPhrases`), and the same
/// posting seam (`AccessibilityAnnouncer.post`, medium priority, attributed to the key window
/// which for a thread change is the card window the user is looking at).
///
/// `nil` silence for a reload that changed nothing in the thread, which is the overwhelming
/// majority of them, and for one whose every change the app itself wrote.
public static func commentSpeech(for changes: CommentThreadChanges, onCard title: String?) -> String? {
guard !changes.isEmpty else { return nil }
return AccessibilityPhrases.commentsChanged(changes, onCard: title)
}
/// A standing condition that was not there before and is now announced with the banner row's
/// own label, so the sentence a VoiceOver user hears is the sentence the strip is showing.
private static func raisedCondition(_ facts: ReloadFacts) -> String? {