diff --git a/DESIGN/01-storage-format.md b/DESIGN/01-storage-format.md index 4596915..5a85fad 100644 --- a/DESIGN/01-storage-format.md +++ b/DESIGN/01-storage-format.md @@ -93,7 +93,7 @@ Schema-owned display fields are Lanework's to interpret — **coerce where a sen Future integrations extend the core schema. Comments — specified here 2026-07-29 ahead of the feature — have since **shipped**: the `comments/` schema below is live, rendered and authored in the app on both platforms. The remaining names stay reserved so nothing else squats on them; the app treats reserved keys as ordinary unknown keys (preserved verbatim, invisible in the UI) and reserved folders as ignored non-level content: -- **`comments/` card child — the comment thread** (storage specified 2026-07-29, the card-window UI designed the same day — 05-card-window.md ▸ The comments column; shipped: thread column, composer, `.draft`, and `comments/.trash/` are all live). A comment is a **UUID-named folder** under `comments/`, holding `index.md` (frontmatter + Markdown body, the card-body subset) and optionally `attachments/` — a card's anatomy one level down, so the fractal rules apply verbatim: two-step-create tolerance, stray tolerance, loose-file relocation, displacement of a squatted `attachments`, shape-only identity. **The heals run window-scoped at this depth** (ruled 2026-07-31): comments are outside the board walk, so the thread read is the detector — it reports loose files and claimed-name squatters as window-scoped defects, and the card window relocates/displaces them memo-less on the read-cadence guard (02-architecture.md ▸ HealScheduler's window-scoped pattern); a loose file beside a comment's `index.md` moves into that comment's `attachments/` with the relocation family's notice ("Moved 'notes.txt' into attachments — comment on 'Fix login'"), and the heals compose as at board level. `comments/` itself stays a plain reserved child — never a level, never identity. **Field table for `kind: comment`** (exercising IntegrityRules' per-kind hook for the first time): `schema` required; `kind: comment` written at creation (the common rule); **`author`** lenient — self-reported *content*, not overlay: unlike `modified-by` it survives app writes; the app writes the macOS account's full name (the identity 06-history-undo.md's derived default already uses), agents write their own (the guide teaches it), tracker sync writes the remote author verbatim, and missing renders unattributed; `created`/`modified` as everywhere — `created` is load-bearing (ordering below), and the edited indicator is simply `modified` differing, no extra field; `remote`/`remote-state` ride as reserved tracker keys. **No `title`, no `order`.** The rules, all ruled 2026-07-29: **Ordering is chronology, not ranks** — the thread sorts by `created` ascending; chronology *is* a conversation's semantics, and cross-machine or tracker-synced comments carry independent clocks where minted ranks would interleave arbitrarily (the trash joined the timestamp family 2026-07-31 — it sorts by `modified` descending, Deletion below); ties and missing/malformed `created` (coerce-tier fallback, logged) sort after dated siblings, folder-name order. **Flat this iteration** — no reply nesting, deliberately deferred with the door open (a future reply relation would be frontmatter, and the window-scoped read below holds either way). **Comment defects never refuse the board** — worst case is the stray posture (tolerated, logged, unrendered): a broken leaf annotation must not brick a load; deliberate, proportionate divergence from card fail-fast. The posture is **load-scoped** (ruled 2026-07-31): a user-initiated copy is a transaction whose stamping preflight refuses whole at comment depth too (Identity lifecycle above) — the board always loads; the gesture may refuse. **Delete skips the board trash, not the pattern** (re-ruled 2026-07-29 — supersedes the earlier capture parenthetical): the root `.trash/` holds cards and lanes; a deleted comment **moves into `comments/.trash/`** — the materialized-trash pattern one level down, joining `.draft` in the claimed names (a squatter displaces by the ladder). The move is immediate, no confirm, and undo is the net: the inverse is the ordinary move back — **no byte capture in any tier** (13-native-undo.md's no-capture rule holds; on git boards the move is a commit like any other). `comments/.trash/` is undo's backing store, never a UI surface: excluded from the thread, **purged when undo no longer needs it** (re-ruled 2026-07-31 — the session-coarsening model, 13-native-undo.md: on free-tier boards the close step's undo restores deleted comments, so the purge waits until that coarse step leaves the board stack cleanly or the board session ends (a stale-skipped step's backing waits for session end — 13's skip-purge decoupling, ruled 2026-07-31); on Pro the close commit nets delete-plus-purge and revert restores from history, so the purge rides the close flush; crash residue sweeps at the next card-window open, armed-then-cleared like every heal memo — residue defined as content no live coarse step owns, ruled 2026-07-31: 13-native-undo.md's sweep gate), and **stripped at every copy boundary** (clipboard staging, Duplicate, Save as Template — a copy must not carry ghosts no window session will ever purge; sibling of the `remote`-severing rule). The container-change stamping rule applies — the move stamps `modified` (`modified`'s scope above). **Copies carry the thread** — a copy is a fork, and dropping a subtree would be the one place a copy loses content; comment folders remint like every copied folder, and the copy's comments drop `remote`/`remote-state` (an instance of the item-level sever rule — Identity lifecycle above); whole-board forks carry verbatim as always. **Comments are window-scoped, outside the board snapshot** — the walk stays O(cards): the card window reads its own thread and live-reloads it from the same FSEvents stream, and the board snapshot never loads comment content. This is the stated exception to snapshot completeness, costs named: the snapshot never carries comment content — board search reaches comment bodies through its own transient search-time sweep instead (04-interactions.md ▸ Search, re-ruled 2026-07-29), the card window's find covers the focused thread (05) — and foreign comment changes are described by **path shape** — the "Update agent guide (vN)" mechanism: a changed path under `…/comments//` composes "Comment on '⟨card title⟩'" / "Edit comment on…" / "Delete comment on…" — and a comment leaving the tree from `comments/.trash/` composes **"Permanently delete comment on…"** (blessed 2026-07-31 — the board trash-pair's leaving-the-tree rule one level down, so purge-only windows never fall to a shrug and foreign purges read honestly); plurals fold noun-first where the post subject forces it (blessed 2026-07-31): "3 comments on 'Fix login'" / "Comment on 3 cards" / "Edit 3 comments on 'Fix login'" / "Draft comment on 3 cards" — (06 gains the verb family when the feature ships), and the announcer speaks arrivals the same way. Git protection needs nothing new — the committer already stages the whole root and its condition is the tree. Trash interplay is free: a trashed card carries its `comments/`, purge kills it, restore returns it. Comments never count in the welcome counts. A comment-count chip on card faces is WISHLIST #9. **`comments/.draft/` is the card's single comment draft** (ruled 2026-07-29, the UI pass — user content with a lifecycle name, the `.trash` pattern one level down): a reserved dot-named folder holding ordinary comment schema, `attachments/` included; excluded from the thread; **posting renames it to a fresh lowercase UUID and restamps `created`/`modified` in the same bracket** — chronology is post time, not drafting time — one commit. `.draft` joins the claimed names (a wrong-kind node squatting it displaces by the ladder); copies and the trash carry it like any comment folder (fork-lossless); a draft emptied of text with no attachments is deleted by the app, never litter. The schema is **tier-blind** (one format, no tier-specific keys), and so is the feature: **comments render and author in every tier** (ruled 2026-07-29 — 05-card-window.md ▸ The comments column); only tracker sync is tier-gated (Teams — 12-editions.md). +- **`comments/` card child — the comment thread** (storage specified 2026-07-29, the card-window UI designed the same day — 05-card-window.md ▸ The comments column; shipped: thread column, composer, `.draft`, and `comments/.trash/` are all live). A comment is a **UUID-named folder** under `comments/`, holding `index.md` (frontmatter + Markdown body, the card-body subset) and optionally `attachments/` — a card's anatomy one level down, so the fractal rules apply verbatim: two-step-create tolerance, stray tolerance, loose-file relocation, displacement of a squatted `attachments`, shape-only identity. **The heals run window-scoped at this depth** (ruled 2026-07-31): comments are outside the board walk, so the thread read is the detector — it reports loose files and claimed-name squatters as window-scoped defects, and the card window relocates/displaces them memo-less on the read-cadence guard (02-architecture.md ▸ HealScheduler's window-scoped pattern); a loose file beside a comment's `index.md` moves into that comment's `attachments/` with the relocation family's notice ("Moved 'notes.txt' into attachments — comment on 'Fix login'"), and the heals compose as at board level. `comments/` itself stays a plain reserved child — never a level, never identity. **Field table for `kind: comment`** (exercising IntegrityRules' per-kind hook for the first time): `schema` required; `kind: comment` written at creation (the common rule); **`author`** lenient — self-reported *content*, not overlay: unlike `modified-by` it survives app writes; the app writes the macOS account's full name (the identity 06-history-undo.md's derived default already uses), agents write their own (the guide teaches it), tracker sync writes the remote author verbatim, and missing renders unattributed; `created`/`modified` as everywhere — `created` is load-bearing (ordering below), and the edited indicator is simply `modified` differing, no extra field; `remote`/`remote-state` ride as reserved tracker keys. **No `title`, no `order`.** The rules, all ruled 2026-07-29: **Ordering is chronology, not ranks** — the thread sorts by `created` ascending; chronology *is* a conversation's semantics, and cross-machine or tracker-synced comments carry independent clocks where minted ranks would interleave arbitrarily (the trash joined the timestamp family 2026-07-31 — it sorts by `modified` descending, Deletion below); ties and missing/malformed `created` (coerce-tier fallback, logged) sort after dated siblings, folder-name order. **Flat this iteration** — no reply nesting, deliberately deferred with the door open (a future reply relation would be frontmatter, and the window-scoped read below holds either way). **Comment defects never refuse the board** — worst case is the stray posture (tolerated, logged, unrendered): a broken leaf annotation must not brick a load; deliberate, proportionate divergence from card fail-fast. The posture is **load-scoped** (ruled 2026-07-31): a user-initiated copy is a transaction whose stamping preflight refuses whole at comment depth too (Identity lifecycle above) — the board always loads; the gesture may refuse. **Delete skips the board trash, not the pattern** (re-ruled 2026-07-29 — supersedes the earlier capture parenthetical): the root `.trash/` holds cards and lanes; a deleted comment **moves into `comments/.trash/`** — the materialized-trash pattern one level down, joining `.draft` in the claimed names (a squatter displaces by the ladder). The move is immediate, no confirm, and undo is the net: the inverse is the ordinary move back — **no byte capture in any tier** (13-native-undo.md's no-capture rule holds; on git boards the move is a commit like any other). `comments/.trash/` is undo's backing store, never a UI surface: excluded from the thread, **purged when undo no longer needs it** (re-ruled 2026-07-31 — the session-coarsening model, 13-native-undo.md: on free-tier boards the close step's undo restores deleted comments, so the purge waits until that coarse step leaves the board stack cleanly or the board session ends (a stale-skipped step's backing waits for session end — 13's skip-purge decoupling, ruled 2026-07-31); on Pro the close commit nets delete-plus-purge and revert restores from history, so the purge rides the close flush; crash residue sweeps at the next card-window open, armed-then-cleared like every heal memo — residue defined as content no live coarse step owns, ruled 2026-07-31: 13-native-undo.md's sweep gate), and **stripped at every copy boundary** (clipboard staging, Duplicate, Save as Template — a copy must not carry ghosts no window session will ever purge; sibling of the `remote`-severing rule). The container-change stamping rule applies — the move stamps `modified` (`modified`'s scope above). **Copies carry the thread** — a copy is a fork, and dropping a subtree would be the one place a copy loses content; comment folders remint like every copied folder, and the copy's comments drop `remote`/`remote-state` (an instance of the item-level sever rule — Identity lifecycle above); whole-board forks carry verbatim as always. **Comments are window-scoped, outside the board snapshot** — the walk stays O(cards): the card window reads its own thread and live-reloads it from the same FSEvents stream, and the board snapshot never loads comment content. This is the stated exception to snapshot completeness, costs named: the snapshot never carries comment content — board search reaches comment bodies through its own transient search-time sweep instead (04-interactions.md ▸ Search, re-ruled 2026-07-29), the card window's find covers the focused thread (05) — and foreign comment changes are described by **path shape** — the "Update agent guide (vN)" mechanism: a changed path under `…/comments//` composes "Comment on '⟨card title⟩'" / "Edit comment on…" / "Delete comment on…" — and a comment leaving the tree from `comments/.trash/` composes **"Permanently delete comment on…"** (blessed 2026-07-31 — the board trash-pair's leaving-the-tree rule one level down, so purge-only windows never fall to a shrug and foreign purges read honestly); plurals fold noun-first where the post subject forces it (blessed 2026-07-31): "3 comments on 'Fix login'" / "Comment on 3 cards" / "Edit 3 comments on 'Fix login'" / "Draft comment on 3 cards" — (06 gains the verb family when the feature ships), and the announcer speaks arrivals the same way. Git protection needs nothing new — the committer already stages the whole root and its condition is the tree. Trash interplay is free: a trashed card carries its `comments/`, purge kills it, restore returns it. Comments never count in the welcome counts. **A comment-count chip shipped 2026-08-09** (design ruling, card e729e30a; formerly WISHLIST #9): the snapshot carries a per-card `commentCount` from a readdir of `comments/`'s identity-shaped, `index.md`-bearing children (`BoardLoader.commentCount(in:)`) — never a parse of one, so this stays exactly the O(cards) walk the paragraph above requires. It agrees with the card window's parsed thread count except when a comment's `index.md` exists but fails to parse, the one case the readdir does not pay to detect (03-board-ui.md § Card face). **`comments/.draft/` is the card's single comment draft** (ruled 2026-07-29, the UI pass — user content with a lifecycle name, the `.trash` pattern one level down): a reserved dot-named folder holding ordinary comment schema, `attachments/` included; excluded from the thread; **posting renames it to a fresh lowercase UUID and restamps `created`/`modified` in the same bracket** — chronology is post time, not drafting time — one commit. `.draft` joins the claimed names (a wrong-kind node squatting it displaces by the ladder); copies and the trash carry it like any comment folder (fork-lossless); a draft emptied of text with no attachments is deleted by the app, never litter. The schema is **tier-blind** (one format, no tier-specific keys), and so is the feature: **comments render and author in every tier** (ruled 2026-07-29 — 05-card-window.md ▸ The comments column); only tracker sync is tier-gated (Teams — 12-editions.md). - **Card metadata keys `labels`, `assignees`, `due`** — issue-style metadata belonging to the tracker-integration story (the pathfinder shipped them natively; the rewrite reserves them instead). One deliberate carve-out: the commit-message composer names changes to these three keys ("Relabel card 'X'" — 06-history-undo.md), invisible in the UI though they are. - **`remote` key** (board, card) and **`remote-state` key** (lane) — future tracker connectors (Gitea/GitHub/GitLab/Jira), per 07-sync-collab.md's out-of-scope note. diff --git a/DESIGN/03-board-ui.md b/DESIGN/03-board-ui.md index 6d703e7..dc452a4 100644 --- a/DESIGN/03-board-ui.md +++ b/DESIGN/03-board-ui.md @@ -25,8 +25,8 @@ Toolbars are **pure enhancement**: every function they host already has a menu i ## 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. -- **No body excerpt** (settled): the face stays title-only — the old "iterate on the card face later" item is closed with no growth. +- 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, 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. - **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. diff --git a/DESIGN/10-accessibility.md b/DESIGN/10-accessibility.md index 3d24571..572069c 100644 --- a/DESIGN/10-accessibility.md +++ b/DESIGN/10-accessibility.md @@ -10,7 +10,7 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na ## 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. - **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. diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index 6561b7b..06980a8 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -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 `/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) diff --git a/Kanban/Storage/BoardModel.swift b/Kanban/Storage/BoardModel.swift index d57bc8f..7ecf6e1 100644 --- a/Kanban/Storage/BoardModel.swift +++ b/Kanban/Storage/BoardModel.swift @@ -236,11 +236,14 @@ public struct Lane: Identifiable, Sendable, Equatable { public var isDeleted: Bool { !deleted.isMissing } } -/// A card: `///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: `///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 diff --git a/Kanban/UI/AccessibilityPhrases.swift b/Kanban/UI/AccessibilityPhrases.swift index 9ea1b69..2cf2863 100644 --- a/Kanban/UI/AccessibilityPhrases.swift +++ b/Kanban/UI/AccessibilityPhrases.swift @@ -128,17 +128,27 @@ enum AccessibilityPhrases { /// the dim is the sighted signal, this is the other one. static let cutPending = "cut, pending paste" - /// A card element's value — the attachment count when it has files, the cut-pending phrase when - /// it is staged for paste, both when both, and **the empty string when neither**. + /// A card element's value — the attachment count and the comment count when the card has either + /// (design ruling 2026-08-09, card e729e30a — the comments chip's face value gains "N comments" + /// beside the existing attachment wording), the cut-pending phrase when it is staged for paste, + /// any mix of the three, and **the empty string when none apply**. + /// + /// **Attachments before comments**, matching the chips' own left-to-right order on the face + /// (`CardFaceView.titleRow`: attachments, then comments) — one reading order for the two + /// surfaces, so a sighted user's eye and a VoiceOver user's ear never disagree about which comes + /// first. `commentCount` is `AccessibilityPhrases`' own — the pane's plural folding, reused + /// rather than restated, so a face value and the comments pane's header can never fold "1 + /// comment" two different ways. /// /// Empty rather than `nil` on purpose: the modifier that consumes it is unconditional, because a /// `if` around `.accessibilityValue` would put the whole card face inside a `_ConditionalContent` /// that flips identity — and therefore rebuilds the face, dropping its measured height and its - /// marquee registration — the moment an attachment lands or a cut is pasted. An empty AXValue - /// speaks as nothing, which is exactly what "no value" should sound like. - static func cardValue(attachments: Int, isCutPending: Bool) -> String { + /// marquee registration — the moment an attachment lands, a comment posts, or a cut is pasted. An + /// empty AXValue speaks as nothing, which is exactly what "no value" should sound like. + static func cardValue(attachments: Int, comments: Int, isCutPending: Bool) -> String { var parts: [String] = [] if attachments > 0 { parts.append(attachmentCount(attachments)) } + if comments > 0 { parts.append(commentCount(comments)) } if isCutPending { parts.append(cutPending) } return parts.joined(separator: ", ") } diff --git a/Kanban/UI/Board/BoardMetrics.swift b/Kanban/UI/Board/BoardMetrics.swift index f0eb73e..64869ea 100644 --- a/Kanban/UI/Board/BoardMetrics.swift +++ b/Kanban/UI/Board/BoardMetrics.swift @@ -185,11 +185,20 @@ enum BoardMetrics { em(0.75, bodyPointSize: bodyPointSize) } - /// Between the icon, the title and the attachments chip. + /// Between the icon, the title and the two trailing chips — attachments, then comments + /// (`CardFaceView.titleRow`; the comments chip joined 2026-08-09). static func cardRowSpacing(bodyPointSize: CGFloat) -> CGFloat { em(0.45, bodyPointSize: bodyPointSize) } + /// Inside the comments chip only: between its bubble glyph and its count text + /// (`CardFaceView.commentsIndicator`) — tighter than `cardRowSpacing`, which separates the + /// face's own row items, because this is one chip's internal rhythm, closer to a badge's own + /// glyph-to-digit spacing than to a row gap. + static func chipGlyphSpacing(bodyPointSize: CGFloat) -> CGFloat { + em(0.2, bodyPointSize: bodyPointSize) + } + /// The masonry's spacing — between interior columns and between stacked cards within a column. /// /// The lane registers this into `LaneDropRegistry.Grid`, so the drop model's analytic resting diff --git a/Kanban/UI/Board/CardFaceView.swift b/Kanban/UI/Board/CardFaceView.swift index 387bd2b..7ae5ac2 100644 --- a/Kanban/UI/Board/CardFaceView.swift +++ b/Kanban/UI/Board/CardFaceView.swift @@ -82,8 +82,9 @@ enum CardFaceRole: Sendable { // MARK: - Card face /// The card face: a rounded plate carrying a leading SF Symbol, the title (or its quiet "Untitled" -/// placeholder), a quiet trailing attachments indicator, and a left-edge colour accent stripe -/// (03-board-ui.md § Card face, § Styling ▸ Capabilities). +/// placeholder), two quiet trailing chips — attachments and comments, each present-only — and a +/// left-edge colour accent stripe (03-board-ui.md § Card face, § Styling ▸ Capabilities). The +/// comments chip joined 2026-08-09 (design ruling, card e729e30a). /// /// ### One face, two containers /// @@ -96,10 +97,13 @@ enum CardFaceRole: Sendable { /// /// ### Title-only, deliberately /// -/// **No body excerpt** — settled, "the face stays title-only … the old 'iterate on the card face -/// later' item is closed with no growth". The only face chip in scope is attachments, "a quiet -/// indicator when the card has files — the title dominates", which is why the paperclip is a -/// secondary-tinted caption and not a count pill: the eye should land on the title. +/// **No body excerpt** — settled, and still true after 2026-08-09's growth (the hero banner, the +/// comments chip): the face never draws a preview of the card's own prose, and never will — what +/// grew is exposure of facts the card already carries structurally (an attachment named as hero, a +/// folder count), not content. Two face chips are in scope: **attachments**, "a quiet indicator when +/// the card has files — the title dominates", and **comments**, its same-vocabulary twin — a +/// secondary-tinted glyph (plus a count, for comments — see `commentsIndicator`) rather than a count +/// pill, so the eye still lands on the title first. /// /// ### Two lenient fields, two different fallbacks /// @@ -356,11 +360,12 @@ struct CardFaceView: View, Equatable { // `isRenaming` is (`CardFaceRole`). .accessibilityElement(children: isRenaming ? .contain : .ignore) .accessibilityLabel(AccessibilityPhrases.cardLabel(title: card.title.value)) - // The attachment count, the deferred cut's "cut, pending paste", or both — and the empty - // string when neither, which speaks as nothing (see `AccessibilityPhrases.cardValue` for why - // it is not a conditional modifier). + // The attachment count, the comment count, the deferred cut's "cut, pending paste", or any + // mix of the three — and the empty string when none apply, which speaks as nothing (see + // `AccessibilityPhrases.cardValue` for why it is not a conditional modifier). .accessibilityValue(AccessibilityPhrases.cardValue( attachments: card.attachments.count, + comments: card.commentCount, isCutPending: store.transient.pendingCut.ids.contains(card.id) )) // "Selection state is always readable from the element (trait)" — the other half of "state @@ -652,6 +657,7 @@ struct CardFaceView: View, Equatable { .lineLimit(4) .frame(maxWidth: .infinity, alignment: .leading) attachmentsIndicator + commentsIndicator } .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize)) .padding(.leading, stripeWidth) @@ -861,10 +867,11 @@ struct CardFaceView: View, Equatable { .foregroundStyle(iconTint) .imageScale(.medium) titleOrEditor - // The title takes the row's width so the indicator sits hard against the trailing + // The title takes the row's width so the chips sit hard against the trailing // edge — and so the rename field fills the same span the title occupied. .frame(maxWidth: .infinity, alignment: .leading) attachmentsIndicator + commentsIndicator } } @@ -908,8 +915,8 @@ struct CardFaceView: View, Equatable { } } - /// The one face chip in scope — shown only when the card actually has files, and quiet enough - /// that the title still dominates (03-board-ui.md § Card face). The count goes to the + /// One of the two face chips in scope — shown only when the card actually has files, and quiet + /// enough that the title still dominates (03-board-ui.md § Card face). The count goes to the /// accessibility *value* rather than onto the face: it is useful to know, not to look at. /// /// **Decorative, and hidden outright** (10-accessibility.md): "face icon and chips are @@ -927,6 +934,37 @@ struct CardFaceView: View, Equatable { } } + /// The comments chip — attachments' twin, joined 2026-08-09 (design ruling, card e729e30a): + /// shown only when the card has one comment or more, same secondary-tinted, decorative, + /// present-only vocabulary as `attachmentsIndicator`. **Carries a visible count**, unlike the + /// paperclip, because the ruling asks for "a quiet indicator (bubble-style SF Symbol + count)" — + /// still quiet (caption size, secondary tint, no pill background), just not icon-only; the count + /// answers "how many" the way a lane's own card-count badge does one level up, without spending a + /// tap to find out. + /// + /// **`card.commentCount` is the one and only source** — a snapshot field the loader fills with a + /// readdir (`BoardLoader.commentCount(in:)`, `Card.commentCount`'s own doc comment), never a + /// per-face parse — so drawing this chip costs nothing beyond reading a field already on the + /// compared `card` parameter (RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card bodies: + /// no new Observable read joins this body, and no new `CardFaceView` parameter was needed either, + /// since the count already rides inside `card`). + /// + /// **Decorative and hidden outright**, `attachmentsIndicator`'s exact reasons: the flattened + /// element carries the comment count in its value (`AccessibilityPhrases.cardValue`), so a label + /// here would be redundant even before the flattening drops it. + @ViewBuilder + private var commentsIndicator: some View { + if card.commentCount > 0 { + HStack(spacing: BoardMetrics.chipGlyphSpacing(bodyPointSize: pointSize)) { + Image(systemName: "bubble") + Text("\(card.commentCount)") + } + .boardFont(.caption) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + } + /// K1 · left edge stripe, painted with the resolved `background` — "a card's [colour paints] a /// stripe along its left edge; the surfaces themselves keep the standard chrome, so coloured /// title text never sits on a coloured fill" (03-board-ui.md § Styling ▸ Capabilities). diff --git a/Kanban/UI/Board/TrashLaneRowView.swift b/Kanban/UI/Board/TrashLaneRowView.swift index 04f7433..6caaa98 100644 --- a/Kanban/UI/Board/TrashLaneRowView.swift +++ b/Kanban/UI/Board/TrashLaneRowView.swift @@ -168,9 +168,11 @@ struct TrashLaneRowView: View, Equatable { .accessibilityElement(children: .ignore) .accessibilityLabel(AccessibilityPhrases.trashedLaneLabel(title: lane.title.value, cards: lane.heldCards)) // The cut-pending phrase, on the row's value — the card element's rule, minus the attachment - // count an opaque unit has no answer for. + // and comment counts an opaque unit has no answer for (comments are card-level only, and a + // trashed lane's cards are not individually addressable). .accessibilityValue(AccessibilityPhrases.cardValue( attachments: 0, + comments: 0, isCutPending: store.transient.pendingCut.ids.contains(lane.id) )) .accessibilityAddTraits(isSelected ? [.isSelected] : []) diff --git a/KanbanTests/AccessibilityPhrasesTests.swift b/KanbanTests/AccessibilityPhrasesTests.swift index b06c533..9482e99 100644 --- a/KanbanTests/AccessibilityPhrasesTests.swift +++ b/KanbanTests/AccessibilityPhrasesTests.swift @@ -81,27 +81,62 @@ struct AccessibilityPhrasesTests { /// and an empty AXValue speaks as nothing. @Test("A plain card carries no value") 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") func cardValueAttachments() { - #expect(AccessibilityPhrases.cardValue(attachments: 1, isCutPending: false) == "1 attachment") - #expect(AccessibilityPhrases.cardValue(attachments: 4, isCutPending: false) == "4 attachments") + #expect( + 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") 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 - /// is a fact about what is about to happen to it. - @Test("A cut card with files carries both fragments") - func cardValueBoth() { + /// Attachments and comments together, attachments first — the chips' own left-to-right order on + /// the face (`CardFaceView.titleRow`). + @Test("Attachments and comments both ride the value, attachments first") + func cardValueAttachmentsAndComments() { #expect( - AccessibilityPhrases.cardValue(attachments: 2, isCutPending: true) - == "2 attachments, cut, pending paste" + AccessibilityPhrases.cardValue(attachments: 2, comments: 3, isCutPending: false) + == "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" ) } diff --git a/KanbanTests/BoardLoaderTests.swift b/KanbanTests/BoardLoaderTests.swift index 0930c35..23d4418 100644 --- a/KanbanTests/BoardLoaderTests.swift +++ b/KanbanTests/BoardLoaderTests.swift @@ -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 // comparison is UUID-value equality, never string equality") diff --git a/KanbanTests/BoardRenderPerformanceTests.swift b/KanbanTests/BoardRenderPerformanceTests.swift index a4248f7..21b3579 100644 --- a/KanbanTests/BoardRenderPerformanceTests.swift +++ b/KanbanTests/BoardRenderPerformanceTests.swift @@ -330,6 +330,40 @@ struct BoardRenderPerformanceTests { "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") func theLaneCostFollowsTheBoard() async throws { let wide = laneCount * 2 diff --git a/KanbanTests/ViewEquatableTests.swift b/KanbanTests/ViewEquatableTests.swift index 49b0188..65aa37e 100644 --- a/KanbanTests/ViewEquatableTests.swift +++ b/KanbanTests/ViewEquatableTests.swift @@ -342,6 +342,37 @@ struct CardFaceViewEquatableTests { #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") func anEditedCardIsADifference() throws { let fixture = try makeFixture() diff --git a/WISHLIST.md b/WISHLIST.md index 6bd8acf..a93b4d4 100644 --- a/WISHLIST.md +++ b/WISHLIST.md @@ -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 — `/.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