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
+15 -5
View File
@@ -128,17 +128,27 @@ enum AccessibilityPhrases {
/// the dim is the sighted signal, this is the other one.
static let cutPending = "cut, pending paste"
/// A card element's value the attachment count when it has files, the cut-pending phrase when
/// it is staged for paste, both when both, and **the empty string when neither**.
/// A card element's value the attachment count and the comment count when the card has either
/// (design ruling 2026-08-09, card e729e30a the comments chip's face value gains "N comments"
/// beside the existing attachment wording), the cut-pending phrase when it is staged for paste,
/// any mix of the three, and **the empty string when none apply**.
///
/// **Attachments before comments**, matching the chips' own left-to-right order on the face
/// (`CardFaceView.titleRow`: attachments, then comments) one reading order for the two
/// surfaces, so a sighted user's eye and a VoiceOver user's ear never disagree about which comes
/// first. `commentCount` is `AccessibilityPhrases`' own the pane's plural folding, reused
/// rather than restated, so a face value and the comments pane's header can never fold "1
/// comment" two different ways.
///
/// Empty rather than `nil` on purpose: the modifier that consumes it is unconditional, because a
/// `if` around `.accessibilityValue` would put the whole card face inside a `_ConditionalContent`
/// that flips identity and therefore rebuilds the face, dropping its measured height and its
/// marquee registration the moment an attachment lands or a cut is pasted. An empty AXValue
/// speaks as nothing, which is exactly what "no value" should sound like.
static func cardValue(attachments: Int, isCutPending: Bool) -> String {
/// marquee registration the moment an attachment lands, a comment posts, or a cut is pasted. An
/// empty AXValue speaks as nothing, which is exactly what "no value" should sound like.
static func cardValue(attachments: Int, comments: Int, isCutPending: Bool) -> String {
var parts: [String] = []
if attachments > 0 { parts.append(attachmentCount(attachments)) }
if comments > 0 { parts.append(commentCount(comments)) }
if isCutPending { parts.append(cutPending) }
return parts.joined(separator: ", ")
}