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
+46 -4
View File
@@ -127,9 +127,11 @@ enum CardWindowMetrics {
/// (05-card-window.md Composition; The comments column).
///
/// Wider than the sidebar and narrower than the body's default measure, which is what it holds:
/// a rendered Markdown paragraph, an author line, a wrapping chip or two, and a composer. It is a
/// *fixed* width for the sidebar's reason "resize flex always goes to the body, never the fixed
/// panes" (05 Composition) so this is not a fraction of anything either.
/// a rendered Markdown paragraph, an author line, a wrapping chip or two, and a composer.
/// **The resting default, not a ceiling** (re-ruled 2026-08-09 the divider between it and the
/// body is user-draggable now, `clampedCommentsColumnWidth` below): this is what a fresh window
/// opens at before anyone has dragged it, `bodyDefaultCharacters`'s own relationship to the body
/// column read for this one instead.
///
/// Stacked, the pane takes the body's width instead and this number is not consulted at all,
/// which is why the window's minimum grows only in the beside mount (`CommentsMount.widensWindow`).
@@ -141,13 +143,53 @@ enum CardWindowMetrics {
/// The narrowest the comments pane is allowed to get the floor its share of the window's
/// minimum is measured at, a shorter measure than the body's because a comment is a remark rather
/// than a document.
/// than a document. **And, since 2026-08-09, the same number the divider drag may not cross**
/// one figure serving both jobs is what keeps the window's minimum and the drag's floor from
/// silently disagreeing (`clampedCommentsColumnWidth`).
static let commentsMinimumCharacters: CGFloat = 28
static func commentsMinimumWidth(bodyPointSize: CGFloat) -> CGFloat {
columnWidth(characters: commentsMinimumCharacters, bodyPointSize: bodyPointSize)
}
/// **Clamps a proposed comments-column width to what the divider drag may reach** (05-card-window.md
/// The comments column, extended 2026-08-09 the beside mount's divider is user-resizable).
///
/// Two floors, read from opposite ends:
/// - never narrower than `commentsMinimumWidth` "the comments pane shouldn't collapse below a
/// usable width", the same figure the window's own minimum already reserves for it;
/// - never wide enough to leave the body under `bodyMinimumWidth` "main content keeps a
/// healthy minimum" made exact, which is why this needs `containerWidth` at all: the beside
/// `HStack`'s own width, the window's content area minus the fixed sidebar, is what the second
/// floor is measured against.
///
/// A container too narrow for both floors at once a window part-way through animating to its
/// minimum, say still answers `commentsMinimumWidth` rather than a maximum below the minimum,
/// which a bare `min(max(proposed, minimum), maximum)` would produce for a negative `maximum`.
/// That is `CommentsMount.bodyHeight`'s `max(0, )` posture again: the floor wins over an input
/// that has gone past what the arithmetic can honor.
///
/// Pure, and deliberately unaware of any live window a drag session and a unit test alike call
/// it with whatever numbers they have; nothing here reads `NSFont` or an `NSWindow`.
static func clampedCommentsColumnWidth(
_ proposed: CGFloat,
bodyPointSize: CGFloat,
containerWidth: CGFloat
) -> CGFloat {
let minimum = commentsMinimumWidth(bodyPointSize: bodyPointSize)
let maximum = max(minimum, containerWidth - bodyMinimumWidth(bodyPointSize: bodyPointSize))
return min(max(proposed, minimum), maximum)
}
/// The comments divider's interactive width wider than the hairline it draws so the cursor has
/// something real to land the resize pointer on, the lane strip's own reasoning
/// (`BoardMetrics.resizeHandleWidth`) applied to a divider that is a genuine `HStack` element
/// rather than an overlay hung off a neighbour. Narrower than a full gutter: it is a grab target,
/// not a gap.
static func commentsDividerHitWidth(bodyPointSize: CGFloat) -> CGFloat {
(bodyPointSize * 0.7).rounded()
}
/// The smallest an attachment chip may be before the row wraps a thumbnail, a few characters of
/// filename, and the padding around them. Middle truncation does the rest, so a long name shrinks
/// rather than widening the pane.