The comments column gets a handle — the beside-mount divider is now user-draggable

Kanban.xcodeproj must be regenerated (xcodegen generate) before building.

CardWindowMetrics.commentsColumnWidth was a fixed figure between the body
column and the comments pane in the side-by-side layout; it is now only the
resting default. A new CommentsColumnDivider replaces the plain Divider()
between the two panes with a real HStack element carrying a resize-left-right
cursor and a drag gesture, clamped through a new pure seam,
CardWindowMetrics.clampedCommentsColumnWidth — never narrower than the
existing commentsMinimumWidth floor, never wide enough to push the body under
its own bodyMinimumWidth. The drag tracks live in memory
(commentsColumnWidthOverride) and writes AppPreferences.commentsColumnWidthKey
exactly once, on release, mirroring LaneResizeSession's live-track/write-once
split rather than hammering UserDefaults per tick.

Persistence is app-wide via @AppStorage, matching showComments and
commentsBesideBody — the pane's other two layout bits — rather than the
per-card BoardRegistry.cardWindowFrames: this is "how the pane is arranged,"
the same kind of fact those two already are, not a per-card window geometry.
Flagged on the card thread as a call worth owner review.

Six new unit tests cover the clamp's two floors, the degenerate case where a
container is too narrow for both, and its agreement with
CardWindowMetrics.minimumSize at the window's own floor.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 08:38:07 -04:00
parent b0c134a896
commit 0a01405ced
5 changed files with 351 additions and 16 deletions
+81
View File
@@ -94,6 +94,87 @@ struct CommentsWindowMinimumTests {
}
}
// MARK: - The column divider's clamp
@Suite("Comments ▸ the column divider")
struct CommentsColumnDividerClampTests {
@Test("A proposal between the two floors passes through untouched")
func withinBoundsPassesThrough() {
// A generous container 1200pt so neither floor is anywhere near the proposal.
#expect(
CardWindowMetrics.clampedCommentsColumnWidth(400, bodyPointSize: 13, containerWidth: 1200) == 400
)
}
@Test("A proposal narrower than the minimum clamps up to it")
func tooNarrowClampsToMinimum() {
let minimum = CardWindowMetrics.commentsMinimumWidth(bodyPointSize: 13)
#expect(
CardWindowMetrics.clampedCommentsColumnWidth(1, bodyPointSize: 13, containerWidth: 1200)
== minimum
)
// Zero and negative proposals a drag that overshoots past the window's own edge read the
// same way, never as a width smaller than the floor.
#expect(
CardWindowMetrics.clampedCommentsColumnWidth(-50, bodyPointSize: 13, containerWidth: 1200)
== minimum
)
}
@Test("A proposal that would starve the body clamps down to the body's own floor")
func tooWideClampsToLeaveTheBodyItsFloor() {
let bodyFloor = CardWindowMetrics.bodyMinimumWidth(bodyPointSize: 13)
let containerWidth: CGFloat = 700
let clamped = CardWindowMetrics.clampedCommentsColumnWidth(
10_000, bodyPointSize: 13, containerWidth: containerWidth
)
#expect(clamped == containerWidth - bodyFloor)
#expect(containerWidth - clamped == bodyFloor, "the body keeps exactly its floor, not less")
}
@Test("A container too narrow for both floors still answers the comments floor, never less")
func aStarvedContainerStillAnswersTheFloor() {
// A container narrower than `commentsMinimumWidth + bodyMinimumWidth` the two floors
// cannot both be honored, and the comments floor wins rather than the arithmetic producing a
// maximum below the minimum (`min(max(proposed, minimum), maximum)` with a negative maximum).
let minimum = CardWindowMetrics.commentsMinimumWidth(bodyPointSize: 13)
#expect(
CardWindowMetrics.clampedCommentsColumnWidth(minimum, bodyPointSize: 13, containerWidth: 10)
== minimum
)
#expect(
CardWindowMetrics.clampedCommentsColumnWidth(10_000, bodyPointSize: 13, containerWidth: 10)
== minimum
)
}
@Test("At the window's own minimum size, the clamp forces exactly the comments floor")
func atTheWindowMinimumTheClampAgreesWithIt() {
// `CardWindowMetrics.minimumSize(commentsColumn: true)` is sidebar + body floor + comments
// floor. The beside `HStack`'s container at that window size is the window minus the sidebar
// body floor plus comments floor so a persisted width far larger than either floor still
// has to land on exactly the comments floor here, or the window's own minimum and the
// divider's drag floor would disagree about what "as small as this gets" means.
let bodyPointSize: CGFloat = 13
let minimum = CardWindowMetrics.minimumSize(bodyPointSize: bodyPointSize, commentsColumn: true)
let sidebarWidth = CardWindowMetrics.sidebarWidth(bodyPointSize: bodyPointSize)
let containerWidth = minimum.width - sidebarWidth
let clamped = CardWindowMetrics.clampedCommentsColumnWidth(
10_000, bodyPointSize: bodyPointSize, containerWidth: containerWidth
)
#expect(clamped == CardWindowMetrics.commentsMinimumWidth(bodyPointSize: bodyPointSize))
}
@Test("The floor scales with the body font, like every other measurement in this window")
func theFloorScalesWithTheBodyFont() {
let small = CardWindowMetrics.clampedCommentsColumnWidth(1, bodyPointSize: 11, containerWidth: 1200)
let large = CardWindowMetrics.clampedCommentsColumnWidth(1, bodyPointSize: 24, containerWidth: 1200)
#expect(large > small)
}
}
// MARK: - The sort direction
@Suite("Comments ▸ the sort direction")