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
+94 -19
View File
@@ -38,42 +38,70 @@ struct CommentRowView: View {
var body: some View {
VStack(alignment: .leading, spacing: padding) {
if let line = authorLine {
Text(line)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
if let session {
authorText
editor(session)
} else {
reading
if !comment.attachments.isEmpty {
// **Read-only** "a posted comment's chips are read-only, Quick Look only Edit
// the comment to change its files" (05). `onRemove` left `nil` is that sentence,
// and the editing branch above draws its *own* removable chips inside the
// authoring surface, which is why these are the reading branch's alone.
//
// They sit **outside** the flattened element deliberately: 10 flattens "author,
// date, edited state, body", and the chips are the one part of a comment that is
// not text but a row of controls with a Quick Look behind each reachable exactly
// as the sidebar's are (`CommentAttachmentChips`). Flattening them in would have
// made a comment's files announceable but not openable.
CommentAttachmentChips(
names: comment.attachments,
url: { comments.attachmentURL($0, in: .comment(comment.id)) },
thumbnails: thumbnails
)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.contextMenu { menu }
.accessibilityElement(children: .contain)
.accessibilityLabel(authorLine ?? "Comment")
}
// MARK: - Reading
/// The author line and the rendered body, as **one flattened element** with the three custom
/// actions (10-accessibility.md Comments see `CommentRowAccessibility`).
private var reading: some View {
VStack(alignment: .leading, spacing: padding) {
CommentBodyView(body: comment.body, cardFolder: cardFolder)
.frame(maxWidth: .infinity, alignment: .leading)
authorText
if !comment.attachments.isEmpty {
// **Read-only** "a posted comment's chips are read-only, Quick Look only Edit the
// comment to change its files" (05). `onRemove` left `nil` is that sentence.
CommentAttachmentChips(
names: comment.attachments,
url: { comments.attachmentURL($0, in: .comment(comment.id)) },
thumbnails: thumbnails
)
}
CommentBodyView(
body: comment.body,
cardFolder: cardFolder,
// The find's hits in this comment, and whether the current one is here the row draws
// them, the session found them (`CommentThreadFind`).
highlights: comments.find.matches(in: comment.id),
currentHighlight: comments.find.currentMatch.flatMap {
$0.comment == comment.id ? $0.range : nil
},
focus: comments
)
.frame(maxWidth: .infinity, alignment: .leading)
}
.modifier(CommentRowAccessibility(comment: comment, comments: comments, authorLine: authorLine))
}
/// The author line, or nothing "a comment with no `author` renders **without a name**"
/// (`CommentAuthorLine`).
@ViewBuilder
private var authorText: some View {
if let line = authorLine {
Text(line)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
@@ -146,3 +174,50 @@ struct CommentRowView: View {
date.formatted(date: .abbreviated, time: .shortened)
}
}
// MARK: - The row as one element
/// **One comment is one flattened element with three custom actions** (10-accessibility.md
/// Comments):
///
/// > each comment is **one flattened element** author, date, edited state, body with its
/// > context-menu rows (Edit / Delete / Reveal in Finder) riding as custom actions per the cut.
///
/// ### It covers the reading surface only, and that is the whole of where it is applied
///
/// While an inline edit session is open the row is a text editor with two buttons in it, and
/// collapsing *that* into one opaque element would put a VoiceOver user in front of a comment they
/// can neither read into nor type into. So this hangs on the reading branch alone
/// (`CommentRowView.reading`), and the editing branch is an ordinary container the same asymmetry
/// the body column keeps between Preview and Edit.
///
/// The actions duplicate the context menu deliberately: 10 asks for the pointer inventory to be
/// reachable without the pointer, and the strings are shared with the menu (`AccessibilityPhrases`)
/// so the two inventories cannot drift. They are also **not** disabled under the read-only lock
/// they call the same handles the menu rows do, and those already refuse (`CardComments.beginEdit`,
/// `.delete`), which keeps one refusal rather than two.
private struct CommentRowAccessibility: ViewModifier {
let comment: Comment
let comments: CardComments
let authorLine: String?
func body(content: Content) -> some View {
content
.accessibilityElement(children: .ignore)
.accessibilityLabel(AccessibilityPhrases.commentLabel(authorLine: authorLine))
.accessibilityValue(AccessibilityPhrases.commentValue(
body: comment.body,
attachments: comment.attachments.count
))
.accessibilityAction(named: AccessibilityPhrases.commentEditAction) {
comments.beginEdit(comment.id)
}
.accessibilityAction(named: AccessibilityPhrases.commentDeleteAction) {
comments.delete(comment.id)
}
.accessibilityAction(named: AccessibilityPhrases.commentRevealAction) {
comments.reveal(comment.id)
}
}
}