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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user