From 1fc15a7dd7594a365b4d5999d48aead3e5db4b48 Mon Sep 17 00:00:00 2001 From: Andres Date: Wed, 29 Jul 2026 16:07:45 -0400 Subject: [PATCH] Polish sidebar transitions and search dismissal --- .../MeridianCore/Stores/BrowserStore.swift | 6 +-- .../Sidebar/SidebarSpacePagerPolicy.swift | 31 +++++++++++++++ .../Views/Sidebar/SidebarView.swift | 33 ++++++++++++++++ .../BrowserStoreTests.swift | 18 +++------ .../SidebarSpacePagerSelectionTests.swift | 38 +++++++++++++++++++ 5 files changed, 110 insertions(+), 16 deletions(-) diff --git a/Sources/MeridianCore/Stores/BrowserStore.swift b/Sources/MeridianCore/Stores/BrowserStore.swift index 2abfc8e..7809e3d 100644 --- a/Sources/MeridianCore/Stores/BrowserStore.swift +++ b/Sources/MeridianCore/Stores/BrowserStore.swift @@ -730,15 +730,12 @@ public final class BrowserStore: ObservableObject { guard let space = spaces.first(where: { $0.id == id }) else { return } - let shouldPreserveCommandBar = isCommandBarPresented && commandBarMode == .newTab selectedSpaceID = id selectedTabID = space.selectedTabID ?? space.favoriteTabIDs.first ?? space.pinnedTabIDs.first ?? space.regularTabIDs.first - if !shouldPreserveCommandBar { - hideCommandBar() - } + hideCommandBar() refreshActivePageSecurityStatus() schedulePersistSession() } @@ -780,6 +777,7 @@ public final class BrowserStore: ObservableObject { updateTab(id) { tab in tab.lastActiveDate = Date() } + hideCommandBar() refreshActivePageSecurityStatus() persistSession() } diff --git a/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerPolicy.swift b/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerPolicy.swift index ccef149..53f9fc5 100644 --- a/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerPolicy.swift +++ b/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerPolicy.swift @@ -18,6 +18,37 @@ enum SidebarSpacePagerMetrics { static let creationReturnAnimation: Animation = .smooth(duration: 0.24, extraBounce: 0.08) } +struct SidebarSpacePagerPageTransitionPresentation: Equatable, Sendable { + let opacity: Double + let scale: CGFloat + let verticalOffset: CGFloat +} + +enum SidebarSpacePagerPageTransition { + /// A compositor-only emphasis curve for page contents. Foregrounds remain + /// contrast-correct for their own themes, while the outgoing page recedes + /// and the incoming page resolves without publishing scroll geometry. + static func presentation( + forPhaseValue phaseValue: Double + ) -> SidebarSpacePagerPageTransitionPresentation { + guard phaseValue.isFinite else { + return SidebarSpacePagerPageTransitionPresentation( + opacity: 1, + scale: 1, + verticalOffset: 0 + ) + } + + let distance = min(abs(phaseValue), 1) + let easedDistance = distance * distance * (3 - 2 * distance) + return SidebarSpacePagerPageTransitionPresentation( + opacity: 1 - 0.42 * easedDistance, + scale: CGFloat(1 - 0.012 * easedDistance), + verticalOffset: CGFloat(2.5 * easedDistance) + ) + } +} + enum SidebarSpacePagerHorizontalIntent: Equatable, Sendable { case backward case forward diff --git a/Sources/MeridianCore/Views/Sidebar/SidebarView.swift b/Sources/MeridianCore/Views/Sidebar/SidebarView.swift index befd76b..89cb14f 100644 --- a/Sources/MeridianCore/Views/Sidebar/SidebarView.swift +++ b/Sources/MeridianCore/Views/Sidebar/SidebarView.swift @@ -371,6 +371,17 @@ public struct SidebarView: View { } .accessibilityHidden(true) } + .simultaneousGesture(sidebarInteractionGesture) + } + + private var sidebarInteractionGesture: some Gesture { + DragGesture(minimumDistance: 0) + .onChanged { _ in + guard store.isCommandBarPresented else { + return + } + store.hideCommandBar() + } } private var browserControlsHeader: some View { @@ -2898,6 +2909,27 @@ private struct WindowReader: NSViewRepresentable { } } +private func sidebarSpacePagerPageTransitionEffect( + _ content: EmptyVisualEffect, + phase: ScrollTransitionPhase +) -> some VisualEffect { + let presentation = SidebarSpacePagerPageTransition.presentation( + forPhaseValue: phase.value + ) + return content + .opacity(presentation.opacity) + .scaleEffect(presentation.scale) + .offset(y: presentation.verticalOffset) +} + +private struct SidebarSpacePagerPageTransitionModifier: ViewModifier { + func body(content: Content) -> some View { + content.scrollTransition(.interactive, axis: .horizontal) { content, phase in + sidebarSpacePagerPageTransitionEffect(content, phase: phase) + } + } +} + private struct SidebarSpacePagerView: View { let snapshot: SidebarSpacePagerSnapshot let navigationRequest: SidebarSpacePagerNavigationRequest? @@ -2959,6 +2991,7 @@ private struct SidebarSpacePagerView: View { pageView(page) .id(page.id) .frame(width: pageWidth, height: proxy.size.height, alignment: .top) + .modifier(SidebarSpacePagerPageTransitionModifier()) // Once horizontal paging owns the gesture, tab rows no // longer participate in hit testing until the pager is idle. .allowsHitTesting(!scrollIsActive) diff --git a/Tests/MeridianBrowserTests/BrowserStoreTests.swift b/Tests/MeridianBrowserTests/BrowserStoreTests.swift index a2fb6a4..2eb118f 100644 --- a/Tests/MeridianBrowserTests/BrowserStoreTests.swift +++ b/Tests/MeridianBrowserTests/BrowserStoreTests.swift @@ -589,7 +589,7 @@ final class BrowserStoreTests: XCTestCase { XCTAssertEqual(store.commandBarFocusRequest, previousFocusRequest + 2) } - func testSelectingSpacePreservesNewTabCommandBar() throws { + func testSelectingSpaceClosesNewTabCommandBar() throws { let store = BrowserStore() let initialSpaceID = try XCTUnwrap(store.selectedSpaceID) let targetSpace = store.createSpace(name: "Work") @@ -599,23 +599,17 @@ final class BrowserStoreTests: XCTestCase { store.selectSpace(targetSpace.id) XCTAssertEqual(store.selectedSpaceID, targetSpace.id) - XCTAssertTrue(store.isCommandBarPresented) - XCTAssertEqual(store.commandBarMode, .newTab) + XCTAssertFalse(store.isCommandBarPresented) + XCTAssertEqual(store.commandBarMode, .address) } - func testNewTabCommandBarSubmissionUsesSelectedTargetSpaceAfterSwitching() throws { + func testSelectingTabClosesNewTabCommandBar() throws { let store = BrowserStore() - let initialSpaceID = try XCTUnwrap(store.selectedSpaceID) - let targetSpace = store.createSpace(name: "Work") - store.selectSpace(initialSpaceID) + let selectedTabID = try XCTUnwrap(store.selectedTabID) store.beginNewTab() - store.selectSpace(targetSpace.id) - store.submitAddressInput("example.com") + store.selectTab(selectedTabID) - let activeTab = try XCTUnwrap(store.activeTab) - XCTAssertEqual(activeTab.parentSpaceID, targetSpace.id) - XCTAssertTrue(store.selectedSpace?.regularTabIDs.contains(activeTab.id) ?? false) XCTAssertFalse(store.isCommandBarPresented) XCTAssertEqual(store.commandBarMode, .address) } diff --git a/Tests/MeridianBrowserTests/SidebarSpacePagerSelectionTests.swift b/Tests/MeridianBrowserTests/SidebarSpacePagerSelectionTests.swift index 82b94b6..5bb76c7 100644 --- a/Tests/MeridianBrowserTests/SidebarSpacePagerSelectionTests.swift +++ b/Tests/MeridianBrowserTests/SidebarSpacePagerSelectionTests.swift @@ -14,6 +14,44 @@ private final class SidebarAddressMorphRendererSpy: SidebarAddressMorphRendering } final class SidebarSpacePagerSelectionTests: XCTestCase { + func testPageTransitionKeepsSettledPageExact() { + XCTAssertEqual( + SidebarSpacePagerPageTransition.presentation(forPhaseValue: 0), + SidebarSpacePagerPageTransitionPresentation( + opacity: 1, + scale: 1, + verticalOffset: 0 + ) + ) + XCTAssertEqual( + SidebarSpacePagerPageTransition.presentation(forPhaseValue: .nan), + SidebarSpacePagerPageTransitionPresentation( + opacity: 1, + scale: 1, + verticalOffset: 0 + ) + ) + } + + func testPageTransitionCrossfadesSymmetricallyWithoutHidingPages() { + let leading = SidebarSpacePagerPageTransition.presentation( + forPhaseValue: -0.5 + ) + let trailing = SidebarSpacePagerPageTransition.presentation( + forPhaseValue: 0.5 + ) + let edge = SidebarSpacePagerPageTransition.presentation( + forPhaseValue: 1 + ) + + XCTAssertEqual(leading, trailing) + XCTAssertEqual(leading.opacity, 0.79, accuracy: 0.0001) + XCTAssertEqual(leading.scale, 0.994, accuracy: 0.0001) + XCTAssertEqual(leading.verticalOffset, 1.25, accuracy: 0.0001) + XCTAssertEqual(edge.opacity, 0.58, accuracy: 0.0001) + XCTAssertGreaterThan(edge.opacity, 0.5) + } + func testSpaceDragPayloadRoundTripsSpaceID() { let spaceID = UUID()