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
+47 -8
View File
@@ -25,12 +25,18 @@ import os
/// shape rule `attachments` and `comments` are non-UUID-shaped and would read as strays, not
/// levels, so they never need special-casing against the stray warning.
///
/// **Two reads inside a card folder**, both of them flat name listings and nothing more neither
/// opens a file, descends, warns, or fails a load; each degrades to `[]`:
/// **Three reads inside a card folder**, all of them flat directory listings and nothing more
/// none opens a file's *contents*, descends past one level, warns, or fails a load; each degrades to
/// its empty answer (`[]` or `0`):
///
/// - `attachmentNames(in:)` `attachments/`, feeding `Card.attachments`. The board window's face
/// needs it before a card window exists (the quiet paperclip indicator 03-board-ui.md § Card
/// face), and the snapshot is where it reads from.
/// - `commentCount(in:)` `comments/`, feeding `Card.commentCount` (design ruling 2026-08-09, card
/// e729e30a). A count of identity-shaped children carrying `index.md`, never a parse of one the
/// distinction that keeps this a directory listing rather than the per-comment read
/// `CommentThread.load` does, and keeps the walk O(cards) exactly as 01-storage-format.md §
/// Enhanced schema's "the board snapshot never loads comment content" already required.
/// - `looseFileNames(in:ignoring:)` the card folder *itself*, feeding `LoadResult.looseCardFiles`.
/// This is the loose-file carve-out's **detection** half (01-storage-format.md § Fractal layout
/// Rules, settled 2026-07-28): a regular file sitting beside a card's `index.md` belongs in
@@ -694,6 +700,7 @@ public enum BoardLoader: Sendable {
storedOrder: Double?,
heldCards: Int,
attachments: [String],
commentCount: Int,
document: FrontmatterDocument
)] = []
var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
@@ -764,8 +771,9 @@ public enum BoardLoader: Sendable {
// **The subtree is counted, never walked** (03-board-ui.md § Trash: an opaque unit
// showing its title and held-card count). The count is the same listing the shape
// fallback asks for, so a `kind: lane` entry pays for exactly one directory read and a
// kindless one pays for none extra and a card pays for its attachment listing only,
// which is why each side is read under its own arm rather than unconditionally.
// kindless one pays for none extra and a card pays for its attachment listing and its
// comment count only, which is why each side is read under its own arm rather than
// unconditionally.
//
// Neither `kind: board` nor `kind: comment` reaches here as itself `trashKind` treats
// both as unrecognized and answers by shape so the non-lane arm is the card answer and
@@ -778,6 +786,7 @@ public enum BoardLoader: Sendable {
storedOrder: order.order,
heldCards: isLane ? children().count : 0,
attachments: isLane ? [] : attachmentNames(in: entryURL),
commentCount: isLane ? 0 : commentCount(in: entryURL),
document: document
))
}
@@ -831,6 +840,7 @@ public enum BoardLoader: Sendable {
hero: document.hero,
order: order,
attachments: entry.attachments,
commentCount: entry.commentCount,
document: document
))
}
@@ -1115,6 +1125,7 @@ public enum BoardLoader: Sendable {
/// input to `Ranks.resolvedOrders(of:stored:name:)`.
let storedOrder: Double?
let attachments: [String]
let commentCount: Int
let document: FrontmatterDocument
/// This card's coerce-tier records for the strict fields, which only the rulebook can make
/// (a missing key leaves no trace in `document.coercedFields`).
@@ -1141,6 +1152,7 @@ public enum BoardLoader: Sendable {
hero: document.hero,
order: order,
attachments: attachments,
commentCount: commentCount,
document: document
)
}
@@ -1156,10 +1168,10 @@ public enum BoardLoader: Sendable {
/// `path` is root-relative and names the *folder*; the errors this throws name its `index.md`.
/// Callers guard `isUUIDShaped` and `hasIndex` first, exactly as the lane walk always has.
///
/// The **attachment listing stays fresh** here, memo or no memo (`ParseMemo` Scope): a hit
/// spares this card's `index.md` read and nothing else, because an attachment arriving in
/// `attachments/` never touches `index.md` and a card whose paperclip went stale would be the
/// memo lying about the tree.
/// The **attachment listing and the comment count stay fresh** here, memo or no memo (`ParseMemo`
/// Scope): a hit spares this card's `index.md` read and nothing else, because a file arriving
/// in `attachments/` or a comment arriving in `comments/` never touches `index.md`, and a card
/// whose paperclip or comment chip went stale would be the memo lying about the tree.
private static func parseCard(
at cardURL: URL,
path: String,
@@ -1182,6 +1194,7 @@ public enum BoardLoader: Sendable {
schema: schema.schema,
storedOrder: order.order,
attachments: attachmentNames(in: cardURL),
commentCount: commentCount(in: cardURL),
document: document,
coercions: [schema.coerced, order.coerced].compactMap { $0 },
stamp: read.stamp
@@ -1281,6 +1294,32 @@ public enum BoardLoader: Sendable {
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}
/// The number of comments `<card>/comments/` holds **a readdir, not a parse** (design ruling
/// 2026-08-09, card e729e30a; WISHLIST #9's own suggested shape). `0` when there is no
/// `comments/` at all, which is the overwhelmingly common card.
///
/// **The same predicate `identityShapedChildren(of:)` already uses for a trash entry's held-card
/// count**: children of the folder that are both identity-shaped and carry their own `index.md`
/// no YAML opened, no frontmatter parsed. `.draft` and `.trash/` need no special-casing here
/// either: both are dot-prefixed, and `directoryCandidates` (`identityShapedChildren`'s own
/// source) skips hidden entries, exactly the exclusion `CommentThread.load` documents for the
/// same two folders.
///
/// **Diverges from `CommentThread.load`'s parsed `comments.count` in exactly one case**: a
/// folder whose `index.md` exists but fails to parse (not UTF-8, unparseable YAML) is a `Stray`
/// the thread read excludes by actually opening and rejecting it a cost this count does not
/// pay, because paying it for every card on every load is precisely the O(cards × parsed
/// comments) walk 01-storage-format.md § Enhanced schema keeps out of the snapshot. The chip may
/// then read one comment high until that one folder is fixed or removed; every well-formed
/// comment, and every card with no malformed one, agrees with the pane exactly.
///
/// Internal rather than `private`, `attachmentNames(in:)`'s own reason: nothing outside this file
/// calls it today, but the count belongs beside the enumeration it is built from
/// (`identityShapedChildren`), not duplicated at a second call site later.
static func commentCount(in cardFolder: URL) -> Int {
identityShapedChildren(of: CommentThread.folder(inCard: cardFolder)).count
}
/// A card folder's **loose top-level files** the one carve-out to uniform stray tolerance
/// (01-storage-format.md § Fractal layout Rules, settled 2026-07-28, "Lanework-owns-the-board"):
/// "a regular file sitting beside a card's `index.md` (not `attachments/`, not a reserved name)
+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