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
+126 -1
View File
@@ -44,6 +44,11 @@ public final class CardComments {
/// Finder resolve against it.
public var cardFolder: URL?
/// The card's title, as the announcer needs it **the subject of every path-shaped comment
/// sentence** ("New comment on 'card'"). Re-derived from every snapshot by the host, so a card
/// renamed mid-session is announced under its new name.
public var cardTitle: String?
/// Whether the pane's mutations are offered at all `!store.isReadOnly`. Under the lock the
/// composer, the paperclips, Post, Edit and Delete disable in place, which is
/// 02-architecture.md's every-entry-point predicate applied to this pane.
@@ -65,6 +70,29 @@ public final class CardComments {
/// a `Bool` would need clearing, and a clear that raced the view would swallow the second one.
public private(set) var focusComposerRequests = 0
// MARK: Find
/// **The pane's find session** Edit Find over the whole rendered thread (05-card-window.md
/// Preview; `CommentThreadFind`). One per window like everything else here, because two card
/// windows are two threads and two searches.
public let find = CommentThreadFind()
/// **Which surface inside the pane holds the keyboard**, as its own text views report it the
/// input F routes on (`CardWindowFind.route`).
///
/// `BoardSearchPresentation.isFocused`'s shape and for its reason: it is the *view's* answer,
/// written on `becomeFirstResponder`/`resignFirstResponder`, which is what makes it true for
/// AppKit's own key-view traversal as well as for a click. Inferring it from SwiftUI focus state
/// would be inferring it from the wrong responder chain every text surface in this pane is an
/// `NSTextView`.
public private(set) var paneFocus: CommentPaneFocus?
/// Puts the stock find bar over whichever authoring editor is focused filled in by that editor
/// as it takes the keyboard, and cleared as it loses it. `nil` whenever the focus is not an
/// authoring surface, which is exactly when there is no such find to run.
@ObservationIgnored
public private(set) var authoringFindInText: (() -> Void)?
// MARK: Seams filled in by the host with the store's own bracketed methods
/// Re-reads the thread `BoardStore.commentThread(inCard:)`.
@@ -109,6 +137,19 @@ public final class CardComments {
@ObservationIgnored
public var removeAttachment: ((String, CommentTarget) -> Void)?
/// **Which of this thread's changes the app itself wrote** `BoardStore.vouchedComments(inCard:)`,
/// consumed once per reload so a foreign change is never mistaken for an echo or the other way
/// about (10-accessibility.md Live board announcements).
@ObservationIgnored
public var vouchedComments: (() -> Set<ItemID>)?
/// **This pane's outlet for spoken announcements** `AccessibilityAnnouncer.post`, the app's one
/// `NSAccessibility.post` call site, injectable exactly as `BoardStore.announce` is and for its
/// reason: what is *said* is decided by pure functions, and a suite has to be able to read the
/// sentence without a screen reader attached.
@ObservationIgnored
public var announce: @MainActor (String?) -> Void = { AccessibilityAnnouncer.post($0) }
public init() {}
// MARK: - Reading
@@ -121,7 +162,11 @@ public final class CardComments {
/// board that closed cleanly (`HealScheduler`'s rest branch), which is every open but one.
public func open() {
sweepTrashResidue?()
reload()
// **The opening read announces nothing.** Every comment on the card is "new" relative to the
// empty thread this pane starts with, and narrating a thread the user has just chosen to open
// would be the app describing its own window (10-accessibility.md's rationing, applied to the
// one reload that is not a change at all).
reload(announcing: false)
}
/// Re-reads the thread and the draft, and routes anything the read found to be repaired.
@@ -132,13 +177,25 @@ public final class CardComments {
/// because this runs from an `onChange` that may still be inside the store's own reload
/// transaction and a thread must not ride the board's structural spring.
public func reload() {
reload(announcing: true)
}
/// - Parameter announcing: whether a foreign change in this re-read is worth speech. `false` for
/// the window's opening read only (see `open()`); every other caller the FSEvents reload, and
/// the immediate re-read a gesture takes passes `true`, because the *narrowing* that keeps a
/// gesture silent is the ledger's rather than the call site's.
private func reload(announcing: Bool) {
guard let readThread else { return }
let previous = thread
let thread = readThread()
let draft = readDraft?()
withAnimation(nil) {
self.thread = thread
}
if announcing {
announceForeignChanges(from: previous, to: thread)
}
composer.adopt(draft: draft)
// The open session follows disk under the same dirty-buffer-wins rule the body has: a clean
// editor takes the foreign edit, a dirty one keeps the keystrokes. A session whose comment
@@ -164,6 +221,27 @@ public final class CardComments {
}
}
/// **Foreign comment changes speak path-shaped** (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").
///
/// Three steps, each of them somebody else's rule: the two threads are diffed
/// (`CommentThreadChanges.between`), the ledger's receipts are consumed to narrow the result to
/// what nobody vouched for (`BoardStore.vouchedComments(inCard:)` which is where
/// `CommentPath.classify` reads the path shape), and the announcer picks one polite sentence
/// (`BoardAnnouncer.commentSpeech(for:onCard:)`). Nothing here is a decision, which is what lets
/// all three be checked without a window.
///
/// **The receipts are consumed whether or not the thread changed.** A reload that observed an
/// app-mediated edit and a reload that observed nothing must both leave the ledger clean: an
/// uncollected comment receipt would silence the *next* change to that comment, which is exactly
/// the misattribution the one-write-one-echo rule exists to prevent.
private func announceForeignChanges(from old: CommentThread, to new: CommentThread) {
let vouched = vouchedComments?() ?? []
let changes = CommentThreadChanges.between(old, new).excluding(vouched)
announce(BoardAnnouncer.commentSpeech(for: changes, onCard: cardTitle))
}
// MARK: - The composer
/// **File Add Comment**, and the pane's own "add a comment" affordances: ask the composer for
@@ -176,6 +254,53 @@ public final class CardComments {
focusComposerRequests += 1
}
// MARK: - Focus, as the pane's text views report it
/// One of the pane's text surfaces took the keyboard.
///
/// - Parameter findInText: the stock find bar over *that* editor, for an authoring surface. A
/// reading surface passes none its find is the thread's, which is this object's own.
public func focusEntered(_ focus: CommentPaneFocus, findInText: (() -> Void)? = nil) {
paneFocus = focus
authoringFindInText = findInText
}
/// One of them lost it **ignored unless it is still the one on record**.
///
/// AppKit resigns the outgoing responder before the incoming one becomes first, so the ordinary
/// case is already safe; the guard covers the one that is not, a late resignation arriving after
/// a sibling has claimed focus. Without it, clicking from one comment straight into the composer
/// could leave the pane reporting no focus at all.
public func focusLeft(_ focus: CommentPaneFocus) {
guard paneFocus == focus else { return }
paneFocus = nil
authoringFindInText = nil
}
/// **F with this pane focused** routed by `CardWindowFind.route`, which is where the rule lives.
/// Answers whether it handled the key, so `FindCommand` can fall through to the body surface.
@discardableResult
public func invokeFind(hasBody: Bool) -> Bool {
switch CardWindowFind.route(
paneFocus: paneFocus,
isThreadFindShowing: find.isShowing,
hasBody: hasBody
) {
case .thread:
find.invoke()
return true
case .authoring:
// "The composer and an inline comment edit are their own focused text surfaces with the
// editor's ordinary find" (05). The editor already has a find bar and a scroll view to put
// it in (`CommentTextEditor`); all that was missing is the key, which the menu item's
// equivalent takes before any text view sees it.
authoringFindInText?()
return true
case .body, nil:
return false
}
}
// MARK: - The inline edit session
/// Opens a session over one comment the context menu's **Edit** (05 The comments column).