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
+91 -3
View File
@@ -3,8 +3,22 @@ import UniformTypeIdentifiers
// MARK: - CardWindowView
/// The card window's content: **two full-height, independently scrolling columns** a wide body
/// column leading, a narrow attributes sidebar trailing (05-card-window.md Composition).
/// The card window's content: **three componentized panes** a wide body pane leading, the comments
/// pane in the middle when it is shown, and the narrow attributes sidebar trailing
/// (05-card-window.md Composition, re-composed 2026-07-29).
///
/// ### The composition arranges; the panes do not know about each other
///
/// > each an independent component with its own scroll, arranged by the window's layout rather than
/// > wired to each other; componentization is the rule, so the comments pane mounts beside the body
/// > or below it (the layout option) without either pane knowing which.
///
/// That is enforced here by there being nothing to enforce: `CardCommentsPane` takes no layout
/// parameter and the body column takes none either. This view puts one of them in a frame; the
/// arithmetic behind the frame is `CommentsMount`, which is pure and therefore checkable.
///
/// The sidebar is unchanged by any of it it is a third pane, it has always been fixed-width, and
/// the resize flex still goes to the body and never to the two fixed panes.
///
/// ### What this milestone builds, and what it deliberately does not
///
@@ -71,6 +85,10 @@ struct CardWindowView: View {
/// This window's attachments section: the listing, the selection, and the two writes it starts
/// (05 Attachments).
let attachments: CardAttachments
/// This window's comments pane: the thread, the composer's draft buffer, and the one open inline
/// edit session (05 The comments column). It lives on the window's *session* so the close flush
/// can reach it, which is why it arrives here rather than being made here.
let comments: CardComments
/// This window's thumbnail memory, held by the host so it outlives a snapshot.
let thumbnails: AttachmentThumbnailCache
/// The whole-window file drop (05 Attachments: "the drop surface remains the **whole
@@ -79,11 +97,24 @@ struct CardWindowView: View {
/// Commits a checkbox flip: the marker's byte offset in the body, and the state the user saw.
let onToggleTask: (Int, Bool) -> Void
/// **View Show Comments** and **View Comments Beside Body** app-wide, persisted, read here
/// rather than passed in (05 The comments column; Composition).
///
/// `@AppStorage` because the two bits genuinely are app-wide: every open card window obeys the
/// same pair, so a window that took them as parameters would need something above it keeping
/// every window in step with a value that has exactly one instance. It is also what makes the
/// menu rows' checkmarks and these panes provably the same bit (`ShowCommentsCommand`).
@AppStorage(AppPreferences.showCommentsKey) private var showComments = true
@AppStorage(AppPreferences.commentsBesideBodyKey) private var commentsBesideBody = true
/// The body font's point size, read once per body evaluation: every measurement in this view
/// the sidebar's width, both gutters, the vertical rhythm is derived from it, so they scale
/// together when the system text size changes.
private var bodyPointSize: CGFloat { CardWindowMetrics.bodyPointSize }
/// Where the comments pane sits, when it is shown at all.
private var mount: CommentsMount { CommentsMount(besideBody: commentsBesideBody) }
/// The two columns **or the raw-source editor in place of both of them**.
///
/// A swap rather than an overlay, which is 05 Raw source outlet's own word for it ("swaps the
@@ -111,10 +142,14 @@ struct CardWindowView: View {
@ViewBuilder
private var content: some View {
if rawSource.isActive {
// **All three panes**, comments included: "Raw Source still swaps the entire content area
// all panes, comments included; the raw outlet's rule is unchanged" (05 The comments
// column). The swap encloses the whole composition below rather than any one pane, which
// is what keeps that true as the composition grows.
CardRawSourceView(session: rawSource, presentation: bodyPresentation)
} else {
HStack(spacing: 0) {
bodyColumn
contentPanes
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Divider()
@@ -128,6 +163,59 @@ struct CardWindowView: View {
}
}
/// The body pane and the comments pane, in whichever of the two mounts is current or the body
/// pane alone, when Show Comments is off.
///
/// **The thread stays visible through body Edit in either mount**, and needs no rule of its own:
/// Edit swaps the content of the body pane (`CardBodySurface`), which is *inside* the body column
/// here, so nothing about the composition changes when the mode flips. That is the sidebar's own
/// precedent, which 05 names when it states the rule.
@ViewBuilder
private var contentPanes: some View {
if showComments {
switch mount {
case .beside:
HStack(spacing: 0) {
bodyColumn
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Divider()
commentsPane
// Fixed, like the sidebar: "resize flex always goes to the body, never the
// fixed panes" (05 Composition).
.frame(width: CardWindowMetrics.commentsColumnWidth(bodyPointSize: bodyPointSize))
.frame(maxHeight: .infinity, alignment: .top)
}
case .stacked:
// The 3:2 split needs a height to divide, and a `GeometryReader` is the only way to
// have one `layoutPriority` and flexible frames express *preferences*, and this is
// a ratio the design fixes. The body takes its share; the comments pane takes the
// remainder, so the divider between them can never leave a gap or overlap.
GeometryReader { proxy in
VStack(spacing: 0) {
bodyColumn
.frame(height: mount.bodyHeight(in: proxy.size.height))
.frame(maxWidth: .infinity, alignment: .topLeading)
Divider()
commentsPane
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
}
} else {
bodyColumn
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
private var commentsPane: some View {
CardCommentsPane(comments: comments, cardFolder: cardFolder, thumbnails: thumbnails)
}
// MARK: - Body column
/// Title, the quiet created/modified line, then the body 05's top-to-bottom order.