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
+38 -5
View File
@@ -50,14 +50,38 @@ struct CardCommentsPane: View {
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: pointSize))
.padding(.top, CardWindowMetrics.gutter(bodyPointSize: pointSize))
// **The find bar sits above the thread it searches** `findBarPosition = .aboveContent`,
// which is where every other find in this window puts its bar (`CardBodySurface`).
if comments.find.isShowing {
CommentFindBar(find: comments.find)
.padding(.top, CardWindowMetrics.previewPadding(bodyPointSize: pointSize))
}
thread
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// 10-accessibility.md's container label for the pane ("Comments, N"). The elements inside it
// and their custom actions are phase 3's; the container is here because the pane would
// otherwise be an unnamed region the moment it exists.
// 10-accessibility.md's container label for the pane ("Comments, N") the count is the
// thread's, so the spoken container and the visible header can never disagree.
.accessibilityElement(children: .contain)
.accessibilityLabel("Comments, \(comments.thread.comments.count)")
.accessibilityLabel(AccessibilityPhrases.commentsContainerLabel(count: comments.thread.comments.count))
// The find's model of the rendered thread, rebuilt only while the bar is up: the search runs
// over every comment, mounted or not (`CommentThreadFind`), and building it costs a render
// pass the pane has no reason to spend on a window nobody is searching.
.onChange(of: FindThreadKey(isShowing: comments.find.isShowing, thread: comments.thread), initial: true) { _, key in
guard key.isShowing else { return }
comments.find.setThread(key.thread.comments, pointSize: pointSize)
}
// **A find cannot outlive the surface it searches.** The pane unmounts on View Show Comments
// and on the raw-source swap, and a session left showing would keep F and G pointed at a bar
// nobody can see (`CardWindowFind.route`'s open-bar clause).
.onDisappear { comments.find.dismiss() }
}
/// The pair the find's rebuild is keyed on. A value rather than two `onChange`s so the bar opening
/// and the thread changing take the same path, and so the rebuild cannot run twice for one update.
private struct FindThreadKey: Equatable {
let isShowing: Bool
let thread: CommentThread
}
// MARK: - Header
@@ -88,7 +112,7 @@ struct CardCommentsPane: View {
}
.buttonStyle(.plain)
.help(direction.controlLabel)
.accessibilityLabel("Sort")
.accessibilityLabel(AccessibilityPhrases.commentSortLabel)
.accessibilityValue(direction.controlLabel)
}
@@ -129,6 +153,15 @@ struct CardCommentsPane: View {
.onChange(of: comments.focusComposerRequests) { _, _ in
proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom)
}
// **A find that steps off screen brings the reader with it** the half of "it reads as one
// find" that the highlight alone cannot do. Scrolling to the comment rather than to the
// range is what a row-shaped thread allows: rows are the scroll targets
// (`ForEach(...).id(comment.id)`), and a comment is short enough that its top is the hit's
// neighbourhood.
.onChange(of: comments.find.currentMatch) { _, match in
guard let match else { return }
proxy.scrollTo(match.comment, anchor: .center)
}
}
}