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
+21 -7
View File
@@ -362,6 +362,11 @@ struct CardWindowHost: View {
attachments.cardFolder = folder
session.comments.cardFolder = folder
}
// The announcer's subject, re-derived from every snapshot for the folder's reason: a card
// renamed mid-session is announced under its new name ("New comment on 'card'").
.onChange(of: placement.card.title.value, initial: true) { _, title in
session.comments.cardTitle = title
}
.onChange(of: store.isReadOnly, initial: true) { _, locked in
attachments.isEditable = !locked
session.comments.isEditable = !locked
@@ -372,13 +377,15 @@ struct CardWindowHost: View {
// *Any* reload, not a filtered one, and that is a deliberate choice worth stating: the
// store's observable surface publishes `snapshotGeneration` and a `BoardModel` it does
// not vend the changed paths, and comments are outside the snapshot entirely
// (01-storage-format.md § Enhanced schema), so there is nothing here to run
// `CommentPath.classify` against. Re-reading one card's thread is a handful of small
// files and happens only while a card window is open; filtering would mean either
// widening the store's surface to carry paths, or the pane keeping its own watcher a
// second stream over the same tree, which the one-way flow rules out. `initial:` is
// deliberately absent: `start()` already did the opening read, after the residue sweep
// that has to precede it.
// (01-storage-format.md § Enhanced schema), so there is nothing to filter *on* here.
// Re-reading one card's thread is a handful of small files and happens only while a card
// window is open; filtering would mean either widening the store's surface to carry paths,
// or the pane keeping its own watcher a second stream over the same tree, which the
// one-way flow rules out. The path shape is read on the other side of the re-read instead,
// where there *are* two pictures to compare: the pane diffs its threads and consumes the
// ledger's comment receipts through `CommentPath.classify` to tell a foreign arrival from
// its own echo (`CardComments.reload`). `initial:` is deliberately absent: `start()`
// already did the opening read, after the residue sweep that has to precede it.
.onChange(of: store.snapshotGeneration) { _, _ in
session.comments.reload()
}
@@ -577,6 +584,13 @@ struct CardWindowHost: View {
comments.composer.post = { [weak store] in
store?.postComment(inCard: cardID)
}
// The announcer's gate: which of this thread's changes the app itself wrote, consumed once per
// reload (10-accessibility.md "app-mediated echoes never announce", per comment). A store
// that has gone vouches for nothing, which is the conservative direction and also the one the
// announcement cannot reach anyway a released store has no window left to speak in.
comments.vouchedComments = { [weak store] in
store?.vouchedComments(inCard: cardID) ?? []
}
}
/// Points the attachments section at its card **the one place Add Attachment and Remove