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
+55 -3
View File
@@ -348,6 +348,15 @@ public final class BoardStore: HealHost {
@ObservationIgnored
public let echoes = EchoLedger()
/// **Board search's transient comment index** (04-interactions.md Search, re-ruled 2026-07-29)
/// the sweep that lets a query reach comment bodies without the snapshot ever carrying one.
///
/// A `let` beside `echoes` and `heals`, and *not* `@ObservationIgnored`: `searchFilter` reads
/// `matchingCards`, so every surface that filters through the store re-renders when a sweep lands.
/// That refinement arriving a moment after the keystroke is the design's own accepted behaviour
/// see `CommentSearchIndex` for the freshness rule and its interim.
public let commentIndex = CommentSearchIndex()
/// The rows the board window's strip renders, in precedence order.
///
/// Composed rather than stored: `readOnlyLock` and `reloadFailure` are the store's truths and
@@ -756,7 +765,13 @@ public final class BoardStore: HealHost {
// "ride whatever transaction is active rather than easing on its own", and a
// re-grounding that landed outside this one would be exactly the independent ease
// that rules out.
transient.resolve(against: result.model)
//
// The comment index' current answer rides along, because the filter half of the
// re-grounding is the *whole* filter (04 Search, re-ruled 2026-07-29) a card
// matching only through its comments must not be evicted from the selection by a
// reload that asked a narrower question. The index is re-swept just below, outside
// the transaction, and its landing re-runs this constraint through `onRefine`.
transient.resolve(against: result.model, commentMatches: commentIndex.matchingCards)
// And *then* the recovery, on top of the set rule rather than instead of it: the
// resolution leaves an emptied selection wherever the focused item used to be, and
// this is 10-accessibility.md's answer to the hole ("focus recovers to the card's
@@ -772,6 +787,14 @@ public final class BoardStore: HealHost {
// this one did not.
reloadFailure = nil
defects = result.defects
// **The comment index' freshness signal, and its stated interim** (04-interactions.md
// Search: "kept fresh by the same FSEvents stream while a query is active"). The store has
// no changed-path channel the watcher reports only *that* the tree changed
// (02-architecture.md) so what a landed reload can offer an index of window-scoped
// content is its generation, and a query still active re-sweeps on it. Coarser than the
// ruling asks for and bounded by the same debounce; a no-op with no query running, which
// is the overwhelmingly common reload.
refreshCommentIndex()
reconcileLock(after: origin)
// The registry write-through, for the same "not board structure" reason the lock
// clearing sits out here: whether this board's row needs a new title, icon, or
@@ -3977,13 +4000,42 @@ public final class BoardStore: HealHost {
set {
guard newValue != transient.searchQuery else { return }
transient.searchQuery = newValue
transient.constrainToSearch(in: snapshot)
// **The comment index tracks the query, not the reload** (04 Search, re-ruled
// 2026-07-29): the first keystroke of a query kicks the sweep, every later one re-filters
// what it found, and clearing throws the whole thing away. Before the constraint, so a
// keystroke that *widens* the comment matches does not first evict a selection the very
// next line would have kept.
refreshCommentIndex()
transient.constrainToSearch(in: snapshot, commentMatches: commentIndex.matchingCards)
}
}
/// The query as the predicate, for the selection grammar's order lists read wherever the board
/// asks "what is on the board, in what order" (`SelectionGrammar.order`).
public var searchFilter: SearchFilter { SearchFilter(query: transient.searchQuery) }
///
/// **It carries the comment index' answer**, which is what makes "a card matches if any of its
/// meaningful content matches" true for comments on every surface at once the masonry, the
/// order lists, the trash column, Select All without any of them learning that comments exist.
public var searchFilter: SearchFilter {
SearchFilter(query: transient.searchQuery, commentMatches: commentIndex.matchingCards)
}
/// Points the comment index at the current query and snapshot the one funnel, called from the
/// query's setter and from a landed reload (`land`).
///
/// The targets are computed **lazily**, inside the index' own decision: an already-fresh index
/// re-filters in memory and never asks, so an ordinary keystroke costs no board walk at all.
private func refreshCommentIndex() {
commentIndex.onRefine = { [weak self] in
guard let self else { return }
transient.constrainToSearch(in: snapshot, commentMatches: commentIndex.matchingCards)
}
commentIndex.update(
query: transient.searchQuery,
generation: snapshotGeneration,
targets: CommentSearchIndex.targets(in: snapshot)
)
}
/// Clears the search **Escape's middle step** (04 § Search's staged Escape: "with *board*
/// focus and an active search, one press clears the search and the full board returns"), and the