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:
@@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user