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
/// Byte offset and the state the user saw straight through to `BoardStore.toggleTaskMarker`.
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 {
Coordinator()
@@ -133,6 +148,17 @@ struct CardBodySurface: NSViewRepresentable {
scrollView.autohidesScrollers = true
scrollView.drawsBackground = false
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.session = session
@@ -158,6 +184,33 @@ struct CardBodySurface: NSViewRepresentable {
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) {
let coordinator = context.coordinator
coordinator.onToggleTask = onToggleTask