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 attachments.cardFolder = folder
session.comments.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 .onChange(of: store.isReadOnly, initial: true) { _, locked in
attachments.isEditable = !locked attachments.isEditable = !locked
session.comments.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 // *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 // store's observable surface publishes `snapshotGeneration` and a `BoardModel` it does
// not vend the changed paths, and comments are outside the snapshot entirely // 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 // (01-storage-format.md § Enhanced schema), so there is nothing to filter *on* here.
// `CommentPath.classify` against. Re-reading one card's thread is a handful of small // Re-reading one card's thread is a handful of small files and happens only while a card
// files and happens only while a card window is open; filtering would mean either // window is open; filtering would mean either widening the store's surface to carry paths,
// widening the store's surface to carry paths, or the pane keeping its own watcher a // or the pane keeping its own watcher a second stream over the same tree, which the
// second stream over the same tree, which the one-way flow rules out. `initial:` is // one-way flow rules out. The path shape is read on the other side of the re-read instead,
// deliberately absent: `start()` already did the opening read, after the residue sweep // where there *are* two pictures to compare: the pane diffs its threads and consumes the
// that has to precede it. // 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 .onChange(of: store.snapshotGeneration) { _, _ in
session.comments.reload() session.comments.reload()
} }
@@ -577,6 +584,13 @@ struct CardWindowHost: View {
comments.composer.post = { [weak store] in comments.composer.post = { [weak store] in
store?.postComment(inCard: cardID) 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 /// 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" /// "disabled in the board window board search is a live filter, not a cursor"
/// (11-command-nexus.md). /// (11-command-nexus.md).
/// ///
// m6-card-window: joins `FindCommand` in the Edit menu once the card window's find-in-text exists /// ### They are live for exactly one find, and disabled for the others on purpose
// (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 /// The card window has three finds (`CardWindowFindRoute`), and two of them are **`NSTextFinder`**'s
// either validation branch to check. /// 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 { 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 { var body: some View {
FutureCommand(title: "Find Next", key: "g", modifiers: .command) Button("Find Next") { comments?.find.step(forward: true) }
FutureCommand(title: "Find Previous", key: "g", modifiers: [.shift, .command]) .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 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. /// 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 /// 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] let richCard = cardURLs[richCardIndex.lane][richCardIndex.card]
try BoardWriter.writeBody(inItemFolder: richCard, body: richCardBody) try BoardWriter.writeBody(inItemFolder: richCard, body: richCardBody)
try importFixtureAttachment(into: richCard) 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 // 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 // finished with and through the ordinary delete door, so `.trash/` ends up holding exactly
@@ -534,6 +569,63 @@ enum UITestLaunch {
return root 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 /// 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/` /// 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. /// folder rather than a hand-placed file the loader would have to normalize.
+28
View File
@@ -265,6 +265,34 @@ public enum BoardAnnouncer {
return AccessibilityPhrases.boardChanged(facts.diff) return AccessibilityPhrases.boardChanged(facts.diff)
} }
// MARK: - A card window's thread
/// **What a card window's landed thread re-read says out loud** the ladder's sibling for the one
/// kind of change the board's snapshot cannot see (10-accessibility.md Comments;
/// 01-storage-format.md Enhanced schema's path shape).
///
/// ### Why it is beside the ladder rather than a sixth rung on it
///
/// `speech(for:)` answers "what does *this board reload* say", and its rationing one sentence per
/// reload debounce is about the board window's own churn. A thread change is not in that reload's
/// picture at all: comments are outside the snapshot, so the facts arrive from the card window's
/// own re-read, on a different window, about a different element. Folding it into the ladder would
/// mean either the board reload waiting on a window-scoped disk read (which 01 rules out) or the
/// ladder silently dropping a comment sentence whenever a banner also moved.
///
/// What it *does* share is everything that matters: the same doctrine (foreign only the narrowing
/// is the caller's, through `CommentThreadChanges.excluding(_:)`), the same rationing (one optional
/// sentence, chosen by precedence), the same vocabulary (`AccessibilityPhrases`), and the same
/// posting seam (`AccessibilityAnnouncer.post`, medium priority, attributed to the key window
/// which for a thread change is the card window the user is looking at).
///
/// `nil` silence for a reload that changed nothing in the thread, which is the overwhelming
/// majority of them, and for one whose every change the app itself wrote.
public static func commentSpeech(for changes: CommentThreadChanges, onCard title: String?) -> String? {
guard !changes.isEmpty else { return nil }
return AccessibilityPhrases.commentsChanged(changes, onCard: title)
}
/// A standing condition that was not there before and is now announced with the banner row's /// A standing condition that was not there before and is now announced with the banner row's
/// own label, so the sentence a VoiceOver user hears is the sentence the strip is showing. /// own label, so the sentence a VoiceOver user hears is the sentence the strip is showing.
private static func raisedCondition(_ facts: ReloadFacts) -> String? { private static func raisedCondition(_ facts: ReloadFacts) -> String? {
+55 -3
View File
@@ -348,6 +348,15 @@ public final class BoardStore: HealHost {
@ObservationIgnored @ObservationIgnored
public let echoes = EchoLedger() 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. /// The rows the board window's strip renders, in precedence order.
/// ///
/// Composed rather than stored: `readOnlyLock` and `reloadFailure` are the store's truths and /// 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 // "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 // re-grounding that landed outside this one would be exactly the independent ease
// that rules out. // 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 // 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 // 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 // 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. // this one did not.
reloadFailure = nil reloadFailure = nil
defects = result.defects 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) reconcileLock(after: origin)
// The registry write-through, for the same "not board structure" reason the lock // 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 // 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 { set {
guard newValue != transient.searchQuery else { return } guard newValue != transient.searchQuery else { return }
transient.searchQuery = newValue 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 /// 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`). /// 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* /// 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 /// focus and an active search, one press clears the search and the full board returns"), and the
+15
View File
@@ -83,6 +83,21 @@ extension BoardStore {
return CommentThread.loadDraft(inCard: card.folder) return CommentThread.loadDraft(inCard: card.folder)
} }
/// **Which of this card's comments the app itself just wrote** the ledger's receipts, classified
/// by path shape and retired (`EchoLedger.vouchedComments(inCard:cardPath:)`).
///
/// The card window asks this on every landed reload, before deciding whether its thread's changes
/// are worth announcing: "app-mediated echoes never announce" (10-accessibility.md Live board
/// announcements) needs a per-comment answer, and the board reload cannot give one because comments
/// are not in the snapshot it compares.
///
/// `[]` for a card that is gone the same vanished-target answer every other card-scoped call
/// gives, and the conservative direction: nothing vouched for means everything speaks.
public func vouchedComments(inCard id: ItemID) -> Set<ItemID> {
guard let card = commentSubject(id) else { return [] }
return echoes.vouchedComments(inCard: card.folder, cardPath: card.path)
}
// MARK: The draft // MARK: The draft
/// Saves the composer's draft one bracket, no step. /// Saves the composer's draft one bracket, no step.
+85
View File
@@ -85,3 +85,88 @@ public struct CommentPath: Sendable, Equatable {
return CommentPath(cardPath: cardPath, kind: .comment(ItemID(rawValue: entry))) return CommentPath(cardPath: cardPath, kind: .comment(ItemID(rawValue: entry)))
} }
} }
// MARK: - CommentThreadChanges
/// **What happened to one card's thread between two reads** `BoardDiff` one level down, and for the
/// same consumer: something has to say what changed before anything can say it out loud.
///
/// ### Why the thread and not the snapshot
///
/// `BoardDiff` compares two `BoardModel`s, and a `BoardModel` has never heard of a comment that is
/// 01-storage-format.md Enhanced schema's stated exception to snapshot completeness, and it is why
/// foreign comment changes are "described by **path shape**" rather than by a diff. The path shape
/// answers *what kind* of change a path is (`CommentPath` above); this answers *which comments*
/// changed, from the only two pictures anything in the app actually holds the thread the card
/// window was showing, and the thread it has just re-read (`CardComments.reload`).
///
/// ### Three buckets, and the one that is deliberately absent
///
/// Arrivals, edits and departures, which are the three the verb family names ("Comment on", "Edit
/// comment on", "Delete comment on" 06-history-undo.md's family per 01). **An attachment landing
/// on a comment is not a change here**: the comparison is the `index.md` document, so a file imported
/// into a comment's `attachments/` moves nothing in this value. That is deliberate rather than an
/// omission 10-accessibility.md announces comment *arrivals*, the design's verb family is about the
/// comment's text, and a chip appearing is a visible change with no sentence written for it.
///
/// `.draft` and `comments/.trash/` never appear because they are not in a thread at all
/// (`CommentThread`'s own exclusion), so "a draft saved on another machine" and "a comment this window
/// deleted a moment ago" are both silent by construction rather than by a filter.
public struct CommentThreadChanges: Sendable, Equatable {
/// Comments in the new thread that were not in the old one.
public var arrived: [ItemID] = []
/// Comments in both, whose `index.md` differs.
public var edited: [ItemID] = []
/// Comments in the old thread that are not in the new one.
public var deleted: [ItemID] = []
public init(arrived: [ItemID] = [], edited: [ItemID] = [], deleted: [ItemID] = []) {
self.arrived = arrived
self.edited = edited
self.deleted = deleted
}
public var isEmpty: Bool {
arrived.isEmpty && edited.isEmpty && deleted.isEmpty
}
/// The comparison. Order follows the **new** thread for the two buckets it can (the display order
/// the loader produced) and the old thread for departures, so a caller naming "the" arrival names
/// the one a reader would reach first.
public static func between(_ old: CommentThread, _ new: CommentThread) -> CommentThreadChanges {
let older = Dictionary(old.comments.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let newer = Set(new.comments.map(\.id))
var changes = CommentThreadChanges()
for comment in new.comments {
guard let was = older[comment.id] else {
changes.arrived.append(comment.id)
continue
}
if was.document != comment.document {
changes.edited.append(comment.id)
}
}
for comment in old.comments where !newer.contains(comment.id) {
changes.deleted.append(comment.id)
}
return changes
}
/// This value with everything the `EchoLedger` vouched for removed **the app's own writes never
/// announce as foreign** (10-accessibility.md Live board announcements), applied per comment.
///
/// It is a narrowing rather than a gate for `EchoVerdicts.foreign`'s reason: a reload can carry an
/// app-mediated edit *and* a foreign arrival at once (an agent files a comment in the same debounce
/// window as the user's inline save), and the honest sentence describes the half nobody vouched
/// for rather than all of it or none of it.
public func excluding(_ vouched: Set<ItemID>) -> CommentThreadChanges {
guard !vouched.isEmpty else { return self }
return CommentThreadChanges(
arrived: arrived.filter { !vouched.contains($0) },
edited: edited.filter { !vouched.contains($0) },
deleted: deleted.filter { !vouched.contains($0) }
)
}
}
+272
View File
@@ -0,0 +1,272 @@
import Foundation
import Observation
// MARK: - What one card contributes to the sweep
/// One card the sweep will read a thread from: its identity, and where its folder is right now.
///
/// A value rather than a pair of parallel arrays because the sweep runs **off the main actor** and
/// the snapshot cannot travel with it `BoardModel` is the board's whole tree, and handing it to a
/// detached task to walk twice would be doing the walk the design keeps at O(cards) all over again.
/// So the walk happens once, on the main actor, and what crosses the boundary is this: identity plus
/// a URL, per card, and nothing else.
public struct CommentSearchTarget: Sendable, Equatable {
public let id: ItemID
/// The card's folder `<root>/<lane>/<card>`, or `<root>/.trash/<card>`. The thread hangs off it
/// (`CommentThread.folder(inCard:)`).
public let folder: URL
public init(id: ItemID, folder: URL) {
self.id = id
self.folder = folder
}
}
// MARK: - CommentSearchIndex
/// **Board search's transient comment index** the search-owned sweep that lets a query reach comment
/// *bodies* without the board snapshot ever carrying one (04-interactions.md Search, re-ruled
/// 2026-07-29; 01-storage-format.md Enhanced schema):
///
/// > **comment bodies join when comments ship** via a search-owned transient comment index, never
/// > the snapshot: the first live-query keystroke kicks an async sweep of `comments/*/index.md` bodies
/// > (`.draft` and `comments/.trash/` excluded), kept fresh while a query is active and discarded
/// > when it clears the board walk stays O(cards), 01's window-scoped read untouched.
///
/// ### Why it is its own object and not a member of `TransientBoardState`
///
/// Because `TransientBoardState`'s charter forbids exactly the thing this is. Its kind 2 is "derived
/// state, stored as its inputs only `searchQuery` is kept; its *result set* is deliberately absent
/// a stored result set would be a second, staler answer to a question the snapshot can always
/// answer". This index is the **opposite** case: the snapshot cannot answer it at all, because comment
/// content is deliberately outside the snapshot, so there is nothing to re-derive from and the answer
/// has to be kept. Keeping it there would mean giving that type a freshness rule of its own a sweep
/// to schedule, a generation to compare, a task to cancel which is precisely the accretion it exists
/// to keep out. So the index lives here, beside `SearchFilter`, owned by the search: one `let` on
/// `BoardStore` (`commentIndex`), created with the store and dying with it, like `echoes` and `heals`.
///
/// ### What it holds, and what that costs
///
/// The index proper is `[ItemID: [String]]` one card's posted comment bodies, kept **as a list**
/// rather than joined, so a query can never match across the seam between two comments. It exists only
/// while a query does: the first keystroke builds it, every later keystroke re-filters it in memory
/// (no I/O per keystroke that is what makes it an index rather than a search), and clearing the
/// query throws it away. The honest cost is stated rather than hidden: while a query is active, every
/// comment body on the board is in memory. A board with a thousand comments of a paragraph each is a
/// few hundred kilobytes, and the alternative re-reading the tree per keystroke is the thing an
/// index is for.
///
/// ### Freshness is snapshot-generation-shaped, and that is an interim
///
/// 04 says "kept fresh by the same FSEvents stream while a query is active". The store has no
/// changed-path channel `FolderWatcher` reports only *that* the tree changed (02-architecture.md),
/// and the reload seam turns that into two snapshots rather than a path list so the freshness signal
/// available today is `BoardStore.snapshotGeneration`: **while a query is active, a landed reload
/// re-sweeps**. That is coarser than the ruling asks for (it re-reads threads a reload may not have
/// touched) and it is bounded by the same thing that bounds the reload itself, the watcher's debounce.
/// A changed-path channel would narrow it to the cards whose `comments/` actually moved; until one
/// exists, this is the accepted interim and is written down here rather than discovered later.
///
/// ### Asynchrony, and the first keystroke's honest lag
///
/// The sweep is I/O and never runs on the main actor. Until it lands, the filter sees whatever the
/// index held before nothing at all on the first keystroke of a fresh query so **the first
/// keystroke may briefly show field-only matches**, and comment matches join a moment later. That is
/// the accepted behaviour rather than a defect: the alternative is a synchronous tree read on the
/// keystroke path, which is the one thing 01's window-scoped rule exists to prevent. Results
/// *refine*; they never regress, because a landed sweep only ever adds the cards it found.
@MainActor
@Observable
public final class CommentSearchIndex {
/// **The cards whose comments match the live query** the index's whole public surface, and the
/// set `BoardStore.searchFilter` hands to `SearchFilter` so that one predicate keeps answering
/// "does this card match" for every surface on the board.
///
/// Empty means "no comment match", which is also what it means with no query, with no index yet,
/// and on a board with no comments four states with one honest answer, so no consumer branches.
public private(set) var matchingCards: Set<ItemID> = []
/// The index proper: posted comment bodies per card, in the thread's own order. `nil` while there
/// is no index at all before the first sweep of a query, and after a clear.
///
/// Not `@ObservationIgnored`: a view reading `matchingCards` must not also wake on the megabytes
/// behind it, but nothing outside this type reads this at all, so its observability costs nothing
/// and its privacy is the guard.
private var bodies: [ItemID: [String]]?
/// The query the current `matchingCards` was computed against.
private var query = ""
/// The snapshot generation `bodies` was swept at, and the one a sweep in flight is for. Together
/// they are the whole freshness rule: an index at the current generation is fresh, one at an older
/// generation is re-swept, and a sweep already in flight for this generation is not started twice.
private var sweptGeneration: Int?
private var sweepingGeneration: Int?
/// The sweep in flight. Cancelled never awaited when a newer one supersedes it: a stale sweep's
/// result would describe a tree that has already changed, and the landing guard drops it anyway.
private var sweep: Task<Void, Never>?
/// **What to do when a sweep lands and the visible universe therefore widened or narrowed**
/// filled in by `BoardStore` with the selection constraint (04-interactions.md Search's "hidden
/// cards leave the selection").
///
/// It is a seam rather than a call into the store because this type has no business knowing what a
/// selection is: the refine is an event, and what it costs is the store's rule. `nil` in every test
/// that drives the index alone.
@ObservationIgnored
public var onRefine: (() -> Void)?
public init() {}
// MARK: - The two things that happen to it
/// **Every write to the query, and every landed reload under one** the single funnel, so the
/// activation, the re-filter and the re-sweep are one decision rather than three call sites
/// agreeing.
///
/// - An **inactive** query discards everything (below), which is 04's "discarded when it clears".
/// - An index already at `generation` is simply **re-filtered** no I/O, which is the whole point
/// of holding bodies rather than a result set.
/// - Anything else starts a sweep and re-filters against whatever is in hand meanwhile, so the
/// board never blanks while a fresher answer is being read.
public func update(query: String, generation: Int, targets: @autoclosure () -> [CommentSearchTarget]) {
guard SearchFilter(query: query).isActive else {
discard()
return
}
self.query = query
if sweptGeneration != generation, sweepingGeneration != generation {
startSweep(generation: generation, targets: targets())
}
recompute()
}
/// **The query cleared** the index is thrown away, the sweep in flight is cancelled, and the
/// board goes back to being O(cards) with nothing held (04 Search: "discarded when it clears").
///
/// Idempotent: `BoardStore.searchQuery`'s setter funnels every clear through here, including the
/// ones that were already clear.
public func discard() {
sweep?.cancel()
sweep = nil
sweepingGeneration = nil
sweptGeneration = nil
bodies = nil
query = ""
matchingCards = []
}
// MARK: - The sweep
private func startSweep(generation: Int, targets: [CommentSearchTarget]) {
sweep?.cancel()
sweepingGeneration = generation
sweep = Task { [weak self] in
// Detached, so the tree read runs off the main actor: this is a directory enumeration and
// a frontmatter parse per comment, and it is happening on the keystroke path.
let swept = await Task.detached(priority: .userInitiated) {
CommentSearchIndex.sweep(targets)
}.value
guard let self, !Task.isCancelled else { return }
self.land(swept, generation: generation)
}
}
/// One sweep's result, if it is still the one being waited for.
///
/// The guard is the stale-apply rule `BoardStore.apply` keeps for reloads, and for its reason: a
/// result whose generation is no longer the one in flight describes a tree that has moved on, and
/// installing it would make the index *less* fresh than the one it replaced.
private func land(_ swept: [ItemID: [String]], generation: Int) {
guard sweepingGeneration == generation else { return }
sweepingGeneration = nil
sweptGeneration = generation
bodies = swept
let before = matchingCards
recompute()
// Only a change is worth telling anyone about: a sweep that confirmed what the filter already
// showed must not re-run the selection constraint, which is a board-sized set computation.
guard matchingCards != before else { return }
onRefine?()
}
/// **The sweep itself** every target's posted comment bodies, read off disk.
///
/// `nonisolated` and taking only `Sendable` values, because it runs on a detached task. It is total
/// and silent: a card with no `comments/`, one whose thread is unreadable, and one holding nothing
/// but strays all contribute nothing, and none of them is a defect this read has any business
/// reporting the thread's own loader is where comment defects are logged and healed
/// (`CommentThread.load(inCard:path:)`), and a search sweep that logged a warning per stray on
/// every keystroke's re-sweep would be a log nobody could read.
nonisolated static func sweep(_ targets: [CommentSearchTarget]) -> [ItemID: [String]] {
var index: [ItemID: [String]] = [:]
for target in targets {
if Task.isCancelled { return index }
let bodies = CommentThread.searchableBodies(inCard: target.folder)
guard !bodies.isEmpty else { continue }
index[target.id] = bodies
}
return index
}
/// **Which cards the current query matches through their comments** `SearchFilter`'s own
/// predicate, borrowed rather than restated.
///
/// That is not tidiness: 04 gives the query *one* matching rule (case- and diacritic-insensitive
/// substring, folded locale-stably), and a second spelling of it here would let a board's comments
/// filter by a different rule than its titles do. `matches(title:body:)` with no title is exactly
/// "does this text contain the query", which is all a comment body has to answer.
nonisolated static func matchingCards(in bodies: [ItemID: [String]], query: String) -> Set<ItemID> {
let filter = SearchFilter(query: query)
guard filter.isActive else { return [] }
var matches: Set<ItemID> = []
for (id, texts) in bodies where texts.contains(where: { filter.matches(title: nil, body: $0) }) {
matches.insert(id)
}
return matches
}
private func recompute() {
matchingCards = Self.matchingCards(in: bodies ?? [:], query: query)
}
// MARK: - The board's cards, as targets
/// Every card whose thread the sweep reads **the live board and the trash**, because a trashed
/// card carries its `comments/` (01-storage-format.md Enhanced schema) and the trash column's
/// rows are filtered by the same predicate as the board's.
///
/// One walk, on the main actor, producing values the sweep can carry (see `CommentSearchTarget`).
/// Trashed *lanes* have no thread of their own the row is an opaque unit matched by title alone
/// (`SearchFilter.matches(_ lane:)`) so they contribute nothing here.
public nonisolated static func targets(in snapshot: BoardModel) -> [CommentSearchTarget] {
let root = snapshot.rootURL
var targets: [CommentSearchTarget] = []
for lane in snapshot.lanes {
for card in lane.cards {
targets.append(CommentSearchTarget(
id: card.id,
folder: ItemPath.card(lane: lane.id, id: card.id).folder(under: root)
))
}
}
for card in snapshot.trash {
targets.append(CommentSearchTarget(
id: card.id,
folder: ItemPath.trashCard(card.id).folder(under: root)
))
}
return targets
}
// MARK: - Tests
/// Suspends until the sweep in flight has landed the index's own `awaitQuiescence`, and the only
/// way a suite can assert about an answer that arrives a task later.
public func awaitSweep() async {
await sweep?.value
}
}
+76
View File
@@ -278,6 +278,69 @@ public final class EchoLedger: Sendable {
receipts.withLock { $0[path]?.isHeal ?? false } receipts.withLock { $0[path]?.isHeal ?? false }
} }
/// **Which comments under one card the app itself just wrote** the receipts, read through
/// `CommentPath.classify`, and **retired on the way out**.
///
/// This is the comment half of "app-mediated echoes never announce" (10-accessibility.md Live
/// board announcements), and it has to be a separate read because the board reload cannot do the
/// job: comments are outside the snapshot, so `verdicts(from:to:diff:includingTrash:)` has no two
/// pictures to compare and `Footprint.observations` deliberately skips comment paths (see its
/// note). What *does* have two pictures is the card window, which re-reads its thread on every
/// landed reload so it asks this, diffs its thread, and speaks only about the changes nobody
/// here vouched for (`CardComments.reload`).
///
/// ### Why classification rather than a prefix test
///
/// Because the answer has to distinguish three homes under one prefix, and `CommentPath` is the
/// app's one reader of that distinction (01-storage-format.md Enhanced schema's path shape): a
/// receipt under `comments/<uuid>/` vouches for a posted comment, one under `comments/.trash/<uuid>/`
/// vouches for a *delete* of that comment which is the same identity disappearing from the thread
/// and must be just as silent and one under `comments/.draft/` vouches for nothing in the thread
/// at all, because a draft is not in it. A `hasPrefix` would fold all three together.
///
/// ### Retired, not merely read
///
/// Consumption is what keeps the rule "one write, one echo": the receipts are removed, so a second
/// reload observing the same thread finds nothing vouching for it and would speak which is
/// correct, because by then the change is a second change. It is also what stops the ledger growing
/// a receipt per comment write for the life of a session; nothing else ever collects them.
///
/// - Parameters:
/// - cardFolder: the card's folder on disk. Receipts are keyed by absolute path, so this is what
/// the sweep is rooted at.
/// - cardPath: the same card's path relative to the board root (`<lane>/<card>`), which is the
/// spelling `CommentPath.classify` reads. The two are handed in together rather than derived
/// from each other because the store already holds both (`BoardStore.commentSubject`).
/// - Returns: the identities of the posted and just-deleted comments the app wrote.
func vouchedComments(inCard cardFolder: URL, cardPath: String) -> Set<ItemID> {
let root = Self.key(cardFolder)
let paths = receiptPaths(under: root)
guard !paths.isEmpty else { return [] }
var vouched: Set<ItemID> = []
var consumed: [String] = []
for path in paths {
let relative = cardPath + path.dropFirst(root.count)
guard let comment = CommentPath.classify(relative) else { continue }
consumed.append(path)
if let id = comment.id { vouched.insert(id) }
}
guard !consumed.isEmpty else { return [] }
receipts.withLock { store in
for path in consumed {
// A move pair is one fact under two keys, and both of a comment's ends are under this
// card so removing the observed key and then the pair's own two ends retires it once
// and leaves nothing dangling at the other end.
if case let .move(from, to) = store[path]?.receipt {
store.removeValue(forKey: from)
store.removeValue(forKey: to)
}
store.removeValue(forKey: path)
}
}
return vouched
}
/// Every path the ledger holds a receipt for strictly *below* `folder`. /// Every path the ledger holds a receipt for strictly *below* `folder`.
/// ///
/// Only a **card** may ask this. A lane's subtree is its cards' business and the board root's is /// Only a **card** may ask this. A lane's subtree is its cards' business and the board root's is
@@ -503,6 +566,17 @@ extension EchoLedger {
/// - A card's remaining receipts, resolved by the snapshot's attachment listing a name the /// - A card's remaining receipts, resolved by the snapshot's attachment listing a name the
/// card still lists is `.present`, anything else (a removed attachment, a loose file that /// card still lists is `.present`, anything else (a removed attachment, a loose file that
/// was relocated out) is `.absent`. /// was relocated out) is `.absent`.
///
/// **A card's `comments/` is not observable here, and is therefore not observed** (added with
/// comments, 01-storage-format.md Enhanced schema: "the board snapshot never loads comment
/// content"). Two snapshots say nothing whatever about a thread not that it changed, not
/// that it did not, not even that it exists so a comment receipt has no observation to be
/// checked against, and the naive reading (anything not an attachment is `.absent`) would
/// declare every comment the app itself just wrote *unsatisfied*: the very next foreign edit to
/// the card's own `index.md` would then classify the card foreign twice over, and the user's
/// own title edit would classify foreign once. Comment receipts are the **card window's** to
/// read and retire (`EchoLedger.vouchedComments(inCard:cardPath:)`), which is the one place
/// that does re-read a thread and can therefore say what happened to it.
func observations(present: Bool, in ledger: EchoLedger) -> [String: EchoLedger.Observation] { func observations(present: Bool, in ledger: EchoLedger) -> [String: EchoLedger.Observation] {
var observations: [String: EchoLedger.Observation] = [ var observations: [String: EchoLedger.Observation] = [
folder: present ? .present : .absent, folder: present ? .present : .absent,
@@ -511,7 +585,9 @@ extension EchoLedger {
] ]
guard ownsItsSubtree else { return observations } guard ownsItsSubtree else { return observations }
let attachmentPrefix = folder + "/" + BoardWriter.attachmentsFolderName + "/" let attachmentPrefix = folder + "/" + BoardWriter.attachmentsFolderName + "/"
let commentPrefix = folder + "/" + IntegrityRules.commentsFolderName + "/"
for path in ledger.receiptPaths(under: folder) where observations[path] == nil { for path in ledger.receiptPaths(under: folder) where observations[path] == nil {
guard !path.hasPrefix(commentPrefix) else { continue }
let name = path.hasPrefix(attachmentPrefix) ? String(path.dropFirst(attachmentPrefix.count)) : nil let name = path.hasPrefix(attachmentPrefix) ? String(path.dropFirst(attachmentPrefix.count)) : nil
observations[path] = present && name.map(attachments.contains) == true ? .present : .absent observations[path] = present && name.map(attachments.contains) == true ? .present : .absent
} }
+36 -7
View File
@@ -6,9 +6,21 @@ import Foundation
/// 04-interactions.md § Search's one sentence of behaviour and nothing else: /// 04-interactions.md § Search's one sentence of behaviour and nothing else:
/// ///
/// > live filter: cards whose title *and* body both miss the query animate out; case/diacritic- /// > live filter: cards whose title *and* body both miss the query animate out; case/diacritic-
/// > insensitive substring. Scope is **title + body only** (settled) attachment filenames are not /// > insensitive substring. Scope is **all card content the format makes meaningful** (re-ruled
/// > searched. /// > 2026-07-29): title + body today; **comment bodies join when comments ship** via a search-owned
/// > transient comment index, never the snapshot. Attachment filenames stay unsearched.
/// ///
/// ### Comments arrive as a set of ids, not as text
///
/// The predicate cannot read a comment: they are window-scoped and outside the snapshot
/// (01-storage-format.md Enhanced schema), so there is no body on a `Card` to fold. What the filter
/// takes instead is `commentMatches` the cards whose threads the search's own transient index found
/// the query in (`CommentSearchIndex`) and ORs it into the card clause. That keeps the design's
/// contract exactly as it was ("a card matches if any of its meaningful content matches") with **one**
/// spelling of the question, and keeps the I/O on the search's side of the boundary rather than in a
/// predicate every layout pass runs.
///
/// ### Why it is a value rather than a function /// ### Why it is a value rather than a function
/// ///
/// The needle is folded **once** per filter and matched against each item's folded haystack, so a /// The needle is folded **once** per filter and matched against each item's folded haystack, so a
@@ -44,9 +56,19 @@ public struct SearchFilter: Sendable, Equatable {
/// = everything visible": every `matches` below short-circuits to `true`. /// = everything visible": every `matches` below short-circuits to `true`.
private let needle: String private let needle: String
public init(query: String) { /// The cards whose **comment bodies** the query was found in the search's transient index'
/// contribution (`CommentSearchIndex.matchingCards`).
///
/// Empty by default and empty whenever the sweep has not landed yet, which is why the first
/// keystroke of a fresh query shows field-only matches for a moment and then refines. A stale set
/// cannot survive a query change: the store rebuilds this value from the query *and* the index on
/// every read (`BoardStore.searchFilter`), and the index recomputes its set on every keystroke.
private let commentMatches: Set<ItemID>
public init(query: String, commentMatches: Set<ItemID> = []) {
self.query = query self.query = query
needle = Self.folded(query) needle = Self.folded(query)
self.commentMatches = commentMatches
} }
/// No search the default every threaded parameter carries, so a call site with no query to /// No search the default every threaded parameter carries, so a call site with no query to
@@ -74,11 +96,18 @@ public struct SearchFilter: Sendable, Equatable {
return Self.folded(body).contains(needle) return Self.folded(body).contains(needle)
} }
/// A card. **Its attachment filenames are not consulted** scope is title + body only /// A card **title, body, or its comments** (04 § Search, re-ruled 2026-07-29).
/// (04 § Search, settled), and that is enforced here by construction rather than by remembering ///
/// not to add `card.attachments` to the line above. /// **Its attachment filenames are still not consulted**, which the re-ruling left standing
/// ("attachment filenames stay unsearched"), and that is enforced here by construction rather than
/// by remembering not to add `card.attachments` to the line above.
///
/// The comment clause is a set membership rather than a text test for the reason the type's note
/// gives: the text is not on the card, and the index that read it is the search's own.
public func matches(_ card: Card) -> Bool { public func matches(_ card: Card) -> Bool {
matches(title: card.title.value, body: card.body) guard isActive else { return true }
if matches(title: card.title.value, body: card.body) { return true }
return commentMatches.contains(card.id)
} }
/// A trashed lane row **by title only** (03-board-ui.md § Trash, re-ruled 2026-07-29: "The row /// A trashed lane row **by title only** (03-board-ui.md § Trash, re-ruled 2026-07-29: "The row
+15 -4
View File
@@ -634,7 +634,13 @@ public final class TransientBoardState {
/// on the freshly resolved sets, and the vanish rule and the filter rule compose in the one /// on the freshly resolved sets, and the vanish rule and the filter rule compose in the one
/// order that makes sense: gone first, then hidden. `isTrashVisible` is the only member with /// order that makes sense: gone first, then hidden. `isTrashVisible` is the only member with
/// nothing to say here at all. /// nothing to say here at all.
public func resolve(against snapshot: BoardModel) { ///
/// - Parameter commentMatches: the transient comment index' current answer
/// (`CommentSearchIndex.matchingCards`), threaded straight through to the constraint below.
/// Defaulted, because it is the store's fact rather than this container's: everything else here
/// is a rule about *this* state, and a caller with no index every test of the resolution rules
/// means "no comment matches", which is what an empty set says.
public func resolve(against snapshot: BoardModel, commentMatches: Set<ItemID> = []) {
selection = selection.resolved(against: snapshot) selection = selection.resolved(against: snapshot)
dragMembers = dragMembers.resolved(against: snapshot) dragMembers = dragMembers.resolved(against: snapshot)
pendingCut = pendingCut.resolved(against: snapshot) pendingCut = pendingCut.resolved(against: snapshot)
@@ -661,7 +667,7 @@ public final class TransientBoardState {
if let head = selectionHead, !universe.contains(head) { selectionHead = nil } if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
} }
constrainToSearch(in: snapshot) constrainToSearch(in: snapshot, commentMatches: commentMatches)
} }
/// **Hidden cards leave the selection** (04-interactions.md § Search) the constraint rule with /// **Hidden cards leave the selection** (04-interactions.md § Search) the constraint rule with
@@ -692,8 +698,13 @@ public final class TransientBoardState {
/// (`RenameEditor`) read for the materialized trash, true container crossings so a foreign /// (`RenameEditor`) read for the materialized trash, true container crossings so a foreign
/// edit that stops the renaming card matching drops it from the selection here and leaves the /// edit that stops the renaming card matching drops it from the selection here and leaves the
/// keystrokes exactly where the user left them. /// keystrokes exactly where the user left them.
public func constrainToSearch(in snapshot: BoardModel) { /// **A third occasion joined the two** with the comment index (04 Search, re-ruled 2026-07-29):
let filter = SearchFilter(query: searchQuery) /// a landed sweep can *widen* the visible universe (a card matching only through its comments
/// appears) and, on a re-sweep, narrow it again so `CommentSearchIndex.onRefine` calls this too.
/// Widening constrains nothing, which is why the seam is worth having anyway: the narrowing half is
/// the one that would otherwise leave a selection on a card nobody can see.
public func constrainToSearch(in snapshot: BoardModel, commentMatches: Set<ItemID> = []) {
let filter = SearchFilter(query: searchQuery, commentMatches: commentMatches)
guard filter.isActive else { return } guard filter.isActive else { return }
let universe = filter.visibleIDs(in: snapshot, container: selection.container) let universe = filter.visibleIDs(in: snapshot, container: selection.container)
+39
View File
@@ -327,6 +327,45 @@ public struct CommentThread: Sendable, Equatable {
return CommentDraft(body: document.body, attachments: attachments) 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, /// **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 /// 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". /// `created` (coerce-tier fallback, logged) sort after dated siblings, folder-name order".
+128
View File
@@ -226,6 +226,134 @@ enum AccessibilityPhrases {
} }
} }
// MARK: - The comments pane
/// The pane's container label **"Comments, N"** (10-accessibility.md Comments: "the pane is a
/// labeled container ('Comments, N')").
///
/// The count is the *thread's*, not the rendered rows' there is no filter over a thread and it
/// is the same number the visible header shows (`CommentsHeader.title(count:)`), which is the same
/// discipline the lane label keeps with its badge.
static func commentsContainerLabel(count: Int) -> String {
"Comments, \(count)"
}
/// "3 comments", "1 comment" the pane's plural folding, beside `cardCount` and `laneCount`.
static func commentCount(_ count: Int) -> String {
"\(count) comment\(count == 1 ? "" : "s")"
}
/// **One comment as a single flattened element's label** (10 Comments: "each comment is **one
/// flattened element** author, date, edited state, body").
///
/// The author line is the label and the body is the value (`commentValue`), which is the same split
/// the card element makes: the label is what the element *is*, the value is what it currently
/// holds. A comment with no author line at all no name, no date falls back to the word
/// "Comment", because an unlabeled element is an audit failure and "unattributed" is the absence of
/// a name rather than a name to speak (`CommentAuthorLine`).
static func commentLabel(authorLine: String?) -> String {
guard let authorLine, !authorLine.isEmpty else { return commentSubject }
return authorLine
}
/// What a comment with nothing to attribute is called. Named rather than inlined because both the
/// label fallback and the empty pane's hint read it.
static let commentSubject = "Comment"
/// A comment element's **value**: its body, with the attachment count appended when it has files.
///
/// The body is spoken as it is rather than summarized: 10 puts "body" in the flattened element, and
/// a comment is short by nature the thing a thread is *for* is the text, and paraphrasing it
/// would be the app deciding what a VoiceOver user may hear of somebody's comment. Empty for a
/// comment with neither, which speaks as nothing (`cardValue`'s rule).
static func commentValue(body: String, attachments: Int) -> String {
var parts: [String] = []
let text = body.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty { parts.append(text) }
if attachments > 0 { parts.append(attachmentCount(attachments)) }
return parts.joined(separator: ", ")
}
/// The three custom actions a comment element carries its context menu's rows, which is what 10
/// asks for ("with its context-menu rows (Edit / Delete / Reveal in Finder) riding as custom
/// actions per the cut").
///
/// Constants rather than literals at the modifier because the menu row and the custom action must
/// be the *same* string: a user who has learned the pointer inventory should hear the same three
/// words from the rotor.
static let commentEditAction = "Edit"
static let commentDeleteAction = "Delete"
static let commentRevealAction = "Reveal in Finder"
/// The composer, labeled "the composer is a labeled text field ( posts)" (10 Comments).
static let commentComposerLabel = "Add a comment"
/// The sort control's label. Its *value* is the direction's own word
/// (`CommentSortDirection.controlLabel`), which is also its help text one string, three readers.
static let commentSortLabel = "Sort"
/// The paperclip on either authoring surface.
static let commentAttachFilesLabel = "Attach Files"
// MARK: - Foreign comment changes
/// **What a foreign comment change says out loud** path-shaped, naming the card
/// (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").
///
/// ### Precedence, not concatenation
///
/// One polite sentence per reload is the whole ladder's rule (`BoardAnnouncer`), so three
/// simultaneous kinds of change pick one: **arrivals lead** they are the news 10 names and the
/// only kind that adds something to read then edits, then deletions. A thread that gained one
/// comment and lost another says the gain; the loss is visible in the pane and has no reader
/// waiting on it.
///
/// ### The verbs are 06's family, and the plurals are built on them
///
/// 10 names one sentence and 01 names the family the other two come from ("a changed path under
/// `/comments/<uuid>/` composes 'Comment on card title' / 'Edit comment on' / 'Delete comment
/// on'"). The singular forms are those verbs verbatim; the plural forms fold a count in front,
/// like every other count in the app, rather than repeating the sentence N times.
static func commentsChanged(_ changes: CommentThreadChanges, onCard title: String?) -> String? {
let card = displayTitle(title)
if let phrase = commentFragment(
count: changes.arrived.count,
singular: "New comment",
plural: { "\($0) new comments" },
onCard: card
) {
return phrase
}
if let phrase = commentFragment(
count: changes.edited.count,
singular: "Edit comment",
plural: { "\($0) comments edited" },
onCard: card
) {
return phrase
}
return commentFragment(
count: changes.deleted.count,
singular: "Delete comment",
plural: { "\($0) comments deleted" },
onCard: card
)
}
/// One bucket's sentence, or `nil` at zero the shape all three share, written once so the three
/// cannot drift in punctuation or in where the card's name sits.
private static func commentFragment(
count: Int,
singular: String,
plural: (Int) -> String,
onCard card: String
) -> String? {
guard count > 0 else { return nil }
return "\(count == 1 ? singular : plural(count)) on '\(card)'"
}
// MARK: - The banner strip // MARK: - The banner strip
/// What VoiceOver says before a banner's headline. "Status" rather than "Info" because that is /// What VoiceOver says before a banner's headline. "Status" rather than "Info" because that is
+12 -1
View File
@@ -194,11 +194,16 @@ extension FocusedValues {
/// **A board window in front is now the whole of the scope**, where m5 additionally required the /// **A board window in front is now the whole of the scope**, where m5 additionally required the
/// toolbar's field to exist: with the item removed, F raises the transient host instead /// toolbar's field to exist: with the item removed, F raises the transient host instead
/// (`BoardSearchPresentation.invokeSearch`), so there is no board window where the row is dead. /// (`BoardSearchPresentation.invokeSearch`), so there is no board window where the row is dead.
/// **The card window's find routes by focus** (05-card-window.md Preview, comments clause): the
/// body's substrate when the body has the keyboard, the whole rendered thread when the comments pane
/// does, and that editor's own stock find bar when the composer or an inline session does. The rule
/// itself is `CardWindowFind.route`, pure and pinned; this row only asks it.
struct FindCommand: View { struct FindCommand: View {
@FocusedValue(\.boardStore) private var store @FocusedValue(\.boardStore) private var store
@FocusedValue(\.boardSearch) private var search @FocusedValue(\.boardSearch) private var search
@FocusedValue(\.cardBody) private var cardBody @FocusedValue(\.cardBody) private var cardBody
@FocusedValue(\.cardComments) private var comments
/// **The card window wins when it is the focused scene**, which is the whole of 11's split /// **The card window wins when it is the focused scene**, which is the whole of 11's split
/// ("Board window: board search; card window: find-in-text"): the two never both publish, so /// ("Board window: board search; card window: find-in-text"): the two never both publish, so
@@ -209,6 +214,12 @@ struct FindCommand: View {
var body: some View { var body: some View {
Button("Find") { Button("Find") {
// The pane answers first and says whether it took the key: with the comments pane focused
// or its find bar already up F is the thread's, and every other case falls through to
// the body exactly as it did before the pane existed.
if comments?.invokeFind(hasBody: findInText != nil) == true {
return
}
if let findInText { if let findInText {
findInText() findInText()
} else { } else {
@@ -216,7 +227,7 @@ struct FindCommand: View {
} }
} }
.keyboardShortcut("f", modifiers: .command) .keyboardShortcut("f", modifiers: .command)
.disabled(findInText == nil && (store == nil || search == nil)) .disabled(findInText == nil && comments == nil && (store == nil || search == nil))
} }
} }
+7 -2
View File
@@ -334,11 +334,16 @@ struct BoardView: View {
// arrivers are the card and row transitions already attached inside the lanes and the trash // arrivers are the card and row transitions already attached inside the lanes and the trash
// column; this is the survivors' spring around them. // column; this is the survivors' spring around them.
// //
// **Every way the query changes rides it**, which is the reason the key is the query rather // **Every way the filter changes rides it**, which is the reason the key is the filter rather
// than the transaction being wrapped at each mutation: typing, the field's Escape, the // than the transaction being wrapped at each mutation: typing, the field's Escape, the
// board's Escape, and creation's clear (`TransientBoardState.beginPlaceholder`) all land // board's Escape, and creation's clear (`TransientBoardState.beginPlaceholder`) all land
// here without any of them knowing about motion. // here without any of them knowing about motion.
.animation(Motion.contentReflow(reduced: reduceMotion), value: store.searchQuery) //
// The *filter*, not the query, since comments joined the search's scope (04 Search, re-ruled
// 2026-07-29): a landed comment-index sweep widens what matches without the query changing at
// all, and keying on the query alone would make those cards pop in while every other arrival
// eased. `SearchFilter` is `Equatable` and carries the index' answer, so one key covers both.
.animation(Motion.contentReflow(reduced: reduceMotion), value: store.searchFilter)
} }
/// The rubber band itself: a translucent accent fill with a hairline border, in strip /// The rubber band itself: a translucent accent fill with a hairline border, in strip
+126 -1
View File
@@ -44,6 +44,11 @@ public final class CardComments {
/// Finder resolve against it. /// Finder resolve against it.
public var cardFolder: URL? 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 /// 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 /// composer, the paperclips, Post, Edit and Delete disable in place, which is
/// 02-architecture.md's every-entry-point predicate applied to this pane. /// 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. /// a `Bool` would need clearing, and a clear that raced the view would swallow the second one.
public private(set) var focusComposerRequests = 0 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 // MARK: Seams filled in by the host with the store's own bracketed methods
/// Re-reads the thread `BoardStore.commentThread(inCard:)`. /// Re-reads the thread `BoardStore.commentThread(inCard:)`.
@@ -109,6 +137,19 @@ public final class CardComments {
@ObservationIgnored @ObservationIgnored
public var removeAttachment: ((String, CommentTarget) -> Void)? 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() {} public init() {}
// MARK: - Reading // MARK: - Reading
@@ -121,7 +162,11 @@ public final class CardComments {
/// board that closed cleanly (`HealScheduler`'s rest branch), which is every open but one. /// board that closed cleanly (`HealScheduler`'s rest branch), which is every open but one.
public func open() { public func open() {
sweepTrashResidue?() 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. /// 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 /// 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. /// transaction and a thread must not ride the board's structural spring.
public func reload() { 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 } guard let readThread else { return }
let previous = thread
let thread = readThread() let thread = readThread()
let draft = readDraft?() let draft = readDraft?()
withAnimation(nil) { withAnimation(nil) {
self.thread = thread self.thread = thread
} }
if announcing {
announceForeignChanges(from: previous, to: thread)
}
composer.adopt(draft: draft) composer.adopt(draft: draft)
// The open session follows disk under the same dirty-buffer-wins rule the body has: a clean // 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 // 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 // MARK: - The composer
/// **File Add Comment**, and the pane's own "add a comment" affordances: ask the composer for /// **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 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 // MARK: - The inline edit session
/// Opens a session over one comment the context menu's **Edit** (05 The comments column). /// Opens a session over one comment the context menu's **Edit** (05 The comments column).
+38 -5
View File
@@ -50,14 +50,38 @@ struct CardCommentsPane: View {
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: pointSize)) .padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: pointSize))
.padding(.top, CardWindowMetrics.gutter(bodyPointSize: pointSize)) .padding(.top, CardWindowMetrics.gutter(bodyPointSize: pointSize))
// **The find bar sits above the thread it searches** `findBarPosition = .aboveContent`,
// which is where every other find in this window puts its bar (`CardBodySurface`).
if comments.find.isShowing {
CommentFindBar(find: comments.find)
.padding(.top, CardWindowMetrics.previewPadding(bodyPointSize: pointSize))
}
thread thread
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// 10-accessibility.md's container label for the pane ("Comments, N"). The elements inside it // 10-accessibility.md's container label for the pane ("Comments, N") the count is the
// and their custom actions are phase 3's; the container is here because the pane would // thread's, so the spoken container and the visible header can never disagree.
// otherwise be an unnamed region the moment it exists.
.accessibilityElement(children: .contain) .accessibilityElement(children: .contain)
.accessibilityLabel("Comments, \(comments.thread.comments.count)") .accessibilityLabel(AccessibilityPhrases.commentsContainerLabel(count: comments.thread.comments.count))
// The find's model of the rendered thread, rebuilt only while the bar is up: the search runs
// over every comment, mounted or not (`CommentThreadFind`), and building it costs a render
// pass the pane has no reason to spend on a window nobody is searching.
.onChange(of: FindThreadKey(isShowing: comments.find.isShowing, thread: comments.thread), initial: true) { _, key in
guard key.isShowing else { return }
comments.find.setThread(key.thread.comments, pointSize: pointSize)
}
// **A find cannot outlive the surface it searches.** The pane unmounts on View Show Comments
// and on the raw-source swap, and a session left showing would keep F and G pointed at a bar
// nobody can see (`CardWindowFind.route`'s open-bar clause).
.onDisappear { comments.find.dismiss() }
}
/// The pair the find's rebuild is keyed on. A value rather than two `onChange`s so the bar opening
/// and the thread changing take the same path, and so the rebuild cannot run twice for one update.
private struct FindThreadKey: Equatable {
let isShowing: Bool
let thread: CommentThread
} }
// MARK: - Header // MARK: - Header
@@ -88,7 +112,7 @@ struct CardCommentsPane: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help(direction.controlLabel) .help(direction.controlLabel)
.accessibilityLabel("Sort") .accessibilityLabel(AccessibilityPhrases.commentSortLabel)
.accessibilityValue(direction.controlLabel) .accessibilityValue(direction.controlLabel)
} }
@@ -129,6 +153,15 @@ struct CardCommentsPane: View {
.onChange(of: comments.focusComposerRequests) { _, _ in .onChange(of: comments.focusComposerRequests) { _, _ in
proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom) proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom)
} }
// **A find that steps off screen brings the reader with it** the half of "it reads as one
// find" that the highlight alone cannot do. Scrolling to the comment rather than to the
// range is what a row-shaped thread allows: rows are the scroll targets
// (`ForEach(...).id(comment.id)`), and a comment is short enough that its top is the hit's
// neighbourhood.
.onChange(of: comments.find.currentMatch) { _, match in
guard let match else { return }
proxy.scrollTo(match.comment, anchor: .center)
}
} }
} }
+9
View File
@@ -167,6 +167,15 @@ enum CardWindowMetrics {
(lineHeight(bodyPointSize: bodyPointSize) * 5.5).rounded() (lineHeight(bodyPointSize: bodyPointSize) * 5.5).rounded()
} }
/// The find bar's query field **twelve characters**, the shortest measure that still shows a
/// two-word search whole. It is deliberately narrower than the board's search field (seventeen):
/// this bar shares a strip with a counter and three controls inside a fixed-width pane, and a
/// field sized to the query would push the Done button off the end of the narrowest window the
/// design allows.
static func findFieldWidth(bodyPointSize: CGFloat) -> CGFloat {
columnWidth(characters: 12, bodyPointSize: bodyPointSize)
}
/// The gap between two comments in the thread a full gutter, one step larger than the rhythm /// The gap between two comments in the thread a full gutter, one step larger than the rhythm
/// *inside* a comment (`sidebarRowSpacing`), so the eye groups an author line with its body /// *inside* a comment (`sidebarRowSpacing`), so the eye groups an author line with its body
/// rather than with its neighbour. /// rather than with its neighbour.
+105 -12
View File
@@ -23,8 +23,15 @@ import SwiftUI
/// taken off and an intrinsic height instead: it lays out at the width it is proposed and reports /// taken off and an intrinsic height instead: it lays out at the width it is proposed and reports
/// exactly the height its text needs. /// exactly the height its text needs.
/// ///
/// The pane's find-in-text over the whole rendered thread is phase 3's (05 Preview scopes F to /// ### It is a find *result* surface, not a find client
/// "the comments pane, where it searches the whole rendered thread"); nothing here forecloses it. ///
/// 05 scopes F over the pane to "the whole rendered thread", which no per-row `NSTextFinder` could
/// span (`CommentThreadFind` explains the mechanism chosen instead). What this view owes that
/// mechanism is two things: the storage it draws must be the same text the search ran over it is,
/// because both come from `BodyMarkupRenderer` over the same body and it must be able to draw a
/// highlight over a range without touching that storage. `NSLayoutManager`'s **temporary attributes**
/// are exactly that: a display-only overlay, discarded and reapplied freely, which cannot end up in
/// anything the user copies out.
struct CommentBodyView: NSViewRepresentable { struct CommentBodyView: NSViewRepresentable {
let body: String let body: String
@@ -32,6 +39,14 @@ struct CommentBodyView: NSViewRepresentable {
/// same way a card body's do, which is what makes `![](attachments/shot.png)` mean one thing in /// same way a card body's do, which is what makes `![](attachments/shot.png)` mean one thing in
/// this window (05 Preview). /// this window (05 Preview).
let cardFolder: URL? let cardFolder: URL?
/// This comment's find hits, in the rendered text's coordinates (`CommentThreadFind.matches(in:)`).
var highlights: [NSRange] = []
/// Which of them is the current one, if it is in this comment drawn in the stronger colour, the
/// find bar's own convention.
var currentHighlight: NSRange?
/// The pane's focus register this surface reports itself as a *reading* surface, which is what
/// makes F over a clicked-into comment mean the thread (`CardComments.paneFocus`).
var focus: CardComments?
/// The height a measurement pass lays out into tall enough that no comment reaches it, finite /// The height a measurement pass lays out into tall enough that no comment reaches it, finite
/// so the arithmetic stays well-defined. /// so the arithmetic stays well-defined.
@@ -41,7 +56,7 @@ struct CommentBodyView: NSViewRepresentable {
Coordinator() Coordinator()
} }
func makeNSView(context: Context) -> NSTextView { func makeNSView(context: Context) -> CommentBodyTextView {
// TextKit 1, explicitly, for `CardBodySurface`'s reason: `NSTextTable` the browser sizing // TextKit 1, explicitly, for `CardBodySurface`'s reason: `NSTextTable` the browser sizing
// rule GFM tables are laid out by does not lay out in TextKit 2. // rule GFM tables are laid out by does not lay out in TextKit 2.
let storage = NSTextStorage() let storage = NSTextStorage()
@@ -52,7 +67,7 @@ struct CommentBodyView: NSViewRepresentable {
container.lineFragmentPadding = 0 container.lineFragmentPadding = 0
layoutManager.addTextContainer(container) layoutManager.addTextContainer(container)
let textView = NSTextView(frame: .zero, textContainer: container) let textView = CommentBodyTextView(frame: .zero, textContainer: container)
textView.delegate = context.coordinator textView.delegate = context.coordinator
textView.isEditable = false textView.isEditable = false
// Selectable and copyable, the whole thread Preview's own posture, and the reason a comment // Selectable and copyable, the whole thread Preview's own posture, and the reason a comment
@@ -65,21 +80,64 @@ struct CommentBodyView: NSViewRepresentable {
textView.textContainerInset = .zero textView.textContainerInset = .zero
textView.linkTextAttributes = [.cursor: NSCursor.pointingHand] textView.linkTextAttributes = [.cursor: NSCursor.pointingHand]
textView.displaysLinkToolTips = true textView.displaysLinkToolTips = true
// The pane's focus register, so F over a comment the user has clicked into means the thread
// (`CardComments.paneFocus`). A weak capture is not needed: the handle outlives the window.
let focus = focus
textView.onFocusChange = { gained in
if gained {
focus?.focusEntered(.thread)
} else {
focus?.focusLeft(.thread)
}
}
return textView return textView
} }
func updateNSView(_ textView: NSTextView, context: Context) { func updateNSView(_ textView: CommentBodyTextView, context: Context) {
let key = Coordinator.RenderKey( let key = Coordinator.RenderKey(
body: body, body: body,
cardFolder: cardFolder, cardFolder: cardFolder,
pointSize: CardWindowMetrics.bodyPointSize pointSize: CardWindowMetrics.bodyPointSize
) )
guard context.coordinator.rendered != key else { return } if context.coordinator.rendered != key {
context.coordinator.rendered = key context.coordinator.rendered = key
textView.textStorage?.setAttributedString(BodyMarkupRenderer.attributedString( textView.textStorage?.setAttributedString(BodyMarkupRenderer.attributedString(
for: BodyMarkup.parse(body), for: BodyMarkup.parse(body),
context: BodyMarkupRenderer.Context(pointSize: key.pointSize, cardFolder: cardFolder) context: BodyMarkupRenderer.Context(pointSize: key.pointSize, cardFolder: cardFolder)
)) ))
// The overlay describes ranges in text that has just been replaced, so it is reapplied
// rather than assumed to have survived.
context.coordinator.highlighted = nil
}
let highlightKey = Coordinator.HighlightKey(ranges: highlights, current: currentHighlight)
guard context.coordinator.highlighted != highlightKey else { return }
context.coordinator.highlighted = highlightKey
Self.applyHighlights(highlightKey, in: textView)
}
/// Draws the find's hits **temporary attributes only**, so nothing here can reach the text
/// storage a reader copies out of, and clearing is a single call rather than a diff.
///
/// The two colours are the platform's own find vocabulary: every hit in the secondary selection
/// colour, the current one in the *find* highlight colour, which is what an `NSTextFinder` bar
/// draws and therefore what a user of this app's other finds has already learned.
private static func applyHighlights(_ key: Coordinator.HighlightKey, in textView: CommentBodyTextView) {
guard let layoutManager = textView.layoutManager, let storage = textView.textStorage else { return }
let whole = NSRange(location: 0, length: storage.length)
layoutManager.removeTemporaryAttribute(.backgroundColor, forCharacterRange: whole)
for range in key.ranges where NSMaxRange(range) <= storage.length {
layoutManager.addTemporaryAttributes(
[.backgroundColor: NSColor.unemphasizedSelectedTextBackgroundColor],
forCharacterRange: range
)
}
guard let current = key.current, NSMaxRange(current) <= storage.length else { return }
layoutManager.addTemporaryAttributes(
[.backgroundColor: NSColor.findHighlightColor],
forCharacterRange: current
)
} }
/// **The intrinsic height** the whole reason this is not a scroll view. /// **The intrinsic height** the whole reason this is not a scroll view.
@@ -87,7 +145,7 @@ struct CommentBodyView: NSViewRepresentable {
/// The container is laid out at the proposed width and asked what it used. `ensureLayout` is not /// The container is laid out at the proposed width and asked what it used. `ensureLayout` is not
/// optional: `usedRect` is only meaningful once the glyphs have been laid, and an unlaid container /// optional: `usedRect` is only meaningful once the glyphs have been laid, and an unlaid container
/// answers a zero-height rect, which would collapse every comment in the thread to nothing. /// answers a zero-height rect, which would collapse every comment in the thread to nothing.
func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSTextView, context: Context) -> CGSize? { func sizeThatFits(_ proposal: ProposedViewSize, nsView: CommentBodyTextView, context: Context) -> CGSize? {
guard let container = nsView.textContainer, let layoutManager = nsView.layoutManager else { guard let container = nsView.textContainer, let layoutManager = nsView.layoutManager else {
return nil return nil
} }
@@ -112,7 +170,16 @@ struct CommentBodyView: NSViewRepresentable {
let pointSize: CGFloat let pointSize: CGFloat
} }
/// What the find overlay currently draws kept for `RenderKey`'s reason one layer down: the
/// update runs on every unrelated state change in the window, and reapplying an identical
/// overlay would redraw every visible comment on each of them.
struct HighlightKey: Equatable {
let ranges: [NSRange]
let current: NSRange?
}
var rendered: RenderKey? var rendered: RenderKey?
var highlighted: HighlightKey?
/// Links behave exactly as they do in a card body: external URLs go to the browser, relative /// Links behave exactly as they do in a card body: external URLs go to the browser, relative
/// ones already resolved to file URLs by the renderer go to their default app. /// ones already resolved to file URLs by the renderer go to their default app.
@@ -138,3 +205,29 @@ struct CommentBodyView: NSViewRepresentable {
} }
} }
} }
// MARK: - The row's text view
/// A rendered comment's text view, subclassed for one thing: **it says when it has the keyboard**.
///
/// `FocusReportingSearchField`'s shape, and for its reason F's route is a *focus* question
/// (05-card-window.md Preview: the find covers "the focused surface"), and the only responder that
/// can answer it is the one taking and losing first responder. Both halves are overridden here rather
/// than one being inferred from the other, because a reading surface has no editing session and
/// therefore no `textDidEndEditing` to hang the loss on.
final class CommentBodyTextView: NSTextView {
var onFocusChange: ((Bool) -> Void)?
override func becomeFirstResponder() -> Bool {
let accepted = super.becomeFirstResponder()
if accepted { onFocusChange?(true) }
return accepted
}
override func resignFirstResponder() -> Bool {
let resigned = super.resignFirstResponder()
if resigned { onFocusChange?(false) }
return resigned
}
}
+10 -3
View File
@@ -88,7 +88,11 @@ struct CommentAuthoringSurface<Actions: View>: View {
onCommandReturn: onCommandReturn, onCommandReturn: onCommandReturn,
onEscape: onEscape, onEscape: onEscape,
onBlur: onBlur, onBlur: onBlur,
focusRequest: focusRequest focusRequest: focusRequest,
// Both authoring surfaces report themselves to the pane's focus register, which is
// what routes F here to the editor's own find bar rather than to the thread's
// (05 Preview; `CardWindowFind.route`).
focus: comments
) )
.frame(height: height) .frame(height: height)
@@ -124,7 +128,7 @@ struct CommentAuthoringSurface<Actions: View>: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(!comments.isEditable) .disabled(!comments.isEditable)
.help("Attach Files…") .help("Attach Files…")
.accessibilityLabel("Attach Files") .accessibilityLabel(AccessibilityPhrases.commentAttachFilesLabel)
} }
} }
@@ -183,7 +187,10 @@ struct CommentComposerView: View {
.controlSize(.small) .controlSize(.small)
.disabled(!comments.isEditable || !session.canPost) .disabled(!comments.isEditable || !session.canPost)
} }
.accessibilityLabel("Add a comment") // "The composer is a labeled text field ( posts)" 10-accessibility.md Comments. The
// label is the placeholder's own sentence, so what a sighted user reads in the empty editor
// and what VoiceOver announces are the same invitation.
.accessibilityLabel(AccessibilityPhrases.commentComposerLabel)
} }
/// Post, then re-read: the rename moved a folder into the thread, and the pane shows the thread. /// Post, then re-read: the rename moved a folder into the thread, and the pane shows the thread.
+94 -19
View File
@@ -38,42 +38,70 @@ struct CommentRowView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: padding) { VStack(alignment: .leading, spacing: padding) {
if let line = authorLine {
Text(line)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
if let session { if let session {
authorText
editor(session) editor(session)
} else { } else {
reading reading
if !comment.attachments.isEmpty {
// **Read-only** "a posted comment's chips are read-only, Quick Look only Edit
// the comment to change its files" (05). `onRemove` left `nil` is that sentence,
// and the editing branch above draws its *own* removable chips inside the
// authoring surface, which is why these are the reading branch's alone.
//
// They sit **outside** the flattened element deliberately: 10 flattens "author,
// date, edited state, body", and the chips are the one part of a comment that is
// not text but a row of controls with a Quick Look behind each reachable exactly
// as the sidebar's are (`CommentAttachmentChips`). Flattening them in would have
// made a comment's files announceable but not openable.
CommentAttachmentChips(
names: comment.attachments,
url: { comments.attachmentURL($0, in: .comment(comment.id)) },
thumbnails: thumbnails
)
}
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle()) .contentShape(Rectangle())
.contextMenu { menu } .contextMenu { menu }
.accessibilityElement(children: .contain) .accessibilityElement(children: .contain)
.accessibilityLabel(authorLine ?? "Comment")
} }
// MARK: - Reading // MARK: - Reading
/// The author line and the rendered body, as **one flattened element** with the three custom
/// actions (10-accessibility.md Comments see `CommentRowAccessibility`).
private var reading: some View { private var reading: some View {
VStack(alignment: .leading, spacing: padding) { VStack(alignment: .leading, spacing: padding) {
CommentBodyView(body: comment.body, cardFolder: cardFolder) authorText
.frame(maxWidth: .infinity, alignment: .leading)
if !comment.attachments.isEmpty { CommentBodyView(
// **Read-only** "a posted comment's chips are read-only, Quick Look only Edit the body: comment.body,
// comment to change its files" (05). `onRemove` left `nil` is that sentence. cardFolder: cardFolder,
CommentAttachmentChips( // The find's hits in this comment, and whether the current one is here the row draws
names: comment.attachments, // them, the session found them (`CommentThreadFind`).
url: { comments.attachmentURL($0, in: .comment(comment.id)) }, highlights: comments.find.matches(in: comment.id),
thumbnails: thumbnails currentHighlight: comments.find.currentMatch.flatMap {
) $0.comment == comment.id ? $0.range : nil
} },
focus: comments
)
.frame(maxWidth: .infinity, alignment: .leading)
}
.modifier(CommentRowAccessibility(comment: comment, comments: comments, authorLine: authorLine))
}
/// The author line, or nothing "a comment with no `author` renders **without a name**"
/// (`CommentAuthorLine`).
@ViewBuilder
private var authorText: some View {
if let line = authorLine {
Text(line)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
} }
} }
@@ -146,3 +174,50 @@ struct CommentRowView: View {
date.formatted(date: .abbreviated, time: .shortened) date.formatted(date: .abbreviated, time: .shortened)
} }
} }
// MARK: - The row as one element
/// **One comment is one flattened element with three custom actions** (10-accessibility.md
/// Comments):
///
/// > each comment is **one flattened element** author, date, edited state, body with its
/// > context-menu rows (Edit / Delete / Reveal in Finder) riding as custom actions per the cut.
///
/// ### It covers the reading surface only, and that is the whole of where it is applied
///
/// While an inline edit session is open the row is a text editor with two buttons in it, and
/// collapsing *that* into one opaque element would put a VoiceOver user in front of a comment they
/// can neither read into nor type into. So this hangs on the reading branch alone
/// (`CommentRowView.reading`), and the editing branch is an ordinary container the same asymmetry
/// the body column keeps between Preview and Edit.
///
/// The actions duplicate the context menu deliberately: 10 asks for the pointer inventory to be
/// reachable without the pointer, and the strings are shared with the menu (`AccessibilityPhrases`)
/// so the two inventories cannot drift. They are also **not** disabled under the read-only lock
/// they call the same handles the menu rows do, and those already refuse (`CardComments.beginEdit`,
/// `.delete`), which keeps one refusal rather than two.
private struct CommentRowAccessibility: ViewModifier {
let comment: Comment
let comments: CardComments
let authorLine: String?
func body(content: Content) -> some View {
content
.accessibilityElement(children: .ignore)
.accessibilityLabel(AccessibilityPhrases.commentLabel(authorLine: authorLine))
.accessibilityValue(AccessibilityPhrases.commentValue(
body: comment.body,
attachments: comment.attachments.count
))
.accessibilityAction(named: AccessibilityPhrases.commentEditAction) {
comments.beginEdit(comment.id)
}
.accessibilityAction(named: AccessibilityPhrases.commentDeleteAction) {
comments.delete(comment.id)
}
.accessibilityAction(named: AccessibilityPhrases.commentRevealAction) {
comments.reveal(comment.id)
}
}
}
+42
View File
@@ -44,6 +44,11 @@ struct CommentTextEditor: NSViewRepresentable {
/// Bumped to ask for the keyboard File Add Comment's second half, and an inline session /// Bumped to ask for the keyboard File Add Comment's second half, and an inline session
/// opening. A counter rather than a flag: two requests in a row are two requests. /// opening. A counter rather than a flag: two requests in a row are two requests.
var focusRequest: Int = 0 var focusRequest: Int = 0
/// The pane's focus register. This surface reports itself as an **authoring** one, which is what
/// makes F here the editor's ordinary find rather than the thread's (05-card-window.md Preview:
/// "the composer and an inline comment edit are their own focused text surfaces with the editor's
/// ordinary find"). `nil` in a preview or a test that mounts the editor alone.
var focus: CardComments?
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
Coordinator() Coordinator()
@@ -98,6 +103,25 @@ struct CommentTextEditor: NSViewRepresentable {
context.coordinator.textView = textView context.coordinator.textView = textView
context.coordinator.onEdit = onEdit context.coordinator.onEdit = onEdit
context.coordinator.onBlur = onBlur context.coordinator.onBlur = onBlur
// The pane's focus register, and while this editor holds the keyboard the find F runs.
// `performTextFinderAction` takes its verb from the sender's `tag`, which is how the standard
// Edit Find item drives it (`CardBodySurface`'s own note); the menu item's key equivalent
// fires before this view ever sees F, so the action has to be reachable from outside.
let focus = focus
textView.onFocusChange = { [weak textView] gained in
guard gained else {
focus?.focusLeft(.authoring)
return
}
focus?.focusEntered(.authoring) { [weak textView] in
guard let textView else { return }
textView.window?.makeFirstResponder(textView)
let sender = NSMenuItem()
sender.tag = NSTextFinder.Action.showFindInterface.rawValue
textView.performTextFinderAction(sender)
}
}
return scrollView return scrollView
} }
@@ -187,6 +211,24 @@ final class CommentEditorTextView: NSTextView {
var onCommandReturn: (() -> Void)? var onCommandReturn: (() -> Void)?
var onEscape: (() -> Void)? var onEscape: (() -> Void)?
/// **It says when it has the keyboard** `CommentBodyTextView`'s pair, and for its reason: F's
/// route is a focus question, and the responder is the only thing that can answer it. Reported
/// here rather than through `textDidEndEditing` because that fires when the *field editor* ends,
/// which for an uneditable editor (the read-only lock) never happens at all.
var onFocusChange: ((Bool) -> Void)?
override func becomeFirstResponder() -> Bool {
let accepted = super.becomeFirstResponder()
if accepted { onFocusChange?(true) }
return accepted
}
override func resignFirstResponder() -> Bool {
let resigned = super.resignFirstResponder()
if resigned { onFocusChange?(false) }
return resigned
}
/// **** "Post the draft / end the edit session at its commit point" /// **** "Post the draft / end the edit session at its commit point"
/// (11-command-nexus.md Fixed grammar keys). Intercepted before `super`, which would otherwise /// (11-command-nexus.md Fixed grammar keys). Intercepted before `super`, which would otherwise
/// insert a newline: the chord is the gesture, not a decorated Return. /// insert a newline: the chord is the gesture, not a decorated Return.
+366
View File
@@ -0,0 +1,366 @@
import AppKit
import Observation
import SwiftUI
// MARK: - Where the card window's F goes
/// Which of the card window's text surfaces Edit Find is about right now (05-card-window.md
/// Preview):
///
/// > **Edit Find (F) is find-in-text here** the standard find bar over the focused surface
/// > (Preview's selectable text, the Edit editor, raw source **and the comments pane, where it
/// > searches the whole rendered thread, `.draft` excluded; the composer and an inline comment edit
/// > are their own focused text surfaces with the editor's ordinary find**).
public enum CardWindowFindRoute: Sendable, Equatable {
/// The body column's substrate Preview, Edit, or the raw-source outlet. `NSTextFinder` over the
/// one hosted scroll view, which is what F has always meant here (`CardBodyPresentation.findInText`).
case body
/// The comments pane's **whole rendered thread**, across rows (`CommentThreadFind`).
case thread
/// The composer, or an open inline edit session: "their own text surfaces with the editor's
/// ordinary find" a stock `NSTextView` find bar over that one editor.
case authoring
}
/// Which surface *inside* the comments pane holds the keyboard, as the pane's own views report it.
///
/// Two cases and not three, because the routing only ever asks one question: is this a *reading*
/// surface (a rendered comment, where find means the thread) or an *authoring* one (the composer or an
/// inline session, where find means that editor). Which comment is being edited is the session's own
/// business and no concern of F's.
public enum CommentPaneFocus: Sendable, Equatable {
case thread
case authoring
}
/// **The routing rule, as a pure function** extracted for every menu-validation rule's reason in
/// this codebase: a menu item's dispatch is otherwise only observable by driving the menu bar, and
/// "F follows focus" is exactly the kind of clause that regresses into "F always means the body".
public enum CardWindowFind {
/// - Parameters:
/// - paneFocus: what the comments pane last reported (`CardComments.paneFocus`).
/// - isThreadFindShowing: whether the thread's find bar is already up. **It outranks an absent
/// pane focus**, because raising the bar takes the keyboard *out* of the thread and into the
/// bar's own field so a second F, which every user expects to return to the search field,
/// would otherwise fall through to the body.
/// - hasBody: whether a body surface exists to find in at all (`CardBodyPresentation.findInText`).
/// - Returns: the route, or `nil` when this window has nothing to find in which is also the
/// window-less case, where Edit Find is the board's.
public static func route(
paneFocus: CommentPaneFocus?,
isThreadFindShowing: Bool,
hasBody: Bool
) -> CardWindowFindRoute? {
if paneFocus == .authoring { return .authoring }
if paneFocus == .thread || isThreadFindShowing { return .thread }
return hasBody ? .body : nil
}
}
// MARK: - The thread's rows, and what a query finds in them
/// One comment as the find sees it: an identity, and the **rendered** text of its body.
///
/// Rendered rather than raw Markdown, and that is the whole reason this type exists. 05 scopes the
/// find to "the whole *rendered* thread", the ranges it produces have to land in the text views the
/// rows actually draw (`CommentBodyView`, whose storage is the same renderer's output), and a user
/// searching for `code` should not match the backticks around it.
public struct CommentThreadRow: Sendable, Equatable {
public let id: ItemID
public let text: String
public init(id: ItemID, text: String) {
self.id = id
self.text = text
}
}
/// One hit: which comment, and where in its rendered text.
public struct CommentThreadMatch: Sendable, Equatable {
public let comment: ItemID
public let range: NSRange
public init(comment: ItemID, range: NSRange) {
self.comment = comment
self.range = range
}
}
/// **Finding a query in a thread** pure, so the whole of what F *means* over the comments pane is
/// checkable without a window (05-card-window.md Preview).
public enum CommentThreadSearch {
/// Every match, in reading order: rows in the order given, hits within a row left to right.
///
/// ### The comparison is the board search's, not `NSTextFinder`'s default
///
/// Case- **and diacritic-insensitive**, which is the rule 04-interactions.md fixes for the board's
/// query and the one this app has therefore taught its users. It is done with
/// `NSString.range(of:options:range:)` rather than by folding both sides, because folding can
/// change a string's length a decomposed character folds to fewer UTF-16 units and a range
/// measured in the folded string would highlight the wrong characters in the real one.
///
/// An **empty query finds nothing** (rather than everything): the find bar opens empty, and a bar
/// that reported "1 of 4000" before a key was pressed would be noise.
public static func matches(in rows: [CommentThreadRow], query: String) -> [CommentThreadMatch] {
guard !query.isEmpty else { return [] }
var matches: [CommentThreadMatch] = []
for row in rows {
let text = row.text as NSString
var searched = NSRange(location: 0, length: text.length)
while searched.length > 0 {
let found = text.range(
of: query,
options: [.caseInsensitive, .diacriticInsensitive],
range: searched
)
guard found.location != NSNotFound else { break }
matches.append(CommentThreadMatch(comment: row.id, range: found))
// Advance past the whole hit, so overlapping occurrences ("aa" in "aaa") are **one**
// match which is what `NSTextFinder` does, and therefore what the body's F in this
// same window already does. `max(1, )` keeps the loop advancing on principle; a
// zero-length range cannot happen, because the query is non-empty.
let next = found.location + max(1, found.length)
searched = NSRange(location: next, length: max(0, text.length - next))
}
}
return matches
}
/// **Stepping, with wraparound** the find bar's own grammar (Edit Find Next / Find Previous,
/// G / G 11-command-nexus.md), and the thing that makes a find spanning several text views
/// read as *one* find: next from the last hit of one comment is the first hit of the next.
///
/// `0` for an empty match list, which the caller never steps into anyway stated so the function
/// is total rather than trapping on a modulo by zero.
public static func step(from index: Int, count: Int, forward: Bool) -> Int {
guard count > 0 else { return 0 }
let next = forward ? index + 1 : index - 1
return ((next % count) + count) % count
}
/// The bar's counter "3 of 12", or the not-found phrase. Pure for the reason every count line in
/// this app is: the zero case is the one that renders wrong.
public static func status(index: Int, count: Int, query: String) -> String {
guard !query.isEmpty else { return "" }
guard count > 0 else { return "No matches" }
return "\(index + 1) of \(count)"
}
}
// MARK: - CommentThreadFind
/// **The comments pane's find session** the bar's state, the matches, and where the current one is
/// (05-card-window.md Preview's comments clause).
///
/// ### Why not `NSTextFinder`
///
/// Because there is no one text view to give it. The thread is a list of rows, each a real
/// `NSTextView` with its own storage (`CommentBodyView`, whose note explains why a scroll view per row
/// was rejected), and `NSTextFinder` finds within **one** client. The two ways out are a
/// discontiguous `NSTextFinderClient` vending the concatenated thread and mapping ranges back to rows,
/// or this: a small find bar over a **model** of the rendered thread, driving the rows' highlighting.
/// This is the smaller correct one the rows already know how to draw a highlight, the model is a
/// pure function (`CommentThreadSearch`) rather than a protocol conformance whose contract is only
/// observable by using it, and it searches comments whose rows are not even mounted, which a client
/// built over live text views could not.
///
/// ### The honest limits, stated
///
/// - **It is the app's bar, not the system's.** Its grammar is deliberately the system's a field,
/// a counter, previous/next, Done, G/G, Escape but Replace, the search-options menu (whole
/// word, regular expression) and the find pasteboard are not there. The body's F is still the real
/// `NSTextFinder`, and so is the composer's, so the two surfaces that are *documents* keep the full
/// thing; the thread, which is a list, gets a list's find.
/// - **It searches bodies, not author lines.** The thread's content is what a reader means by "find
/// in this thread", and the author line is metadata rendered beside it, in a SwiftUI `Text` with no
/// range to highlight. Names are searchable where names are content the board's query over titles.
/// - **The rows are rebuilt when the thread changes.** That is a render pass per reload *while the bar
/// is open*, which is why the rows are built lazily and dropped with the bar.
@MainActor
@Observable
public final class CommentThreadFind {
/// Whether the bar is on screen. F raises it; Done and Escape take it down.
public private(set) var isShowing = false
/// The query, written live by the bar's field.
public var query = "" {
didSet {
guard query != oldValue else { return }
recompute(preservingPosition: false)
}
}
/// Every hit in the thread, in reading order.
public private(set) var matches: [CommentThreadMatch] = []
/// Which hit is current an index into `matches`, meaningless (and unread) when it is empty.
public private(set) var index = 0
/// Bumped to ask the bar's field for the keyboard. A **counter**, not a flag, for
/// `CardComments.focusComposerRequests`' reason: two Fs in a row are two requests.
public private(set) var focusRequests = 0
/// The rendered thread the matches were computed against, rebuilt whenever the thread changes
/// while the bar is open.
private var rows: [CommentThreadRow] = []
public init() {}
// MARK: The bar
/// **F over a focused comments pane**: raise the bar if it is down, and put the keyboard in its
/// field either way the same "focus, not toggle" rule Edit Find keeps on the board
/// (`FindCommand`), so a second press is a re-focus rather than a dismissal.
public func invoke() {
isShowing = true
focusRequests += 1
}
/// Done, and Escape the bar's two ways out. The query is kept: re-opening the bar on the search
/// the user was just running is what every find bar on this platform does.
public func dismiss() {
isShowing = false
}
/// The thread, as the pane knows it called when the bar opens and whenever the thread changes
/// under an open bar.
///
/// **The position survives a re-read**: an agent editing a comment three rows up must not move the
/// user's place in their own search, so the index is clamped rather than reset. It *is* reset by a
/// query change, which is a new search.
public func setThread(_ comments: [Comment], pointSize: CGFloat) {
let rows = comments.map { comment in
CommentThreadRow(id: comment.id, text: Self.renderedText(of: comment.body, pointSize: pointSize))
}
guard rows != self.rows else { return }
self.rows = rows
recompute(preservingPosition: true)
}
/// The rendered plain text of one comment body **the same renderer the row draws with**
/// (`CommentBodyView` `BodyMarkupRenderer`), so a range found here lands on the characters the
/// user is looking at. A second way of turning Markdown into text would be a second answer to
/// "what does this comment say", and the ranges would drift wherever the two disagreed.
private static func renderedText(of body: String, pointSize: CGFloat) -> String {
BodyMarkupRenderer.attributedString(
for: BodyMarkup.parse(body),
context: BodyMarkupRenderer.Context(pointSize: pointSize, cardFolder: nil)
).string
}
// MARK: Stepping
/// Edit Find Next / Find Previous, and the bar's own chevrons. A no-op with nothing found.
public func step(forward: Bool) {
guard !matches.isEmpty else { return }
index = CommentThreadSearch.step(from: index, count: matches.count, forward: forward)
}
/// The hit the pane scrolls to and draws in the current colour, or `nil` when there is none.
///
/// **`nil` with the bar down**, which is what takes the highlighting off the thread when the user
/// presses Done: the query survives a dismissal (so re-opening lands on the same search), and
/// without this gate the matches would survive as marks on a thread nobody is searching.
public var currentMatch: CommentThreadMatch? {
guard isShowing, matches.indices.contains(index) else { return nil }
return matches[index]
}
/// Every hit inside one comment what a row highlights. `[]` for a row with none, which is the
/// overwhelming majority and costs the row nothing, and `[]` for every row while the bar is down.
public func matches(in comment: ItemID) -> [NSRange] {
guard isShowing else { return [] }
return matches.compactMap { $0.comment == comment ? $0.range : nil }
}
/// The bar's counter.
public var status: String {
CommentThreadSearch.status(index: index, count: matches.count, query: query)
}
private func recompute(preservingPosition: Bool) {
matches = CommentThreadSearch.matches(in: rows, query: query)
index = preservingPosition ? min(index, max(0, matches.count - 1)) : 0
}
}
// MARK: - The bar
/// The comments pane's find bar **the system find bar's grammar, in the app's own strip**
/// (see `CommentThreadFind` for why it is not an `NSTextFinder` bar).
///
/// A field, a counter, previous/next, Done: the four things a find bar on this platform has, in the
/// order it has them, so a user who has used the body's F recognises this one. Return steps forward
/// and Escape dismisses the field's own two keys while G/G are the menu's
/// (`FindSteppingCommands`), which is where the platform puts stepping that must work with the
/// keyboard anywhere in the window.
///
/// **Return is deliberately not bound.** It is the obvious twin of Return, and binding it would mean
/// a window-wide key equivalent that outranks the composer's newline for as long as the bar is up
/// a bar left open while the user goes back to typing would silently swallow their line breaks.
struct CommentFindBar: View {
let find: CommentThreadFind
@FocusState private var isFieldFocused: Bool
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
var body: some View {
HStack(spacing: CardWindowMetrics.previewPadding(bodyPointSize: pointSize)) {
field
Text(find.status)
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
// A live-updating count is a status, not a control: announced when it changes rather
// than only when the rotor reaches it (10-accessibility.md's live-region posture).
.accessibilityLabel(find.status)
stepper(forward: false, symbol: "chevron.up", label: "Find Previous")
stepper(forward: true, symbol: "chevron.down", label: "Find Next")
Button("Done") { find.dismiss() }
.controlSize(.small)
}
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: pointSize))
.padding(.vertical, CardWindowMetrics.previewPadding(bodyPointSize: pointSize))
.background(.background.secondary)
.overlay(alignment: .bottom) { Divider() }
// F's second half: the bar is raised by the menu item one update earlier, so the keyboard is
// claimed here, where the field exists (`BoardSearchFieldView.updateNSView`'s rule).
.onChange(of: find.focusRequests, initial: true) { _, _ in
isFieldFocused = true
}
}
private var field: some View {
TextField("Find", text: Binding(get: { find.query }, set: { find.query = $0 }))
.textFieldStyle(.roundedBorder)
.controlSize(.small)
.frame(width: CardWindowMetrics.findFieldWidth(bodyPointSize: pointSize))
.focused($isFieldFocused)
.onSubmit { find.step(forward: true) }
// Escape dismisses the bar and nothing else it never clears the query, which is the
// find pasteboard's convention and the reason re-opening lands on the same search.
.onExitCommand { find.dismiss() }
.accessibilityLabel("Find in comments")
}
private func stepper(forward: Bool, symbol: String, label: String) -> some View {
Button {
find.step(forward: forward)
} label: {
Image(systemName: symbol)
.font(.caption.weight(.semibold))
}
.buttonStyle(.plain)
.disabled(find.matches.isEmpty)
.help(label)
.accessibilityLabel(label)
}
}
@@ -277,6 +277,58 @@ struct AccessibilityPhrasesTests {
#expect(AccessibilityPhrases.readOnlyLockCleared == "The board is editable again") #expect(AccessibilityPhrases.readOnlyLockCleared == "The board is editable again")
#expect(AccessibilityPhrases.reloadBreakageCleared == "The board is loading again") #expect(AccessibilityPhrases.reloadBreakageCleared == "The board is loading again")
} }
// MARK: - The comments pane
/// 10-accessibility.md Comments: "the pane is a labeled container ('Comments, N')".
@Test("The pane's container names itself and its count")
func commentsContainer() {
#expect(AccessibilityPhrases.commentsContainerLabel(count: 3) == "Comments, 3")
#expect(
AccessibilityPhrases.commentsContainerLabel(count: 0) == "Comments, 0",
"a comment-less card still shows the pane — the invitation is the point"
)
}
@Test("A comment count folds its plural, like every other count in the app")
func commentCounts() {
#expect(AccessibilityPhrases.commentCount(1) == "1 comment")
#expect(AccessibilityPhrases.commentCount(4) == "4 comments")
}
/// The flattened element: the author line is what it *is*, the body is what it holds.
@Test("A comment's label is its author line, and its value is its body")
func commentElement() {
#expect(AccessibilityPhrases.commentLabel(authorLine: "Ada Lovelace · 1 Jan 2026") == "Ada Lovelace · 1 Jan 2026")
#expect(AccessibilityPhrases.commentValue(body: "A remark.\n", attachments: 0) == "A remark.")
#expect(
AccessibilityPhrases.commentValue(body: "A remark.\n", attachments: 2) == "A remark., 2 attachments"
)
}
@Test("A comment with nothing to attribute is still a named element")
func unattributedComment() {
// "Unattributed" is the absence of a name, never a name to speak (`CommentAuthorLine`) but an
// unlabeled element is an audit failure, so the fallback is the noun itself.
#expect(AccessibilityPhrases.commentLabel(authorLine: nil) == "Comment")
#expect(AccessibilityPhrases.commentValue(body: " \n", attachments: 0) == "")
}
/// The custom actions must be the *same* strings as the context menu's rows (10 Comments), so a
/// user who has learned the pointer inventory hears the same three words from the rotor.
@Test("The comment's custom actions are its context menu's rows")
func commentActions() {
#expect(AccessibilityPhrases.commentEditAction == "Edit")
#expect(AccessibilityPhrases.commentDeleteAction == "Delete")
#expect(AccessibilityPhrases.commentRevealAction == "Reveal in Finder")
}
@Test("The composer, the sort control and the paperclip are labeled")
func commentControls() {
#expect(AccessibilityPhrases.commentComposerLabel == "Add a comment")
#expect(AccessibilityPhrases.commentSortLabel == "Sort")
#expect(AccessibilityPhrases.commentAttachFilesLabel == "Attach Files")
}
} }
/// Distinct identities for the digest cases, which care only about *counts* the diff's own suite /// Distinct identities for the digest cases, which care only about *counts* the diff's own suite
+395
View File
@@ -0,0 +1,395 @@
import Foundation
import Testing
@testable import Kanban
/// **What a foreign comment change says out loud** 10-accessibility.md Comments ("Foreign comment
/// arrivals announce path-shaped ('New comment on 'card'')"), 01-storage-format.md Enhanced schema
/// (the path shape, and the verb family 06 gains with the feature).
///
/// Three layers, three suites, because the rule is three separate claims stacked:
///
/// 1. **What changed** `CommentThreadChanges`, a pure comparison of two threads.
/// 2. **Whose it was** the `EchoLedger`'s comment receipts, read through `CommentPath.classify`;
/// the app's own writes never announce.
/// 3. **What that says** `BoardAnnouncer.commentSpeech(for:onCard:)` over `AccessibilityPhrases`.
///
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`; `CommentIdent`,
/// `commentText` and `makeCommentBoard` from `CommentThreadTests.swift`.
// MARK: - Fixtures
private func comment(_ id: String, body: String) -> Kanban.Comment {
Kanban.Comment(
id: ItemID(rawValue: id),
schema: .valid(1),
author: .missing,
created: .missing,
modified: .missing,
modifiedBy: .missing,
attachments: [],
document: FrontmatterDocument(body: body)
)
}
private func thread(_ comments: [Kanban.Comment]) -> CommentThread {
CommentThread(comments: comments, strays: [], defects: [], hasDraft: false)
}
// MARK: - What changed
@Suite("Comment announcements ▸ the thread diff")
struct CommentThreadChangesTests {
@Test("An arrival, an edit and a departure, each in its own bucket")
func threeBuckets() {
let old = thread([comment(CommentIdent.one, body: "one"), comment(CommentIdent.two, body: "two")])
let new = thread([comment(CommentIdent.one, body: "one, amended"), comment(CommentIdent.three, body: "three")])
let changes = CommentThreadChanges.between(old, new)
#expect(changes.arrived == [ItemID(rawValue: CommentIdent.three)])
#expect(changes.edited == [ItemID(rawValue: CommentIdent.one)])
#expect(changes.deleted == [ItemID(rawValue: CommentIdent.two)])
}
@Test("An unchanged thread is empty — the overwhelmingly common reload")
func unchangedIsEmpty() {
let same = thread([comment(CommentIdent.one, body: "one")])
#expect(CommentThreadChanges.between(same, same).isEmpty)
}
@Test("An attachment landing on a comment is not a change here — the comparison is index.md")
func attachmentsAreNotEdits() {
let before = comment(CommentIdent.one, body: "one")
let after = Kanban.Comment(
id: before.id,
schema: before.schema,
author: before.author,
created: before.created,
modified: before.modified,
modifiedBy: before.modifiedBy,
attachments: ["shot.png"],
document: before.document
)
#expect(CommentThreadChanges.between(thread([before]), thread([after])).isEmpty)
}
@Test("Vouched changes are removed one at a time, never all-or-nothing")
func exclusionIsPerComment() {
let changes = CommentThreadChanges(
arrived: [ItemID(rawValue: CommentIdent.one), ItemID(rawValue: CommentIdent.two)],
edited: [ItemID(rawValue: CommentIdent.three)]
)
// The app posted one of the two arrivals; an agent filed the other in the same window.
let foreign = changes.excluding([ItemID(rawValue: CommentIdent.one), ItemID(rawValue: CommentIdent.three)])
#expect(foreign.arrived == [ItemID(rawValue: CommentIdent.two)])
#expect(foreign.edited.isEmpty)
}
}
// MARK: - Whose it was
@MainActor
@Suite("Comment announcements ▸ the ledger's comment receipts")
struct CommentReceiptTests {
@Test("A posted comment is vouched for, and the receipts are retired on the way out")
func postIsVouched() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let store = try BoardStore(rootURL: fixture.root)
let cardID = ItemID(rawValue: Ident.card1)
store.saveCommentDraft(inCard: cardID, body: "A remark.")
let posted = try #require(store.postComment(inCard: cardID))
#expect(store.vouchedComments(inCard: cardID) == [posted])
// One write, one echo: a second reload finds nothing vouching for the same comment.
#expect(store.vouchedComments(inCard: cardID).isEmpty)
#expect(fixture.exists("\(card)/comments/\(posted.rawValue)"))
}
@Test("An inline edit's save is vouched for — the debounce writes without a re-read")
func editIsVouched() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "before\n"))
let store = try BoardStore(rootURL: fixture.root)
let cardID = ItemID(rawValue: Ident.card1)
#expect(store.editComment(ItemID(rawValue: CommentIdent.one), inCard: cardID, body: "after"))
#expect(store.vouchedComments(inCard: cardID) == [ItemID(rawValue: CommentIdent.one)])
}
@Test("A delete is vouched for at both ends of its move — the thread's and the trash's")
func deleteIsVouched() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "doomed\n"))
let store = try BoardStore(rootURL: fixture.root)
let cardID = ItemID(rawValue: Ident.card1)
#expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: cardID))
#expect(store.vouchedComments(inCard: cardID) == [ItemID(rawValue: CommentIdent.one)])
#expect(store.echoes.outstandingReceipts == 0)
}
@Test("A draft save vouches for nothing in the thread — a draft is not in it")
func draftVouchesForNothing() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeCommentBoard(fixture)
let store = try BoardStore(rootURL: fixture.root)
let cardID = ItemID(rawValue: Ident.card1)
store.saveCommentDraft(inCard: cardID, body: "half a thought")
#expect(store.vouchedComments(inCard: cardID).isEmpty)
// Retired all the same: a receipt nothing ever collects is a receipt that outlives its write.
#expect(store.echoes.outstandingReceipts == 0)
}
@Test("A comment receipt never makes its card's own edit look foreign")
func commentReceiptsDoNotPoisonTheCard() throws {
// The regression this guards: a card's footprint used to resolve *every* receipt under its
// folder against its attachment listing, so a comment's `index.md` which no snapshot can
// observe read as absent, failed its receipt, and classified the card foreign. The user's
// own next title or body edit would then have been announced as somebody else's.
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let cardFolder = fixture.url(card)
let old = try BoardLoader.load(boardRoot: fixture.root).model
try BoardWriter.writeBody(inItemFolder: cardFolder, body: "An amended body.")
let new = try BoardLoader.load(boardRoot: fixture.root).model
let card1 = try #require(new.lanes.first?.cards.first)
let ledger = EchoLedger()
ledger.recordWrite(
atPath: EchoLedger.key(cardFolder.appendingPathComponent(BoardLoader.indexFileName)),
hash: EchoLedger.hash(of: card1.document.serialized())
)
ledger.recordWrite(
atPath: EchoLedger.key(
CommentThread
.commentFolder(ItemID(rawValue: CommentIdent.one), inCard: cardFolder)
.appendingPathComponent(BoardLoader.indexFileName)
),
hash: "a hash no snapshot can ever produce"
)
let diff = BoardDiff.between(old, new, includingTrash: false)
let verdicts = ledger.verdicts(from: old, to: new, diff: diff, includingTrash: false)
#expect(verdicts.foreignItems.isEmpty)
#expect(!verdicts.foreign.boardChanged)
// And the comment receipt is still there for the card window to collect.
#expect(ledger.outstandingReceipts == 1)
}
}
// MARK: - What it says
@Suite("Comment announcements ▸ the sentence")
struct CommentSpeechTests {
private let card = "Fix login"
@Test("An arrival is 10's own sentence")
func arrivalSpeaks() {
let changes = CommentThreadChanges(arrived: [ItemID(rawValue: CommentIdent.one)])
#expect(BoardAnnouncer.commentSpeech(for: changes, onCard: card) == "New comment on 'Fix login'")
}
@Test("Edits and deletions speak in 06's verb family")
func theFamilySpeaks() {
#expect(
BoardAnnouncer.commentSpeech(
for: CommentThreadChanges(edited: [ItemID(rawValue: CommentIdent.one)]),
onCard: card
) == "Edit comment on 'Fix login'"
)
#expect(
BoardAnnouncer.commentSpeech(
for: CommentThreadChanges(deleted: [ItemID(rawValue: CommentIdent.one)]),
onCard: card
) == "Delete comment on 'Fix login'"
)
}
@Test("Counts fold, like every other count in the app")
func countsFold() {
let two = [ItemID(rawValue: CommentIdent.one), ItemID(rawValue: CommentIdent.two)]
#expect(
BoardAnnouncer.commentSpeech(for: CommentThreadChanges(arrived: two), onCard: card)
== "2 new comments on 'Fix login'"
)
#expect(
BoardAnnouncer.commentSpeech(for: CommentThreadChanges(edited: two), onCard: card)
== "2 comments edited on 'Fix login'"
)
#expect(
BoardAnnouncer.commentSpeech(for: CommentThreadChanges(deleted: two), onCard: card)
== "2 comments deleted on 'Fix login'"
)
}
@Test("Arrivals lead — one polite sentence, chosen by precedence rather than concatenated")
func arrivalsLead() {
let changes = CommentThreadChanges(
arrived: [ItemID(rawValue: CommentIdent.one)],
edited: [ItemID(rawValue: CommentIdent.two)],
deleted: [ItemID(rawValue: CommentIdent.three)]
)
#expect(BoardAnnouncer.commentSpeech(for: changes, onCard: card) == "New comment on 'Fix login'")
}
@Test("An untitled card wears the placeholder the board wears")
func untitledCard() {
let changes = CommentThreadChanges(arrived: [ItemID(rawValue: CommentIdent.one)])
#expect(BoardAnnouncer.commentSpeech(for: changes, onCard: nil) == "New comment on 'Untitled'")
}
@Test("Nothing changed is silence")
func nothingIsSilence() {
#expect(BoardAnnouncer.commentSpeech(for: CommentThreadChanges(), onCard: card) == nil)
}
}
// MARK: - The pane's own voice
@MainActor
@Suite("Comment announcements ▸ the pane")
struct CommentPaneAnnouncementTests {
/// A pane wired to a real store on a real board the only way the receipts half is honest, since
/// what silences an echo is a write having actually happened.
///
/// The store rides on the recorder rather than being returned beside it, and that is not tidiness:
/// `CardWindowHost.configureComments` captures the store **weakly** (a save landing after the board
/// window has gone must write nothing), so a test that let it go out of scope would be driving a
/// pane whose every seam answers "vanished" which looks exactly like a bug in the pane.
private func makePane(_ fixture: WriterFixture) throws -> (CardComments, Spoken) {
let store = try BoardStore(rootURL: fixture.root)
let comments = CardComments()
comments.isEditable = true
comments.cardFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)")
comments.cardTitle = "Fix login"
CardWindowHost.configureComments(comments, store: store, cardID: ItemID(rawValue: Ident.card1))
let spoken = Spoken(store: store)
comments.announce = { spoken.record($0) }
return (comments, spoken)
}
/// What the pane said, and the store it said it about held here so the weak seams stay wired.
@MainActor
final class Spoken {
let store: BoardStore
private(set) var phrases: [String] = []
init(store: BoardStore) {
self.store = store
}
func record(_ phrase: String?) {
guard let phrase else { return }
phrases.append(phrase)
}
}
@Test("Opening a card with a thread announces nothing — the window is not a change")
func openingIsSilent() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "Already here.\n"))
let (comments, spoken) = try makePane(fixture)
comments.open()
#expect(comments.thread.comments.count == 1)
#expect(spoken.phrases.isEmpty)
}
@Test("A foreign arrival speaks path-shaped, naming the card")
func foreignArrivalSpeaks() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let (comments, spoken) = try makePane(fixture)
comments.open()
try fixture.item("\(card)/comments/\(CommentIdent.two)", commentText(body: "An agent's remark.\n"))
comments.reload()
#expect(spoken.phrases == ["New comment on 'Fix login'"])
}
@Test("Our own post is silent — the receipt vouches for it")
func ownPostIsSilent() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeCommentBoard(fixture)
let (comments, spoken) = try makePane(fixture)
comments.open()
comments.composer.edited("A remark of our own.")
comments.composer.flush()
#expect(comments.composer.postNow() != nil)
comments.reload()
#expect(comments.thread.comments.count == 1)
#expect(spoken.phrases.isEmpty)
}
@Test("Our own inline edit is silent, even though nothing re-read the thread in between")
func ownEditIsSilent() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "before\n"))
let (comments, spoken) = try makePane(fixture)
let store = spoken.store
comments.open()
// The debounced save the inline session makes, without a reload of its own so the *next*
// reload is the first to see it, which is exactly the case the ledger has to cover.
#expect(store.editComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1), body: "after"))
comments.reload()
#expect(comments.thread.comments.first?.body == "after")
#expect(spoken.phrases.isEmpty)
}
@Test("Our own delete is silent")
func ownDeleteIsSilent() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "doomed\n"))
let (comments, spoken) = try makePane(fixture)
comments.open()
comments.delete(ItemID(rawValue: CommentIdent.one))
#expect(comments.thread.comments.isEmpty)
#expect(spoken.phrases.isEmpty)
}
@Test("A foreign arrival alongside our own post announces the half nobody vouched for")
func mixedReloadSpeaksTheForeignHalf() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let (comments, spoken) = try makePane(fixture)
let store = spoken.store
comments.open()
store.saveCommentDraft(inCard: ItemID(rawValue: Ident.card1), body: "Ours.")
#expect(store.postComment(inCard: ItemID(rawValue: Ident.card1)) != nil)
try fixture.item("\(card)/comments/\(CommentIdent.three)", commentText(body: "Theirs.\n"))
comments.reload()
#expect(comments.thread.comments.count == 2)
#expect(spoken.phrases == ["New comment on 'Fix login'"])
}
}
+343
View File
@@ -0,0 +1,343 @@
import Foundation
import Testing
@testable import Kanban
/// **The card window's F over the comments pane** 05-card-window.md Preview:
///
/// > Edit Find (F) is find-in-text here the standard find bar over the focused surface (Preview's
/// > selectable text, the Edit editor, raw source and the comments pane, where it searches the whole
/// > rendered thread, `.draft` excluded; the composer and an inline comment edit are their own focused
/// > text surfaces with the editor's ordinary find).
///
/// Two claims and therefore two halves: **where the key goes** (a pure routing rule over the pane's
/// focus, `CardWindowFind`), and **what the find does once it is there** (a pure search over the
/// rendered thread plus a session that steps through it, `CommentThreadSearch` / `CommentThreadFind`).
///
/// The thread itself is never `.draft` or `comments/.trash/` here for free those are excluded from
/// the listing by the loader (`CommentThreadTests`), so a find that searches "the thread" cannot see
/// them by construction.
// MARK: - Fixtures
private func row(_ id: String, _ text: String) -> CommentThreadRow {
CommentThreadRow(id: ItemID(rawValue: id), text: text)
}
private func comment(_ id: String, body: String) -> Kanban.Comment {
Kanban.Comment(
id: ItemID(rawValue: id),
schema: .valid(1),
author: .missing,
created: .missing,
modified: .missing,
modifiedBy: .missing,
attachments: [],
document: FrontmatterDocument(body: body)
)
}
// MARK: - Routing
@Suite("Comment find ▸ where ⌘F goes")
struct CardWindowFindRouteTests {
@Test("A focused comment routes to the thread; a focused authoring surface to its own editor")
func focusDecides() {
#expect(CardWindowFind.route(paneFocus: .thread, isThreadFindShowing: false, hasBody: true) == .thread)
#expect(CardWindowFind.route(paneFocus: .authoring, isThreadFindShowing: false, hasBody: true) == .authoring)
}
@Test("With the pane unfocused it is the body's find, exactly as before the pane existed")
func bodyIsTheDefault() {
#expect(CardWindowFind.route(paneFocus: nil, isThreadFindShowing: false, hasBody: true) == .body)
#expect(CardWindowFind.route(paneFocus: nil, isThreadFindShowing: false, hasBody: false) == nil)
}
@Test("An open find bar outranks an absent focus — a second ⌘F returns to the search field")
func theOpenBarKeepsTheKey() {
// Raising the bar takes the keyboard out of the thread and into the bar's own field, so the
// pane reports no focus; without this clause the next F would fall through to the body.
#expect(CardWindowFind.route(paneFocus: nil, isThreadFindShowing: true, hasBody: true) == .thread)
}
@Test("An authoring surface outranks even an open bar — the user is typing in an editor")
func authoringWins() {
#expect(CardWindowFind.route(paneFocus: .authoring, isThreadFindShowing: true, hasBody: true) == .authoring)
}
}
// MARK: - The search
@Suite("Comment find ▸ the search")
struct CommentThreadSearchTests {
@Test("Matches are in reading order: rows in order, hits within a row left to right")
func readingOrder() {
let matches = CommentThreadSearch.matches(
in: [row(CommentIdent.one, "kestrel and kestrel"), row(CommentIdent.two, "a kestrel")],
query: "kestrel"
)
#expect(matches.count == 3)
#expect(matches[0] == CommentThreadMatch(comment: ItemID(rawValue: CommentIdent.one), range: NSRange(location: 0, length: 7)))
#expect(matches[1].comment == ItemID(rawValue: CommentIdent.one))
#expect(matches[1].range.location == 12)
#expect(matches[2].comment == ItemID(rawValue: CommentIdent.two))
}
@Test("The comparison folds case and diacritics — the board query's own rule")
func folding() {
let matches = CommentThreadSearch.matches(in: [row(CommentIdent.one, "A Résumé")], query: "resume")
#expect(matches.count == 1)
// The range is in the *unfolded* text, which is what makes the highlight land on what is drawn.
#expect(matches[0].range == NSRange(location: 2, length: 6))
}
@Test("Overlapping occurrences are one hit — NSTextFinder's answer, and the body's ⌘F's")
func overlapping() {
// Two finds in one window must not disagree about how many times "aa" occurs in "aaa".
#expect(CommentThreadSearch.matches(in: [row(CommentIdent.one, "aaa")], query: "aa").count == 1)
#expect(CommentThreadSearch.matches(in: [row(CommentIdent.one, "aaaa")], query: "aa").count == 2)
}
@Test("An empty query finds nothing — the bar opens empty and says nothing")
func emptyQuery() {
#expect(CommentThreadSearch.matches(in: [row(CommentIdent.one, "anything")], query: "").isEmpty)
}
@Test("Stepping wraps in both directions — which is what makes it one find across rows")
func stepping() {
#expect(CommentThreadSearch.step(from: 0, count: 3, forward: true) == 1)
#expect(CommentThreadSearch.step(from: 2, count: 3, forward: true) == 0)
#expect(CommentThreadSearch.step(from: 0, count: 3, forward: false) == 2)
#expect(CommentThreadSearch.step(from: 0, count: 1, forward: true) == 0)
#expect(CommentThreadSearch.step(from: 0, count: 0, forward: true) == 0)
}
@Test("The counter reads as a find bar's does, and says so when there is nothing")
func status() {
#expect(CommentThreadSearch.status(index: 0, count: 3, query: "k") == "1 of 3")
#expect(CommentThreadSearch.status(index: 2, count: 3, query: "k") == "3 of 3")
#expect(CommentThreadSearch.status(index: 0, count: 0, query: "k") == "No matches")
#expect(CommentThreadSearch.status(index: 0, count: 0, query: "") == "")
}
}
// MARK: - The session
@MainActor
@Suite("Comment find ▸ the session")
struct CommentThreadFindTests {
private let pointSize: CGFloat = 13
@Test("⌘F raises the bar and asks for the keyboard, every time")
func invoking() {
let find = CommentThreadFind()
#expect(!find.isShowing)
find.invoke()
#expect(find.isShowing)
#expect(find.focusRequests == 1)
// A second press is a re-focus, not a dismissal `FindCommand`'s "focus, not toggle" rule.
find.invoke()
#expect(find.isShowing)
#expect(find.focusRequests == 2)
}
@Test("Done keeps the query — re-opening lands on the search the user was running")
func dismissKeepsTheQuery() {
let find = CommentThreadFind()
find.invoke()
find.setThread([comment(CommentIdent.one, body: "kestrel")], pointSize: 13)
find.query = "kestrel"
#expect(find.matches(in: ItemID(rawValue: CommentIdent.one)).count == 1)
find.dismiss()
#expect(!find.isShowing)
#expect(find.query == "kestrel")
// The marks come off the thread with the bar a dismissed find must not leave a highlighted
// thread behind, even though the search itself is remembered.
#expect(find.matches(in: ItemID(rawValue: CommentIdent.one)).isEmpty)
#expect(find.currentMatch == nil)
}
@Test("It searches the *rendered* thread — markup is not what the reader sees, so it is not searched")
func searchesRenderedText() {
let find = CommentThreadFind()
find.setThread([comment(CommentIdent.one, body: "A **bold** remark")], pointSize: pointSize)
find.query = "bold"
#expect(find.matches.count == 1)
// The asterisks are markup the renderer consumed; a user looking at the row cannot see them.
find.query = "**bold**"
#expect(find.matches.isEmpty)
}
@Test("Every comment is searched, mounted or not — the rows come from the thread, not the views")
func spansTheWholeThread() {
let find = CommentThreadFind()
find.invoke()
find.setThread(
[
comment(CommentIdent.one, body: "first kestrel"),
comment(CommentIdent.two, body: "nothing here"),
comment(CommentIdent.three, body: "second kestrel")
],
pointSize: pointSize
)
find.query = "kestrel"
#expect(find.matches.count == 2)
#expect(find.matches.map(\.comment) == [ItemID(rawValue: CommentIdent.one), ItemID(rawValue: CommentIdent.three)])
#expect(find.matches(in: ItemID(rawValue: CommentIdent.two)).isEmpty)
#expect(find.matches(in: ItemID(rawValue: CommentIdent.three)).count == 1)
}
@Test("Next crosses rows, and wraps — one find, not one per comment")
func steppingCrossesRows() {
let find = CommentThreadFind()
find.invoke()
find.setThread(
[comment(CommentIdent.one, body: "kestrel"), comment(CommentIdent.two, body: "kestrel")],
pointSize: pointSize
)
find.query = "kestrel"
#expect(find.currentMatch?.comment == ItemID(rawValue: CommentIdent.one))
find.step(forward: true)
#expect(find.currentMatch?.comment == ItemID(rawValue: CommentIdent.two))
find.step(forward: true)
#expect(find.currentMatch?.comment == ItemID(rawValue: CommentIdent.one))
find.step(forward: false)
#expect(find.currentMatch?.comment == ItemID(rawValue: CommentIdent.two))
}
@Test("A new query is a new search, so it starts at the first hit")
func queryChangeResets() {
let find = CommentThreadFind()
find.setThread(
[comment(CommentIdent.one, body: "kestrel kestrel kestrel")],
pointSize: pointSize
)
find.query = "kestrel"
find.step(forward: true)
find.step(forward: true)
#expect(find.status == "3 of 3")
find.query = "kestre"
#expect(find.status == "1 of 3")
}
@Test("A thread re-read under an open bar keeps the user's place")
func reloadPreservesPosition() {
let find = CommentThreadFind()
find.setThread(
[comment(CommentIdent.one, body: "kestrel"), comment(CommentIdent.two, body: "kestrel")],
pointSize: pointSize
)
find.query = "kestrel"
find.step(forward: true)
#expect(find.status == "2 of 2")
// An agent files a third comment while the user is stepping through the search.
find.setThread(
[
comment(CommentIdent.one, body: "kestrel"),
comment(CommentIdent.two, body: "kestrel"),
comment(CommentIdent.three, body: "kestrel")
],
pointSize: pointSize
)
#expect(find.status == "2 of 3")
}
@Test("A re-read that removes the matches clamps rather than pointing past the end")
func reloadClamps() {
let find = CommentThreadFind()
find.invoke()
find.setThread([comment(CommentIdent.one, body: "kestrel kestrel")], pointSize: pointSize)
find.query = "kestrel"
find.step(forward: true)
find.setThread([comment(CommentIdent.one, body: "a falcon instead")], pointSize: pointSize)
#expect(find.matches.isEmpty)
#expect(find.currentMatch == nil)
#expect(find.status == "No matches")
}
}
// MARK: - The pane's routing, end to end
@MainActor
@Suite("Comment find ▸ the pane's focus")
struct CommentPaneFindRoutingTests {
@Test("A focused comment sends ⌘F to the thread's bar")
func threadFocusRaisesTheBar() {
let comments = CardComments()
comments.focusEntered(.thread)
#expect(comments.invokeFind(hasBody: true))
#expect(comments.find.isShowing)
}
@Test("A focused authoring surface sends ⌘F to that editor's own find bar")
func authoringFocusRunsTheEditorsFind() {
let comments = CardComments()
var editorFinds = 0
comments.focusEntered(.authoring) { editorFinds += 1 }
#expect(comments.invokeFind(hasBody: true))
#expect(editorFinds == 1)
#expect(!comments.find.isShowing)
}
@Test("With nothing in the pane focused, ⌘F is declined and the body keeps it")
func unfocusedDeclines() {
let comments = CardComments()
#expect(!comments.invokeFind(hasBody: true))
#expect(!comments.find.isShowing)
}
@Test("A late resignation from a surface that no longer holds focus is ignored")
func lateResignationIsIgnored() {
// AppKit resigns the outgoing responder before the incoming one becomes first, but the
// guard covers the case where it does not: clicking from a comment into the composer must
// not leave the pane reporting no focus at all.
let comments = CardComments()
comments.focusEntered(.thread)
comments.focusEntered(.authoring) {}
comments.focusLeft(.thread)
#expect(comments.paneFocus == .authoring)
}
@Test("Losing focus for real clears it, and the editor's find goes with it")
func focusLeftClears() {
let comments = CardComments()
comments.focusEntered(.authoring) {}
comments.focusLeft(.authoring)
#expect(comments.paneFocus == nil)
#expect(!comments.invokeFind(hasBody: true))
}
@Test("Find Next and Find Previous validate on the thread's bar alone")
func steppingRowsValidate() {
// The two other finds in this window are `NSTextFinder`'s, and `NSTextView` answers G through
// the responder chain an enabled menu item would fire first and break the stepping it exists
// to provide (`FindSteppingCommands`).
#expect(!FindSteppingCommands.isEnabled(nil))
let comments = CardComments()
#expect(!FindSteppingCommands.isEnabled(comments))
comments.focusEntered(.thread)
_ = comments.invokeFind(hasBody: true)
#expect(FindSteppingCommands.isEnabled(comments))
comments.find.dismiss()
#expect(!FindSteppingCommands.isEnabled(comments))
}
}
+464
View File
@@ -0,0 +1,464 @@
import Foundation
import Testing
@testable import Kanban
/// **Board search's reach into comment bodies** 04-interactions.md Search, re-ruled 2026-07-29:
///
/// > comment bodies join when comments ship via a search-owned transient comment index, never the
/// > snapshot: the first live-query keystroke kicks an async sweep of `comments/*/index.md` bodies
/// > (`.draft` and `comments/.trash/` excluded), kept fresh while a query is active and discarded
/// > when it clears the board walk stays O(cards), 01's window-scoped read untouched.
///
/// Four claims, and each of them gets a suite: what the sweep reads (and what it must not), how a
/// comment match reaches the one predicate every board surface filters through, what happens to the
/// index when the query clears, and the asynchrony the first keystroke's field-only answer, and the
/// refinement that lands afterwards.
///
/// The boards are **real loads off real temp trees**, like every other suite that touches storage:
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`, `CommentIdent` and
/// `commentText` from `CommentThreadTests.swift`.
// MARK: - Fixtures
/// Two lanes, three cards, and comments only where a test needs them:
///
/// | card | title | body | comments |
/// |---|---|---|---|
/// | `card1` | Fix login | (no "kestrel") | one, mentioning a kestrel |
/// | `card2` | Kestrel plans | (title match) | none |
/// | `card3` | Ship it | (no match) | none |
///
/// So "kestrel" matches `card2` by its title with no index at all, and `card1` **only** through its
/// thread which is the case the whole ruling is about.
private func makeSearchBoard(_ fixture: WriterFixture) throws {
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Kestrel plans"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Ship it"))
}
private func cardFolder(_ fixture: WriterFixture, lane: String, card: String) -> URL {
fixture.url("\(lane)/\(card)")
}
/// Writes one comment's `index.md` under a card, verbatim.
private func writeComment(
_ fixture: WriterFixture,
inCard cardPath: String,
named name: String,
body: String
) throws {
try fixture.item("\(cardPath)/comments/\(name)", commentText(body: body))
}
private let kestrelBody = "The kestrel hovers over the release notes.\n"
// MARK: - The sweep
@Suite("Comment search ▸ the sweep")
struct CommentSweepTests {
@Test("It reads every posted comment's body")
func readsPostedBodies() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try writeComment(fixture, inCard: card, named: CommentIdent.one, body: kestrelBody)
try writeComment(fixture, inCard: card, named: CommentIdent.two, body: "A second remark.\n")
let bodies = CommentThread.searchableBodies(inCard: fixture.url(card))
#expect(bodies.count == 2)
#expect(bodies.contains(kestrelBody))
#expect(bodies.contains("A second remark.\n"))
}
@Test("A card with no comments/ contributes nothing, and does not fail")
func noThreadIsNoBodies() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
#expect(CommentThread.searchableBodies(inCard: fixture.url(card)).isEmpty)
}
@Test("The draft is excluded — it is not in the thread, so it is not in the search")
func draftIsExcluded() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try writeComment(fixture, inCard: card, named: ".draft", body: "A kestrel I have not posted.\n")
try writeComment(fixture, inCard: card, named: CommentIdent.one, body: "Posted.\n")
#expect(CommentThread.searchableBodies(inCard: fixture.url(card)) == ["Posted.\n"])
}
@Test("comments/.trash/ is excluded — a deleted comment is undo's, never a search result")
func commentTrashIsExcluded() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try writeComment(
fixture,
inCard: card,
named: ".trash/\(CommentIdent.two)",
body: "A kestrel I deleted.\n"
)
try writeComment(fixture, inCard: card, named: CommentIdent.one, body: "Posted.\n")
#expect(CommentThread.searchableBodies(inCard: fixture.url(card)) == ["Posted.\n"])
}
@Test("Malformed comments are tolerated: a stray folder, one with no index.md, one that will not parse")
func defectsAreTolerated() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try writeComment(fixture, inCard: card, named: CommentIdent.one, body: kestrelBody)
// Not identity-shaped a hand-made folder inside the thread.
try writeComment(fixture, inCard: card, named: "notes", body: "A kestrel in a stray.\n")
// Identity-shaped with no `index.md` the two-step-create shape.
try FileManager.default.createDirectory(
at: fixture.url("\(card)/comments/\(CommentIdent.three)"),
withIntermediateDirectories: true
)
// Frontmatter that opens a flow sequence and never closes it.
try fixture.item(
"\(card)/comments/\(CommentIdent.upper)",
"---\nschema: 1\norder: [1024\n---\nA kestrel in a broken file.\n"
)
// The one readable comment, and nothing else none of the three costs the sweep an error.
#expect(CommentThread.searchableBodies(inCard: fixture.url(card)) == [kestrelBody])
}
@Test("The sweep indexes bodies per card, and skips cards with nothing to say")
func sweepBuildsTheIndex() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeSearchBoard(fixture)
try writeComment(
fixture,
inCard: "\(Ident.lane1)/\(Ident.card1)",
named: CommentIdent.one,
body: kestrelBody
)
let index = CommentSearchIndex.sweep([
CommentSearchTarget(id: ItemID(rawValue: Ident.card1), folder: cardFolder(fixture, lane: Ident.lane1, card: Ident.card1)),
CommentSearchTarget(id: ItemID(rawValue: Ident.card2), folder: cardFolder(fixture, lane: Ident.lane1, card: Ident.card2))
])
#expect(index[ItemID(rawValue: Ident.card1)] == [kestrelBody])
#expect(index[ItemID(rawValue: Ident.card2)] == nil)
}
@Test("Targets cover the board and the trash — a trashed card carries its comments/")
func targetsIncludeTheTrash() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeSearchBoard(fixture)
try fixture.item(".trash/\(Ident.card4)", Item.rich(order: "1024", title: "Gone"))
let model = try BoardLoader.load(boardRoot: fixture.root).model
let targets = CommentSearchIndex.targets(in: model)
let ids = Set(targets.map(\.id))
#expect(ids.contains(ItemID(rawValue: Ident.card1)))
#expect(ids.contains(ItemID(rawValue: Ident.card3)))
#expect(ids.contains(ItemID(rawValue: Ident.card4)))
// The folder is where a thread would be read from the trash card's own, not its old lane's.
let trashed = try #require(targets.first { $0.id == ItemID(rawValue: Ident.card4) })
#expect(trashed.folder.path.hasSuffix(".trash/\(Ident.card4)"))
}
}
// MARK: - Matching
@Suite("Comment search ▸ matching")
struct CommentIndexMatchingTests {
@Test("The query folds exactly as the board's does — case and diacritics")
func foldingIsTheBoardsOwn() {
let card = ItemID(rawValue: Ident.card1)
let index = [card: ["A Résumé of the kestrel.\n"]]
#expect(CommentSearchIndex.matchingCards(in: index, query: "KESTREL") == [card])
#expect(CommentSearchIndex.matchingCards(in: index, query: "resume") == [card])
#expect(CommentSearchIndex.matchingCards(in: index, query: "falcon").isEmpty)
}
@Test("An empty query matches nothing — the index is only consulted while a search is running")
func emptyQueryMatchesNothing() {
let card = ItemID(rawValue: Ident.card1)
#expect(CommentSearchIndex.matchingCards(in: [card: ["anything"]], query: "").isEmpty)
}
@Test("A query cannot match across two comments — the bodies are a list, never a joined string")
func noMatchAcrossComments() {
let card = ItemID(rawValue: Ident.card1)
let index = [card: ["ends with kes", "trel begins here"]]
#expect(CommentSearchIndex.matchingCards(in: index, query: "kestrel").isEmpty)
}
@Test("A card matches when its comments do, even though its own fields miss")
func filterRoutesCommentMatches() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeSearchBoard(fixture)
let model = try BoardLoader.load(boardRoot: fixture.root).model
let card = try #require(model.lanes.first?.cards.first { $0.id == ItemID(rawValue: Ident.card1) })
// Fields only: "Fix login" is not a kestrel.
#expect(!SearchFilter(query: "kestrel").matches(card))
// With the index' answer, the same card is on the board.
#expect(SearchFilter(query: "kestrel", commentMatches: [card.id]).matches(card))
}
@Test("The comment clause never widens an inactive filter, and never reaches the trash lane row")
func theClauseIsNarrow() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeSearchBoard(fixture)
let model = try BoardLoader.load(boardRoot: fixture.root).model
let card = try #require(model.lanes.first?.cards.first)
// An empty query shows everything, index or no index.
#expect(SearchFilter(query: "", commentMatches: []).matches(card))
// A trashed lane row is matched by title alone it is an opaque unit with no thread of its
// own, so an id in the comment set cannot make one appear.
let lane = TrashedLane(
id: ItemID(rawValue: Ident.lane3),
schema: 1,
title: .valid("Retired"),
order: 1024,
heldCards: 2,
document: FrontmatterDocument(body: "")
)
#expect(!SearchFilter(query: "kestrel", commentMatches: [lane.id]).matches(lane))
}
@Test("Visible ids carry the comment match through to every surface that filters")
func visibleIDsCarryTheMatch() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeSearchBoard(fixture)
let model = try BoardLoader.load(boardRoot: fixture.root).model
let plain = SearchFilter(query: "kestrel").visibleIDs(in: model, container: .board)
#expect(!plain.contains(ItemID(rawValue: Ident.card1)))
#expect(plain.contains(ItemID(rawValue: Ident.card2)))
let withComments = SearchFilter(query: "kestrel", commentMatches: [ItemID(rawValue: Ident.card1)])
.visibleIDs(in: model, container: .board)
#expect(withComments.contains(ItemID(rawValue: Ident.card1)))
#expect(withComments.contains(ItemID(rawValue: Ident.card2)))
#expect(!withComments.contains(ItemID(rawValue: Ident.card3)))
}
}
// MARK: - The index' lifecycle
@MainActor
@Suite("Comment search ▸ the transient index")
struct CommentSearchIndexTests {
private func makeIndexed(_ fixture: WriterFixture) throws -> [CommentSearchTarget] {
try makeSearchBoard(fixture)
try writeComment(
fixture,
inCard: "\(Ident.lane1)/\(Ident.card1)",
named: CommentIdent.one,
body: kestrelBody
)
let model = try BoardLoader.load(boardRoot: fixture.root).model
return CommentSearchIndex.targets(in: model)
}
@Test("The first keystroke answers field-only, and refines when the sweep lands")
func refinesWhenReady() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let targets = try makeIndexed(fixture)
let index = CommentSearchIndex()
var refinements = 0
index.onRefine = { refinements += 1 }
index.update(query: "kestrel", generation: 1, targets: targets)
// Nothing yet: the sweep is I/O and does not run on the keystroke's actor. This is the
// documented moment where the board shows field-only matches.
#expect(index.matchingCards.isEmpty)
#expect(refinements == 0)
await index.awaitSweep()
#expect(index.matchingCards == [ItemID(rawValue: Ident.card1)])
#expect(refinements == 1)
}
@Test("A landed refinement that changes nothing does not re-run the selection constraint")
func silentWhenNothingChanged() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let targets = try makeIndexed(fixture)
let index = CommentSearchIndex()
var refinements = 0
index.onRefine = { refinements += 1 }
index.update(query: "no such bird", generation: 1, targets: targets)
await index.awaitSweep()
#expect(index.matchingCards.isEmpty)
#expect(refinements == 0)
}
@Test("A later keystroke re-filters in memory — no second sweep, and the answer is immediate")
func laterKeystrokesDoNotSweep() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let targets = try makeIndexed(fixture)
let index = CommentSearchIndex()
index.update(query: "kes", generation: 1, targets: targets)
await index.awaitSweep()
#expect(index.matchingCards == [ItemID(rawValue: Ident.card1)])
// Synchronously correct, with no await: the bodies are in hand, so a keystroke costs a
// predicate pass and no I/O at all.
index.update(query: "kestrel", generation: 1, targets: targets)
#expect(index.matchingCards == [ItemID(rawValue: Ident.card1)])
index.update(query: "kestrels", generation: 1, targets: targets)
#expect(index.matchingCards.isEmpty)
}
@Test("A new snapshot generation re-sweeps, and the fresh answer replaces the old one")
func generationChangeResweeps() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let targets = try makeIndexed(fixture)
let index = CommentSearchIndex()
index.update(query: "kestrel", generation: 1, targets: targets)
await index.awaitSweep()
#expect(index.matchingCards == [ItemID(rawValue: Ident.card1)])
// The comment is edited on disk out of the match the case an agent produces.
try writeComment(
fixture,
inCard: "\(Ident.lane1)/\(Ident.card1)",
named: CommentIdent.one,
body: "The falcon hovers instead.\n"
)
// The stale answer stands until the sweep lands results refine, they never blank.
index.update(query: "kestrel", generation: 2, targets: targets)
#expect(index.matchingCards == [ItemID(rawValue: Ident.card1)])
await index.awaitSweep()
#expect(index.matchingCards.isEmpty)
}
@Test("Clearing the query discards the index outright")
func clearingDiscards() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let targets = try makeIndexed(fixture)
let index = CommentSearchIndex()
index.update(query: "kestrel", generation: 1, targets: targets)
await index.awaitSweep()
#expect(!index.matchingCards.isEmpty)
index.update(query: "", generation: 1, targets: targets)
#expect(index.matchingCards.isEmpty)
// And the *bodies* went with it: re-activating at the same generation has to sweep again,
// which is observable as the field-only moment coming back.
index.update(query: "kestrel", generation: 1, targets: targets)
#expect(index.matchingCards.isEmpty)
await index.awaitSweep()
#expect(index.matchingCards == [ItemID(rawValue: Ident.card1)])
}
}
// MARK: - Through the store
@MainActor
@Suite("Comment search ▸ the store's funnel")
struct CommentSearchStoreTests {
@Test("Typing a query sweeps, and the card matching only through its comments joins the board")
func theQueryReachesComments() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeSearchBoard(fixture)
try writeComment(
fixture,
inCard: "\(Ident.lane1)/\(Ident.card1)",
named: CommentIdent.one,
body: kestrelBody
)
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "kestrel"
#expect(store.commentIndex.matchingCards.isEmpty)
await store.commentIndex.awaitSweep()
#expect(store.searchFilter.matches(try #require(card(Ident.card1, in: store))))
#expect(store.searchFilter.matches(try #require(card(Ident.card2, in: store))))
#expect(!store.searchFilter.matches(try #require(card(Ident.card3, in: store))))
}
@Test("Clearing the search discards the index — the board goes back to holding nothing")
func clearingTheSearchDiscards() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeSearchBoard(fixture)
try writeComment(
fixture,
inCard: "\(Ident.lane1)/\(Ident.card1)",
named: CommentIdent.one,
body: kestrelBody
)
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "kestrel"
await store.commentIndex.awaitSweep()
#expect(!store.commentIndex.matchingCards.isEmpty)
store.clearSearch()
#expect(store.commentIndex.matchingCards.isEmpty)
// Everything is visible again, which is what an inactive filter means.
#expect(store.searchFilter.matches(try #require(card(Ident.card3, in: store))))
}
@Test("A selection kept alive by a comment match is not evicted by the filter")
func commentMatchesKeepTheSelection() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeSearchBoard(fixture)
try writeComment(
fixture,
inCard: "\(Ident.lane1)/\(Ident.card1)",
named: CommentIdent.one,
body: kestrelBody
)
let store = try BoardStore(rootURL: fixture.root)
store.select([ItemID(rawValue: Ident.card1)], in: .board)
// The keystroke lands before the sweep does, so the card is hidden and leaves the selection
// 04's "hidden cards leave the selection", applied honestly to what is known at the time.
store.searchQuery = "kestrel"
#expect(store.selection.ids.isEmpty)
// A card selected *after* the sweep survives the next constraint, which is the half the
// refine seam exists to keep true.
await store.commentIndex.awaitSweep()
store.select([ItemID(rawValue: Ident.card1)], in: .board)
store.searchQuery = "kestrel h"
store.searchQuery = "kestrel"
#expect(store.selection.ids == [ItemID(rawValue: Ident.card1)])
}
private func card(_ id: String, in store: BoardStore) -> Card? {
store.snapshot.lanes.flatMap(\.cards).first { $0.id == ItemID(rawValue: id) }
}
}
+38
View File
@@ -199,6 +199,44 @@ struct UITestFixtureBoardTests {
#expect(richCard.attachments == [UITestLaunch.attachmentName]) #expect(richCard.attachments == [UITestLaunch.attachmentName])
} }
/// The rich card's **comment thread** what the comments-pane audit is an audit *of*
/// (10-accessibility.md Comments).
///
/// The three shapes are the point: an ordinary comment, an unattributed one, and an edited one.
/// Each drives a different branch of the author line a flattened comment element wears
/// (`CommentAuthorLine`), and a fixture that quietly lost one of them would leave the audit
/// passing over a thread of three identical rows.
@Test("The rich card carries a thread the pane's audit can read")
func fixtureSeedsAThread() throws {
UITestLaunch.prepareScratchDirectory()
defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) }
let root = try UITestLaunch.materializeFixtureBoard()
let model = try BoardLoader.load(boardRoot: root).model
let lane = model.lanes[UITestLaunch.richCardIndex.lane]
let card = try #require(lane.cards.first)
let cardFolder = ItemPath.card(lane: lane.id, id: card.id).folder(under: root)
let thread = CommentThread.load(inCard: cardFolder, path: "\(lane.id.rawValue)/\(card.id.rawValue)")
#expect(thread.comments.count == UITestLaunch.commentBodies.count)
#expect(thread.strays.isEmpty)
// Posting restamps `created`, so the thread's chronological order is the order they were made.
#expect(thread.comments.map(\.body) != [])
let unattributed = try #require(
thread.comments.first { $0.body.contains("no `author` key") }
)
#expect(unattributed.author.value == nil)
#expect(!unattributed.isEdited)
let edited = try #require(thread.comments.first { $0.body == UITestLaunch.editedCommentBody })
#expect(edited.isEdited, "the edited marker is `modified` differing from `created`, and no extra field")
#expect(edited.author.value != nil)
// The board walk never loads any of it the pane reads its own thread (01 Enhanced schema).
#expect(!card.body.contains("specimen thread"))
}
/// Everything the fixture launch writes stays inside the app's own container the sandbox /// Everything the fixture launch writes stays inside the app's own container the sandbox
/// constraint that decided the whole design (a path handed over on the command line would not be /// constraint that decided the whole design (a path handed over on the command line would not be
/// readable), stated as a test so a future "just use `/tmp`" cannot land quietly. /// readable), stated as a test so a future "just use `/tmp`" cannot land quietly.
@@ -125,6 +125,28 @@ final class AccessibilityAuditTests: XCTestCase {
try app.performAccessibilityAudit() try app.performAccessibilityAudit()
} }
/// **The comments pane**, over the fixture's seeded thread (10-accessibility.md Comments: the
/// labeled container, the flattened comment elements with their three custom actions, the labeled
/// composer and the Tab-reachable sort control).
///
/// The pane is on screen already View Show Comments is one persisted app-wide bit and its
/// shipped default is on (05-card-window.md The comments column) so this test's navigation is
/// the card window's, plus a wait on the container's own label to prove the pane rendered rather
/// than auditing a body column that happened to be alone.
///
/// The thread is three comments, one of them unattributed and one edited (`UITestLaunch`), which
/// is what makes this an audit of the *rows* rather than of an empty invitation.
@MainActor
func testCardWindowCommentsPane() throws {
let app = XCUIApplication.launchedWithFixtureBoard()
try app.openRichCardWindow()
XCTAssertTrue(
app.element(labeled: Phrase.comments(3)).waitForExistence(timeout: XCUIApplication.uiTimeout),
"the comments pane did not appear"
)
try app.performAccessibilityAudit()
}
// MARK: - Welcome, the template chooser, the board popover // MARK: - Welcome, the template chooser, the board popover
/// The welcome window, reached by its own Window-menu row and reached *after* the fixture board /// The welcome window, reached by its own Window-menu row and reached *after* the fixture board
+18 -3
View File
@@ -30,7 +30,7 @@ Have a scratch board to hand for Part 2 — a new one from File ▸ New Board…
## Part 1 — run the audit suite ## Part 1 — run the audit suite
`KanbanUITests/AccessibilityAuditTests.swift` runs Xcode's accessibility audit over all eight surfaces the design names. **Violations are test failures, not warnings**, and nothing is waived: the audits pass no issue handler at all. `KanbanUITests/AccessibilityAuditTests.swift` runs Xcode's accessibility audit over all nine surfaces the design names. **Violations are test failures, not warnings**, and nothing is waived: the audits pass no issue handler at all.
``` ```
xcodebuild test -project Kanban.xcodeproj -scheme Kanban \ xcodebuild test -project Kanban.xcodeproj -scheme Kanban \
@@ -38,7 +38,7 @@ xcodebuild test -project Kanban.xcodeproj -scheme Kanban \
-only-testing:KanbanUITests/AccessibilityAuditTests -only-testing:KanbanUITests/AccessibilityAuditTests
``` ```
The eight surfaces, and how each test gets there: The nine surfaces, and how each test gets there:
| Test | Surface | Navigation | | Test | Surface | Navigation |
| --- | --- | --- | | --- | --- | --- |
@@ -47,11 +47,12 @@ The eight surfaces, and how each test gets there:
| `testCardWindowPreviewMode` | Card window, Preview | ↓ then → to select, Board ▸ Open Card | | `testCardWindowPreviewMode` | Card window, Preview | ↓ then → to select, Board ▸ Open Card |
| `testCardWindowEditMode` | Card window, Edit | …then View ▸ Edit Body | | `testCardWindowEditMode` | Card window, Edit | …then View ▸ Edit Body |
| `testCardWindowRawSourceMode` | Card window, raw source outlet | …then View ▸ Raw Source | | `testCardWindowRawSourceMode` | Card window, raw source outlet | …then View ▸ Raw Source |
| `testCardWindowCommentsPane` | Card window, comments pane over a seeded thread | The pane is shown by default; the test waits on "Comments, 3" |
| `testWelcomeWindow` | Welcome, with a recents row | Window ▸ Welcome to Lanework | | `testWelcomeWindow` | Welcome, with a recents row | Window ▸ Welcome to Lanework |
| `testTemplateChooser` | Template chooser | File ▸ New Board… | | `testTemplateChooser` | Template chooser | File ▸ New Board… |
| `testBoardInfoPopover` | Board popover | File ▸ Board Info | | `testBoardInfoPopover` | Board popover | File ▸ Board Info |
Every test launches the app with `--ui-test-fixture-board`, which makes the app build a known board inside its own container and open it — three lanes ("To Do", "Doing", "Done"), six cards, one card with a rich Markdown body and an attachment, one card already in the trash. That is the `standard` fixture variant; the bare flag means it, and the other two shapes (`large`, `malformed`) belong to the end-to-end pass. The board and the registry both live in a scratch directory that is wiped on every launch, so an audit run never touches your real boards or your recents list. See `Kanban/App/UITestLaunch.swift` for why the board cannot simply be handed to the app on the command line (the sandbox). Every test launches the app with `--ui-test-fixture-board`, which makes the app build a known board inside its own container and open it — three lanes ("To Do", "Doing", "Done"), six cards, one card with a rich Markdown body, an attachment and a three-comment thread (one unattributed, one edited), one card already in the trash. That is the `standard` fixture variant; the bare flag means it, and the other two shapes (`large`, `malformed`) belong to the end-to-end pass. The board and the registry both live in a scratch directory that is wiped on every launch, so an audit run never touches your real boards or your recents list. See `Kanban/App/UITestLaunch.swift` for why the board cannot simply be handed to the app on the command line (the sandbox).
If a test fails, read the issue's `compactDescription` and fix the app. Adding a waiver is a design change and needs an entry on the Redesign board first. If a test fails, read the issue's `compactDescription` and fix the app. Adding a waiver is a design change and needs an entry on the Redesign board first.
@@ -131,6 +132,20 @@ The four implementation cards' manual items, consolidated. Each line is a claim
- [ ] **The banner row's spoken label is the banner's own sentence** — tone first, so a VoiceOver user hears *that* it is an error before hearing what it is: "Error: ⟨headline⟩". The strip's container reads as "Board status". - [ ] **The banner row's spoken label is the banner's own sentence** — tone first, so a VoiceOver user hears *that* it is an error before hearing what it is: "Error: ⟨headline⟩". The strip's container reads as "Board status".
- [ ] **Announcements never interrupt.** Start VoiceOver reading a long card body (VO-A), then trigger a foreign edit; the digest must wait its turn rather than cutting the reading off. - [ ] **Announcements never interrupt.** Start VoiceOver reading a long card body (VO-A), then trigger a foreign edit; the digest must wait its turn rather than cutting the reading off.
### 3.2b The comments pane
The pane ships with the comments feature; 10-accessibility.md ▸ Comments is the whole of what it owes. Open the fixture board's rich card ("Write the smoke script") — its thread carries an ordinary comment, an unattributed one, and an edited one on purpose.
- [ ] **The pane is a labeled container**: "Comments, 3", with the count matching the visible header ("Comments · 3"). It stays a container with zero comments — a comment-less card shows the empty thread and the composer, and reads "Comments, 0".
- [ ] **Each comment is one flattened element**: the author line as label ("⟨name⟩ · ⟨date⟩", ending "· edited" on the edited one, and the date alone on the unattributed one), the body as value with "N attachments" appended when it has files. The rendered body's paragraphs, links and code are not separately focusable — the row is one stop.
- [ ] **The three custom actions** (VO-⌘-Space, or the Actions rotor) are **Edit / Delete / Reveal in Finder**, the same three words the right-click menu carries. Edit opens the inline session — and while it is open the row stops being flattened, so the editor and its Save/Cancel buttons are reachable.
- [ ] **A comment's attachment chips stay reachable** beside the flattened element, with the sidebar's Quick Look behaviour.
- [ ] **The composer is a labeled text field** ("Add a comment"), and its paperclip reads "Attach Files". ⌘↩ posts from inside it.
- [ ] **The sort control is Tab-reachable** beside the count, labeled "Sort" with the direction as its value ("Oldest First" / "Newest First").
- [ ] **A foreign comment announces path-shaped.** With the card window open, add a comment folder to that card's `comments/` from a terminal: expect **"New comment on 'Write the smoke script'"**, once. Edit one of its files: **"Edit comment on '…'"**. Remove one: **"Delete comment on '…'"**. Then post, edit and delete comments *in the app* with VoiceOver running and expect total silence — the app never narrates its own writes.
- [ ] **⌘F follows focus.** Click into a comment's text and press ⌘F: the pane's find bar appears (not the body's). Type a word that occurs in two different comments and press ⌘G — the highlight moves *across* rows and the thread scrolls to it; ⇧⌘G steps back; at the last hit ⌘G wraps to the first. Done takes the bar down and the highlighting with it. Click into the composer and press ⌘F: the standard AppKit find bar appears over the composer alone. Click into the body and press ⌘F: the body's find bar, unchanged.
- [ ] **Full Keyboard Access reaches the pane's controls** with VoiceOver off: Tab must reach the sort control, the composer, its paperclip, the Comment button, and the find bar's field and chevrons when it is up.
### 3.3 Text scaling, visual accommodations, Full Keyboard Access ### 3.3 Text scaling, visual accommodations, Full Keyboard Access
- [ ] **Largest system text size**: System Settings ▸ Accessibility ▸ Display ▸ Text size, at maximum. Card faces, lane headers, masonry spacing, the trash hatch, the toolbar search field and both window minimum sizes all grow with it; nothing clips, nothing overlaps. - [ ] **Largest system text size**: System Settings ▸ Accessibility ▸ Display ▸ Text size, at maximum. Card faces, lane headers, masonry spacing, the trash hatch, the toolbar search field and both window minimum sizes all grow with it; nothing clips, nothing overlaps.
+5
View File
@@ -107,6 +107,11 @@ enum Phrase {
/// The inline title editor's placeholders (`NewCardStubView`, `LaneView`, `CardFaceView`). /// The inline title editor's placeholders (`NewCardStubView`, `LaneView`, `CardFaceView`).
static let cardTitlePrompt = "Card title" static let cardTitlePrompt = "Card title"
/// The comments pane's container label "Comments, N" (10-accessibility.md Comments).
static func comments(_ count: Int) -> String {
"Comments, \(count)"
}
} }
// MARK: - Driving the app // MARK: - Driving the app