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
+71 -3
View File
@@ -119,6 +119,61 @@ enum CardWindowMetrics {
previewPadding(bodyPointSize: bodyPointSize)
}
// MARK: - The comments pane
/// How wide the comments pane is when it is mounted **beside** the body, in characters
/// (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.
///
/// 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`).
static let commentsColumnCharacters: CGFloat = 40
static func commentsColumnWidth(bodyPointSize: CGFloat) -> CGFloat {
columnWidth(characters: commentsColumnCharacters, bodyPointSize: bodyPointSize)
}
/// 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.
static let commentsMinimumCharacters: CGFloat = 28
static func commentsMinimumWidth(bodyPointSize: CGFloat) -> CGFloat {
columnWidth(characters: commentsMinimumCharacters, bodyPointSize: bodyPointSize)
}
/// 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.
static func commentChipMinimumWidth(bodyPointSize: CGFloat) -> CGFloat {
(attachmentThumbnailSide(bodyPointSize: bodyPointSize) + bodyPointSize * 6).rounded()
}
/// The composer's resting height **four lines and a bit**, which is the shape of the thing it
/// invites: enough that a two-sentence remark is visible whole, short enough that it never
/// dominates a thread. It scrolls internally past that rather than growing the pane, so a long
/// draft cannot push the thread off screen.
static func composerHeight(bodyPointSize: CGFloat) -> CGFloat {
(lineHeight(bodyPointSize: bodyPointSize) * 4.5).rounded()
}
/// An inline edit session's editor, one line taller than the composer: it opens over text that
/// already exists, so the common case is reading it before changing it.
static func inlineEditorHeight(bodyPointSize: CGFloat) -> CGFloat {
(lineHeight(bodyPointSize: bodyPointSize) * 5.5).rounded()
}
/// The gap between two comments in the thread a full gutter, one step larger than the rhythm
/// *inside* a comment (`sidebarRowSpacing`), so the eye groups an author line with its body
/// rather than with its neighbour.
static func commentSpacing(bodyPointSize: CGFloat) -> CGFloat {
gutter(bodyPointSize: bodyPointSize)
}
// MARK: - The rendered body
/// One step of structural indent in Preview a list level, a quote level. One and a half ems,
@@ -149,9 +204,22 @@ enum CardWindowMetrics {
/// The window's minimum size: the sidebar's fixed width plus the body's floor, and tall enough
/// for a title, its date line and a few lines of body.
static func minimumSize(bodyPointSize: CGFloat) -> CGSize {
CGSize(
width: sidebarWidth(bodyPointSize: bodyPointSize) + bodyMinimumWidth(bodyPointSize: bodyPointSize),
///
/// - Parameter commentsColumn: whether the comments pane is currently mounted **beside** the body
/// "the window's minimum width grows only while the column is shown side-by-side"
/// (05-card-window.md Composition). Stacked, or hidden, the pane costs the window no width at
/// all, which is the narrow-display case the layout option exists for. The height is unchanged
/// either way: a stacked pane divides the height it is given rather than demanding more, and a
/// window at its minimum height simply gets a short thread.
///
/// Defaulted to `false` so every caller that predates the comments pane still asks the same
/// question it always did.
static func minimumSize(bodyPointSize: CGFloat, commentsColumn: Bool = false) -> CGSize {
let comments = commentsColumn ? commentsMinimumWidth(bodyPointSize: bodyPointSize) : 0
return CGSize(
width: sidebarWidth(bodyPointSize: bodyPointSize)
+ bodyMinimumWidth(bodyPointSize: bodyPointSize)
+ comments,
height: (lineHeight(bodyPointSize: bodyPointSize) * 16).rounded()
)
}