Comments join attachments on the card face — a quiet bubble-and-count chip, present-only
A card whose thread holds one comment or more now draws a second trailing chip beside the paperclip: a secondary-tinted bubble glyph plus its count, shown only when the count is above zero (design ruling 2026-08-09, card e729e30a). Same styling family as the attachments chip — caption size, secondary tint, decorative and hidden outright from the accessibility tree — but this one carries a visible count rather than staying icon-only, per the ruling's own "bubble-style SF Symbol + count." It sits after the attachments chip at the row's trailing edge, in both the live title row and the drag replica. The count is a new `Card.commentCount` field the loader fills with a readdir over `comments/`'s identity-shaped children that carry their own `index.md` — `BoardLoader.commentCount(in:)`, built on the same `identityShapedChildren` predicate a trash entry's held-card count already uses. Never a parse: `.draft` and `.trash/` are excluded for free, the same dot-prefixed hidden-entry skip `CommentThread.load` documents for both, so the walk stays exactly the O(cards) shape 01-storage-format.md § Enhanced schema already commits to. Because the count rides inside the `card: Card` parameter `CardFaceView` already takes — not a new parameter of its own — drawing the chip costs nothing beyond a field read on an already-compared value: no new Observable read joins the body, and the equatable gate already covers it via `Card`'s synthesized `Equatable`. The one divergence from the comments pane's parsed count is documented rather than hidden: a comment folder whose `index.md` exists but fails to parse is a `Stray` the thread read excludes by opening and rejecting it, a cost this readdir does not pay. The face may then read one comment high until that folder is fixed or removed — the trade the ruling's "cheap directory-entry count… not a parse" asks for, over paying full parse cost on every card of every load. Every well-formed comment, and every card with no malformed one, agrees with the pane exactly. VoiceOver: `AccessibilityPhrases.cardValue` gains a `comments: Int` parameter, appended after attachments and before the cut-pending phrase — the same left-to-right order the two chips draw in, so a sighted read and a VoiceOver read never disagree about which comes first. The trashed lane row's own call site (an opaque unit with no comments to speak of) passes `comments: 0`. Docs: DESIGN/03-board-ui.md's card-face section describes both chips and retires the stale "closed with no growth" sentence, honestly recording the 2026-08-09 growth (the hero banner landed hours earlier, this chip after it) as exposure of facts the card already carries rather than a body excerpt. DESIGN/10-accessibility.md's flattened-element sentence gains the comment count. DESIGN/01-storage-format.md's Enhanced schema paragraph records the chip as shipped. WISHLIST #9 is marked shipped in place — not renumbered, since #10 and #11 are cross-referenced elsewhere. Tests: CardCommentCountListingTests (BoardLoaderTests.swift) pins the readdir against a synthetic tree — no comments/ folder, an empty one, non-identity-shaped and index-less strays excluded, .draft/.trash/ excluded for free, agreement with CommentThread.load's parsed count in the well-formed case, and the one documented divergence on a malformed index.md. AccessibilityPhrasesTests covers cardValue's new parameter alone, alongside attachments, and all three fragments together. ViewEquatableTests pins that a comment landing on a card is a gate difference. BoardRenderPerformanceTests adds a render-cost guard: one comment added to one card on a hosted 180-card board re-renders a handful of bodies, not the board. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -566,6 +566,144 @@ struct CardAttachmentListingTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card comment counts (design ruling 2026-08-09, card e729e30a)
|
||||
|
||||
/// `Card.commentCount` — the loader's cheap readdir over `comments/`, `CardAttachmentListingTests`'
|
||||
/// shape one folder over: what a synthetic tree can arrange that isn't worth a golden fixture (a
|
||||
/// non-identity-shaped stray, a comment folder missing its `index.md`, the two dot-named
|
||||
/// exclusions, and the one documented divergence from `CommentThread.load`'s parsed count).
|
||||
struct CardCommentCountListingTests {
|
||||
|
||||
/// `CardAttachmentListingTests.card(in:)`'s exact twin.
|
||||
private func card(in fixture: BoardFixture) throws -> Card {
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
return try #require(result.model.lanes.first?.cards.first)
|
||||
}
|
||||
|
||||
private func boardWithOneCard(_ fixture: BoardFixture) throws -> String {
|
||||
let lane = "10000000-0000-4000-8000-000000000001"
|
||||
let cardID = "20000000-0000-4000-8000-000000000002"
|
||||
try fixture.index("", "schema: 1\n")
|
||||
try fixture.index(lane, "schema: 1\norder: 1024\n")
|
||||
try fixture.index("\(lane)/\(cardID)", "schema: 1\norder: 1024\n")
|
||||
return "\(lane)/\(cardID)"
|
||||
}
|
||||
|
||||
@Test func aCardWithNoCommentsFolderCountsZero() throws {
|
||||
let fixture = try BoardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
_ = try boardWithOneCard(fixture)
|
||||
|
||||
#expect(try card(in: fixture).commentCount == 0)
|
||||
}
|
||||
|
||||
@Test func anEmptyCommentsFolderCountsZero() throws {
|
||||
let fixture = try BoardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let cardPath = try boardWithOneCard(fixture)
|
||||
try fixture.emptyFolder("\(cardPath)/comments")
|
||||
|
||||
#expect(try card(in: fixture).commentCount == 0)
|
||||
}
|
||||
|
||||
@Test func identityShapedFoldersWithIndexAreCounted() throws {
|
||||
let fixture = try BoardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let cardPath = try boardWithOneCard(fixture)
|
||||
|
||||
try fixture.index("\(cardPath)/comments/\(uuidFolderName())", "schema: 1\n")
|
||||
try fixture.index("\(cardPath)/comments/\(uuidFolderName())", "schema: 1\n")
|
||||
try fixture.index("\(cardPath)/comments/\(uuidFolderName())", "schema: 1\n")
|
||||
|
||||
#expect(try card(in: fixture).commentCount == 3)
|
||||
}
|
||||
|
||||
/// A hand-made folder — `notes`, say — under `comments/` is a stray to `CommentThread.load`
|
||||
/// (`.notIdentityShaped`), and is excluded here for the identical reason: only a name with a
|
||||
/// UUID's shape is a comment candidate at all (`identityShapedChildren`, shared with the
|
||||
/// trash entry's own held-card count).
|
||||
@Test func nonIdentityShapedFoldersAreExcluded() throws {
|
||||
let fixture = try BoardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let cardPath = try boardWithOneCard(fixture)
|
||||
|
||||
try fixture.index("\(cardPath)/comments/\(uuidFolderName())", "schema: 1\n")
|
||||
try fixture.index("\(cardPath)/comments/notes", "schema: 1\n")
|
||||
|
||||
#expect(try card(in: fixture).commentCount == 1)
|
||||
}
|
||||
|
||||
/// The two-step-create tolerance, one level down: a UUID-shaped folder with no `index.md` yet
|
||||
/// (`.missingIndex` to `CommentThread.load`) is not a comment to count either.
|
||||
@Test func identityShapedFoldersMissingIndexAreExcluded() throws {
|
||||
let fixture = try BoardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let cardPath = try boardWithOneCard(fixture)
|
||||
|
||||
try fixture.index("\(cardPath)/comments/\(uuidFolderName())", "schema: 1\n")
|
||||
try fixture.emptyFolder("\(cardPath)/comments/\(uuidFolderName())")
|
||||
|
||||
#expect(try card(in: fixture).commentCount == 1)
|
||||
}
|
||||
|
||||
/// `.draft` and `.trash/` are excluded **for free** — both dot-prefixed, and the loader's
|
||||
/// directory listing skips hidden entries, exactly the exclusion `CommentThread.load`
|
||||
/// documents for the same two folders. A trashed comment sitting inside `.trash/` must not
|
||||
/// inflate the count even though it is itself identity-shaped with a readable `index.md`.
|
||||
@Test func draftAndTrashAreExcludedForFree() throws {
|
||||
let fixture = try BoardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let cardPath = try boardWithOneCard(fixture)
|
||||
|
||||
try fixture.index("\(cardPath)/comments/\(uuidFolderName())", "schema: 1\n")
|
||||
try fixture.index("\(cardPath)/comments/.draft", "schema: 1\n")
|
||||
try fixture.index("\(cardPath)/comments/.trash/\(uuidFolderName())", "schema: 1\n")
|
||||
|
||||
#expect(try card(in: fixture).commentCount == 1)
|
||||
}
|
||||
|
||||
/// **The loader's count and `CommentThread.load`'s parsed count agree** in the well-formed
|
||||
/// case — the design ruling's "read how the comments pane counts and match it exactly" (card
|
||||
/// e729e30a), pinned the way `theSnapshotsListingAndTheWritersAreTheSameAnswer` pins the
|
||||
/// attachment listing's agreement one type over.
|
||||
@Test func theLoadersCountAgreesWithTheThreadsParsedCount() throws {
|
||||
let fixture = try BoardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let cardPath = try boardWithOneCard(fixture)
|
||||
|
||||
for _ in 0..<4 {
|
||||
try fixture.index("\(cardPath)/comments/\(uuidFolderName())", "schema: 1\n")
|
||||
}
|
||||
|
||||
let cardFolder = fixture.root.appendingPathComponent(cardPath, isDirectory: true)
|
||||
let thread = CommentThread.load(inCard: cardFolder, path: cardPath)
|
||||
#expect(thread.comments.count == 4)
|
||||
#expect(try card(in: fixture).commentCount == thread.comments.count)
|
||||
}
|
||||
|
||||
/// **The one documented divergence**: a comment whose `index.md` exists but fails to parse is
|
||||
/// a `Stray` to the thread read (`.unreadable`) — excluded from the pane's count — but the
|
||||
/// loader's readdir only checks that `index.md` exists, not that it parses, so the face may
|
||||
/// read one higher than the pane until the folder is fixed or removed (`Card.commentCount`'s
|
||||
/// own doc comment; `BoardLoader.commentCount(in:)`'s).
|
||||
@Test func aMalformedCommentIndexIsCountedByTheLoaderButNotByTheThread() throws {
|
||||
let fixture = try BoardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let cardPath = try boardWithOneCard(fixture)
|
||||
let commentID = uuidFolderName()
|
||||
// Unparseable YAML — `CommentThreadTests.brokenCommentsAreStrays`' exact shape.
|
||||
try fixture.strayFile(
|
||||
"\(cardPath)/comments/\(commentID)/index.md", contents: "---\nschema: 1\n bad: [\n---\nbody\n"
|
||||
)
|
||||
|
||||
let cardFolder = fixture.root.appendingPathComponent(cardPath, isDirectory: true)
|
||||
let thread = CommentThread.load(inCard: cardFolder, path: cardPath)
|
||||
#expect(thread.comments.isEmpty)
|
||||
#expect(thread.strays.count == 1)
|
||||
#expect(try card(in: fixture).commentCount == 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ItemID value semantics (01-storage-format.md § Fractal layout ▸ Rules, "Identity
|
||||
// comparison is UUID-value equality, never string equality")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user