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:
File diff suppressed because one or more lines are too long
@@ -25,8 +25,8 @@ Toolbars are **pure enhancement**: every function they host already has a menu i
|
|||||||
|
|
||||||
## Card face
|
## Card face
|
||||||
|
|
||||||
- Leading icon + title. The only face chip in scope is **attachments** (a quiet indicator when the card has files — the title dominates). Metadata chips (labels/assignees/due) went to the enhanced schema with their fields — out of scope.
|
- Leading icon + title. Two face chips are in scope, both present-only and the same secondary-tinted, decorative styling family: **attachments** (a quiet glyph when the card has files — the title dominates) and **comments** (ruled 2026-08-09, card e729e30a — a quiet bubble glyph plus its count when the card has one or more, joining the attachments chip at the row's trailing edge). Metadata chips (labels/assignees/due) went to the enhanced schema with their fields — out of scope.
|
||||||
- **No body excerpt** (settled): the face stays title-only — the old "iterate on the card face later" item is closed with no growth.
|
- **No body excerpt, but not "closed with no growth"** (re-settled 2026-08-09 — that old phrasing is retired as stale): the face still never draws a preview of the card's own prose, and that stays closed for good. What *has* grown since is exposure of facts the card already carries structurally, never content: the hero banner (below) and the comments chip both draw from a fact — a named attachment, a folder count — that a sighted glance or a screen reader already had another way to learn, not from anything a body excerpt would have shown.
|
||||||
- **Hero image** (ruled 2026-08-09): a card whose `hero` key names one of its own attachments (a bare filename — 01-storage-format.md § Frontmatter's card table) draws it as a **banner across the full plate width above the icon-and-title row**, aspect-fill cropped to a fixed em-scaled band (2.75 em — 36pt at the standard body, so it scales with system text size and board zoom like every other face figure). Opt-in and hand-written: **no in-app setter this version** — "Set as Hero" from the attachment row is future work. A key that is absent, malformed, or names a file that is missing, unreadable or not an image renders **exactly as no key at all** — the band has no height until a picture actually decodes, so the degrade is structural rather than a branch. Everything else the face draws is unchanged and sits outside the band: the accent stripe runs the plate's full leading edge over it, the selection and file-hover strokes ring the whole plate, the cut/drag dims cover it, and the drop model registers the plate's real height — a hero card is simply a taller card. The trash draws it too (the one-face rule). The drag replica carries the band when the picture is already decoded, which it is for any card the user can see to drag.
|
- **Hero image** (ruled 2026-08-09): a card whose `hero` key names one of its own attachments (a bare filename — 01-storage-format.md § Frontmatter's card table) draws it as a **banner across the full plate width above the icon-and-title row**, aspect-fill cropped to a fixed em-scaled band (2.75 em — 36pt at the standard body, so it scales with system text size and board zoom like every other face figure). Opt-in and hand-written: **no in-app setter this version** — "Set as Hero" from the attachment row is future work. A key that is absent, malformed, or names a file that is missing, unreadable or not an image renders **exactly as no key at all** — the band has no height until a picture actually decodes, so the degrade is structural rather than a branch. Everything else the face draws is unchanged and sits outside the band: the accent stripe runs the plate's full leading edge over it, the selection and file-hover strokes ring the whole plate, the cut/drag dims cover it, and the drop model registers the plate's real height — a hero card is simply a taller card. The trash draws it too (the one-face rule). The drag replica carries the band when the picture is already decoded, which it is for any card the user can see to drag.
|
||||||
- **Titles are optional at every level.** On cards and lanes, a missing `title` renders as a quiet placeholder ("Untitled", secondary styling) wherever the title would appear. On boards, the fallback is the folder name (sans extension), never "Untitled" — see 01-storage-format.md's board-naming rule; window title and welcome recents show `title` when present, folder name otherwise.
|
- **Titles are optional at every level.** On cards and lanes, a missing `title` renders as a quiet placeholder ("Untitled", secondary styling) wherever the title would appear. On boards, the fallback is the folder name (sans extension), never "Untitled" — see 01-storage-format.md's board-naming rule; window title and welcome recents show `title` when present, folder name otherwise.
|
||||||
- Attachments on the face: **the chip only — there is no face carousel** (resettled 2026-07-28, reversing the carry-over): the pathfinder's selection-keyed in-place expansion — compact unselected, media carousel when sole-selected — **proved undesirable and does not carry over**. A card has **one presentation**: selection changes styling (the selection treatment), never geometry, so the masonry never reflows on click and a card face is the same object whatever the selection state. The attachment chip above is the face's whole attachment story; viewing media is the card window's job (⌘↩ / double-click — the attachments section and QuickLook, 05-card-window.md). The earlier 2026-07-28 carousel settlements (metrics, tick paging, clamp, dots, marquee suppression, trash exclusion) are superseded with it — recorded on their Resolved cards.
|
- Attachments on the face: **the chip only — there is no face carousel** (resettled 2026-07-28, reversing the carry-over): the pathfinder's selection-keyed in-place expansion — compact unselected, media carousel when sole-selected — **proved undesirable and does not carry over**. A card has **one presentation**: selection changes styling (the selection treatment), never geometry, so the masonry never reflows on click and a card face is the same object whatever the selection state. The attachment chip above is the face's whole attachment story; viewing media is the card window's job (⌘↩ / double-click — the attachments section and QuickLook, 05-card-window.md). The earlier 2026-07-28 carousel settlements (metrics, tick paging, clamp, dots, marquee suppression, trash exclusion) are superseded with it — recorded on their Resolved cards.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na
|
|||||||
|
|
||||||
## The board through VoiceOver
|
## The board through VoiceOver
|
||||||
|
|
||||||
- **Tree shape**: window → lanes (accessibility containers, in lane `order`) → cards (leaf elements, in card `order`). A lane container is labeled "⟨title⟩, lane, N cards" — the count reads the search filter like the visible badge (04-interactions.md). The lane header's collapse chevron is a labeled child ("Collapse ⟨lane⟩"). A card is **one flattened element**: label = title (or the untitled placeholder), value carries the attachment count when present, selected state via trait. Face icon and chips are decorative — folded into the element, never separately focusable: the flattened element carries the attachment count in its value, and the accessible attachment surface is the card window's keyboard-native section (below); the face itself has no media presentation (03-board-ui.md's no-carousel resettlement).
|
- **Tree shape**: window → lanes (accessibility containers, in lane `order`) → cards (leaf elements, in card `order`). A lane container is labeled "⟨title⟩, lane, N cards" — the count reads the search filter like the visible badge (04-interactions.md). The lane header's collapse chevron is a labeled child ("Collapse ⟨lane⟩"). A card is **one flattened element**: label = title (or the untitled placeholder), value carries the attachment count and the comment count when present (comments joined 2026-08-09, card e729e30a — 03-board-ui.md § Card face), selected state via trait. Face icon and chips are decorative — folded into the element, never separately focusable: the flattened element carries both counts in its value, the accessible attachment surface is the card window's keyboard-native section (below), and the accessible comment surface is the card window's comments column ("Comments, N" — Comments below); the face itself has no media presentation (03-board-ui.md's no-carousel resettlement).
|
||||||
- **Logical order, not masonry position** (decided): within a wide lane, VoiceOver reads cards by `order` — the interior grid columns are presentation only. This deliberately diverges from on-screen geometry (narrowed by the 2026-07-31 column-major masonry: walking down one column now *is* consecutive `order`; the divergence that remains is a geometry-sorted reading-order sweep — left-to-right, then down — which interleaves the columns); the spatial arrow-key model (04-interactions.md) remains available alongside, since board keyboard navigation keeps working with VoiceOver running.
|
- **Logical order, not masonry position** (decided): within a wide lane, VoiceOver reads cards by `order` — the interior grid columns are presentation only. This deliberately diverges from on-screen geometry (narrowed by the 2026-07-31 column-major masonry: walking down one column now *is* consecutive `order`; the divergence that remains is a geometry-sorted reading-order sweep — left-to-right, then down — which interleaves the columns); the spatial arrow-key model (04-interactions.md) remains available alongside, since board keyboard navigation keeps working with VoiceOver running.
|
||||||
- **VO cursor and app selection are independent** (Finder-style): moving the VoiceOver cursor never mutates selection. VO-Space on any selectable element — card or lane header — toggles its selection (the ⌘-click analogue — a toggle, never plain click's replace, 04-interactions.md ▸ Selection; ruled 2026-07-29: a replace would silently wipe a multi-element selection, and one uniform VO-Space rule means the user never has to know the element kind to predict Space); ⌘↩ opens the card window; arrow keys and ⇧-arrows drive selection exactly as without VoiceOver. Selection state is always readable from the element (trait), and cut cards expose their dimmed pending state in the value ("cut, pending paste").
|
- **VO cursor and app selection are independent** (Finder-style): moving the VoiceOver cursor never mutates selection. VO-Space on any selectable element — card or lane header — toggles its selection (the ⌘-click analogue — a toggle, never plain click's replace, 04-interactions.md ▸ Selection; ruled 2026-07-29: a replace would silently wipe a multi-element selection, and one uniform VO-Space rule means the user never has to know the element kind to predict Space); ⌘↩ opens the card window; arrow keys and ⇧-arrows drive selection exactly as without VoiceOver. Selection state is always readable from the element (trait), and cut cards expose their dimmed pending state in the value ("cut, pending paste").
|
||||||
- **Actions come from the context menu.** Context menus are the single inventory of per-item actions (Open, Rename, Delete, width stepper, …), reachable the standard way (VO-⇧-M); where SwiftUI additionally surfaces menu items as custom accessibility actions, that's free improvement, not a separate design surface. **The custom-action cut** (confirmed 2026-07-29): every plain button row of an item's context menu becomes a custom action; rows that open their own accessible surface (Style…'s popover) and non-action controls (the quick-style swatch picker) stay menu-only — the context menu remains the full inventory either way. Each action must call the same method as its menu row, so the two surfaces cannot drift.
|
- **Actions come from the context menu.** Context menus are the single inventory of per-item actions (Open, Rename, Delete, width stepper, …), reachable the standard way (VO-⇧-M); where SwiftUI additionally surfaces menu items as custom accessibility actions, that's free improvement, not a separate design surface. **The custom-action cut** (confirmed 2026-07-29): every plain button row of an item's context menu becomes a custom action; rows that open their own accessible surface (Style…'s popover) and non-action controls (the quick-style swatch picker) stay menu-only — the context menu remains the full inventory either way. Each action must call the same method as its menu row, so the two surfaces cannot drift.
|
||||||
|
|||||||
@@ -25,12 +25,18 @@ import os
|
|||||||
/// shape rule — `attachments` and `comments` are non-UUID-shaped and would read as strays, not
|
/// 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.
|
/// 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
|
/// **Three reads inside a card folder**, all of them flat directory listings and nothing more —
|
||||||
/// opens a file, descends, warns, or fails a load; each degrades to `[]`:
|
/// 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
|
/// - `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
|
/// 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.
|
/// 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`.
|
/// - `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 ▸
|
/// 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
|
/// 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?,
|
storedOrder: Double?,
|
||||||
heldCards: Int,
|
heldCards: Int,
|
||||||
attachments: [String],
|
attachments: [String],
|
||||||
|
commentCount: Int,
|
||||||
document: FrontmatterDocument
|
document: FrontmatterDocument
|
||||||
)] = []
|
)] = []
|
||||||
var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
|
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
|
// **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
|
// 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
|
// 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,
|
// kindless one pays for none extra — and a card pays for its attachment listing and its
|
||||||
// which is why each side is read under its own arm rather than unconditionally.
|
// 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
|
// 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
|
// 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,
|
storedOrder: order.order,
|
||||||
heldCards: isLane ? children().count : 0,
|
heldCards: isLane ? children().count : 0,
|
||||||
attachments: isLane ? [] : attachmentNames(in: entryURL),
|
attachments: isLane ? [] : attachmentNames(in: entryURL),
|
||||||
|
commentCount: isLane ? 0 : commentCount(in: entryURL),
|
||||||
document: document
|
document: document
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -831,6 +840,7 @@ public enum BoardLoader: Sendable {
|
|||||||
hero: document.hero,
|
hero: document.hero,
|
||||||
order: order,
|
order: order,
|
||||||
attachments: entry.attachments,
|
attachments: entry.attachments,
|
||||||
|
commentCount: entry.commentCount,
|
||||||
document: document
|
document: document
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -1115,6 +1125,7 @@ public enum BoardLoader: Sendable {
|
|||||||
/// input to `Ranks.resolvedOrders(of:stored:name:)`.
|
/// input to `Ranks.resolvedOrders(of:stored:name:)`.
|
||||||
let storedOrder: Double?
|
let storedOrder: Double?
|
||||||
let attachments: [String]
|
let attachments: [String]
|
||||||
|
let commentCount: Int
|
||||||
let document: FrontmatterDocument
|
let document: FrontmatterDocument
|
||||||
/// This card's coerce-tier records for the strict fields, which only the rulebook can make
|
/// 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`).
|
/// (a missing key leaves no trace in `document.coercedFields`).
|
||||||
@@ -1141,6 +1152,7 @@ public enum BoardLoader: Sendable {
|
|||||||
hero: document.hero,
|
hero: document.hero,
|
||||||
order: order,
|
order: order,
|
||||||
attachments: attachments,
|
attachments: attachments,
|
||||||
|
commentCount: commentCount,
|
||||||
document: document
|
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`.
|
/// `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.
|
/// 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
|
/// The **attachment listing and the comment count stay fresh** here, memo or no memo (`ParseMemo`
|
||||||
/// spares this card's `index.md` read and nothing else, because an attachment arriving in
|
/// ▸ Scope): a hit spares this card's `index.md` read and nothing else, because a file arriving
|
||||||
/// `attachments/` never touches `index.md` and a card whose paperclip went stale would be the
|
/// in `attachments/` or a comment arriving in `comments/` never touches `index.md`, and a card
|
||||||
/// memo lying about the tree.
|
/// whose paperclip or comment chip went stale would be the memo lying about the tree.
|
||||||
private static func parseCard(
|
private static func parseCard(
|
||||||
at cardURL: URL,
|
at cardURL: URL,
|
||||||
path: String,
|
path: String,
|
||||||
@@ -1182,6 +1194,7 @@ public enum BoardLoader: Sendable {
|
|||||||
schema: schema.schema,
|
schema: schema.schema,
|
||||||
storedOrder: order.order,
|
storedOrder: order.order,
|
||||||
attachments: attachmentNames(in: cardURL),
|
attachments: attachmentNames(in: cardURL),
|
||||||
|
commentCount: commentCount(in: cardURL),
|
||||||
document: document,
|
document: document,
|
||||||
coercions: [schema.coerced, order.coerced].compactMap { $0 },
|
coercions: [schema.coerced, order.coerced].compactMap { $0 },
|
||||||
stamp: read.stamp
|
stamp: read.stamp
|
||||||
@@ -1281,6 +1294,32 @@ public enum BoardLoader: Sendable {
|
|||||||
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
.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
|
/// 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"):
|
/// (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)
|
/// "a regular file sitting beside a card's `index.md` (not `attachments/`, not a reserved name)
|
||||||
|
|||||||
@@ -236,11 +236,14 @@ public struct Lane: Identifiable, Sendable, Equatable {
|
|||||||
public var isDeleted: Bool { !deleted.isMissing }
|
public var isDeleted: Bool { !deleted.isMissing }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A card: `<root>/<guid>/<guid>/index.md`, plus the *names* of its attachments. Structurally
|
/// A card: `<root>/<guid>/<guid>/index.md`, plus the *names* of its attachments and a *count* of
|
||||||
/// still a leaf — `comments/` (future, out-of-scope) and the attachment files' contents live
|
/// its comments. Structurally still a leaf — the attachment files' contents and every comment's
|
||||||
/// alongside `index.md` on disk and are not modeled here; `attachments` is the one thing the
|
/// own frontmatter and body live alongside `index.md` on disk and are not modeled here; `comments/`
|
||||||
/// snapshot reaches inside a card folder for, because two board-window surfaces need it before
|
/// stays window-scoped exactly as 01-storage-format.md § Enhanced schema rules ("the board snapshot
|
||||||
/// any card window exists (see its own doc comment).
|
/// 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 struct Card: Identifiable, Sendable, Equatable {
|
||||||
public let id: ItemID
|
public let id: ItemID
|
||||||
|
|
||||||
@@ -293,6 +296,24 @@ public struct Card: Identifiable, Sendable, Equatable {
|
|||||||
/// `index.md` does — no separate invalidation path to keep honest.
|
/// `index.md` does — no separate invalidation path to keep honest.
|
||||||
public let attachments: [String]
|
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.
|
/// The full parsed `index.md`; unknown/reserved keys ride along uninterpreted.
|
||||||
public let document: FrontmatterDocument
|
public let document: FrontmatterDocument
|
||||||
|
|
||||||
|
|||||||
@@ -128,17 +128,27 @@ enum AccessibilityPhrases {
|
|||||||
/// the dim is the sighted signal, this is the other one.
|
/// the dim is the sighted signal, this is the other one.
|
||||||
static let cutPending = "cut, pending paste"
|
static let cutPending = "cut, pending paste"
|
||||||
|
|
||||||
/// A card element's value — the attachment count when it has files, the cut-pending phrase when
|
/// A card element's value — the attachment count and the comment count when the card has either
|
||||||
/// it is staged for paste, both when both, and **the empty string when neither**.
|
/// (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
|
/// 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`
|
/// `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
|
/// 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
|
/// marquee registration — the moment an attachment lands, a comment posts, or a cut is pasted. An
|
||||||
/// speaks as nothing, which is exactly what "no value" should sound like.
|
/// empty AXValue speaks as nothing, which is exactly what "no value" should sound like.
|
||||||
static func cardValue(attachments: Int, isCutPending: Bool) -> String {
|
static func cardValue(attachments: Int, comments: Int, isCutPending: Bool) -> String {
|
||||||
var parts: [String] = []
|
var parts: [String] = []
|
||||||
if attachments > 0 { parts.append(attachmentCount(attachments)) }
|
if attachments > 0 { parts.append(attachmentCount(attachments)) }
|
||||||
|
if comments > 0 { parts.append(commentCount(comments)) }
|
||||||
if isCutPending { parts.append(cutPending) }
|
if isCutPending { parts.append(cutPending) }
|
||||||
return parts.joined(separator: ", ")
|
return parts.joined(separator: ", ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,11 +185,20 @@ enum BoardMetrics {
|
|||||||
em(0.75, bodyPointSize: bodyPointSize)
|
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 {
|
static func cardRowSpacing(bodyPointSize: CGFloat) -> CGFloat {
|
||||||
em(0.45, bodyPointSize: bodyPointSize)
|
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 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
|
/// The lane registers this into `LaneDropRegistry.Grid`, so the drop model's analytic resting
|
||||||
|
|||||||
@@ -82,8 +82,9 @@ enum CardFaceRole: Sendable {
|
|||||||
// MARK: - Card face
|
// MARK: - Card face
|
||||||
|
|
||||||
/// The card face: a rounded plate carrying a leading SF Symbol, the title (or its quiet "Untitled"
|
/// 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
|
/// placeholder), two quiet trailing chips — attachments and comments, each present-only — and a
|
||||||
/// (03-board-ui.md § Card face, § Styling ▸ Capabilities).
|
/// 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
|
/// ### One face, two containers
|
||||||
///
|
///
|
||||||
@@ -96,10 +97,13 @@ enum CardFaceRole: Sendable {
|
|||||||
///
|
///
|
||||||
/// ### Title-only, deliberately
|
/// ### Title-only, deliberately
|
||||||
///
|
///
|
||||||
/// **No body excerpt** — settled, "the face stays title-only … the old 'iterate on the card face
|
/// **No body excerpt** — settled, and still true after 2026-08-09's growth (the hero banner, the
|
||||||
/// later' item is closed with no growth". The only face chip in scope is attachments, "a quiet
|
/// comments chip): the face never draws a preview of the card's own prose, and never will — what
|
||||||
/// indicator when the card has files — the title dominates", which is why the paperclip is a
|
/// grew is exposure of facts the card already carries structurally (an attachment named as hero, a
|
||||||
/// secondary-tinted caption and not a count pill: the eye should land on the title.
|
/// 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
|
/// ### Two lenient fields, two different fallbacks
|
||||||
///
|
///
|
||||||
@@ -356,11 +360,12 @@ struct CardFaceView: View, Equatable {
|
|||||||
// `isRenaming` is (`CardFaceRole`).
|
// `isRenaming` is (`CardFaceRole`).
|
||||||
.accessibilityElement(children: isRenaming ? .contain : .ignore)
|
.accessibilityElement(children: isRenaming ? .contain : .ignore)
|
||||||
.accessibilityLabel(AccessibilityPhrases.cardLabel(title: card.title.value))
|
.accessibilityLabel(AccessibilityPhrases.cardLabel(title: card.title.value))
|
||||||
// The attachment count, the deferred cut's "cut, pending paste", or both — and the empty
|
// The attachment count, the comment count, the deferred cut's "cut, pending paste", or any
|
||||||
// string when neither, which speaks as nothing (see `AccessibilityPhrases.cardValue` for why
|
// mix of the three — and the empty string when none apply, which speaks as nothing (see
|
||||||
// it is not a conditional modifier).
|
// `AccessibilityPhrases.cardValue` for why it is not a conditional modifier).
|
||||||
.accessibilityValue(AccessibilityPhrases.cardValue(
|
.accessibilityValue(AccessibilityPhrases.cardValue(
|
||||||
attachments: card.attachments.count,
|
attachments: card.attachments.count,
|
||||||
|
comments: card.commentCount,
|
||||||
isCutPending: store.transient.pendingCut.ids.contains(card.id)
|
isCutPending: store.transient.pendingCut.ids.contains(card.id)
|
||||||
))
|
))
|
||||||
// "Selection state is always readable from the element (trait)" — the other half of "state
|
// "Selection state is always readable from the element (trait)" — the other half of "state
|
||||||
@@ -652,6 +657,7 @@ struct CardFaceView: View, Equatable {
|
|||||||
.lineLimit(4)
|
.lineLimit(4)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
attachmentsIndicator
|
attachmentsIndicator
|
||||||
|
commentsIndicator
|
||||||
}
|
}
|
||||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||||
.padding(.leading, stripeWidth)
|
.padding(.leading, stripeWidth)
|
||||||
@@ -861,10 +867,11 @@ struct CardFaceView: View, Equatable {
|
|||||||
.foregroundStyle(iconTint)
|
.foregroundStyle(iconTint)
|
||||||
.imageScale(.medium)
|
.imageScale(.medium)
|
||||||
titleOrEditor
|
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.
|
// edge — and so the rename field fills the same span the title occupied.
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
attachmentsIndicator
|
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
|
/// One of the two face chips in scope — shown only when the card actually has files, and quiet
|
||||||
/// that the title still dominates (03-board-ui.md § Card face). The count goes to the
|
/// 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.
|
/// 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
|
/// **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
|
/// 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
|
/// 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).
|
/// title text never sits on a coloured fill" (03-board-ui.md § Styling ▸ Capabilities).
|
||||||
|
|||||||
@@ -168,9 +168,11 @@ struct TrashLaneRowView: View, Equatable {
|
|||||||
.accessibilityElement(children: .ignore)
|
.accessibilityElement(children: .ignore)
|
||||||
.accessibilityLabel(AccessibilityPhrases.trashedLaneLabel(title: lane.title.value, cards: lane.heldCards))
|
.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
|
// 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(
|
.accessibilityValue(AccessibilityPhrases.cardValue(
|
||||||
attachments: 0,
|
attachments: 0,
|
||||||
|
comments: 0,
|
||||||
isCutPending: store.transient.pendingCut.ids.contains(lane.id)
|
isCutPending: store.transient.pendingCut.ids.contains(lane.id)
|
||||||
))
|
))
|
||||||
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
|
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
|
||||||
|
|||||||
@@ -81,27 +81,62 @@ struct AccessibilityPhrasesTests {
|
|||||||
/// and an empty AXValue speaks as nothing.
|
/// and an empty AXValue speaks as nothing.
|
||||||
@Test("A plain card carries no value")
|
@Test("A plain card carries no value")
|
||||||
func cardValueEmpty() {
|
func cardValueEmpty() {
|
||||||
#expect(AccessibilityPhrases.cardValue(attachments: 0, isCutPending: false).isEmpty)
|
#expect(AccessibilityPhrases.cardValue(attachments: 0, comments: 0, isCutPending: false).isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Attachments ride the value, plural-folded")
|
@Test("Attachments ride the value, plural-folded")
|
||||||
func cardValueAttachments() {
|
func cardValueAttachments() {
|
||||||
#expect(AccessibilityPhrases.cardValue(attachments: 1, isCutPending: false) == "1 attachment")
|
#expect(
|
||||||
#expect(AccessibilityPhrases.cardValue(attachments: 4, isCutPending: false) == "4 attachments")
|
AccessibilityPhrases.cardValue(attachments: 1, comments: 0, isCutPending: false)
|
||||||
|
== "1 attachment"
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
AccessibilityPhrases.cardValue(attachments: 4, comments: 0, isCutPending: false)
|
||||||
|
== "4 attachments"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Comments ride the value too (design ruling 2026-08-09, card e729e30a) — the same plural
|
||||||
|
/// folding the comments pane's own header uses (`commentCount`), so a face value and the pane
|
||||||
|
/// can never disagree about how "1 comment" reads.
|
||||||
|
@Test("Comments ride the value, plural-folded")
|
||||||
|
func cardValueComments() {
|
||||||
|
#expect(
|
||||||
|
AccessibilityPhrases.cardValue(attachments: 0, comments: 1, isCutPending: false)
|
||||||
|
== "1 comment"
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
AccessibilityPhrases.cardValue(attachments: 0, comments: 3, isCutPending: false)
|
||||||
|
== "3 comments"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A cut-pending card says so")
|
@Test("A cut-pending card says so")
|
||||||
func cardValueCutPending() {
|
func cardValueCutPending() {
|
||||||
#expect(AccessibilityPhrases.cardValue(attachments: 0, isCutPending: true) == "cut, pending paste")
|
#expect(
|
||||||
|
AccessibilityPhrases.cardValue(attachments: 0, comments: 0, isCutPending: true)
|
||||||
|
== "cut, pending paste"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Both fragments in one value, attachments first: the count is a fact about the card, the cut
|
/// Attachments and comments together, attachments first — the chips' own left-to-right order on
|
||||||
/// is a fact about what is about to happen to it.
|
/// the face (`CardFaceView.titleRow`).
|
||||||
@Test("A cut card with files carries both fragments")
|
@Test("Attachments and comments both ride the value, attachments first")
|
||||||
func cardValueBoth() {
|
func cardValueAttachmentsAndComments() {
|
||||||
#expect(
|
#expect(
|
||||||
AccessibilityPhrases.cardValue(attachments: 2, isCutPending: true)
|
AccessibilityPhrases.cardValue(attachments: 2, comments: 3, isCutPending: false)
|
||||||
== "2 attachments, cut, pending paste"
|
== "2 attachments, 3 comments"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All three fragments in one value, in the fixed order: attachments, then comments, then the
|
||||||
|
/// cut-pending phrase last, since it describes what is about to happen rather than a fact about
|
||||||
|
/// the card's own content.
|
||||||
|
@Test("A cut card with files and comments carries all three fragments")
|
||||||
|
func cardValueAllThree() {
|
||||||
|
#expect(
|
||||||
|
AccessibilityPhrases.cardValue(attachments: 2, comments: 1, isCutPending: true)
|
||||||
|
== "2 attachments, 1 comment, cut, pending paste"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -566,6 +566,144 @@ struct CardAttachmentListingTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Card comment counts (design ruling 2026-08-09, card e729e30a)
|
||||||
|
|
||||||
|
/// `Card.commentCount` — the loader's cheap readdir over `comments/`, `CardAttachmentListingTests`'
|
||||||
|
/// shape one folder over: what a synthetic tree can arrange that isn't worth a golden fixture (a
|
||||||
|
/// non-identity-shaped stray, a comment folder missing its `index.md`, the two dot-named
|
||||||
|
/// exclusions, and the one documented divergence from `CommentThread.load`'s parsed count).
|
||||||
|
struct CardCommentCountListingTests {
|
||||||
|
|
||||||
|
/// `CardAttachmentListingTests.card(in:)`'s exact twin.
|
||||||
|
private func card(in fixture: BoardFixture) throws -> Card {
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
return try #require(result.model.lanes.first?.cards.first)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func boardWithOneCard(_ fixture: BoardFixture) throws -> String {
|
||||||
|
let lane = "10000000-0000-4000-8000-000000000001"
|
||||||
|
let cardID = "20000000-0000-4000-8000-000000000002"
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index(lane, "schema: 1\norder: 1024\n")
|
||||||
|
try fixture.index("\(lane)/\(cardID)", "schema: 1\norder: 1024\n")
|
||||||
|
return "\(lane)/\(cardID)"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func aCardWithNoCommentsFolderCountsZero() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
_ = try boardWithOneCard(fixture)
|
||||||
|
|
||||||
|
#expect(try card(in: fixture).commentCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func anEmptyCommentsFolderCountsZero() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let cardPath = try boardWithOneCard(fixture)
|
||||||
|
try fixture.emptyFolder("\(cardPath)/comments")
|
||||||
|
|
||||||
|
#expect(try card(in: fixture).commentCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func identityShapedFoldersWithIndexAreCounted() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let cardPath = try boardWithOneCard(fixture)
|
||||||
|
|
||||||
|
try fixture.index("\(cardPath)/comments/\(uuidFolderName())", "schema: 1\n")
|
||||||
|
try fixture.index("\(cardPath)/comments/\(uuidFolderName())", "schema: 1\n")
|
||||||
|
try fixture.index("\(cardPath)/comments/\(uuidFolderName())", "schema: 1\n")
|
||||||
|
|
||||||
|
#expect(try card(in: fixture).commentCount == 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hand-made folder — `notes`, say — under `comments/` is a stray to `CommentThread.load`
|
||||||
|
/// (`.notIdentityShaped`), and is excluded here for the identical reason: only a name with a
|
||||||
|
/// UUID's shape is a comment candidate at all (`identityShapedChildren`, shared with the
|
||||||
|
/// trash entry's own held-card count).
|
||||||
|
@Test func nonIdentityShapedFoldersAreExcluded() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let cardPath = try boardWithOneCard(fixture)
|
||||||
|
|
||||||
|
try fixture.index("\(cardPath)/comments/\(uuidFolderName())", "schema: 1\n")
|
||||||
|
try fixture.index("\(cardPath)/comments/notes", "schema: 1\n")
|
||||||
|
|
||||||
|
#expect(try card(in: fixture).commentCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two-step-create tolerance, one level down: a UUID-shaped folder with no `index.md` yet
|
||||||
|
/// (`.missingIndex` to `CommentThread.load`) is not a comment to count either.
|
||||||
|
@Test func identityShapedFoldersMissingIndexAreExcluded() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let cardPath = try boardWithOneCard(fixture)
|
||||||
|
|
||||||
|
try fixture.index("\(cardPath)/comments/\(uuidFolderName())", "schema: 1\n")
|
||||||
|
try fixture.emptyFolder("\(cardPath)/comments/\(uuidFolderName())")
|
||||||
|
|
||||||
|
#expect(try card(in: fixture).commentCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `.draft` and `.trash/` are excluded **for free** — both dot-prefixed, and the loader's
|
||||||
|
/// directory listing skips hidden entries, exactly the exclusion `CommentThread.load`
|
||||||
|
/// documents for the same two folders. A trashed comment sitting inside `.trash/` must not
|
||||||
|
/// inflate the count even though it is itself identity-shaped with a readable `index.md`.
|
||||||
|
@Test func draftAndTrashAreExcludedForFree() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let cardPath = try boardWithOneCard(fixture)
|
||||||
|
|
||||||
|
try fixture.index("\(cardPath)/comments/\(uuidFolderName())", "schema: 1\n")
|
||||||
|
try fixture.index("\(cardPath)/comments/.draft", "schema: 1\n")
|
||||||
|
try fixture.index("\(cardPath)/comments/.trash/\(uuidFolderName())", "schema: 1\n")
|
||||||
|
|
||||||
|
#expect(try card(in: fixture).commentCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The loader's count and `CommentThread.load`'s parsed count agree** in the well-formed
|
||||||
|
/// case — the design ruling's "read how the comments pane counts and match it exactly" (card
|
||||||
|
/// e729e30a), pinned the way `theSnapshotsListingAndTheWritersAreTheSameAnswer` pins the
|
||||||
|
/// attachment listing's agreement one type over.
|
||||||
|
@Test func theLoadersCountAgreesWithTheThreadsParsedCount() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let cardPath = try boardWithOneCard(fixture)
|
||||||
|
|
||||||
|
for _ in 0..<4 {
|
||||||
|
try fixture.index("\(cardPath)/comments/\(uuidFolderName())", "schema: 1\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
let cardFolder = fixture.root.appendingPathComponent(cardPath, isDirectory: true)
|
||||||
|
let thread = CommentThread.load(inCard: cardFolder, path: cardPath)
|
||||||
|
#expect(thread.comments.count == 4)
|
||||||
|
#expect(try card(in: fixture).commentCount == thread.comments.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The one documented divergence**: a comment whose `index.md` exists but fails to parse is
|
||||||
|
/// a `Stray` to the thread read (`.unreadable`) — excluded from the pane's count — but the
|
||||||
|
/// loader's readdir only checks that `index.md` exists, not that it parses, so the face may
|
||||||
|
/// read one higher than the pane until the folder is fixed or removed (`Card.commentCount`'s
|
||||||
|
/// own doc comment; `BoardLoader.commentCount(in:)`'s).
|
||||||
|
@Test func aMalformedCommentIndexIsCountedByTheLoaderButNotByTheThread() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let cardPath = try boardWithOneCard(fixture)
|
||||||
|
let commentID = uuidFolderName()
|
||||||
|
// Unparseable YAML — `CommentThreadTests.brokenCommentsAreStrays`' exact shape.
|
||||||
|
try fixture.strayFile(
|
||||||
|
"\(cardPath)/comments/\(commentID)/index.md", contents: "---\nschema: 1\n bad: [\n---\nbody\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
let cardFolder = fixture.root.appendingPathComponent(cardPath, isDirectory: true)
|
||||||
|
let thread = CommentThread.load(inCard: cardFolder, path: cardPath)
|
||||||
|
#expect(thread.comments.isEmpty)
|
||||||
|
#expect(thread.strays.count == 1)
|
||||||
|
#expect(try card(in: fixture).commentCount == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - ItemID value semantics (01-storage-format.md § Fractal layout ▸ Rules, "Identity
|
// MARK: - ItemID value semantics (01-storage-format.md § Fractal layout ▸ Rules, "Identity
|
||||||
// comparison is UUID-value equality, never string equality")
|
// comparison is UUID-value equality, never string equality")
|
||||||
|
|
||||||
|
|||||||
@@ -330,6 +330,40 @@ struct BoardRenderPerformanceTests {
|
|||||||
"a one-card external edit re-rendered \(edit.containers) bodies for \(laneCount) containers")
|
"a one-card external edit re-rendered \(edit.containers) bodies for \(laneCount) containers")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The comments chip's render-cost guard** (design ruling 2026-08-09, card e729e30a): a
|
||||||
|
/// comment landing on one card must cost this suite's card budget, not the board's — proof that
|
||||||
|
/// `Card.commentCount` riding inside the already-compared `card` parameter (no new
|
||||||
|
/// `CardFaceView` parameter, no new Observable read) actually holds under a real hosted board,
|
||||||
|
/// not just in the equality-gate unit tests (`ViewEquatableTests.aCommentCountChangeIsADifference`
|
||||||
|
/// pins the gate; this pins what the gate is *for*). Structurally `aOneCardEditIsNotAWholeBoardRebuild`,
|
||||||
|
/// a comment folder in place of an edited `index.md`.
|
||||||
|
@Test("A comment added to one card re-renders a handful of bodies, not the board")
|
||||||
|
func aOneCommentAddedIsNotAWholeBoardRebuild() async throws {
|
||||||
|
let fixture = try makeFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let board = try host(fixture)
|
||||||
|
let total = laneCount * cardsPerLane
|
||||||
|
|
||||||
|
// A foreign comment arriving under exactly one card — the shape a card window's post, or an
|
||||||
|
// agent's own `mkdir` + `index.md`, leaves on disk.
|
||||||
|
try fixture.item(
|
||||||
|
"\(laneName(3))/\(cardName(3, 17))/comments/\(UUID().uuidString.lowercased())",
|
||||||
|
"---\nschema: 1\n---\nA comment.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
BoardRenderMetrics.reset()
|
||||||
|
await board.reload()
|
||||||
|
let commented = Cost()
|
||||||
|
|
||||||
|
print("── reload, ONE comment added — \(commented.summary)")
|
||||||
|
|
||||||
|
#expect(board.store.snapshotGeneration > 0, "the comment never landed")
|
||||||
|
#expect(commented.cards <= 8,
|
||||||
|
"one comment on one card re-rendered \(commented.cards) of \(total) card faces")
|
||||||
|
#expect(commented.containers <= laneCount,
|
||||||
|
"one comment on one card re-rendered \(commented.containers) bodies for \(laneCount) containers")
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Every lane re-runs on a one-card edit, and not because its gate compared unequal")
|
@Test("Every lane re-runs on a one-card edit, and not because its gate compared unequal")
|
||||||
func theLaneCostFollowsTheBoard() async throws {
|
func theLaneCostFollowsTheBoard() async throws {
|
||||||
let wide = laneCount * 2
|
let wide = laneCount * 2
|
||||||
|
|||||||
@@ -342,6 +342,37 @@ struct CardFaceViewEquatableTests {
|
|||||||
#expect(face(sketch) != face(fixture.root.appendingPathComponent("cover.png")))
|
#expect(face(sketch) != face(fixture.root.appendingPathComponent("cover.png")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The comment count is a difference too** (design ruling 2026-08-09, card e729e30a) — but
|
||||||
|
/// unlike the hero it needs no parameter of its own: it rides inside `card`, a snapshot field
|
||||||
|
/// the loader fills with a readdir (`Card.commentCount`), so this pins that `Card`'s synthesized
|
||||||
|
/// `Equatable` is not quietly excluding it from the gate the way a hand-written `==` might.
|
||||||
|
@Test("A comment added to the card is a difference")
|
||||||
|
func aCommentCountChangeIsADifference() throws {
|
||||||
|
let fixture = try makeFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let session = DragSession()
|
||||||
|
let registry = LaneDropRegistry()
|
||||||
|
let marquee = MarqueeControl(
|
||||||
|
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
|
||||||
|
)
|
||||||
|
let drops = makeDrops(store: store, session: session, registry: registry)
|
||||||
|
let before = try firstCard(fixture.snapshot())
|
||||||
|
|
||||||
|
try fixture.item(
|
||||||
|
"\(Ident.lane1)/\(Ident.card1)/comments/\(UUID().uuidString.lowercased())",
|
||||||
|
"---\nschema: 1\n---\n"
|
||||||
|
)
|
||||||
|
let after = try firstCard(fixture.snapshot())
|
||||||
|
#expect(before.commentCount == 0)
|
||||||
|
#expect(after.commentCount == 1)
|
||||||
|
|
||||||
|
#expect(CardFaceView(store: store, card: before, role: .board(openCard: { _ in }),
|
||||||
|
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
|
||||||
|
!= CardFaceView(store: store, card: after, role: .board(openCard: { _ in }),
|
||||||
|
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1))
|
||||||
|
}
|
||||||
|
|
||||||
@Test("An edited card is unequal — the gate never withholds a repaint")
|
@Test("An edited card is unequal — the gate never withholds a repaint")
|
||||||
func anEditedCardIsADifference() throws {
|
func anEditedCardIsADifference() throws {
|
||||||
let fixture = try makeFixture()
|
let fixture = try makeFixture()
|
||||||
|
|||||||
+2
-2
@@ -36,9 +36,9 @@ Drags run as system `NSItemProvider` sessions (cross-board transfer, the copy ba
|
|||||||
|
|
||||||
The materialized `.trash/` (DESIGN/03 ▸ Trash, the 2026-07-28 pivot) establishes the pattern: a reserved dot-named container at board root holding ordinary card folders, special only in how cards enter it. An archive lane — `<root>/.archive`, receiving cards that meet a criterion to be designed (age? a Done-lane dwell time? an explicit Archive command?) — is the anticipated second instance. Design open: intake criteria, visibility (a Show Archive toggle like the trash's?), search participation, whether archived cards count anywhere. Nothing committed; the trash proves the mechanism first.
|
The materialized `.trash/` (DESIGN/03 ▸ Trash, the 2026-07-28 pivot) establishes the pattern: a reserved dot-named container at board root holding ordinary card folders, special only in how cards enter it. An archive lane — `<root>/.archive`, receiving cards that meet a criterion to be designed (age? a Done-lane dwell time? an explicit Archive command?) — is the anticipated second instance. Design open: intake criteria, visibility (a Show Archive toggle like the trash's?), search participation, whether archived cards count anywhere. Nothing committed; the trash proves the mechanism first.
|
||||||
|
|
||||||
### 9. Comment-count chip on card faces
|
### 9. Comment-count chip on card faces — **shipped 2026-08-09**
|
||||||
|
|
||||||
Comments are window-scoped by design (DESIGN/01 ▸ Enhanced schema — the board snapshot never loads comment content, keeping the walk O(cards)), so card faces can't show a live count today. A chip would need either a cheap directory-entry count folded into the loader's card walk (a readdir, not a parse — the likely shape) or a cached count with staleness rules. Decide alongside the comments UI design pass; the one-presentation rule (DESIGN/03) constrains where a chip may live on the face.
|
Comments are window-scoped by design (DESIGN/01 ▸ Enhanced schema — the board snapshot never loads comment content, keeping the walk O(cards)), so card faces couldn't show a live count. The shipped chip took this item's own anticipated shape — a cheap directory-entry count folded into the loader's card walk, a readdir rather than a parse — over the cached/stale-tolerant alternative. See DESIGN/03-board-ui.md § Card face and DESIGN/01 ▸ Enhanced schema for the design as built; number kept in place so #10 and #11 below stay addressable.
|
||||||
|
|
||||||
### 10. Search scope options
|
### 10. Search scope options
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user