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
@@ -277,6 +277,58 @@ struct AccessibilityPhrasesTests {
#expect(AccessibilityPhrases.readOnlyLockCleared == "The board is editable again")
#expect(AccessibilityPhrases.reloadBreakageCleared == "The board is loading again")
}
// MARK: - The comments pane
/// 10-accessibility.md Comments: "the pane is a labeled container ('Comments, N')".
@Test("The pane's container names itself and its count")
func commentsContainer() {
#expect(AccessibilityPhrases.commentsContainerLabel(count: 3) == "Comments, 3")
#expect(
AccessibilityPhrases.commentsContainerLabel(count: 0) == "Comments, 0",
"a comment-less card still shows the pane — the invitation is the point"
)
}
@Test("A comment count folds its plural, like every other count in the app")
func commentCounts() {
#expect(AccessibilityPhrases.commentCount(1) == "1 comment")
#expect(AccessibilityPhrases.commentCount(4) == "4 comments")
}
/// The flattened element: the author line is what it *is*, the body is what it holds.
@Test("A comment's label is its author line, and its value is its body")
func commentElement() {
#expect(AccessibilityPhrases.commentLabel(authorLine: "Ada Lovelace · 1 Jan 2026") == "Ada Lovelace · 1 Jan 2026")
#expect(AccessibilityPhrases.commentValue(body: "A remark.\n", attachments: 0) == "A remark.")
#expect(
AccessibilityPhrases.commentValue(body: "A remark.\n", attachments: 2) == "A remark., 2 attachments"
)
}
@Test("A comment with nothing to attribute is still a named element")
func unattributedComment() {
// "Unattributed" is the absence of a name, never a name to speak (`CommentAuthorLine`) but an
// unlabeled element is an audit failure, so the fallback is the noun itself.
#expect(AccessibilityPhrases.commentLabel(authorLine: nil) == "Comment")
#expect(AccessibilityPhrases.commentValue(body: " \n", attachments: 0) == "")
}
/// The custom actions must be the *same* strings as the context menu's rows (10 Comments), so a
/// user who has learned the pointer inventory hears the same three words from the rotor.
@Test("The comment's custom actions are its context menu's rows")
func commentActions() {
#expect(AccessibilityPhrases.commentEditAction == "Edit")
#expect(AccessibilityPhrases.commentDeleteAction == "Delete")
#expect(AccessibilityPhrases.commentRevealAction == "Reveal in Finder")
}
@Test("The composer, the sort control and the paperclip are labeled")
func commentControls() {
#expect(AccessibilityPhrases.commentComposerLabel == "Add a comment")
#expect(AccessibilityPhrases.commentSortLabel == "Sort")
#expect(AccessibilityPhrases.commentAttachFilesLabel == "Attach Files")
}
}
/// Distinct identities for the digest cases, which care only about *counts* the diff's own suite