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
+39
View File
@@ -327,6 +327,45 @@ public struct CommentThread: Sendable, Equatable {
return CommentDraft(body: document.body, attachments: attachments)
}
/// **Board search's read** every posted comment's body under `cardFolder`, and nothing else
/// (04-interactions.md Search: "an async sweep of `comments/*/index.md` bodies (`.draft` and
/// `comments/.trash/` excluded)").
///
/// It is a second entry point rather than `load(inCard:path:)` reused, and the difference is
/// deliberately narrow: **it is silent and it reports nothing**. The thread read logs every stray,
/// records every coerce-tier field and hands back the claimed-name work a heal will act on all of
/// which is right for a window opening a thread and wrong for a sweep that re-runs whenever a
/// reload lands under a live query. A warning per stray per keystroke's re-sweep would be a log a
/// human could not read, and defects surfaced from a *search* would be repaired by a gesture the
/// user never made.
///
/// `nonisolated` and total: this runs on a detached task (`CommentSearchIndex.sweep`), and a card
/// with no `comments/`, one whose thread is held by a file, and one whose comments are all
/// unreadable each answer `[]` the same shrug the thread read gives, minus the paperwork.
///
/// The two exclusions are the enumeration's, exactly as in `load`: `.draft` and `.trash` are
/// dot-prefixed, and `BoardLoader.directoryCandidates` skips hidden entries.
public static func searchableBodies(inCard cardFolder: URL) -> [String] {
let threadFolder = folder(inCard: cardFolder)
guard IntegrityRules.node(at: threadFolder) == .directory else { return [] }
var bodies: [String] = []
for commentURL in (try? BoardLoader.directoryCandidates(in: threadFolder)) ?? [] {
guard IntegrityRules.isIdentityShaped(commentURL.lastPathComponent),
let data = try? Data(contentsOf: commentURL.appendingPathComponent(IntegrityRules.indexFileName)),
// A malformed comment is *tolerated*, which here means unsearched: a body the parser
// could not find is not a body a query can honestly be said to miss, and searching
// the raw bytes would let a query match frontmatter the thread never renders.
let document = try? BoardLoader.parseDocument(data, path: commentURL.lastPathComponent),
!document.body.isEmpty
else {
continue
}
bodies.append(document.body)
}
return bodies
}
/// **Chronology, with the undated after the dated** (01-storage-format.md § Enhanced schema,
/// ruled 2026-07-29): "the thread sorts by `created` ascending ties and missing/malformed
/// `created` (coerce-tier fallback, logged) sort after dated siblings, folder-name order".