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:
@@ -362,6 +362,11 @@ struct CardWindowHost: View {
|
||||
attachments.cardFolder = folder
|
||||
session.comments.cardFolder = folder
|
||||
}
|
||||
// The announcer's subject, re-derived from every snapshot for the folder's reason: a card
|
||||
// renamed mid-session is announced under its new name ("New comment on '⟨card⟩'").
|
||||
.onChange(of: placement.card.title.value, initial: true) { _, title in
|
||||
session.comments.cardTitle = title
|
||||
}
|
||||
.onChange(of: store.isReadOnly, initial: true) { _, locked in
|
||||
attachments.isEditable = !locked
|
||||
session.comments.isEditable = !locked
|
||||
@@ -372,13 +377,15 @@ struct CardWindowHost: View {
|
||||
// *Any* reload, not a filtered one, and that is a deliberate choice worth stating: the
|
||||
// store's observable surface publishes `snapshotGeneration` and a `BoardModel` — it does
|
||||
// not vend the changed paths, and comments are outside the snapshot entirely
|
||||
// (01-storage-format.md § Enhanced schema), so there is nothing here to run
|
||||
// `CommentPath.classify` against. Re-reading one card's thread is a handful of small
|
||||
// files and happens only while a card window is open; filtering would mean either
|
||||
// widening the store's surface to carry paths, or the pane keeping its own watcher — a
|
||||
// second stream over the same tree, which the one-way flow rules out. `initial:` is
|
||||
// deliberately absent: `start()` already did the opening read, after the residue sweep
|
||||
// that has to precede it.
|
||||
// (01-storage-format.md § Enhanced schema), so there is nothing to filter *on* here.
|
||||
// Re-reading one card's thread is a handful of small files and happens only while a card
|
||||
// window is open; filtering would mean either widening the store's surface to carry paths,
|
||||
// or the pane keeping its own watcher — a second stream over the same tree, which the
|
||||
// one-way flow rules out. The path shape is read on the other side of the re-read instead,
|
||||
// where there *are* two pictures to compare: the pane diffs its threads and consumes the
|
||||
// ledger's comment receipts through `CommentPath.classify` to tell a foreign arrival from
|
||||
// its own echo (`CardComments.reload`). `initial:` is deliberately absent: `start()`
|
||||
// already did the opening read, after the residue sweep that has to precede it.
|
||||
.onChange(of: store.snapshotGeneration) { _, _ in
|
||||
session.comments.reload()
|
||||
}
|
||||
@@ -577,6 +584,13 @@ struct CardWindowHost: View {
|
||||
comments.composer.post = { [weak store] in
|
||||
store?.postComment(inCard: cardID)
|
||||
}
|
||||
// The announcer's gate: which of this thread's changes the app itself wrote, consumed once per
|
||||
// reload (10-accessibility.md — "app-mediated echoes never announce", per comment). A store
|
||||
// that has gone vouches for nothing, which is the conservative direction and also the one the
|
||||
// announcement cannot reach anyway — a released store has no window left to speak in.
|
||||
comments.vouchedComments = { [weak store] in
|
||||
store?.vouchedComments(inCard: cardID) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// Points the attachments section at its card — **the one place Add Attachment… and Remove
|
||||
|
||||
@@ -54,14 +54,36 @@ struct FutureCommand: View {
|
||||
/// "disabled in the board window — board search is a live filter, not a cursor"
|
||||
/// (11-command-nexus.md).
|
||||
///
|
||||
// m6-card-window: joins `FindCommand` in the Edit menu once the card window's find-in-text exists
|
||||
// (05-card-window.md). Both rows are unconditionally disabled here rather than reading `boardStore`
|
||||
// to prove "board window" disables them: there is no card-window find session anywhere yet for
|
||||
// either validation branch to check.
|
||||
/// ### They are live for exactly one find, and disabled for the others on purpose
|
||||
///
|
||||
/// The card window has three finds (`CardWindowFindRoute`), and two of them are **`NSTextFinder`**'s —
|
||||
/// the body's and an authoring editor's. `NSTextView` already answers ⌘G and ⇧⌘G through the responder
|
||||
/// chain, and an *enabled* menu item's key equivalent fires before the responder chain is consulted,
|
||||
/// so a row that claimed the chord unconditionally would break the stepping it exists to provide. So
|
||||
/// these validate on the **thread** find alone — the one find with no responder to fall through to,
|
||||
/// because its bar is the app's own — and stay disabled everywhere else, which lets the platform's
|
||||
/// stepping keep working where the platform owns the find.
|
||||
///
|
||||
/// `.disabled` on the rows rather than a guard in the action, for the reason every menu row here
|
||||
/// wears its validation: a key equivalent that fires and does nothing is a chord the user cannot tell
|
||||
/// from a broken one.
|
||||
struct FindSteppingCommands: View {
|
||||
|
||||
@FocusedValue(\.cardComments) private var comments
|
||||
|
||||
/// The row's validation, as a value a test can hold: the pane's find bar is up, which is the only
|
||||
/// state in which this app owns ⌘G.
|
||||
static func isEnabled(_ comments: CardComments?) -> Bool {
|
||||
comments?.find.isShowing == true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
FutureCommand(title: "Find Next", key: "g", modifiers: .command)
|
||||
FutureCommand(title: "Find Previous", key: "g", modifiers: [.shift, .command])
|
||||
Button("Find Next") { comments?.find.step(forward: true) }
|
||||
.keyboardShortcut("g", modifiers: .command)
|
||||
.disabled(!Self.isEnabled(comments))
|
||||
Button("Find Previous") { comments?.find.step(forward: false) }
|
||||
.keyboardShortcut("g", modifiers: [.shift, .command])
|
||||
.disabled(!Self.isEnabled(comments))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -334,6 +334,40 @@ enum UITestLaunch {
|
||||
The audit fixture's attachment. Its only job is to exist, so the attachments section has a row.
|
||||
"""
|
||||
|
||||
/// **The rich card's comment thread** — three comments, because the pane's accessibility audit
|
||||
/// needs one of each shape 10-accessibility.md's comments row can take (▸ Comments: "each comment
|
||||
/// is one flattened element — author, date, edited state, body").
|
||||
///
|
||||
/// In order: an ordinary comment (author line, date, body), an **authorless** one (the date alone
|
||||
/// carries the line — "missing renders unattributed", and no placeholder stands in for a name),
|
||||
/// and one that has been **edited** (its author line ends "· edited", which is `modified`
|
||||
/// differing from `created` and no extra field). Between them they cover every branch of
|
||||
/// `CommentAuthorLine.text(author:timestamp:isEdited:)` that a written file can produce.
|
||||
static let commentBodies = [
|
||||
"""
|
||||
The audit's specimen thread. This one is ordinary: a name, a date, and a paragraph of \
|
||||
Markdown with some *emphasis* in it.
|
||||
""",
|
||||
"""
|
||||
This one has no `author` key at all, so it renders unattributed — a date and a body, and no \
|
||||
placeholder standing in for a name.
|
||||
""",
|
||||
"""
|
||||
And this one has been edited since it was posted, so its author line carries the edited marker.
|
||||
"""
|
||||
]
|
||||
|
||||
/// Which comment gets its `author` key removed, by index into `commentBodies`.
|
||||
static let authorlessCommentIndex = 1
|
||||
|
||||
/// Which comment is edited after posting, by index into `commentBodies`, and what it is edited to.
|
||||
static let editedCommentIndex = 2
|
||||
|
||||
static let editedCommentBody = """
|
||||
And this one has been edited since it was posted, so its author line carries the edited \
|
||||
marker — this sentence is the edit.
|
||||
"""
|
||||
|
||||
/// The card that is deleted into `.trash/`, named by `(lane, card)` index.
|
||||
///
|
||||
/// A trash with something in it is the only way the trash-shown audit reaches the elements
|
||||
@@ -460,6 +494,7 @@ enum UITestLaunch {
|
||||
let richCard = cardURLs[richCardIndex.lane][richCardIndex.card]
|
||||
try BoardWriter.writeBody(inItemFolder: richCard, body: richCardBody)
|
||||
try importFixtureAttachment(into: richCard)
|
||||
try seedCommentThread(into: richCard, cardTitle: cardTitles[richCardIndex.lane][richCardIndex.card])
|
||||
|
||||
// The delete goes last so the trashed card's identity is one the lanes above have already
|
||||
// finished with — and through the ordinary delete door, so `.trash/` ends up holding exactly
|
||||
@@ -534,6 +569,63 @@ enum UITestLaunch {
|
||||
return root
|
||||
}
|
||||
|
||||
/// Builds the rich card's thread **the way the composer does** — a draft saved, then posted, once
|
||||
/// per body — so the fixture's `comments/` is a folder the app made: minted identities, the
|
||||
/// `kind: comment` field table, `created`/`modified` restamped at the post.
|
||||
///
|
||||
/// Two of the three then need a shape no gesture in the app produces, and each is applied
|
||||
/// afterwards, narrowly:
|
||||
///
|
||||
/// - **The edit** is an ordinary Writer call (`editComment`), which is exactly what an inline edit
|
||||
/// session's save does — so the edited marker in the fixture is the real mechanism, not a
|
||||
/// hand-set field.
|
||||
/// - **The two frontmatter amendments** are raw writes, for the malformed variant's reason (see
|
||||
/// this type's note). Neither shape has a door in the app: it always writes the account's full
|
||||
/// name, and it cannot post a comment an hour ago. Both shapes are ordinary on disk, though —
|
||||
/// agents and tracker sync write comments with no `author` at all (01-storage-format.md ▸
|
||||
/// Enhanced schema), and every comment that has ever been edited was posted before it. Both go
|
||||
/// through `FrontmatterDocument`, so the rest of each file is byte-identical to what the Writer
|
||||
/// produced.
|
||||
///
|
||||
/// The backdating is not decoration: `created` and `modified` serialize to the second, and a
|
||||
/// comment posted and edited inside one second would render as **not** edited — the marker is
|
||||
/// `modified` differing from `created` and no extra field (`Comment.isEdited`).
|
||||
private static func seedCommentThread(into cardFolder: URL, cardTitle: String) throws {
|
||||
var posted: [ItemID] = []
|
||||
for body in commentBodies {
|
||||
try BoardWriter.saveCommentDraft(inCard: cardFolder, body: body, cardTitle: cardTitle)
|
||||
posted.append(try BoardWriter.postComment(inCard: cardFolder, cardTitle: cardTitle).id)
|
||||
}
|
||||
|
||||
try BoardWriter.editComment(
|
||||
at: CommentThread.commentFolder(posted[editedCommentIndex], inCard: cardFolder),
|
||||
body: editedCommentBody,
|
||||
cardTitle: cardTitle
|
||||
)
|
||||
|
||||
try amendComment(posted[authorlessCommentIndex], inCard: cardFolder) { document in
|
||||
document.remove(FrontmatterKeys.author)
|
||||
}
|
||||
try amendComment(posted[editedCommentIndex], inCard: cardFolder) { document in
|
||||
document.set(FrontmatterKeys.created, to: .date(Date().addingTimeInterval(-3600)))
|
||||
}
|
||||
}
|
||||
|
||||
/// One comment's frontmatter, amended in place — the fixture's narrow way around the Writer, kept
|
||||
/// to one function so both amendments share its round trip and neither invents a second one.
|
||||
private static func amendComment(
|
||||
_ id: ItemID,
|
||||
inCard cardFolder: URL,
|
||||
_ amend: (inout FrontmatterDocument) -> Void
|
||||
) throws {
|
||||
let indexURL = CommentThread
|
||||
.commentFolder(id, inCard: cardFolder)
|
||||
.appendingPathComponent(BoardLoader.indexFileName, isDirectory: false)
|
||||
var document = try FrontmatterDocument.parse(String(decoding: try Data(contentsOf: indexURL), as: UTF8.self))
|
||||
amend(&document)
|
||||
try Data(document.serialized().utf8).write(to: indexURL, options: .atomic)
|
||||
}
|
||||
|
||||
/// Writes the attachment's source into the scratch root and imports it the way a Finder drop
|
||||
/// would (`BoardWriter.importAttachments`), so the card ends up with a real `attachments/`
|
||||
/// folder rather than a hand-placed file the loader would have to normalize.
|
||||
|
||||
Reference in New Issue
Block a user