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