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:
@@ -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