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
+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
/// both places is rendered once.
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
/// control under Configuration controls). Read here rather than mirrored onto the window's handle
@@ -59,7 +70,14 @@ struct CardCommentsPane: View {
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
// thread's, so the spoken container and the visible header can never disagree.
.accessibilityElement(children: .contain)
@@ -118,51 +136,71 @@ struct CardCommentsPane: View {
// 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 {
ScrollViewReader { proxy in
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: CardWindowMetrics.commentSpacing(bodyPointSize: pointSize)) {
if direction.placesComposerFirst {
composer.id(Self.composerAnchor)
}
ForEach(ordered) { comment in
CommentRowView(
comment: comment,
comments: comments,
cardFolder: cardFolder,
thumbnails: thumbnails
)
.id(comment.id)
}
if !direction.placesComposerFirst {
composer.id(Self.composerAnchor)
}
if let embeddedProxy {
threadRows(proxy: embeddedProxy)
} else {
ScrollViewReader { proxy in
ScrollView(.vertical) {
threadRows(proxy: proxy)
}
.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 {
await Task.yield()
proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom)
}
}
/// The rows and the composer, at whichever end `direction` puts it the one place either is
/// laid out, standalone or embedded. `proxy` is this pane's own when standalone and the outer
/// 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
// 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)
ForEach(ordered) { comment in
CommentRowView(
comment: comment,
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
// 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)
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) **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