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
+28 -6
View File
@@ -54,14 +54,36 @@ struct FutureCommand: View {
/// "disabled in the board window board search is a live filter, not a cursor"
/// (11-command-nexus.md).
///
// m6-card-window: joins `FindCommand` in the Edit menu once the card window's find-in-text exists
// (05-card-window.md). Both rows are unconditionally disabled here rather than reading `boardStore`
// to prove "board window" disables them: there is no card-window find session anywhere yet for
// either validation branch to check.
/// ### They are live for exactly one find, and disabled for the others on purpose
///
/// The card window has three finds (`CardWindowFindRoute`), and two of them are **`NSTextFinder`**'s
/// the body's and an authoring editor's. `NSTextView` already answers G and G through the responder
/// chain, and an *enabled* menu item's key equivalent fires before the responder chain is consulted,
/// so a row that claimed the chord unconditionally would break the stepping it exists to provide. So
/// these validate on the **thread** find alone the one find with no responder to fall through to,
/// because its bar is the app's own and stay disabled everywhere else, which lets the platform's
/// stepping keep working where the platform owns the find.
///
/// `.disabled` on the rows rather than a guard in the action, for the reason every menu row here
/// wears its validation: a key equivalent that fires and does nothing is a chord the user cannot tell
/// from a broken one.
struct FindSteppingCommands: View {
@FocusedValue(\.cardComments) private var comments
/// The row's validation, as a value a test can hold: the pane's find bar is up, which is the only
/// state in which this app owns G.
static func isEnabled(_ comments: CardComments?) -> Bool {
comments?.find.isShowing == true
}
var body: some View {
FutureCommand(title: "Find Next", key: "g", modifiers: .command)
FutureCommand(title: "Find Previous", key: "g", modifiers: [.shift, .command])
Button("Find Next") { comments?.find.step(forward: true) }
.keyboardShortcut("g", modifiers: .command)
.disabled(!Self.isEnabled(comments))
Button("Find Previous") { comments?.find.step(forward: false) }
.keyboardShortcut("g", modifiers: [.shift, .command])
.disabled(!Self.isEnabled(comments))
}
}
+92
View File
@@ -334,6 +334,40 @@ enum UITestLaunch {
The audit fixture's attachment. Its only job is to exist, so the attachments section has a row.
"""
/// **The rich card's comment thread** three comments, because the pane's accessibility audit
/// needs one of each shape 10-accessibility.md's comments row can take ( Comments: "each comment
/// is one flattened element author, date, edited state, body").
///
/// In order: an ordinary comment (author line, date, body), an **authorless** one (the date alone
/// carries the line "missing renders unattributed", and no placeholder stands in for a name),
/// and one that has been **edited** (its author line ends "· edited", which is `modified`
/// differing from `created` and no extra field). Between them they cover every branch of
/// `CommentAuthorLine.text(author:timestamp:isEdited:)` that a written file can produce.
static let commentBodies = [
"""
The audit's specimen thread. This one is ordinary: a name, a date, and a paragraph of \
Markdown with some *emphasis* in it.
""",
"""
This one has no `author` key at all, so it renders unattributed — a date and a body, and no \
placeholder standing in for a name.
""",
"""
And this one has been edited since it was posted, so its author line carries the edited marker.
"""
]
/// Which comment gets its `author` key removed, by index into `commentBodies`.
static let authorlessCommentIndex = 1
/// Which comment is edited after posting, by index into `commentBodies`, and what it is edited to.
static let editedCommentIndex = 2
static let editedCommentBody = """
And this one has been edited since it was posted, so its author line carries the edited \
marker — this sentence is the edit.
"""
/// The card that is deleted into `.trash/`, named by `(lane, card)` index.
///
/// A trash with something in it is the only way the trash-shown audit reaches the elements
@@ -460,6 +494,7 @@ enum UITestLaunch {
let richCard = cardURLs[richCardIndex.lane][richCardIndex.card]
try BoardWriter.writeBody(inItemFolder: richCard, body: richCardBody)
try importFixtureAttachment(into: richCard)
try seedCommentThread(into: richCard, cardTitle: cardTitles[richCardIndex.lane][richCardIndex.card])
// The delete goes last so the trashed card's identity is one the lanes above have already
// finished with and through the ordinary delete door, so `.trash/` ends up holding exactly
@@ -534,6 +569,63 @@ enum UITestLaunch {
return root
}
/// Builds the rich card's thread **the way the composer does** a draft saved, then posted, once
/// per body so the fixture's `comments/` is a folder the app made: minted identities, the
/// `kind: comment` field table, `created`/`modified` restamped at the post.
///
/// Two of the three then need a shape no gesture in the app produces, and each is applied
/// afterwards, narrowly:
///
/// - **The edit** is an ordinary Writer call (`editComment`), which is exactly what an inline edit
/// session's save does so the edited marker in the fixture is the real mechanism, not a
/// hand-set field.
/// - **The two frontmatter amendments** are raw writes, for the malformed variant's reason (see
/// this type's note). Neither shape has a door in the app: it always writes the account's full
/// name, and it cannot post a comment an hour ago. Both shapes are ordinary on disk, though
/// agents and tracker sync write comments with no `author` at all (01-storage-format.md
/// Enhanced schema), and every comment that has ever been edited was posted before it. Both go
/// through `FrontmatterDocument`, so the rest of each file is byte-identical to what the Writer
/// produced.
///
/// The backdating is not decoration: `created` and `modified` serialize to the second, and a
/// comment posted and edited inside one second would render as **not** edited the marker is
/// `modified` differing from `created` and no extra field (`Comment.isEdited`).
private static func seedCommentThread(into cardFolder: URL, cardTitle: String) throws {
var posted: [ItemID] = []
for body in commentBodies {
try BoardWriter.saveCommentDraft(inCard: cardFolder, body: body, cardTitle: cardTitle)
posted.append(try BoardWriter.postComment(inCard: cardFolder, cardTitle: cardTitle).id)
}
try BoardWriter.editComment(
at: CommentThread.commentFolder(posted[editedCommentIndex], inCard: cardFolder),
body: editedCommentBody,
cardTitle: cardTitle
)
try amendComment(posted[authorlessCommentIndex], inCard: cardFolder) { document in
document.remove(FrontmatterKeys.author)
}
try amendComment(posted[editedCommentIndex], inCard: cardFolder) { document in
document.set(FrontmatterKeys.created, to: .date(Date().addingTimeInterval(-3600)))
}
}
/// One comment's frontmatter, amended in place the fixture's narrow way around the Writer, kept
/// to one function so both amendments share its round trip and neither invents a second one.
private static func amendComment(
_ id: ItemID,
inCard cardFolder: URL,
_ amend: (inout FrontmatterDocument) -> Void
) throws {
let indexURL = CommentThread
.commentFolder(id, inCard: cardFolder)
.appendingPathComponent(BoardLoader.indexFileName, isDirectory: false)
var document = try FrontmatterDocument.parse(String(decoding: try Data(contentsOf: indexURL), as: UTF8.self))
amend(&document)
try Data(document.serialized().utf8).write(to: indexURL, options: .atomic)
}
/// Writes the attachment's source into the scratch root and imports it the way a Finder drop
/// would (`BoardWriter.importAttachments`), so the card ends up with a real `attachments/`
/// folder rather than a hand-placed file the loader would have to normalize.