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
+128
View File
@@ -226,6 +226,134 @@ enum AccessibilityPhrases {
}
}
// 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/<uuid>/` 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