From 9345565ed08e37a859f95d1056ac94db33cacfe6 Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 15:19:24 -0700 Subject: [PATCH] Artifactory rail: the board takes a share of the rail, never enough to push the foot off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: with the summary, the diagram and the board all present, the rail's content can push the lower sections off the bottom of the window. Three flexible sections, two behaviours. The summary and the diagram are greedy (maxHeight .infinity) and yield when squeezed. The board hugs its posts with fixedSize up to a fixed 600pt — rigid — and on a short window that 600, plus THIS LOOP's 118 and the summary's 120 floor, adds up past the rail. A VStack with more rigid content than space overflows, and what slides off is the foot: the button, the footer. The rail now measures its own height with an outer GeometryReader — the container, not a guess from inside a scroll view — and hands the board a cap of 40% of the rail (floor 160, ceiling the old 600). A share cannot overflow on its own, and 40% still shows a conversation. The section takes the cap as a parameter. Suite: 1506 tests / 156 suites pass (+3); swiftlint 0 errors, format clean. Signed-off-by: scgopi Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019A6NULwEiBXEdRXwEKKcRH --- .../LoopWorkspace/ArtifactorySection.swift | 15 +++++--- .../LoopWorkspace/LoopWorkspaceRail.swift | 30 +++++++++++++-- .../Tests/ArtifactoryRailShareTests.swift | 37 +++++++++++++++++++ 3 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 graphcode/Tests/ArtifactoryRailShareTests.swift diff --git a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift b/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift index a29683f6..9d1032fa 100644 --- a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift +++ b/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift @@ -50,6 +50,10 @@ struct ArtifactorySection: View { /// the same thing — a fact about a person at a screen, never about the loop. let seenPostID: Int? let isFolded: Bool + /// How tall the scroll box may grow before it scrolls — the rail's share for the + /// board (`LoopWorkspaceRail.artifactoryHeightCap`), never more than + /// `maxScrollHeight`. + var maxHeight: CGFloat = ArtifactorySection.maxScrollHeight let onToggleFold: () -> Void /// Posts as "a human" — a click in the app has no `ZMX_SESSION` and no loop identity, /// which is exactly what a person talking to the whole graph is. @@ -58,11 +62,10 @@ struct ArtifactorySection: View { /// Whether the mirrored records are unfolded. Local and unpersisted, unlike the /// section's own fold: opening the receipts is a thing you do once to answer a /// question, not a way you prefer to read the board. - /// Past this the board scrolls rather than pushing the sections above it off the rail. - /// Every note is on the board and reachable — this is only how much of it is on screen - /// before the wheel takes over. About ten posts at the rail's default width, which is - /// the number asked for: enough to read a conversation, not so many that the rail - /// is nothing but the board. + /// The most the board's scroll box will ever be, on any window: about ten posts at + /// the rail's default width — enough to read a conversation, not so many that the + /// rail is nothing but the board. The rail hands down a smaller cap on a short + /// window (`LoopWorkspaceRail.artifactoryHeightCap`); this is the ceiling on that. static let maxScrollHeight: CGFloat = 600 @State private var showsRecords = false @@ -112,7 +115,7 @@ struct ArtifactorySection: View { // content's, and `frame(maxHeight:)` under it clamps that. The box is exactly // as tall as the posts until the cap, and scrolls after — no state, nothing to // go stale, nothing to fire late. - .frame(maxHeight: Self.maxScrollHeight) + .frame(maxHeight: maxHeight) .fixedSize(horizontal: false, vertical: true) // Outside the scroll view on purpose. Inside it the composer was one more row // in a list that can be taller than the rail — it could open scrolled out of diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift index 669f914d..7c0a0c4b 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift @@ -199,7 +199,32 @@ struct LoopWorkspaceRail: View { graph.edges.filter { $0.to == node.id }.compactMap { graph.nodes[id: $0.from] } } + /// The most of the rail the board may take before it scrolls, as a share of the + /// rail's own height. The other flexible sections — the summary, the diagram — are + /// greedy and yield; the board hugs its posts with `fixedSize` and does not. On a + /// short window a fixed 600pt cap was enough, with THIS LOOP's 118 and the summary's + /// 120 floor, to overflow the stack and push the foot of the rail off the bottom. + /// A share cannot overflow on its own, and 40% still shows a conversation. + static func artifactoryHeightCap(railHeight: CGFloat) -> CGFloat { + min(ArtifactorySection.maxScrollHeight, max(160, railHeight * 0.4)) + } + var body: some View { + // Measured at the outside, where a `GeometryReader` is the container and not a + // guess from within a scroll view — the rail's height is what the board's share + // is a share *of*. + GeometryReader { proxy in + stack(artifactoryCap: Self.artifactoryHeightCap(railHeight: proxy.size.height)) + } + .frame(width: width) + .frame(maxHeight: .infinity) + .background(Theme.workspaceRail) + .overlay(alignment: .leading) { + Rectangle().fill(.white.opacity(0.07)).frame(width: 1) + } + } + + private func stack(artifactoryCap: CGFloat) -> some View { VStack(alignment: .leading, spacing: 10) { // Above `THIS LOOP` rather than below it: what the loop is doing this second // outranks where it sits in the graph, and a section you have to scroll to is a @@ -244,6 +269,7 @@ struct LoopWorkspaceRail: View { if ArtifactoryPresentation.hasContent(graph: graph, enabled: artifactoryEnabled) { ArtifactorySection( graph: graph, seenPostID: seenArtifactoryPostID, isFolded: isArtifactoryFolded, + maxHeight: artifactoryCap, onToggleFold: onArtifactoryFoldToggled, onPost: onArtifactoryPost) } footer @@ -251,10 +277,6 @@ struct LoopWorkspaceRail: View { .padding(12) .frame(width: width, alignment: .leading) .frame(maxHeight: .infinity) - .background(Theme.workspaceRail) - .overlay(alignment: .leading) { - Rectangle().fill(.white.opacity(0.07)).frame(width: 1) - } } private func section( diff --git a/graphcode/Tests/ArtifactoryRailShareTests.swift b/graphcode/Tests/ArtifactoryRailShareTests.swift new file mode 100644 index 00000000..2e5bb8be --- /dev/null +++ b/graphcode/Tests/ArtifactoryRailShareTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing + +@testable import graphcode + +/// The board's scroll box is capped by a share of the rail, so a rigid box can never be +/// what pushes the foot of the rail off a short window. +@Suite +struct ArtifactoryRailShareTests { + @Test + func theBoardTakesAShareOfTheRailNotAFixedHeight() { + // A 900pt rail: 40% is 360, well under the 600 ceiling and well over the floor. + #expect(LoopWorkspaceRail.artifactoryHeightCap(railHeight: 900) == 360) + // A tall rail: the share would exceed the ceiling, so the ceiling wins — ten posts + // is enough on any window. + #expect(LoopWorkspaceRail.artifactoryHeightCap(railHeight: 2000) == 600) + #expect(ArtifactorySection.maxScrollHeight == 600) + } + + /// A very short rail still shows a couple of posts rather than a sliver; below the + /// floor the rail has bigger problems than this section. + @Test + func aShortRailStillShowsSomething() { + #expect(LoopWorkspaceRail.artifactoryHeightCap(railHeight: 300) == 160) + #expect(LoopWorkspaceRail.artifactoryHeightCap(railHeight: 0) == 160) + } + + /// The share leaves room for everything rigid above it: THIS LOOP (118) plus the + /// summary's floor (120) plus chrome, on a rail that fits one 1280×800 window. + @Test + func theShareLeavesRoomForTheRigidSections() { + let rail: CGFloat = 700 + let board = LoopWorkspaceRail.artifactoryHeightCap(railHeight: rail) + let rigidAbove: CGFloat = 118 + 120 + 24 + 24 + 12 * 2 + #expect(board + rigidAbove < rail) + } +}