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: ", ")
}
+10 -1
View File
@@ -185,11 +185,20 @@ enum BoardMetrics {
em(0.75, bodyPointSize: bodyPointSize)
}
/// Between the icon, the title and the attachments chip.
/// Between the icon, the title and the two trailing chips attachments, then comments
/// (`CardFaceView.titleRow`; the comments chip joined 2026-08-09).
static func cardRowSpacing(bodyPointSize: CGFloat) -> CGFloat {
em(0.45, bodyPointSize: bodyPointSize)
}
/// Inside the comments chip only: between its bubble glyph and its count text
/// (`CardFaceView.commentsIndicator`) tighter than `cardRowSpacing`, which separates the
/// face's own row items, because this is one chip's internal rhythm, closer to a badge's own
/// glyph-to-digit spacing than to a row gap.
static func chipGlyphSpacing(bodyPointSize: CGFloat) -> CGFloat {
em(0.2, bodyPointSize: bodyPointSize)
}
/// The masonry's spacing between interior columns and between stacked cards within a column.
///
/// The lane registers this into `LaneDropRegistry.Grid`, so the drop model's analytic resting
+50 -12
View File
@@ -82,8 +82,9 @@ enum CardFaceRole: Sendable {
// MARK: - Card face
/// The card face: a rounded plate carrying a leading SF Symbol, the title (or its quiet "Untitled"
/// placeholder), a quiet trailing attachments indicator, and a left-edge colour accent stripe
/// (03-board-ui.md § Card face, § Styling Capabilities).
/// placeholder), two quiet trailing chips attachments and comments, each present-only and a
/// left-edge colour accent stripe (03-board-ui.md § Card face, § Styling Capabilities). The
/// comments chip joined 2026-08-09 (design ruling, card e729e30a).
///
/// ### One face, two containers
///
@@ -96,10 +97,13 @@ enum CardFaceRole: Sendable {
///
/// ### Title-only, deliberately
///
/// **No body excerpt** settled, "the face stays title-only the old 'iterate on the card face
/// later' item is closed with no growth". The only face chip in scope is attachments, "a quiet
/// indicator when the card has files the title dominates", which is why the paperclip is a
/// secondary-tinted caption and not a count pill: the eye should land on the title.
/// **No body excerpt** settled, and still true after 2026-08-09's growth (the hero banner, the
/// comments chip): the face never draws a preview of the card's own prose, and never will what
/// grew is exposure of facts the card already carries structurally (an attachment named as hero, a
/// folder count), not content. Two face chips are in scope: **attachments**, "a quiet indicator when
/// the card has files the title dominates", and **comments**, its same-vocabulary twin a
/// secondary-tinted glyph (plus a count, for comments see `commentsIndicator`) rather than a count
/// pill, so the eye still lands on the title first.
///
/// ### Two lenient fields, two different fallbacks
///
@@ -356,11 +360,12 @@ struct CardFaceView: View, Equatable {
// `isRenaming` is (`CardFaceRole`).
.accessibilityElement(children: isRenaming ? .contain : .ignore)
.accessibilityLabel(AccessibilityPhrases.cardLabel(title: card.title.value))
// The attachment count, the deferred cut's "cut, pending paste", or both and the empty
// string when neither, which speaks as nothing (see `AccessibilityPhrases.cardValue` for why
// it is not a conditional modifier).
// The attachment count, the comment count, the deferred cut's "cut, pending paste", or any
// mix of the three and the empty string when none apply, which speaks as nothing (see
// `AccessibilityPhrases.cardValue` for why it is not a conditional modifier).
.accessibilityValue(AccessibilityPhrases.cardValue(
attachments: card.attachments.count,
comments: card.commentCount,
isCutPending: store.transient.pendingCut.ids.contains(card.id)
))
// "Selection state is always readable from the element (trait)" the other half of "state
@@ -652,6 +657,7 @@ struct CardFaceView: View, Equatable {
.lineLimit(4)
.frame(maxWidth: .infinity, alignment: .leading)
attachmentsIndicator
commentsIndicator
}
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
.padding(.leading, stripeWidth)
@@ -861,10 +867,11 @@ struct CardFaceView: View, Equatable {
.foregroundStyle(iconTint)
.imageScale(.medium)
titleOrEditor
// The title takes the row's width so the indicator sits hard against the trailing
// The title takes the row's width so the chips sit hard against the trailing
// edge and so the rename field fills the same span the title occupied.
.frame(maxWidth: .infinity, alignment: .leading)
attachmentsIndicator
commentsIndicator
}
}
@@ -908,8 +915,8 @@ struct CardFaceView: View, Equatable {
}
}
/// The one face chip in scope shown only when the card actually has files, and quiet enough
/// that the title still dominates (03-board-ui.md § Card face). The count goes to the
/// One of the two face chips in scope shown only when the card actually has files, and quiet
/// enough that the title still dominates (03-board-ui.md § Card face). The count goes to the
/// accessibility *value* rather than onto the face: it is useful to know, not to look at.
///
/// **Decorative, and hidden outright** (10-accessibility.md): "face icon and chips are
@@ -927,6 +934,37 @@ struct CardFaceView: View, Equatable {
}
}
/// The comments chip attachments' twin, joined 2026-08-09 (design ruling, card e729e30a):
/// shown only when the card has one comment or more, same secondary-tinted, decorative,
/// present-only vocabulary as `attachmentsIndicator`. **Carries a visible count**, unlike the
/// paperclip, because the ruling asks for "a quiet indicator (bubble-style SF Symbol + count)"
/// still quiet (caption size, secondary tint, no pill background), just not icon-only; the count
/// answers "how many" the way a lane's own card-count badge does one level up, without spending a
/// tap to find out.
///
/// **`card.commentCount` is the one and only source** a snapshot field the loader fills with a
/// readdir (`BoardLoader.commentCount(in:)`, `Card.commentCount`'s own doc comment), never a
/// per-face parse so drawing this chip costs nothing beyond reading a field already on the
/// compared `card` parameter (RENDER-INSTRUMENTATION.md Selection is O(board) in card bodies:
/// no new Observable read joins this body, and no new `CardFaceView` parameter was needed either,
/// since the count already rides inside `card`).
///
/// **Decorative and hidden outright**, `attachmentsIndicator`'s exact reasons: the flattened
/// element carries the comment count in its value (`AccessibilityPhrases.cardValue`), so a label
/// here would be redundant even before the flattening drops it.
@ViewBuilder
private var commentsIndicator: some View {
if card.commentCount > 0 {
HStack(spacing: BoardMetrics.chipGlyphSpacing(bodyPointSize: pointSize)) {
Image(systemName: "bubble")
Text("\(card.commentCount)")
}
.boardFont(.caption)
.foregroundStyle(.secondary)
.accessibilityHidden(true)
}
}
/// K1 · left edge stripe, painted with the resolved `background` "a card's [colour paints] a
/// stripe along its left edge; the surfaces themselves keep the standard chrome, so coloured
/// title text never sits on a coloured fill" (03-board-ui.md § Styling Capabilities).
+3 -1
View File
@@ -168,9 +168,11 @@ struct TrashLaneRowView: View, Equatable {
.accessibilityElement(children: .ignore)
.accessibilityLabel(AccessibilityPhrases.trashedLaneLabel(title: lane.title.value, cards: lane.heldCards))
// The cut-pending phrase, on the row's value the card element's rule, minus the attachment
// count an opaque unit has no answer for.
// and comment counts an opaque unit has no answer for (comments are card-level only, and a
// trashed lane's cards are not individually addressable).
.accessibilityValue(AccessibilityPhrases.cardValue(
attachments: 0,
comments: 0,
isCutPending: store.transient.pendingCut.ids.contains(lane.id)
))
.accessibilityAddTraits(isSelected ? [.isSelected] : [])