From 2db9bf098b16cf85874cc7548963468b8dc89300 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 14:51:20 -0700 Subject: [PATCH 1/4] test(sidebar): require workspace previews to touch their rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar-Look: no visual change; regression coverage for Leo’s workspace preview gap. Agent-Session: https://cmux.com/deeplink/agent?id=aid-f0e2e06c2fc61fa2b914 --- .../PinnedRowHoverCardTests.swift | 2 ++ .../SidebarHoverCardEscapeTests.swift | 15 +++++++-------- .../SidebarRailHoverCardTests.swift | 8 ++++---- .../HoverCardEscapeGeometryTests.swift | 19 ++++++++++--------- .../HoverCardRowAlignmentTests.swift | 8 ++++---- .../HoverCardWindowClampTests.swift | 6 +++--- 6 files changed, 30 insertions(+), 28 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedRowHoverCardTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedRowHoverCardTests.swift index 4015b551fab1..99e30caed989 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedRowHoverCardTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedRowHoverCardTests.swift @@ -123,6 +123,8 @@ import Testing #expect(abs(anchor.minY - onScreen.minY) < 0.5 && abs(anchor.maxY - onScreen.maxY) < 0.5, "\(anchor) vs \(onScreen)") let band = try #require(harness.list.hoverCardBand(for: Self.pinnedID)) #expect(abs(band.maxY - onScreen.maxY) < 0.5) + let pill = harness.window.convertToScreen(row.convert(row.pill.frame, to: nil)) + #expect(band == pill, "a pinned preview must touch its fill rather than its layout cell") } /// The list row of the other workspace still opens its own card (the pinned lookup takes nothing from it). diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarHoverCardEscapeTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarHoverCardEscapeTests.swift index 5ca91515fb60..8b3e6b31203b 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarHoverCardEscapeTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarHoverCardEscapeTests.swift @@ -6,7 +6,7 @@ import CmuxNextWakeupsTestSupport /// A sidebar workspace's hover card in the real list. Leo 2026-10-21 ("WHY HAS IT NOT BEEN /// ALIGNED"): the card's top lines up with its row's top, as Chrome's and Arc's tab cards, and -/// follows the row from row to row; it stands one device pixel off the sidebar's edge. The escape slot beside the +/// follows the row from row to row; it touches the sidebar's edge. The escape slot beside the /// row's upper half (#22473, Leo 2026-10-20 "trapped by the preview") is a Debug Settings layout /// now, covered by `HoverCardEscapeGeometryTests`. A pointer heading into the card keeps it, one /// going down past the row lets it go, and leaving both the row and the card dismisses it. @@ -84,18 +84,17 @@ import CmuxNextWakeupsTestSupport try await WorkspaceHoverCardScrollTests.tearDown(window, coordinator) } - /// Lawrence 2026-10-21: "horizontally only 1 pixel gap". The card's near edge is one device - /// pixel past the sidebar's edge (here the list fills its window), not a gap off the row's - /// pill, which the row's inset and trim moved. - @Test func theCardIsOnePixelOffTheSidebarsEdge() async throws { + /// The row ends before the sidebar's drag thumb. Its preview touches the row, not the thumb. + @Test func theCardTouchesItsWorkspaceRowBeforeTheDragThumb() async throws { let clock = ManualClock() let (list, window, coordinator, rows) = Self.sidebar(clock: clock) let (row, card) = try await Self.showSecondRowCard(list, coordinator, clock, rows) let edge = window.convertToScreen(list.convert(list.bounds, to: nil)).maxX let pixel = 1 / max(window.backingScaleFactor, 1) - #expect(abs(card.minX - (edge + pixel)) < 0.01, - "the card's left edge is \(card.minX - edge) pt from the sidebar's edge, not one pixel (\(pixel) pt); row ends at \(row.maxX)") - #expect(card.maxX > edge) + #expect(row.maxX < edge, "the fixture needs a row-to-thumb gutter") + #expect(card.minX <= row.maxX + 0.001, "a \(card.minX - row.maxX) pt gap from row to preview") + #expect(row.maxX - card.minX < pixel + 0.001, "overlap must stay below one device pixel") + #expect(card.minX < edge, "the preview was pushed past the drag thumb") try await WorkspaceHoverCardScrollTests.tearDown(window, coordinator) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarRailHoverCardTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarRailHoverCardTests.swift index ab3092cbb387..6829d9d2d069 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarRailHoverCardTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarRailHoverCardTests.swift @@ -5,8 +5,8 @@ import Testing /// The rail's hover card (`sidebar.style` = rail). Lawrence 2026-10-21: "im talking about the /// floating card... floating card relative to the workspace row". Pointing at a mark shows one -/// small card for that workspace, its top on the mark's row's top, exactly one device pixel right -/// of the rail, following the pointer row to row, inside the window. Here: the rows the card is +/// small card for that workspace, its top on the mark's row's top, flush with the rail, +/// following the pointer row to row, inside the window. Here: the rows the card is /// anchored to, where the list rows' one anchor rule (`HoverCardEscapeGeometry.rowBand`, placement /// `.besideRow`) puts the card for them, and the toggle. @MainActor @@ -44,14 +44,14 @@ struct SidebarRailHoverCardTests { return (row, geometry.card) } - @Test func aMarksCardTopIsItsRowsTopAndOnePixelPastTheRailAtTopMiddleAndBottom() { + @Test func aMarksCardTopIsItsRowsTopAndFlushWithTheRailAtTopMiddleAndBottom() { let layout = Self.layout() let rail = SidebarRailLayout.Metrics().railWidth for index in [0, 20, 39] { let (_, card) = Self.card(layout, index) // The row's top is where the mark's row starts (`rowY`), not the 2 pt tick's top. #expect(card.maxY == 700 - layout.ticks[index].rowY, "row \(index): the card's top is the mark's row's top") - #expect(card.minX == rail + 0.5, "row \(index): one device pixel past the rail") + #expect(card.minX == rail, "row \(index): card is not flush with the rail") } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/HoverCardEscapeGeometryTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/HoverCardEscapeGeometryTests.swift index 23efc8ae73b8..0bf419c2c269 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/HoverCardEscapeGeometryTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/HoverCardEscapeGeometryTests.swift @@ -13,7 +13,7 @@ import CmuxNextDesignTokens private let row = CGRect(x: 8, y: 600, width: 224, height: 32) private let size = CGSize(width: 280, height: 160) private let screen = CGRect(x: 0, y: 0, width: 1440, height: 900) - /// A wide gap, so the gap zone is easy to aim at; the sidebar's own is one device pixel. + /// A wide gap, so the gap zone is easy to aim at. The default sidebar row gap is zero. private let gap: CGFloat = 10 @Test func theCardSitsRightOfTheRowWithItsTopOnTheRowsMiddle() { @@ -99,20 +99,21 @@ import CmuxNextDesignTokens #expect(g.zone(at: CGPoint(x: right.minX - gap - 4, y: right.minY + 2)) == .card) } - /// Lawrence 2026-10-21: "horizontally only 1 pixel gap". At every scale the card's near edge is - /// exactly one device pixel past the device pixel the sidebar's edge falls on, on both sides. + /// Sidebar row cards touch the sidebar edge at every display scale. Non-sidebar `.beside` + /// cards retain their one-device-pixel breathing room (`edgeGap`). @Test(arguments: [1, 2, 3] as [CGFloat]) - func theNearEdgeIsOnePixelPastTheEdgesPixel(scale: CGFloat) { + func theNearEdgeTouchesTheSidebarAtEveryScale(scale: CGFloat) { let fractional = CGRect(x: 8.3, y: 600.3, width: 224.1, height: 31.7) let g = HoverCardEscapeGeometry(anchor: fractional, cardSize: size, visible: screen, scale: scale) - let edge = (fractional.maxX * scale).rounded() / scale - #expect(abs(g.card.minX - (edge + 1 / scale)) < 0.0001, "@\(scale)x: \(g.card.minX - edge) pt off the edge's pixel") + let gap = g.card.minX - fractional.maxX + #expect(gap <= 0.0001 && gap > -1 / scale, "@\(scale)x: \(gap) pt between row and preview") + #expect(abs((g.card.minX * scale).rounded() - g.card.minX * scale) < 0.0001) #expect(HoverCardEscapeGeometry.edgeGap(scale: scale) == 1 / scale) let right = CGRect(x: 1440 - 240.3, y: 600, width: 240.3, height: 32) let leading = HoverCardEscapeGeometry(anchor: right, cardSize: size, visible: screen, scale: scale) - let leftEdge = (right.minX * scale).rounded() / scale #expect(leading.isLeading) - #expect(abs(leading.card.maxX - (leftEdge - 1 / scale)) < 0.0001, "@\(scale)x leading: \(leftEdge - leading.card.maxX) pt") + let leadingGap = right.minX - leading.card.maxX + #expect(leadingGap <= 0.0001 && leadingGap > -1 / scale, "@\(scale)x leading: \(leadingGap) pt") } /// A row's anchor is its height across the whole sidebar column: a row's inset, trim or group @@ -123,7 +124,7 @@ import CmuxNextDesignTokens let band = HoverCardEscapeGeometry.rowBand(row: nested, column: column) #expect(band == CGRect(x: 0, y: 500, width: 260, height: 28)) let top = HoverCardEscapeGeometry(anchor: band, cardSize: size, visible: screen, scale: 2) - #expect(top.card.minX == 260.5) + #expect(top.card.minX == 260) #expect(top.card.maxY == nested.maxY) } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/HoverCardRowAlignmentTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/HoverCardRowAlignmentTests.swift index 26dc8b4aa7c7..ee79e98d0b21 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/HoverCardRowAlignmentTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/HoverCardRowAlignmentTests.swift @@ -5,7 +5,7 @@ import CmuxNextDesignTokens /// Leo 2026-10-21: "WHY HAS IT NOT BEEN ALIGNED". A sidebar row's hover card sat with its top on /// the row's middle (#22473), so it never lined up with the row it describes. It now sits beside -/// the sidebar with its top on the row's top, one device pixel off the sidebar's edge, as Chrome's and Arc's tab +/// the sidebar with its top on the row's top, flush with the sidebar's edge, as Chrome's and Arc's tab /// cards do, follows the row from row to row, and stays inside the screen's visible frame. @MainActor @Suite struct HoverCardRowAlignmentTests { /// A 32 pt sidebar row on a 1440 x 900 screen (y up), and a 280 x 160 card. @@ -13,8 +13,8 @@ import CmuxNextDesignTokens private let size = CGSize(width: 280, height: 160) private let screen = CGRect(x: 0, y: 0, width: 1440, height: 900) private let margin: CGFloat = 8 - /// One device pixel at the default scale of 2 (`HoverCardEscapeGeometry.edgeGap`). - private let gap = HoverCardEscapeGeometry.edgeGap(scale: 2) + /// Workspace row cards touch the sidebar edge; non-sidebar `.beside` cards keep their own gap. + private let gap: CGFloat = 0 /// The card's frame at rest beside `anchor`, as the panel places a workspace card. private func card(for anchor: CGRect, visible: CGRect? = nil, scale: CGFloat = 2) -> CGRect { @@ -39,7 +39,7 @@ import CmuxNextDesignTokens #expect(g.card == CGRect(x: row.maxX + gap, y: row.maxY - size.height, width: size.width, height: size.height)) #expect(!g.isLeading) #expect(g.escapeSlot == .zero) - #expect(g.gapStrip == CGRect(x: row.maxX, y: g.card.minY, width: gap, height: size.height)) + #expect(g.gapStrip == .zero) // Right of the row's upper half is the card now, not an empty slot. #expect(g.zone(at: CGPoint(x: row.maxX + gap + 4, y: row.maxY - 3)) == .card) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/HoverCardWindowClampTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/HoverCardWindowClampTests.swift index f28331a5a6e7..11ed49def23e 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/HoverCardWindowClampTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/HoverCardWindowClampTests.swift @@ -7,7 +7,7 @@ import CmuxNextDesignTokens /// below the window at the bottom mark, add a window clamp"). The card only stayed on the visible /// screen, so in a window that ends above the screen's bottom, the bottom row's card (a thin /// rail's last mark, or the list's last row) hung below the window. The rule is still the one row -/// rule (`HoverCardEscapeGeometry`, `.besideRow`): top on the row's top, 1 px off the sidebar edge, +/// rule (`HoverCardEscapeGeometry`, `.besideRow`): top on the row's top, flush with the sidebar edge, /// moved up only as far as the window needs. @MainActor @Suite struct HoverCardWindowClampTests { private let screen = CGRect(x: 0, y: 0, width: 1512, height: 982) @@ -28,7 +28,7 @@ import CmuxNextDesignTokens let row = CGRect(x: window.minX, y: window.minY + 10, width: 6, height: 6) let card = rest(row, window: window) #expect(card.minY >= window.minY, "the card does not hang below the window") - #expect(card.minX - row.maxX == 0.5, "still one device pixel off the sidebar edge") + #expect(card.minX - row.maxX == 0, "the card is not flush with the sidebar edge") #expect(card.maxY > row.minY, "still beside its row") } @@ -45,7 +45,7 @@ import CmuxNextDesignTokens let narrow = CGRect(x: 200, y: 100, width: 240, height: 400) let row = CGRect(x: narrow.minX, y: narrow.maxY - 120, width: narrow.width, height: 28) let card = rest(row, window: narrow) - #expect(card.minX - row.maxX == 0.5, "the card's near edge is one device pixel off the sidebar's edge, not over the rows") + #expect(card.minX - row.maxX == 0, "the card's near edge is not flush with the sidebar edge") #expect(card.maxY == row.maxY, "still top-aligned with its row") } From 9b4b32cf6ab61901cd695a35709be8c1c3e55077 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 14:51:21 -0700 Subject: [PATCH 2/4] fix(sidebar): place workspace previews against their drawn rows Sidebar-Look: Leo: "the gap between the hover preview and ... the workspace sidebar element thingy". Touch the row before the drag-out thumb; snap inward so fractional edges leave no seam. Agent-Session: https://cmux.com/deeplink/agent?id=aid-f0e2e06c2fc61fa2b914 --- .../HoverCards/HoverCardPanel.swift | 12 +++--- .../HoverCards/HoverCardEscapeGeometry.swift | 43 ++++++++++--------- .../Views/NSView+SidebarColumn.swift | 6 +-- .../Views/SidebarContainerView+Side.swift | 2 +- .../Views/SidebarListView+HoverCard.swift | 9 ++-- .../Views/SidebarRailHoverCards.swift | 2 +- .../Views/WorkspaceHoverCard.swift | 6 +-- plans/cmux-next/sidebar-owner.md | 5 +++ scripts/cmux-next/media/tours/hover-card.json | 2 +- 9 files changed, 46 insertions(+), 41 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/HoverCards/HoverCardPanel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/HoverCards/HoverCardPanel.swift index 2754f9f9b59f..1b30f6d81432 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/HoverCards/HoverCardPanel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/HoverCards/HoverCardPanel.swift @@ -9,8 +9,8 @@ public enum HoverCardPlacement: Sendable { case above /// Right of the target, top-aligned (the update card). case beside - /// Beside a sidebar row, one device pixel off the sidebar's edge (the anchor is the row's band - /// across the sidebar, `HoverCardEscapeGeometry.rowBand`), its top on the row's top as Chrome's + /// Beside a sidebar row, touching its anchor (a workspace's drawn fill, or a group/rail band), + /// its top on the row's top as Chrome's /// and Arc's tab cards (or on the row's middle, below an escape slot, under a Debug switch: /// `HoverCardEscapeGeometry`). case besideRow @@ -417,8 +417,7 @@ extension HoverCardPanel { /// The room between a card and its tab: none. The card touches its tab, so the pointer never /// crosses empty space between them (Lawrence 2026-10-19: "there is a gap in between ... such - /// that there is no gap in between"). A card beside a sidebar row stands - /// `HoverCardEscapeGeometry.edgeGap(scale:)` off the sidebar's edge instead. + /// that there is no gap in between"). Sidebar previews also touch their row anchor. static let targetGap: CGFloat = 0 /// The card's origin (screen coordinates, y up) for `placement` next to `anchor`, before @@ -435,9 +434,8 @@ extension HoverCardPanel { case .below: CGPoint(x: anchor.minX, y: up(anchor.minY - targetGap) - size.height) // The card's bottom edge on the tab's top edge (rounded down: into the tab). case .above: CGPoint(x: anchor.minX, y: down(anchor.maxY + targetGap)) - // The card's leading edge one device pixel past the target's trailing edge, top-aligned: the - // sidebar row cards' gap (`HoverCardEscapeGeometry.edgeGap(scale:)`, Lawrence 2026-10-21 "horizontally - // only 1 pixel gap"), so the update card sits at the hover cards' gap (Lawrence 2026-10-19). + // The non-sidebar `.beside` card keeps its one-device-pixel breathing room. Sidebar row + // cards use `.besideRow` and touch the row's anchor instead. case .beside: CGPoint(x: down(anchor.maxX + HoverCardEscapeGeometry.edgeGap(scale: scale)), y: anchor.maxY - size.height) // Right of the row, its top on the row's top (before any clamp or flip the screen needs). case .besideRow: HoverCardEscapeGeometry(anchor: anchor, cardSize: size, scale: scale, alignment: rowAlignment).card.origin diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesignTokens/HoverCards/HoverCardEscapeGeometry.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesignTokens/HoverCards/HoverCardEscapeGeometry.swift index 0af7af97cef1..c411f1776eec 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesignTokens/HoverCards/HoverCardEscapeGeometry.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesignTokens/HoverCards/HoverCardEscapeGeometry.swift @@ -8,17 +8,16 @@ public import CoreGraphics /// from row to row. Near the screen's bottom edge the visible frame clamps it up (it still covers /// the row's side); near its right edge it goes left of the row. /// -/// Lawrence 2026-10-21: "horizontally only 1 pixel gap". The anchor is the row's band across the -/// sidebar column (`rowBand(row:column:)`), not the row's pill: the card's near edge is one device -/// pixel past the column's visible edge (`edgeGap(scale:)`), whatever the row's inset, trim or -/// nesting depth. Every sidebar card (a workspace row, a group header, a thin rail's mark) builds -/// its anchor with `rowBand` and places it here, so there is one rule for where the card goes. +/// Leo 2026-10-28: "the gap between the hover preview and ... the workspace sidebar element". +/// Workspace cards anchor on the drawn row's fill, before the resize/drag thumb. Group headers +/// and rail marks retain their column band (`rowBand(row:column:)`). The card touches its anchor. /// /// ``` -/// +---- sidebar column ----+ +--------------------+ -/// | +-----------+ |1| | (the card's top on the row's top, -/// | | row | |px card | 1 device pixel off the column) -/// | +-----------+ | +--------------------+ +/// +---- sidebar column ---------------------------+ +/// | +----------------+--------------------+ | +/// | | row | card | | (the card's top on the row's top, +/// | +----------------+ | | touching the row's fill) +/// | +--------------------+ | /// ``` /// /// The gap keeps the card only for a pointer heading into it (`HoverCardCoordinator`); one @@ -63,11 +62,15 @@ public nonisolated struct HoverCardEscapeGeometry: Hashable, Sendable { case middle } - /// The room between the sidebar column's visible edge and a row's card, and between a target - /// and a `.beside` card (the update card, a pull request badge's card): one device pixel of - /// `scale` (Lawrence 2026-10-21: "horizontally only 1 pixel gap"). + /// The room between a target and a `.beside` card (the update card or a pull request badge's + /// card): one device pixel of `scale`. Sidebar row cards use ``rowGap`` so they sit + /// directly against the row and its resize/drag edge. public static func edgeGap(scale: CGFloat) -> CGFloat { 1 / max(scale, 1) } + /// Sidebar row cards sit directly against their anchor. Keeping this separate from + /// ``edgeGap(scale:)`` preserves the small breathing room used by non-sidebar `.beside` cards. + public static let rowGap: CGFloat = 0 + /// A sidebar row's anchor for its card: the row's height across the whole sidebar column. /// /// The card's top lines up with `row`'s top and its near edge with the column's visible edge, @@ -103,23 +106,23 @@ public nonisolated struct HoverCardEscapeGeometry: Hashable, Sendable { /// The alignment the sidebar uses now: top, or middle while the Debug Settings switch is on. public static var currentAlignment: RowAlignment { escapeSlotTunable.value ? .middle : .top } - /// The card for `anchor` (a row's band across the sidebar, `rowBand(row:column:)`) at `size`, + /// The card for `anchor` (a workspace's fill, or a group/rail band) at `size`, /// kept inside `visible` (the screen's visible frame, already inset by any margin) when given, - /// its near edge `gap` (one device pixel when nil, `edgeGap(scale:)`) off the anchor's edge on + /// its near edge `gap` (zero when nil, ``rowGap``) off the anchor's edge on /// device pixels of `scale`, lined up with the row by `alignment`. public init(anchor: CGRect, cardSize size: CGSize, visible: CGRect? = nil, scale: CGFloat = 2, gap: CGFloat? = nil, alignment: RowAlignment = .top) { let scale = max(scale, 1) - let gap = gap ?? Self.edgeGap(scale: scale) - func pixel(_ value: CGFloat) -> CGFloat { (value * scale).rounded() / scale } + let gap = gap ?? Self.rowGap + func up(_ value: CGFloat) -> CGFloat { (value * scale).rounded(.up) / scale } func down(_ value: CGFloat) -> CGFloat { (value * scale).rounded(.down) / scale } self.anchor = anchor let middle = down(anchor.midY) - // Right of the sidebar: its left edge `gap` past the edge's device pixel. - var x = pixel(anchor.maxX) + gap + // Snap toward the row so a fractional edge never leaves a visible seam. + var x = down(anchor.maxX) + gap var leading = false - if let visible, x + size.width > visible.maxX, pixel(anchor.minX) - gap - size.width >= visible.minX { - x = pixel(anchor.minX) - gap - size.width + if let visible, x + size.width > visible.maxX, up(anchor.minX) - gap - size.width >= visible.minX { + x = up(anchor.minX) - gap - size.width leading = true } var y: CGFloat diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/NSView+SidebarColumn.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/NSView+SidebarColumn.swift index c3381895ac4d..176b4b1d0bfc 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/NSView+SidebarColumn.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/NSView+SidebarColumn.swift @@ -7,10 +7,8 @@ extension NSView { /// `SidebarContainerView.visibleEdgeOutset`). A view in no sidebar container answers its own /// frame. Nil out of a window. /// - /// A sidebar row's hover card stands one device pixel off this edge - /// (`HoverCardEscapeGeometry.rowBand(row:column:)`, Lawrence 2026-10-21: "horizontally only - /// 1 pixel gap"). Every sidebar surface with row cards (the list, a thin rail) reads it here, - /// so the cards all line up on one edge. + /// Group and rail hover cards use this column band (`HoverCardEscapeGeometry.rowBand(row:column:)`). + /// Workspace previews use their drawn row's fill instead, before the resize/drag edge. public var sidebarColumnOnScreen: CGRect? { guard let window else { return nil } var container: SidebarContainerView? diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarContainerView+Side.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarContainerView+Side.swift index 09060ddb99ab..36110f9e6aee 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarContainerView+Side.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarContainerView+Side.swift @@ -14,7 +14,7 @@ extension SidebarContainerView { /// How far past the container's content-side edge the sidebar shows: the outer half of the /// border line when it shows at rest (the resize handle centers it on the edge), else 0. - /// Hover cards beside a row stand one device pixel past this (`sidebarColumnOnScreen`). + /// Hover cards beside a row touch this edge (`sidebarColumnOnScreen`). var visibleEdgeOutset: CGFloat { guard !handle.isHiddenOrHasHiddenAncestor, handle.restingLineWidth > 0 else { return 0 } return handle.lineWidth / 2 diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+HoverCard.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+HoverCard.swift index e4a35fe4820f..44b4c0af480c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+HoverCard.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+HoverCard.swift @@ -73,11 +73,12 @@ extension SidebarListViewComponents { guard let pinned = blockItemView(for: id) else { return nil } return window.convertToScreen(convert(convert(pinned.bounds, from: pinned), to: nil)) } - /// Where a workspace row's hover card goes: the row's band across the sidebar column - /// (`HoverCardEscapeGeometry.rowBand`), so the card's top is the row's top and its near edge - /// one device pixel off the sidebar's visible edge. Nil when the row is not laid out. + /// A workspace preview touches the row's fill, before the sidebar's resize/drag thumb. + /// Pinned items use their drawn pill rather than their wider layout cell. func hoverCardBand(for id: WorkspaceID) -> CGRect? { - hoverCardAnchor(for: id).flatMap(hoverCardBand(row:)) + if displayed.row(for: .workspace(id)) != nil { return hoverCardAnchor(for: id) } + guard let window, let pinned = blockItemView(for: id) else { return nil } + return window.convertToScreen(pinned.convert(pinned.pill.frame, to: nil)) } /// A group header's band across the sidebar column, as `hoverCardBand(for:)`. func hoverCardBand(forGroup id: GroupID) -> CGRect? { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarRailHoverCards.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarRailHoverCards.swift index 863f0c3bcd1f..a47df87dbccc 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarRailHoverCards.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarRailHoverCards.swift @@ -6,7 +6,7 @@ public import CmuxNextDesign /// Cmd-digit), anchored to the mark's row. It is a source of the app's one `HoverCardCoordinator`, /// as the sidebar list's rows are, with the list's one anchor rule: the mark's row as a band /// across the sidebar column (`HoverCardEscapeGeometry.rowBand`, `sidebarColumnOnScreen`), placement -/// `.besideRow`, so the card's top is the row's top and it stands one device pixel past the rail. Rows +/// `.besideRow`, so the card's top is the row's top and it touches the rail. Rows /// of a group touch, and the gaps between groups bridge, so moving from mark to mark retargets /// the one card instead of hiding and showing it. @MainActor diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCard.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCard.swift index 70bb567ea5ab..66e798db3b89 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCard.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCard.swift @@ -24,7 +24,7 @@ final class WorkspaceHoverCardController: HoverCardSource { /// leaving the row and the card fades it out at once, as a tab card does. A gap or header /// between rows (`hoverCardBridges`) holds it for the reshow window, so the next row slides /// it over instead of hiding and reshowing it. Leo 2026-10-21: the card's top lines up with - /// its row's top, one device pixel off the sidebar's edge (`HoverCardPlacement.besideRow`). + /// its row's top, touching the row's fill (`HoverCardPlacement.besideRow`). var leave: HoverCardLeave { .hides(reshow: HoverCardTiming.reshowWindow) } weak var list: SidebarListView? /// The app's one coordinator; the App injects it. @@ -115,7 +115,7 @@ final class WorkspaceHoverCardController: HoverCardSource { } func hoverCardAnchor(for id: HoverTargetID) -> CGRect? { - // The row's band across the sidebar: the card's top on the row's, one pixel off the edge. + // The row's band across the sidebar: the card's top on the row's, touching the edge. if let group = groupID(id) { return list?.hoverCardBand(forGroup: group) } return workspaceID(id).flatMap { list?.hoverCardBand(for: $0) } } @@ -162,7 +162,7 @@ final class WorkspaceHoverCardController: HoverCardSource { body.configure(Self.groupSummary(item, workspaces: list.subtreeWorkspaces(of: group), options: DesignSettings.shared.groupHoverCard, now: Date())) bodyID = id - // One rule for sidebar cards: beside the sidebar, its top on the header's, one pixel off the edge. + // One rule for sidebar cards: beside the sidebar, its top on the header's, touching the edge. return HoverCardBody(view: body, placement: .besideRow, themeAnchor: list) { [weak body] in body?.applyColors() } } diff --git a/plans/cmux-next/sidebar-owner.md b/plans/cmux-next/sidebar-owner.md index eb34c96bb39b..d60c16baa893 100644 --- a/plans/cmux-next/sidebar-owner.md +++ b/plans/cmux-next/sidebar-owner.md @@ -519,3 +519,8 @@ existing colors, glass, row geometry and drag behavior. ## Home, pinned and list rows share one leading inset and text column (2026-10-28) - Lawrence 2026-10-28 ("home and pinned are not aligned", screenshot). Before: Home's glyph drew centered in its 20 pt box (ink about 3 pt right of the inset), a pin's ink at the inset, and band labels at box end + 8 pt while pinned titles sat at pin end + 4 pt and workspace titles after an icon at `titleTextX`. Now a bare band glyph starts its ink at the inset (as the pin does), and Home's label, pinned titles and pinned group names use `SidebarStyle.leadingTextX`, the column workspace rows with an icon already used. Wells, tiles, chips and rail buttons are unchanged. Test: `HomePinnedRowAlignmentTests` (every density, Minimal Mode, 200 and 260 pt). + +## Workspace preview touches its row (S2, 2026-10-28) + +Leo: "I mean the gap between the hover preview and the, you know, the workspace sidebar element thingy." +Workspace previews anchor on the drawn row fill, including pinned workspace pills, instead of the sidebar column beyond the resize/drag thumb. Sidebar row-card placement leaves no gap, snapping toward the target by less than one device pixel. Top alignment, contents, dwell, the row-to-thumb gutter and non-sidebar hover cards keep their existing behavior. `SidebarHoverCardEscapeTests` exercises the real panel against the row rather than the column; `PinnedRowHoverCardTests` covers the pinned fill and geometry tests cover fractional edges at 1x, 2x and 3x. diff --git a/scripts/cmux-next/media/tours/hover-card.json b/scripts/cmux-next/media/tours/hover-card.json index d9d0ecb9c09f..b81a1b451e77 100644 --- a/scripts/cmux-next/media/tours/hover-card.json +++ b/scripts/cmux-next/media/tours/hover-card.json @@ -12,7 +12,7 @@ {"title": "Tab cards, light", "cmux": ["settings", "unset", "appearance.background"], "wait": 3}, {"title": "Tab cards, dark", "cmux": ["settings", "set", "appearance.theme", "Soft Blue"], "wait": 2}, {"title": "Close the gallery", "rpc": {"method": "debug.hover_cards", "params": {"gallery": "none"}}, "wait": 1}, - {"title": "New workspace", "cmux": ["workspace", "new", "--name", "Make the hover cards look like Codex's"], "wait": 2}, + {"title": "New workspace", "rpc": {"method": "action.run", "params": {"action": "workspace new", "args": {"name": "Make the hover cards look like Codex's", "focus": true}, "origin": "script"}}, "wait": 2}, {"title": "A real workspace card, dark", "cmux": ["action", "run", "workspace.showResources"], "wait": 3} ] } From 6287fdf3b289dc686c6522e6eb7b1ba9a2a7b70a Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 15:11:36 -0700 Subject: [PATCH 3/4] fix(sidebar): keep pinned preview hit geometry on its fill Sidebar-Look: no visual change; align pointer escape with the new drawn-fill placement. Exercise vertical dismissal on the list side, outside the now-flush preview. Agent-Session: https://cmux.com/deeplink/agent?id=aid-f0e2e06c2fc61fa2b914 --- .../CmuxNextSidebar/Views/WorkspaceHoverCard.swift | 4 ++-- .../Tests/CmuxNextAppTests/PinnedRowHoverCardTests.swift | 3 +++ .../CmuxNextAppTests/SidebarHoverCardEscapeTests.swift | 8 ++++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCard.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCard.swift index 66e798db3b89..b18d97d5b9df 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCard.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCard.swift @@ -115,14 +115,14 @@ final class WorkspaceHoverCardController: HoverCardSource { } func hoverCardAnchor(for id: HoverTargetID) -> CGRect? { - // The row's band across the sidebar: the card's top on the row's, touching the edge. + // Workspace fill or group band: the card touches its anchor, its top on the row's top. if let group = groupID(id) { return list?.hoverCardBand(forGroup: group) } return workspaceID(id).flatMap { list?.hoverCardBand(for: $0) } } func hoverCardTargetFrame(for id: HoverTargetID) -> CGRect? { if let group = groupID(id) { return list?.hoverCardAnchor(forGroup: group) } - return workspaceID(id).flatMap { list?.hoverCardAnchor(for: $0) } + return workspaceID(id).flatMap { list?.hoverCardBand(for: $0) } } /// The pointer is on a row's pull request badge, whose own card (`SidebarPullRequestCard`) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedRowHoverCardTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedRowHoverCardTests.swift index 99e30caed989..1171d412f5b6 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedRowHoverCardTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedRowHoverCardTests.swift @@ -125,6 +125,9 @@ import Testing #expect(abs(band.maxY - onScreen.maxY) < 0.5) let pill = harness.window.convertToScreen(row.convert(row.pill.frame, to: nil)) #expect(band == pill, "a pinned preview must touch its fill rather than its layout cell") + let target = WorkspaceHoverCardController.targetID(Self.pinnedID) + #expect(harness.list.hoverCard.hoverCardTargetFrame(for: target) == band, + "placement and pointer escape must use the same pinned fill") } /// The list row of the other workspace still opens its own card (the pinned lookup takes nothing from it). diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarHoverCardEscapeTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarHoverCardEscapeTests.swift index 8b3e6b31203b..571e1b971536 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarHoverCardEscapeTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarHoverCardEscapeTests.swift @@ -103,10 +103,14 @@ import CmuxNextWakeupsTestSupport @Test func movingDownPastTheRowsRightEndDismissesTheCard() async throws { let clock = ManualClock() let (list, window, coordinator, rows) = Self.sidebar(clock: clock) - let (row, _) = try await Self.showSecondRowCard(list, coordinator, clock, rows) + let (row, card) = try await Self.showSecondRowCard(list, coordinator, clock, rows) coordinator.pointerMoved(to: CGPoint(x: row.maxX - 2, y: row.minY + 3)) let shown = try #require(coordinator.machine.shownTarget, "the card went on the row itself") - coordinator.pointerMoved(to: CGPoint(x: row.maxX + 3, y: row.minY - 14)) + // Stay on the list side of the row's edge: with a flush preview, a point to its right + // is on the preview itself and correctly keeps it open. + let below = CGPoint(x: row.maxX - 2, y: row.minY - 14) + try #require(!card.contains(below), "the dismissal path must not enter the preview") + coordinator.pointerMoved(to: below) // Gone, or handed to the row below (the sidebar's margin can still hit-test as that row). #expect(coordinator.machine.shownTarget?.id != shown.id, "the card stayed (\(coordinator.machine.phase)) with the pointer below its row") From f3769628205cedb74e2f2131d6b974cd21c29552 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 13:16:11 -0700 Subject: [PATCH 4/4] test(sidebar): keep update-card gap separate from workspace previews The workspace preview now touches its row while the update card retains one device pixel of spacing. Assert each intended placement independently. Sidebar-Look: no visual change Agent-Session: https://cmux.com/deeplink/agent?id=aid-acedf8848325f4f883bb --- .../CmuxNextAppTests/UpdateCardHoverParityTests.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateCardHoverParityTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateCardHoverParityTests.swift index 69325e96afc1..ce03a9685c68 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateCardHoverParityTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateCardHoverParityTests.swift @@ -119,7 +119,7 @@ import Testing #expect(update == workspace.played, "the update card enters with \(update), a workspace card with \(workspace.played)") } - @Test func theUpdateCardSitsAtTheWorkspaceCardsGap() async throws { + @Test func theUpdateCardKeepsItsPixelGapWhileWorkspaceCardsTouch() async throws { let h = try await make() defer { h.tearDown() } try #require(!h.card.isHidden, "the staged update shows no card") @@ -131,8 +131,12 @@ import Testing let popup = try #require(h.window.childWindows?.first { $0 !== workspace.panel }, "no update hover card") let card = h.window.convertToScreen(h.card.convert(h.card.bounds, to: nil)) let surface = Self.surface(of: popup) - #expect(abs((surface.minX - card.maxX) - workspace.gap) <= 0.5, - "the update card sits \(surface.minX - card.maxX) pt from its card, a workspace card \(workspace.gap) pt from its row") + let pixel = 1 / max(h.window.backingScaleFactor, 1) + let updateGap = surface.minX - card.maxX + #expect(updateGap > 0 && updateGap <= pixel + 0.001, + "the non-sidebar update card keeps its one-device-pixel gap, rounded toward its target") + #expect(workspace.gap <= 0.001 && workspace.gap > -pixel, + "the workspace preview touches its row, with less than one device pixel of inward snapping") #expect(abs(surface.maxY - card.maxY) <= 1, "the update card is not top-aligned with its card") }