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:
2026-08-09 01:21:33 -04:00
parent 9e6f4567df
commit c87616f3fb
14 changed files with 405 additions and 48 deletions
+45 -10
View File
@@ -81,27 +81,62 @@ struct AccessibilityPhrasesTests {
/// and an empty AXValue speaks as nothing.
@Test("A plain card carries no value")
func cardValueEmpty() {
#expect(AccessibilityPhrases.cardValue(attachments: 0, isCutPending: false).isEmpty)
#expect(AccessibilityPhrases.cardValue(attachments: 0, comments: 0, isCutPending: false).isEmpty)
}
@Test("Attachments ride the value, plural-folded")
func cardValueAttachments() {
#expect(AccessibilityPhrases.cardValue(attachments: 1, isCutPending: false) == "1 attachment")
#expect(AccessibilityPhrases.cardValue(attachments: 4, isCutPending: false) == "4 attachments")
#expect(
AccessibilityPhrases.cardValue(attachments: 1, comments: 0, isCutPending: false)
== "1 attachment"
)
#expect(
AccessibilityPhrases.cardValue(attachments: 4, comments: 0, isCutPending: false)
== "4 attachments"
)
}
/// Comments ride the value too (design ruling 2026-08-09, card e729e30a) the same plural
/// folding the comments pane's own header uses (`commentCount`), so a face value and the pane
/// can never disagree about how "1 comment" reads.
@Test("Comments ride the value, plural-folded")
func cardValueComments() {
#expect(
AccessibilityPhrases.cardValue(attachments: 0, comments: 1, isCutPending: false)
== "1 comment"
)
#expect(
AccessibilityPhrases.cardValue(attachments: 0, comments: 3, isCutPending: false)
== "3 comments"
)
}
@Test("A cut-pending card says so")
func cardValueCutPending() {
#expect(AccessibilityPhrases.cardValue(attachments: 0, isCutPending: true) == "cut, pending paste")
#expect(
AccessibilityPhrases.cardValue(attachments: 0, comments: 0, isCutPending: true)
== "cut, pending paste"
)
}
/// Both fragments in one value, attachments first: the count is a fact about the card, the cut
/// is a fact about what is about to happen to it.
@Test("A cut card with files carries both fragments")
func cardValueBoth() {
/// Attachments and comments together, attachments first the chips' own left-to-right order on
/// the face (`CardFaceView.titleRow`).
@Test("Attachments and comments both ride the value, attachments first")
func cardValueAttachmentsAndComments() {
#expect(
AccessibilityPhrases.cardValue(attachments: 2, isCutPending: true)
== "2 attachments, cut, pending paste"
AccessibilityPhrases.cardValue(attachments: 2, comments: 3, isCutPending: false)
== "2 attachments, 3 comments"
)
}
/// All three fragments in one value, in the fixed order: attachments, then comments, then the
/// cut-pending phrase last, since it describes what is about to happen rather than a fact about
/// the card's own content.
@Test("A cut card with files and comments carries all three fragments")
func cardValueAllThree() {
#expect(
AccessibilityPhrases.cardValue(attachments: 2, comments: 1, isCutPending: true)
== "2 attachments, 1 comment, cut, pending paste"
)
}
+138
View File
@@ -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")
@@ -330,6 +330,40 @@ struct BoardRenderPerformanceTests {
"a one-card external edit re-rendered \(edit.containers) bodies for \(laneCount) containers")
}
/// **The comments chip's render-cost guard** (design ruling 2026-08-09, card e729e30a): a
/// comment landing on one card must cost this suite's card budget, not the board's proof that
/// `Card.commentCount` riding inside the already-compared `card` parameter (no new
/// `CardFaceView` parameter, no new Observable read) actually holds under a real hosted board,
/// not just in the equality-gate unit tests (`ViewEquatableTests.aCommentCountChangeIsADifference`
/// pins the gate; this pins what the gate is *for*). Structurally `aOneCardEditIsNotAWholeBoardRebuild`,
/// a comment folder in place of an edited `index.md`.
@Test("A comment added to one card re-renders a handful of bodies, not the board")
func aOneCommentAddedIsNotAWholeBoardRebuild() async throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let board = try host(fixture)
let total = laneCount * cardsPerLane
// A foreign comment arriving under exactly one card the shape a card window's post, or an
// agent's own `mkdir` + `index.md`, leaves on disk.
try fixture.item(
"\(laneName(3))/\(cardName(3, 17))/comments/\(UUID().uuidString.lowercased())",
"---\nschema: 1\n---\nA comment.\n"
)
BoardRenderMetrics.reset()
await board.reload()
let commented = Cost()
print("── reload, ONE comment added — \(commented.summary)")
#expect(board.store.snapshotGeneration > 0, "the comment never landed")
#expect(commented.cards <= 8,
"one comment on one card re-rendered \(commented.cards) of \(total) card faces")
#expect(commented.containers <= laneCount,
"one comment on one card re-rendered \(commented.containers) bodies for \(laneCount) containers")
}
@Test("Every lane re-runs on a one-card edit, and not because its gate compared unequal")
func theLaneCostFollowsTheBoard() async throws {
let wide = laneCount * 2
+31
View File
@@ -342,6 +342,37 @@ struct CardFaceViewEquatableTests {
#expect(face(sketch) != face(fixture.root.appendingPathComponent("cover.png")))
}
/// **The comment count is a difference too** (design ruling 2026-08-09, card e729e30a) but
/// unlike the hero it needs no parameter of its own: it rides inside `card`, a snapshot field
/// the loader fills with a readdir (`Card.commentCount`), so this pins that `Card`'s synthesized
/// `Equatable` is not quietly excluding it from the gate the way a hand-written `==` might.
@Test("A comment added to the card is a difference")
func aCommentCountChangeIsADifference() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = DragSession()
let registry = LaneDropRegistry()
let marquee = MarqueeControl(
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
)
let drops = makeDrops(store: store, session: session, registry: registry)
let before = try firstCard(fixture.snapshot())
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)/comments/\(UUID().uuidString.lowercased())",
"---\nschema: 1\n---\n"
)
let after = try firstCard(fixture.snapshot())
#expect(before.commentCount == 0)
#expect(after.commentCount == 1)
#expect(CardFaceView(store: store, card: before, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
!= CardFaceView(store: store, card: after, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1))
}
@Test("An edited card is unequal — the gate never withholds a repaint")
func anEditedCardIsADifference() throws {
let fixture = try makeFixture()