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
+53
View File
@@ -64,6 +64,21 @@ struct CardBodySurface: NSViewRepresentable {
let isTaskToggleEnabled: Bool let isTaskToggleEnabled: Bool
/// Byte offset and the state the user saw straight through to `BoardStore.toggleTaskMarker`. /// Byte offset and the state the user saw straight through to `BoardStore.toggleTaskMarker`.
let onToggleTask: (Int, Bool) -> Void let onToggleTask: (Int, Bool) -> Void
/// Whether this surface scrolls on its own `true`, the default, for every mount and mode
/// today. `false` is the stacked mount's continuous Preview arrangement
/// (`CardWindowView.continuousStackedContent`), where this surface's rendered content is one
/// section of a single shared document scroll rather than its own scrolling region: the hosted
/// `NSScrollView` loses its scroller and elasticity (the outer `ScrollView` owns wheel/trackpad
/// scrolling instead) and `sizeThatFits(_:nsView:context:)` below reports the text's own height
/// for whatever width it is proposed, rather than the view answering nothing and falling back to
/// a frame nothing constrains.
var scrolls: Bool = true
/// The height a measurement pass lays out into while `scrolls` is `false` tall enough that no
/// card body reaches it, finite so the arithmetic stays well-defined. `CommentBodyView`'s own
/// constant, applied one level down through the hosting scroll view rather than straight to the
/// represented view.
private static let embeddedLayoutCeiling: CGFloat = 100_000
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
Coordinator() Coordinator()
@@ -133,6 +148,17 @@ struct CardBodySurface: NSViewRepresentable {
scrollView.autohidesScrollers = true scrollView.autohidesScrollers = true
scrollView.drawsBackground = false scrollView.drawsBackground = false
scrollView.findBarPosition = .aboveContent scrollView.findBarPosition = .aboveContent
// **`scrolls == false` switches this scroll view off** no scroller to grab, no elastic
// bounce to fight the outer `ScrollView`'s own wheel/trackpad scrolling, which is what
// "embedded in an outer scroll" (the continuous arrangement) actually means at the AppKit
// layer. `sizeThatFits` below is the other half: without it this view would still have
// nothing to report and would collapse to zero height inside an unbounded `ScrollView`
// proposal.
if !scrolls {
scrollView.hasVerticalScroller = false
scrollView.verticalScrollElasticity = .none
scrollView.horizontalScrollElasticity = .none
}
context.coordinator.textView = textView context.coordinator.textView = textView
context.coordinator.session = session context.coordinator.session = session
@@ -158,6 +184,33 @@ struct CardBodySurface: NSViewRepresentable {
return scrollView return scrollView
} }
/// **The intrinsic height, while `scrolls` is `false`.** `nil` otherwise the default
/// (unimplemented) answer, which lets a scrolling instance keep taking whatever frame its
/// `.frame(maxHeight: .infinity)` modifier proposes exactly as it always has.
///
/// `CommentBodyView.sizeThatFits`'s own trick, one level down through the hosting scroll view:
/// the container tracks the text view's own frame width (`widthTracksTextView`), so forcing that
/// frame to the proposed width *is* how the width is proposed at all, and `ensureLayout` is not
/// optional `usedRect` only means something once the glyphs are laid, and an unlaid container
/// answers a zero-height rect, which would collapse the whole body to nothing.
func sizeThatFits(_ proposal: ProposedViewSize, nsView scrollView: NSScrollView, context: Context) -> CGSize? {
guard !scrolls else { return nil }
guard let textView = scrollView.documentView as? NSTextView,
let container = textView.textContainer,
let layoutManager = textView.layoutManager
else { return nil }
guard let width = proposal.width, width > 0, width.isFinite else { return nil }
textView.frame = NSRect(x: 0, y: 0, width: width, height: Self.embeddedLayoutCeiling)
layoutManager.ensureLayout(for: container)
let usedHeight = layoutManager.usedRect(for: container).height
// `usedRect` is the text alone; the gutter inset on both edges is this surface's own, added
// back in rather than folded into the container width above (`textContainerInset` already
// does that subtraction for `widthTracksTextView`, so doing it twice here would double it).
let totalHeight = usedHeight + textView.textContainerInset.height * 2
return CGSize(width: width, height: totalHeight.rounded(.up))
}
func updateNSView(_ scrollView: NSScrollView, context: Context) { func updateNSView(_ scrollView: NSScrollView, context: Context) {
let coordinator = context.coordinator let coordinator = context.coordinator
coordinator.onToggleTask = onToggleTask coordinator.onToggleTask = onToggleTask
+14
View File
@@ -57,6 +57,20 @@ public enum CommentsMount: Sendable, Equatable {
/// Stacked, the comments pane takes the width the body already had, so a narrow display keeps the /// Stacked, the comments pane takes the width the body already had, so a narrow display keeps the
/// minimum it has always had. That is the whole reason the option exists. /// minimum it has always had. That is the whole reason the option exists.
public var widensWindow: Bool { self == .beside } public var widensWindow: Bool { self == .beside }
/// **Whether Preview shows the stacked mount's continuous, single-scroll arrangement** title,
/// body and the comment thread stacked directly atop each other in one document, rather than the
/// fixed 3:2 split with each pane keeping its own scroll.
///
/// Pure and mode-driven, so "continuous is Preview-only; Edit keeps the split; beside is
/// untouched" is a fact a test can hold rather than something read off a running window
/// (`CardWindowView.contentPanes` is the one caller). Edit needs its own stable scroll a text
/// editor's caret, selection and undo session cannot share a document scroll with the thread
/// beneath it and stay usable and the beside mount never had a split to begin with, so neither
/// case answers `true` regardless of `mode`.
public func showsContinuousDocument(mode: CardBodyMode) -> Bool {
self == .stacked && mode == .preview
}
} }
// MARK: - Which way the thread runs // MARK: - Which way the thread runs
+77 -39
View File
@@ -30,6 +30,17 @@ struct CardCommentsPane: View {
/// The window's thumbnail memory, shared with the sidebar's attachment rows so a file shown in /// The window's thumbnail memory, shared with the sidebar's attachment rows so a file shown in
/// both places is rendered once. /// both places is rendered once.
let thumbnails: AttachmentThumbnailCache let thumbnails: AttachmentThumbnailCache
/// **The proxy of an outer `ScrollViewReader` this pane's content already lives inside** the
/// stacked mount's continuous Preview arrangement (`CardWindowView.continuousStackedContent`),
/// where title, body and the thread share one document scroll rather than this pane owning its
/// own. `nil`, the default, is every other mount and mode: this pane wraps its own content in a
/// `ScrollView`/`ScrollViewReader` exactly as it always has.
///
/// **Nothing about a comment row, the composer, the find bar or the header changes with this**
/// the type's own doc above ("it does not know where it is mounted") extended one step further:
/// the one thing that does change is which `ScrollViewProxy` a scrollTo call reaches for, and
/// whether this view supplies the `ScrollView` those calls need a proxy over in the first place.
var embeddedProxy: ScrollViewProxy? = nil
/// **App-wide and persisted** (05 The comments column; 11-command-nexus.md files the header /// **App-wide and persisted** (05 The comments column; 11-command-nexus.md files the header
/// control under Configuration controls). Read here rather than mirrored onto the window's handle /// control under Configuration controls). Read here rather than mirrored onto the window's handle
@@ -59,7 +70,14 @@ struct CardCommentsPane: View {
thread thread
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) // **`maxHeight` drops out while embedded.** The standalone mounts hand this pane a *fixed*
// remaining height to fill (the beside `HStack`'s column, the stacked split's own row under
// its `GeometryReader`), so `.infinity` there is exactly what claims all of it. Embedded, the
// outer document `ScrollView` proposes an *unbounded* height instead, and a pane that still
// claimed `.infinity` of an unbounded proposal would grow to its content's ideal height
// anyway but stating `nil` here is the honest version of that rather than a coincidence
// this file would have to keep re-deriving by eye.
.frame(maxWidth: .infinity, maxHeight: embeddedProxy == nil ? .infinity : nil, alignment: .topLeading)
// 10-accessibility.md's container label for the pane ("Comments, N") the count is the // 10-accessibility.md's container label for the pane ("Comments, N") the count is the
// thread's, so the spoken container and the visible header can never disagree. // thread's, so the spoken container and the visible header can never disagree.
.accessibilityElement(children: .contain) .accessibilityElement(children: .contain)
@@ -118,51 +136,71 @@ struct CardCommentsPane: View {
// MARK: - The thread // MARK: - The thread
/// The rows, the composer, and their own `ScrollView` or, embedded, the same rows and composer
/// without one, since `embeddedProxy`'s owner already supplies the scroll they sit inside.
@ViewBuilder
private var thread: some View { private var thread: some View {
ScrollViewReader { proxy in if let embeddedProxy {
ScrollView(.vertical) { threadRows(proxy: embeddedProxy)
LazyVStack(alignment: .leading, spacing: CardWindowMetrics.commentSpacing(bodyPointSize: pointSize)) { } else {
if direction.placesComposerFirst { ScrollViewReader { proxy in
composer.id(Self.composerAnchor) ScrollView(.vertical) {
} threadRows(proxy: proxy)
ForEach(ordered) { comment in
CommentRowView(
comment: comment,
comments: comments,
cardFolder: cardFolder,
thumbnails: thumbnails
)
.id(comment.id)
}
if !direction.placesComposerFirst {
composer.id(Self.composerAnchor)
}
} }
.padding(CardWindowMetrics.gutter(bodyPointSize: pointSize))
.frame(maxWidth: .infinity, alignment: .leading)
} }
// **The window opens scrolled to the composer** (05). Deferred one turn rather than run }
// inline: `scrollTo` needs the content laid out to have somewhere to scroll to, and a }
// thread's rows measure their own rendered height (`CommentBodyView`).
.task { /// The rows and the composer, at whichever end `direction` puts it the one place either is
await Task.yield() /// laid out, standalone or embedded. `proxy` is this pane's own when standalone and the outer
proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom) /// document's when embedded; the scrollTo calls below do not know which.
@ViewBuilder
private func threadRows(proxy: ScrollViewProxy) -> some View {
LazyVStack(alignment: .leading, spacing: CardWindowMetrics.commentSpacing(bodyPointSize: pointSize)) {
if direction.placesComposerFirst {
composer.id(Self.composerAnchor)
} }
// File Add Comment focuses the composer which is no use if the composer is off ForEach(ordered) { comment in
// screen, so the same request scrolls to it. One request, both effects. CommentRowView(
.onChange(of: comments.focusComposerRequests) { _, _ in comment: comment,
proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom) comments: comments,
cardFolder: cardFolder,
thumbnails: thumbnails
)
.id(comment.id)
} }
// **A find that steps off screen brings the reader with it** the half of "it reads as one if !direction.placesComposerFirst {
// find" that the highlight alone cannot do. Scrolling to the comment rather than to the composer.id(Self.composerAnchor)
// range is what a row-shaped thread allows: rows are the scroll targets
// (`ForEach(...).id(comment.id)`), and a comment is short enough that its top is the hit's
// neighbourhood.
.onChange(of: comments.find.currentMatch) { _, match in
guard let match else { return }
proxy.scrollTo(match.comment, anchor: .center)
} }
} }
.padding(CardWindowMetrics.gutter(bodyPointSize: pointSize))
.frame(maxWidth: .infinity, alignment: .leading)
// **The window opens scrolled to the composer** (05) **standalone only**. Embedded, that
// would mean skipping past the title and the body to the thread's newest end on every open,
// which reads wrong for a single continuous document; the composer still gets the reader
// brought to it by an explicit request (`focusComposerRequests`, just below), only the
// *opening* auto-scroll is standalone-only. Deferred one turn rather than run inline:
// `scrollTo` needs the content laid out to have somewhere to scroll to, and a thread's rows
// measure their own rendered height (`CommentBodyView`).
.task {
guard embeddedProxy == nil else { return }
await Task.yield()
proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom)
}
// File Add Comment focuses the composer which is no use if the composer is off
// screen, so the same request scrolls to it. One request, both effects.
.onChange(of: comments.focusComposerRequests) { _, _ in
proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom)
}
// **A find that steps off screen brings the reader with it** the half of "it reads as one
// find" that the highlight alone cannot do. Scrolling to the comment rather than to the
// range is what a row-shaped thread allows: rows are the scroll targets
// (`ForEach(...).id(comment.id)`), and a comment is short enough that its top is the hit's
// neighbourhood.
.onChange(of: comments.find.currentMatch) { _, match in
guard let match else { return }
proxy.scrollTo(match.comment, anchor: .center)
}
} }
// MARK: - The composer // MARK: - The composer
+101 -48
View File
@@ -267,61 +267,70 @@ struct CardWindowView: View {
@ViewBuilder @ViewBuilder
private var contentPanes: some View { private var contentPanes: some View {
if showComments { if showComments {
switch mount { // **Preview, stacked: one continuous document** checked ahead of the mount switch
case .beside: // below because it is the one case that is not a fixed division of the body and the
// The `GeometryReader` is new here for the divider's sake a resizable column needs // comments pane at all; everything under the switch still divides *something* (a width
// the `HStack`'s own live width to clamp against (`clampedCommentsColumnWidth(in:)`), // in `.beside`, a height in `.stacked`), and this arrangement doesn't.
// exactly the reason the stacked branch below already reaches for one. if mount.showsContinuousDocument(mode: bodyPresentation.mode) {
GeometryReader { proxy in continuousStackedContent
HStack(spacing: 0) { } else {
bodyColumn switch mount {
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 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( CommentsColumnDivider(
bodyPointSize: bodyPointSize, bodyPointSize: bodyPointSize,
containerWidth: proxy.size.width, containerWidth: proxy.size.width,
currentWidth: clampedCommentsColumnWidth(in: proxy.size.width), currentWidth: clampedCommentsColumnWidth(in: proxy.size.width),
onChange: { commentsColumnWidthOverride = $0 }, onChange: { commentsColumnWidthOverride = $0 },
onCommit: { width in onCommit: { width in
storedCommentsColumnWidth = Double(width) storedCommentsColumnWidth = Double(width)
// The persisted figure now says the same thing the override does // The persisted figure now says the same thing the override does
// clearing it hands `commentsColumnWidth` back to one source of truth // clearing it hands `commentsColumnWidth` back to one source of truth
// rather than two that happen to agree. // rather than two that happen to agree.
commentsColumnWidthOverride = nil commentsColumnWidthOverride = nil
} }
) )
commentsPane commentsPane
// **User-resizable since 2026-08-09** the sidebar's "resize flex // **User-resizable since 2026-08-09** the sidebar's "resize flex
// always goes to the body" (05 Composition) still holds between the // always goes to the body" (05 Composition) still holds between the
// body and the *sidebar*; the body and the *comments column* now split // 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 // it by the divider's drag instead, clamped so neither pane's own floor
// gives way (`CardWindowMetrics.clampedCommentsColumnWidth`). // gives way (`CardWindowMetrics.clampedCommentsColumnWidth`).
.frame(width: clampedCommentsColumnWidth(in: proxy.size.width)) .frame(width: clampedCommentsColumnWidth(in: proxy.size.width))
.frame(maxHeight: .infinity, alignment: .top) .frame(maxHeight: .infinity, alignment: .top)
}
} }
}
case .stacked: case .stacked:
// The 3:2 split needs a height to divide, and a `GeometryReader` is the only way to // Edit only, here Preview took the branch above. The 3:2 split needs a height
// have one `layoutPriority` and flexible frames express *preferences*, and this is // to divide, and a `GeometryReader` is the only way to have one
// a ratio the design fixes. The body takes its share; the comments pane takes the // `layoutPriority` and flexible frames express *preferences*, and this is a ratio
// remainder, so the divider between them can never leave a gap or overlap. // the design fixes. The body takes its share; the comments pane takes the
GeometryReader { proxy in // remainder, so the divider between them can never leave a gap or overlap.
VStack(spacing: 0) { GeometryReader { proxy in
bodyColumn VStack(spacing: 0) {
.frame(height: mount.bodyHeight(in: proxy.size.height)) bodyColumn()
.frame(maxWidth: .infinity, alignment: .topLeading) .frame(height: mount.bodyHeight(in: proxy.size.height))
.frame(maxWidth: .infinity, alignment: .topLeading)
Divider() Divider()
commentsPane commentsPane
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
} }
} }
} }
} else { } else {
bodyColumn bodyColumn()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
} }
} }
@@ -330,10 +339,49 @@ struct CardWindowView: View {
CardCommentsPane(comments: comments, cardFolder: cardFolder, thumbnails: thumbnails) 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 // MARK: - Body column
/// Title, the quiet created/modified line, then the body 05's top-to-bottom order. /// 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: 0) {
VStack(alignment: .leading, spacing: bodyPointSize * 0.5) { VStack(alignment: .leading, spacing: bodyPointSize * 0.5) {
titleRow titleRow
@@ -360,9 +408,14 @@ struct CardWindowView: View {
presentation: bodyPresentation, presentation: bodyPresentation,
session: bodySession, session: bodySession,
isTaskToggleEnabled: isEditable, 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 // **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 // disk's word for what the file says, and takes it into the editor only when the buffer has
+11
View File
@@ -58,6 +58,17 @@ struct CommentsMountTests {
#expect(CommentsMount.beside.widensWindow) #expect(CommentsMount.beside.widensWindow)
#expect(!CommentsMount.stacked.widensWindow) #expect(!CommentsMount.stacked.widensWindow)
} }
@Test("The continuous document arrangement is Preview-only, and stacked-only")
func continuousDocumentIsStackedPreviewOnly() {
// Title, body and the thread share one scroll only when the mount is stacked *and* the body
// is showing Preview Edit needs its own stable scroll, and beside never had a split to
// fold away in the first place.
#expect(CommentsMount.stacked.showsContinuousDocument(mode: .preview))
#expect(!CommentsMount.stacked.showsContinuousDocument(mode: .edit))
#expect(!CommentsMount.beside.showsContinuousDocument(mode: .preview))
#expect(!CommentsMount.beside.showsContinuousDocument(mode: .edit))
}
} }
// MARK: - The window's minimum // MARK: - The window's minimum