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
+26 -5
View File
@@ -236,11 +236,14 @@ public struct Lane: Identifiable, Sendable, Equatable {
public var isDeleted: Bool { !deleted.isMissing }
}
/// A card: `<root>/<guid>/<guid>/index.md`, plus the *names* of its attachments. Structurally
/// still a leaf `comments/` (future, out-of-scope) and the attachment files' contents live
/// alongside `index.md` on disk and are not modeled here; `attachments` is the one thing the
/// snapshot reaches inside a card folder for, because two board-window surfaces need it before
/// any card window exists (see its own doc comment).
/// A card: `<root>/<guid>/<guid>/index.md`, plus the *names* of its attachments and a *count* of
/// its comments. Structurally still a leaf the attachment files' contents and every comment's
/// own frontmatter and body live alongside `index.md` on disk and are not modeled here; `comments/`
/// stays window-scoped exactly as 01-storage-format.md § Enhanced schema rules ("the board snapshot
/// never loads comment content"), and `commentCount` does not change that it is a readdir, not a
/// parse. `attachments` and `commentCount` are what the snapshot reaches inside a card folder for,
/// because board-window surfaces need them before any card window exists (see each field's own doc
/// comment).
public struct Card: Identifiable, Sendable, Equatable {
public let id: ItemID
@@ -293,6 +296,24 @@ public struct Card: Identifiable, Sendable, Equatable {
/// `index.md` does no separate invalidation path to keep honest.
public let attachments: [String]
/// The card's comment count **a readdir, not a parse** (design ruling 2026-08-09, card
/// e729e30a; WISHLIST #9's own suggested shape). Counts `comments/`'s identity-shaped children
/// that carry a readable `index.md` (`BoardLoader.commentCount(in:)`, `identityShapedChildren`'s
/// pattern) the same cost class as `attachments` above, so the walk stays O(cards) exactly as
/// 01-storage-format.md § Enhanced schema requires. It agrees with `CommentThread.load`'s parsed
/// count in the overwhelming case; the one divergence is a comment whose `index.md` exists but
/// fails to parse (bad YAML, non-UTF-8), which the thread read excludes as a `Stray` and this
/// count does not pay to detect the face may then read one comment high until that folder is
/// fixed or removed. `.draft` and `.trash/` are excluded for free, the way they are everywhere
/// else this thread is read: both are dot-prefixed, and the loader's directory listing skips
/// hidden entries.
///
/// The board-window comments pane feeds the face's chip nothing this field is the one and only
/// source, so a chip and the pane it opens onto can never quietly show two different numbers for
/// the same reason (the divergence above aside, which is a stray on disk, not a bug in either
/// reader).
public let commentCount: Int
/// The full parsed `index.md`; unknown/reserved keys ride along uninterpreted.
public let document: FrontmatterDocument