Comments, phase 2 — the pane, the composer, and the inline session

The card window recomposes into three componentized panes (body,
comments, attributes) with two mounts — beside or body-over-comments
at ~3:2 — behind View ▸ Comments Beside Body. View ▸ Show Comments is
one persisted app-wide bit, no content-derived auto-show; File ▸ Add
Comment flips it on and focuses the composer. The thread renders
author lines, edited markers, card-subset Markdown bodies, and
read-only Quick Look chips under a count header with the sort-
direction control. The composer edits comments/.draft/ on the slow
cadence (blur, close, quit, ~30s interval), Escape only moves focus,
⌘↩ posts. Inline edit is a body-edit session in miniature: 700ms
debounce, Save/⌘↩ commits, Cancel and Escape revert to session-start
bytes, close flushes. File drops within either authoring surface
carve out of the window-wide card default into that surface's
attachments/; paperclips cover the no-drag path. Close flush runs
inline flush, then draft save, then the comments/.trash purge;
open sweeps crash residue.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-30 20:19:52 -04:00
parent f68ac3668e
commit fe3ffac48e
27 changed files with 4496 additions and 24 deletions
+261
View File
@@ -0,0 +1,261 @@
import CoreGraphics
import Foundation
import Testing
@testable import Kanban
/// The comments pane's **pure** seams: where it mounts, which way the thread runs, what a comment's
/// author line says, and which folder a dropped file lands in (05-card-window.md Composition, The
/// comments column; Attachments' carve-out).
///
/// Every one of them is a rule that fails silently in a running window a 3:2 split that is 2:3
/// looks deliberate, a composer at the wrong end looks like a design choice, and a file that landed
/// in the card's folder instead of the draft's is not visible at all until someone opens Finder. So
/// they are values and functions, and this is their suite; the views that arrange them are the
/// manual-verification list's.
// MARK: - The mount
@Suite("Comments ▸ where the pane mounts")
struct CommentsMountTests {
@Test("The menu row's bit reads one way: checked is beside")
func theBitReadsOneWay() {
#expect(CommentsMount(besideBody: true) == .beside)
#expect(CommentsMount(besideBody: false) == .stacked)
}
@Test("Stacked splits three parts body to two parts comments")
func stackedSplitsThreeToTwo() {
let mount = CommentsMount.stacked
#expect(mount.bodyHeight(in: 500) == 300)
#expect(mount.commentsHeight(in: 500) == 200)
}
@Test("The two heights always add up to the window — no gap, no overlap")
func theHeightsSum() {
for height in [0.0, 1.0, 37.0, 500.0, 1013.5] as [CGFloat] {
let mount = CommentsMount.stacked
#expect(mount.bodyHeight(in: height) + mount.commentsHeight(in: height) == height)
}
}
@Test("Beside, the body owns the full height and the comments pane divides none of it")
func besideDividesNoHeight() {
#expect(CommentsMount.beside.bodyHeight(in: 500) == 500)
#expect(CommentsMount.beside.commentsHeight(in: 500) == 0)
}
@Test("A negative proposed height is clamped rather than laid out")
func negativeHeightsClamp() {
#expect(CommentsMount.stacked.bodyHeight(in: -10) == 0)
#expect(CommentsMount.beside.bodyHeight(in: -10) == 0)
}
@Test("Only the beside mount widens the window")
func onlyBesideWidens() {
// "The window's minimum width grows only while the column is shown side-by-side" (05
// Composition) which is the entire reason the stacked mount exists.
#expect(CommentsMount.beside.widensWindow)
#expect(!CommentsMount.stacked.widensWindow)
}
}
// MARK: - The window's minimum
@Suite("Comments ▸ the window minimum")
struct CommentsWindowMinimumTests {
@Test("The minimum grows by the comments floor only with the column beside the body")
func theMinimumGrowsOnlyBeside() {
let without = CardWindowMetrics.minimumSize(bodyPointSize: 13)
let with = CardWindowMetrics.minimumSize(bodyPointSize: 13, commentsColumn: true)
#expect(with.width == without.width + CardWindowMetrics.commentsMinimumWidth(bodyPointSize: 13))
#expect(with.height == without.height, "a stacked pane divides height rather than demanding more")
}
@Test("Every comments measurement scales with the body font")
func measurementsScale() {
// 10-accessibility.md Text: "metrics derive from font metrics, so layout survives the
// largest system text sizes". The pane's numbers are no exception to the window's rule.
#expect(
CardWindowMetrics.commentsColumnWidth(bodyPointSize: 18)
> CardWindowMetrics.commentsColumnWidth(bodyPointSize: 11)
)
#expect(
CardWindowMetrics.composerHeight(bodyPointSize: 18)
> CardWindowMetrics.composerHeight(bodyPointSize: 11)
)
#expect(
CardWindowMetrics.inlineEditorHeight(bodyPointSize: 13)
> CardWindowMetrics.composerHeight(bodyPointSize: 13),
"an inline editor opens over text that already exists"
)
}
}
// MARK: - The sort direction
@Suite("Comments ▸ the sort direction")
struct CommentSortDirectionTests {
private func comments(_ ids: [String]) -> [Kanban.Comment] {
ids.map { id in
Kanban.Comment(
id: ItemID(rawValue: id),
schema: .valid(1),
author: .missing,
created: .missing,
modified: .missing,
modifiedBy: .missing,
attachments: [],
document: FrontmatterDocument(body: "")
)
}
}
@Test("Ascending is the default, and the control's bit reads one way")
func ascendingIsTheDefault() {
#expect(CommentSortDirection(newestFirst: false) == .ascending)
#expect(CommentSortDirection(newestFirst: true) == .descending)
#expect(!CommentSortDirection.ascending.isNewestFirst)
}
@Test("Descending is the loader's order reversed, never a second sort")
func descendingReverses() {
let thread = comments([CommentIdent.one, CommentIdent.two, CommentIdent.three])
#expect(CommentSortDirection.ascending.apply(to: thread).map(\.id) == thread.map(\.id))
#expect(CommentSortDirection.descending.apply(to: thread).map(\.id) == thread.reversed().map(\.id))
}
@Test("An empty thread reverses to an empty thread")
func emptyReverses() {
#expect(CommentSortDirection.descending.apply(to: []).isEmpty)
}
@Test("The composer sits at the newest end — bottom ascending, top descending")
func theComposerSitsAtTheNewestEnd() {
#expect(!CommentSortDirection.ascending.placesComposerFirst)
#expect(CommentSortDirection.descending.placesComposerFirst)
}
@Test("The control names the order it would give, and names it once")
func theControlLabelIsOneString() {
#expect(CommentSortDirection.ascending.controlLabel == "Oldest First")
#expect(CommentSortDirection.descending.controlLabel == "Newest First")
}
}
// MARK: - The header
@Suite("Comments ▸ the header count")
struct CommentsHeaderTests {
@Test("An empty thread still counts — the pane is an invitation, not an absence")
func zeroIsACount() {
#expect(CommentsHeader.title(count: 0) == "Comments · 0")
#expect(CommentsHeader.title(count: 3) == "Comments · 3")
}
}
// MARK: - The author line
@Suite("Comments ▸ the author line")
struct CommentAuthorLineTests {
@Test("Name and timestamp join with the window's own separator")
func nameAndTimestamp() {
#expect(
CommentAuthorLine.text(author: "Ada Lovelace", timestamp: "1 Jan 2026", isEdited: false)
== "Ada Lovelace · 1 Jan 2026"
)
}
@Test("A missing author renders without a name — never a placeholder string")
func missingAuthorRendersNothingInItsPlace() {
// "Missing renders unattributed" (`Comment.author`), and unattributed is the absence of a
// name: a placeholder there would be the app inventing an identity for a file that carries
// none, which is the same reason there are no avatars.
#expect(CommentAuthorLine.text(author: nil, timestamp: "1 Jan 2026", isEdited: false) == "1 Jan 2026")
}
@Test("An empty author string is absent too — the Writer never writes one")
func blankAuthorIsAbsent() {
#expect(CommentAuthorLine.text(author: "", timestamp: "1 Jan 2026", isEdited: false) == "1 Jan 2026")
#expect(CommentAuthorLine.text(author: " ", timestamp: "1 Jan 2026", isEdited: false) == "1 Jan 2026")
}
@Test("'edited' appends when modified differs from created")
func editedAppends() {
#expect(
CommentAuthorLine.text(author: "Ada", timestamp: "1 Jan 2026", isEdited: true)
== "Ada · 1 Jan 2026 · edited"
)
#expect(CommentAuthorLine.text(author: nil, timestamp: "1 Jan 2026", isEdited: true) == "1 Jan 2026 · edited")
}
@Test("A comment with neither a name nor a date renders no line at all")
func nothingToSayIsNoLine() {
#expect(CommentAuthorLine.text(author: nil, timestamp: nil, isEdited: false) == nil)
// Not even for the edit marker: "· edited" hangs off something, and a row saying only
// "edited" would be a row about nothing.
#expect(CommentAuthorLine.text(author: nil, timestamp: nil, isEdited: true) == nil)
}
}
// MARK: - The drop carve-out
@Suite("Comments ▸ the drop carve-out")
struct CommentDropCarveOutTests {
@Test("Everywhere else in the window is the card's — the default stands")
func theWindowDefaultStands() {
#expect(CommentDropCarveOut.landing(for: CommentDropCarveOut.Hover()) == .card)
}
@Test("Within the composer's bounds, files land in the draft")
func composerLandsInTheDraft() {
#expect(
CommentDropCarveOut.landing(for: CommentDropCarveOut.Hover(isOverComposer: true))
== .comment(.draft)
)
}
@Test("Within an inline edit session's bounds, files land in that comment")
func inlineEditLandsInItsComment() {
let id = ItemID(rawValue: CommentIdent.one)
#expect(
CommentDropCarveOut.landing(for: CommentDropCarveOut.Hover(inlineEdit: id))
== .comment(.comment(id))
)
}
@Test("The inline session outranks the composer where they would ever overlap")
func theInlineSessionWins() {
// Moot today an inline session opens over a comment row and the composer is a separate
// surface and fixed anyway, because the case where it stops being moot is a layout change
// and a layout change must not silently move a user's files into the wrong folder.
let id = ItemID(rawValue: CommentIdent.one)
let hover = CommentDropCarveOut.Hover(isOverComposer: true, inlineEdit: id)
#expect(CommentDropCarveOut.landing(for: hover) == .comment(.comment(id)))
}
}
// MARK: - Where a target lives
@Suite("Comments ▸ the target's folder")
struct CommentTargetTests {
@Test("The draft and a posted comment resolve to their own folders, and the thread's rule owns both")
func targetsResolve() {
let card = URL(fileURLWithPath: "/board/lane/card", isDirectory: true)
let id = ItemID(rawValue: CommentIdent.one)
#expect(CommentTarget.draft.folder(inCard: card) == CommentThread.draftFolder(inCard: card))
#expect(
CommentTarget.comment(id).folder(inCard: card)
== CommentThread.commentFolder(id, inCard: card)
)
}
}