The stacked mount reads as one document in view mode — title, body and the thread share a single scroll

Preview, stacked (over/under): title, the rendered body and the comment
thread now stack in one continuous document with one scroll, instead of
the fixed ≈3:2 split with each pane keeping its own. Edit mode keeps the
split unchanged (an editor needs a stable scroll of its own), and the
beside mount is untouched.

CardBodySurface gains a `scrolls` flag: false switches off the hosted
NSScrollView's scroller and elasticity and reports the NSTextView's own
height for the proposed width via `sizeThatFits`, the layout-manager
height-fit trick CommentBodyView already uses one level up. CardCommentsPane
gains an `embeddedProxy`: supplied, it renders the same header, find bar,
rows and composer without wrapping them in a second ScrollView, driving
scrollTo off the outer document's proxy instead of its own. CardWindowView
composes the two behind a new pure predicate, CommentsMount.showsContinuousDocument(mode:),
tested in CardCommentsLayoutTests.

The continuous↔split swap within stacked mount is a genuine remount of the
body pane (two independent scrolls can't become one shared scroll by
reconfiguration) — the same accepted cost the raw-source outlet already
takes elsewhere in this window. No new animation on that swap, matching
this file's existing precedent (the raw-source swap and the beside↔stacked
mount switch are both instant cuts today). Decisions recorded on the card's
thread, flagged for owner review where they're user-visible.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 12:30:41 -04:00
parent 7a4d27f62d
commit 6bf3308915
5 changed files with 256 additions and 87 deletions
+101 -48
View File
@@ -267,61 +267,70 @@ struct CardWindowView: View {
@ViewBuilder
private var contentPanes: some View {
if showComments {
switch mount {
case .beside:
// The `GeometryReader` is new here for the divider's sake a resizable column needs
// the `HStack`'s own live width to clamp against (`clampedCommentsColumnWidth(in:)`),
// exactly the reason the stacked branch below already reaches for one.
GeometryReader { proxy in
HStack(spacing: 0) {
bodyColumn
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// **Preview, stacked: one continuous document** checked ahead of the mount switch
// below because it is the one case that is not a fixed division of the body and the
// comments pane at all; everything under the switch still divides *something* (a width
// in `.beside`, a height in `.stacked`), and this arrangement doesn't.
if mount.showsContinuousDocument(mode: bodyPresentation.mode) {
continuousStackedContent
} else {
switch mount {
case .beside:
// The `GeometryReader` is new here for the divider's sake a resizable column needs
// the `HStack`'s own live width to clamp against (`clampedCommentsColumnWidth(in:)`),
// exactly the reason the stacked branch below already reaches for one.
GeometryReader { proxy in
HStack(spacing: 0) {
bodyColumn()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
CommentsColumnDivider(
bodyPointSize: bodyPointSize,
containerWidth: proxy.size.width,
currentWidth: clampedCommentsColumnWidth(in: proxy.size.width),
onChange: { commentsColumnWidthOverride = $0 },
onCommit: { width in
storedCommentsColumnWidth = Double(width)
// The persisted figure now says the same thing the override does
// clearing it hands `commentsColumnWidth` back to one source of truth
// rather than two that happen to agree.
commentsColumnWidthOverride = nil
}
)
CommentsColumnDivider(
bodyPointSize: bodyPointSize,
containerWidth: proxy.size.width,
currentWidth: clampedCommentsColumnWidth(in: proxy.size.width),
onChange: { commentsColumnWidthOverride = $0 },
onCommit: { width in
storedCommentsColumnWidth = Double(width)
// The persisted figure now says the same thing the override does
// clearing it hands `commentsColumnWidth` back to one source of truth
// rather than two that happen to agree.
commentsColumnWidthOverride = nil
}
)
commentsPane
// **User-resizable since 2026-08-09** the sidebar's "resize flex
// always goes to the body" (05 Composition) still holds between the
// body and the *sidebar*; the body and the *comments column* now split
// it by the divider's drag instead, clamped so neither pane's own floor
// gives way (`CardWindowMetrics.clampedCommentsColumnWidth`).
.frame(width: clampedCommentsColumnWidth(in: proxy.size.width))
.frame(maxHeight: .infinity, alignment: .top)
commentsPane
// **User-resizable since 2026-08-09** the sidebar's "resize flex
// always goes to the body" (05 Composition) still holds between the
// body and the *sidebar*; the body and the *comments column* now split
// it by the divider's drag instead, clamped so neither pane's own floor
// gives way (`CardWindowMetrics.clampedCommentsColumnWidth`).
.frame(width: clampedCommentsColumnWidth(in: proxy.size.width))
.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)
case .stacked:
// Edit only, here Preview took the branch above. 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()
Divider()
commentsPane
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
commentsPane
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
}
}
} else {
bodyColumn
bodyColumn()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
@@ -330,10 +339,49 @@ struct CardWindowView: View {
CardCommentsPane(comments: comments, cardFolder: cardFolder, thumbnails: thumbnails)
}
/// **The stacked mount's continuous Preview arrangement** "title, body, comments stacked
/// directly atop each other in a continuous fashion" (the card this milestone implements): one
/// `ScrollView`, so the body pane's own scroll switches off in favor of reporting its intrinsic
/// height (`CardBodySurface`'s `scrolls: false`), and the comments pane's own `ScrollView`
/// switches off the same way (`CardCommentsPane`'s `embeddedProxy`) both panes' internals
/// otherwise untouched: the same header, the same rendering, the same composer, the same row.
///
/// **Preview only** (`CommentsMount.showsContinuousDocument(mode:)`), and deliberately so: Edit
/// needs its own stable scroll and its own session-scoped undo, which a shared document scroll
/// cannot give it. Entering or leaving Edit therefore swaps this arrangement for the fixed 3:2
/// split above a genuinely different view of the body pane, not a reconfiguration of the one
/// in `bodyColumn(embedded:)`'s doc the same accepted cost the raw-source outlet already takes
/// elsewhere in this window: the buffer survives the swap (`bodySession`, dirty-buffer-wins), the
/// scroll position and the find state do not.
private var continuousStackedContent: some View {
ScrollViewReader { proxy in
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 0) {
bodyColumn(embedded: true)
Divider()
CardCommentsPane(
comments: comments,
cardFolder: cardFolder,
thumbnails: thumbnails,
embeddedProxy: proxy
)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
// MARK: - Body column
/// Title, the quiet created/modified line, then the body 05's top-to-bottom order.
private var bodyColumn: some View {
///
/// - Parameter embedded: `true` only from `continuousStackedContent`, which mounts this inside an
/// outer document `ScrollView` rather than giving it a fixed or flexible height of its own
/// see `CardBodySurface.scrolls` for what that switches off. `false`, the default, is every
/// other call site, byte-for-byte the arrangement this had before that mode existed.
private func bodyColumn(embedded: Bool = false) -> some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: bodyPointSize * 0.5) {
titleRow
@@ -360,9 +408,14 @@ struct CardWindowView: View {
presentation: bodyPresentation,
session: bodySession,
isTaskToggleEnabled: isEditable,
onToggleTask: onToggleTask
onToggleTask: onToggleTask,
scrolls: !embedded
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// `maxHeight` drops to `nil` while embedded: `sizeThatFits` (`CardBodySurface`) reports
// the text's own height for the width it is given, and a modifier still claiming
// `.infinity` of the outer scroll's unbounded proposal would ask for an undefined amount
// of it instead.
.frame(maxWidth: .infinity, maxHeight: embedded ? nil : .infinity)
}
// **Dirty-buffer-wins, applied on every snapshot** (05 Write rules): the session takes
// disk's word for what the file says, and takes it into the editor only when the buffer has