From 649f602c81c00c77c079cc83982d545797acffe5 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 21 Sep 2026 01:56:51 -0700 Subject: [PATCH 01/13] test: catch overlapping Cloud unread indicators --- .github/workflows/ci.yml | 7 +- cmuxTests/CloudSidebarAcceptanceTests.swift | 3 +- .../CloudSidebarAttentionLayoutTests.swift | 115 +++++++++++++++--- scripts/ci/cmux_unit_test_shard.py | 3 + 4 files changed, 108 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 726f6e6fa811..f6eb4de48bd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1568,10 +1568,10 @@ jobs: -only-testing:cmuxTests/BrowserSystemProxyMirrorTests \ test-without-building - - name: Run Cloud notification dismiss parity regression + - name: Run Cloud notification dismiss parity and layout regressions if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_B_SHARD) }} run: | - # Focused gate for https://github.com/manaflow-ai/cmux/issues/13000. + # Focused gate for notification state (#13000) and placement (#13366). # The tolerant full-suite step accepts ordinary Swift Testing # failures, so keep the one-read-state contract between the local # store, the left sidebar, and the Cloud tree on a non-tolerant @@ -1583,6 +1583,9 @@ jobs: -destination "platform=macOS" \ CMUX_SKIP_ZIG_BUILD=1 \ -only-testing:cmuxTests/CloudNotificationDismissParityTests \ + -only-testing:cmuxTests/CloudSidebarAttentionLayoutTests \ + -only-testing:cmuxTests/CloudSidebarPinGeometryTests \ + -only-testing:cmuxTests/CloudTreeCompactLayoutTests \ test-without-building - name: Run agent restore admission regression diff --git a/cmuxTests/CloudSidebarAcceptanceTests.swift b/cmuxTests/CloudSidebarAcceptanceTests.swift index 53198d66c6e6..edf1e231c70d 100644 --- a/cmuxTests/CloudSidebarAcceptanceTests.swift +++ b/cmuxTests/CloudSidebarAcceptanceTests.swift @@ -36,8 +36,9 @@ struct CloudSidebarAcceptanceTests { } let attention = CloudSidebarAttentionLayoutTests() for width in [140.0, 300.0] { - for kind in ["workspace", "terminal"] { try attention.attentionPrecedesIcon(width: width, kind: kind) } + for kind in ["workspace", "terminal"] { try attention.attentionPlacement(width: width, kind: kind) } } + for width in [220.0, 380.0] { try attention.outlineAttentionTransitions(width: width) } try attention.collapsedFolderAttention() try attention.collapsedFolderIsInvalidatedByDescendantReadChanges() } diff --git a/cmuxTests/CloudSidebarAttentionLayoutTests.swift b/cmuxTests/CloudSidebarAttentionLayoutTests.swift index 1f1d41e98387..88aac5d8b487 100644 --- a/cmuxTests/CloudSidebarAttentionLayoutTests.swift +++ b/cmuxTests/CloudSidebarAttentionLayoutTests.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxFoundation import Testing #if canImport(cmux_DEV) @testable import cmux_DEV @@ -7,11 +8,25 @@ import Testing #endif @MainActor -@Suite("Cloud sidebar attention layout") +@Suite("Cloud sidebar attention layout", .serialized) struct CloudSidebarAttentionLayoutTests { - @Test("Read and unread rows differ only in the icon badge, even pinned and narrow", + @Test("Unread indicators remain separate from row ink without moving the identity", arguments: [140.0, 300.0], ["workspace", "terminal"]) - func attentionPrecedesIcon(width: Double, kind: String) throws { + func attentionPlacement(width: Double, kind: String) throws { + for percent in [75, 100, 150, 200] { + for pinned in [false, true] { + try attentionPlacement(width: width, kind: kind, percent: percent, pinned: pinned) + } + } + } + + private func attentionPlacement(width: Double, kind: String, percent: Int, pinned: Bool) throws { + let oldPercent = UserDefaults.standard.object(forKey: GlobalFontMagnification.percentKey) + UserDefaults.standard.set(percent, forKey: GlobalFontMagnification.percentKey) + defer { + if let oldPercent { UserDefaults.standard.set(oldPercent, forKey: GlobalFontMagnification.percentKey) } + else { UserDefaults.standard.removeObject(forKey: GlobalFontMagnification.percentKey) } + } let fixture = CloudSidebarOrderingFixture() defer { fixture.close() } fixture.coordinator.apply(nodes: fixture.nodes()) @@ -20,38 +35,103 @@ struct CloudSidebarAttentionLayoutTests { let unreadNode = try #require(CloudTreeNodeBuilder.flattened(fixture.nodes(unread: ["term_ws_1"])) .first { $0.id == readNode.id }) let cell = try #require(outline.view(atColumn: 0, row: outline.row(forItem: readNode), makeIfNecessary: true) as? CloudTreeCellView) - let host = NSView(frame: NSRect(x: 0, y: 0, width: width, height: 40)) + let height = CloudTreeStyle.compact.rowHeight * Double(percent) / 100 + let host = NSView(frame: NSRect(x: 0, y: 0, width: width, height: height)) let window = NSWindow(contentRect: host.frame, styleMask: [], backing: .buffered, defer: false) window.contentView = host defer { window.contentView = nil } cell.removeFromSuperview() cell.frame = host.bounds host.addSubview(cell) - readNode.isPinned = true - unreadNode.isPinned = true + readNode.isPinned = pinned + unreadNode.isPinned = pinned let read = try render(cell, node: readNode, fixture: fixture) let unread = try render(cell, node: unreadNode, fixture: fixture) + #if compiler(>=6.2) + let name = "attention-\(kind)-\(Int(width))-\(percent)-pinned-\(pinned)" + Attachment.record(try #require(read.representation(using: .png, properties: [:])), named: name + "-read.png") + Attachment.record(try #require(unread.representation(using: .png, properties: [:])), named: name + "-unread.png") + #endif + try expectSeparateIndicator(read: read, unread: unread, width: width) + cell.prepareForReuse() + let cleared = try render(cell, node: readNode, fixture: fixture) + #expect(cleared.tiffRepresentation == read.tiffRepresentation, + "A reused cell must remove the dot without shifting the pin, icon or title") + } + + private func expectSeparateIndicator(read: NSBitmapImageRep, unread: NSBitmapImageRep, width: Double) throws { #expect(read.pixelsWide == unread.pixelsWide) #expect(read.pixelsHigh == unread.pixelsHigh) - var changedX: [Int] = [] + var changed = CGRect.null for y in 0.. 0.15 { changedX.append(x) } + if difference > 0.15 { changed = changed.union(CGRect(x: x, y: y, width: 1, height: 1)) } } } - let right = try #require(changedX.max(), "The unread indicator must actually render") + try #require(!changed.isNull, "The unread indicator must actually render") let scale = Double(unread.pixelsWide) / width - #expect(Double(right) / scale < 20, - "The badge stays over the leading icon; pin, title and trailing controls cannot shift") - let cleared = try render(cell, node: readNode, fixture: fixture) - #expect(cleared.tiffRepresentation == read.tiffRepresentation) - #if compiler(>=6.2) - Attachment.record(try #require(unread.representation(using: .png, properties: [:])), named: "leading-dot-\(kind)-\(Int(width)).png") - #endif + #expect(changed.minX / scale >= width - CloudTreeStyle.compact.rowGrid.trailingPadding, + "Only the existing trailing padding may change; icons, pins and titles must stay put") + #expect(changed.maxX < Double(unread.pixelsWide), "The dot must not clip at the sidebar edge") + #expect(abs(changed.midY - Double(unread.pixelsHigh) / 2) <= scale, + "Center the notification on the row, not on the icon's upper corner") + #expect((5...7).contains(changed.width / scale) && (5...7).contains(changed.height / scale), + "The complete six-point indicator remains visible at every supported font scale") + var overlappingInk = 0 + for y in max(0, Int(changed.minY) - 1).. 0.15 { overlappingInk += 1 } + } + } + #expect(overlappingInk == 0, "Unread dots need visible separation from existing row ink") + } + + @Test("The real outline repaints unread and cleared rows without changing disclosure geometry", + arguments: [220.0, 380.0]) + func outlineAttentionTransitions(width: Double) throws { + let fixture = CloudSidebarOrderingFixture() + defer { fixture.close() } + fixture.window.setContentSize(NSSize(width: width, height: 560)) + fixture.coordinator.apply(style: .compact) + fixture.coordinator.apply(nodes: fixture.nodes()) + let outline = try #require(fixture.coordinator.outlineView) + let rows = CloudTreeNodeBuilder.flattened(fixture.coordinator.nodes).filter { + $0.structureTag == "workspace" || $0.structureTag == "terminal" + } + for pinned in [false, true] { + if pinned { + for node in rows { #expect(fixture.coordinator.organize(.pin, nodeID: node.id)) } + } + let indexes = rows.map { outline.row(forItem: $0) } + let disclosure = indexes.map { outline.frameOfOutlineCell(atRow: $0) } + let read = try indexes.map { try captureRow($0, in: outline) } + fixture.coordinator.apply(nodes: fixture.nodes(unread: ["term_ws_1", "term_ws_2"])) + try fixture.attachScreenshot(named: "outline-unread-\(Int(width))-pinned-\(pinned)") + for (index, row) in indexes.enumerated() { + let unread = try captureRow(row, in: outline) + try expectSeparateIndicator(read: read[index], unread: unread, + width: outline.frameOfCell(atColumn: 0, row: row).width) + #expect(outline.frameOfOutlineCell(atRow: row) == disclosure[index]) + } + fixture.coordinator.apply(nodes: fixture.nodes()) + for (index, row) in indexes.enumerated() { + let cleared = try captureRow(row, in: outline) + #expect(cleared.tiffRepresentation == read[index].tiffRepresentation) + } + try fixture.attachScreenshot(named: "outline-cleared-\(Int(width))-pinned-\(pinned)") + } + } + + private func captureRow(_ row: Int, in outline: CloudTreeNSOutlineView) throws -> NSBitmapImageRep { + let cell = try #require(outline.view(atColumn: 0, row: row, makeIfNecessary: true) as? CloudTreeCellView) + cell.layoutSubtreeIfNeeded() + let bitmap = try #require(cell.bitmapImageRepForCachingDisplay(in: cell.bounds)) + cell.cacheDisplay(in: cell.bounds, to: bitmap) + return bitmap } @Test("Collapsed folders retain descendant attention and hover controls at narrow widths") @@ -101,7 +181,8 @@ struct CloudSidebarAttentionLayoutTests { } private func render(_ cell: CloudTreeCellView, node: CloudTreeNode, fixture: CloudSidebarOrderingFixture) throws -> NSBitmapImageRep { - cell.configure(node: node, machineActions: fixture.coordinator.machineActions, nodeActions: fixture.coordinator.nodeActions) + cell.configure(node: node, machineActions: fixture.coordinator.machineActions, + nodeActions: fixture.coordinator.nodeActions, style: .compact) cell.layoutSubtreeIfNeeded() cell.displayIfNeeded() let bitmap = try #require(cell.bitmapImageRepForCachingDisplay(in: cell.bounds)) diff --git a/scripts/ci/cmux_unit_test_shard.py b/scripts/ci/cmux_unit_test_shard.py index 3503245e781d..46280d6b8cf9 100755 --- a/scripts/ci/cmux_unit_test_shard.py +++ b/scripts/ci/cmux_unit_test_shard.py @@ -57,6 +57,9 @@ "cmuxTests/CloudMachineDragSourceTests", "cmuxTests/CloudMachineOrderingTests", "cmuxTests/CloudNotificationDismissParityTests", + "cmuxTests/CloudSidebarAttentionLayoutTests", + "cmuxTests/CloudSidebarPinGeometryTests", + "cmuxTests/CloudTreeCompactLayoutTests", "cmuxTests/CloudWorkspaceRenameSurfaceParityTests", "cmuxTests/CmuxBundledBinPathIntegrationTests", "cmuxTests/DeviceDirectoryLifecycleTests", From a4acf3fdd72e1552d566cf5d45c327043a4b078d Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 21 Sep 2026 02:16:59 -0700 Subject: [PATCH 02/13] fix: separate Cloud unread indicators from row icons --- Sources/Cloud/CloudTreeCellView.swift | 7 +++- .../Sidebar/CloudSidebarRowDecoration.swift | 14 +++++--- .../CloudSidebarAttentionLayoutTests.swift | 33 +++++++++++++------ 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/Sources/Cloud/CloudTreeCellView.swift b/Sources/Cloud/CloudTreeCellView.swift index 2565c532137a..a294e4d43f66 100644 --- a/Sources/Cloud/CloudTreeCellView.swift +++ b/Sources/Cloud/CloudTreeCellView.swift @@ -71,7 +71,12 @@ final class CloudTreeCellView: NSTableCellView { displayHost.isHidden = false displayHost.rootView = AnyView( CloudTreeRowContentView(kind: node.kind, style: style) - .modifier(CloudSidebarRowDecoration(isPinned: node.isPinned, showsAttentionSlot: node.showsAttentionSlot, hasUnreadNotification: node.hasUnreadAttention)) + .modifier(CloudSidebarRowDecoration( + isPinned: node.isPinned, + showsAttentionSlot: node.showsAttentionSlot, + hasUnreadNotification: node.hasUnreadAttention, + trailingPadding: style.rowGrid.trailingPadding + )) .frame(maxWidth: .infinity, alignment: .leading) ) // An in-place row reload reuses this cell; the new content can be wider diff --git a/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift b/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift index b84c4e9f287d..f39fc793ecd8 100644 --- a/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift +++ b/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift @@ -1,17 +1,16 @@ import SwiftUI -/// An optional leading pin and an unread badge over the icon, with no empty -/// leading column. Read/unread changes never move the row's icon or title. +/// An optional leading pin and an unread badge in the row's trailing padding. +/// Read/unread changes never move the row's icon or title. /// Immutable input keeps AppKit cell reuse independent of observable stores. struct CloudSidebarRowDecoration: ViewModifier { let isPinned: Bool let showsAttentionSlot: Bool let hasUnreadNotification: Bool + var trailingPadding: CGFloat = CloudTreeStyle.compact.rowGrid.trailingPadding func body(content: Content) -> some View { // Keep the pin in the same compact leading cluster as the row icon. - // Four points made the unread badge spill past the narrow sidebar's - // identity column; two points matches the tree's shared gaps. HStack(spacing: 2) { if isPinned { Image(systemName: "pin.fill") @@ -21,7 +20,7 @@ struct CloudSidebarRowDecoration: ViewModifier { .accessibilityLabel(String(localized: "taskManager.row.pinned", defaultValue: "Pinned")) } content - .overlay(alignment: .topLeading) { + .overlay(alignment: .trailing) { if showsAttentionSlot { Circle() .fill(Color.accentColor) @@ -31,6 +30,11 @@ struct CloudSidebarRowDecoration: ViewModifier { .accessibilityLabel(String(localized: "cloudTree.organization.unread", defaultValue: "Unread notification")) .help(hasUnreadNotification ? String(localized: "cloudTree.organization.unread", defaultValue: "Unread notification") : "") + // The leaf row already reserves trailing padding for + // its edge. Center the indicator in that space so it + // stays separate from text without consuming a new + // layout slot or colliding with hover controls. + .frame(width: trailingPadding) .allowsHitTesting(false) } } diff --git a/cmuxTests/CloudSidebarAttentionLayoutTests.swift b/cmuxTests/CloudSidebarAttentionLayoutTests.swift index 88aac5d8b487..e1605f5fac4c 100644 --- a/cmuxTests/CloudSidebarAttentionLayoutTests.swift +++ b/cmuxTests/CloudSidebarAttentionLayoutTests.swift @@ -52,14 +52,16 @@ struct CloudSidebarAttentionLayoutTests { Attachment.record(try #require(read.representation(using: .png, properties: [:])), named: name + "-read.png") Attachment.record(try #require(unread.representation(using: .png, properties: [:])), named: name + "-unread.png") #endif - try expectSeparateIndicator(read: read, unread: unread, width: width) + try expectSeparateIndicator(read: read, unread: unread, in: cell) cell.prepareForReuse() let cleared = try render(cell, node: readNode, fixture: fixture) #expect(cleared.tiffRepresentation == read.tiffRepresentation, "A reused cell must remove the dot without shifting the pin, icon or title") } - private func expectSeparateIndicator(read: NSBitmapImageRep, unread: NSBitmapImageRep, width: Double) throws { + private func expectSeparateIndicator( + read: NSBitmapImageRep, unread: NSBitmapImageRep, in cell: CloudTreeCellView + ) throws { #expect(read.pixelsWide == unread.pixelsWide) #expect(read.pixelsHigh == unread.pixelsHigh) var changed = CGRect.null @@ -73,13 +75,18 @@ struct CloudSidebarAttentionLayoutTests { } } try #require(!changed.isNull, "The unread indicator must actually render") - let scale = Double(unread.pixelsWide) / width - #expect(changed.minX / scale >= width - CloudTreeStyle.compact.rowGrid.trailingPadding, + let scale = CGFloat(unread.pixelsWide) / cell.bounds.width + // The native cell also owns hover buttons. The display host stops + // before those controls, so measure its actual edge inside the cell. + let host = try #require(cell.subviews.first { $0 is CloudTreePassthroughHostingView }) + let content = cell.convert(host.bounds, from: host) + #expect(changed.minX / scale >= content.maxX - CloudTreeStyle.compact.rowGrid.trailingPadding, "Only the existing trailing padding may change; icons, pins and titles must stay put") - #expect(changed.maxX < Double(unread.pixelsWide), "The dot must not clip at the sidebar edge") - #expect(abs(changed.midY - Double(unread.pixelsHigh) / 2) <= scale, + #expect(changed.maxX < CGFloat(unread.pixelsWide), "The dot must not clip at the sidebar edge") + #expect(abs(changed.midY - CGFloat(unread.pixelsHigh) / 2) <= scale, "Center the notification on the row, not on the icon's upper corner") - #expect((5...7).contains(changed.width / scale) && (5...7).contains(changed.height / scale), + #expect(changed.width / scale >= 5 && changed.width / scale <= 7 + && changed.height / scale >= 5 && changed.height / scale <= 7, "The complete six-point indicator remains visible at every supported font scale") var overlappingInk = 0 for y in max(0, Int(changed.minY) - 1).. Int in + let row = outline.row(forItem: node) + try #require(row >= 0) + return row + } let disclosure = indexes.map { outline.frameOfOutlineCell(atRow: $0) } let read = try indexes.map { try captureRow($0, in: outline) } fixture.coordinator.apply(nodes: fixture.nodes(unread: ["term_ws_1", "term_ws_2"])) try fixture.attachScreenshot(named: "outline-unread-\(Int(width))-pinned-\(pinned)") for (index, row) in indexes.enumerated() { let unread = try captureRow(row, in: outline) - try expectSeparateIndicator(read: read[index], unread: unread, - width: outline.frameOfCell(atColumn: 0, row: row).width) + let cell = try #require(outline.view(atColumn: 0, row: row, makeIfNecessary: true) as? CloudTreeCellView) + try expectSeparateIndicator(read: read[index], unread: unread, in: cell) #expect(outline.frameOfOutlineCell(atRow: row) == disclosure[index]) } fixture.coordinator.apply(nodes: fixture.nodes()) @@ -128,6 +140,7 @@ struct CloudSidebarAttentionLayoutTests { private func captureRow(_ row: Int, in outline: CloudTreeNSOutlineView) throws -> NSBitmapImageRep { let cell = try #require(outline.view(atColumn: 0, row: row, makeIfNecessary: true) as? CloudTreeCellView) + cell.setHovered(true) cell.layoutSubtreeIfNeeded() let bitmap = try #require(cell.bitmapImageRepForCachingDisplay(in: cell.bounds)) cell.cacheDisplay(in: cell.bounds, to: bitmap) From 05ba773229fc3a7165225b7e283b9e6be3043c57 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 21 Sep 2026 18:23:14 -0700 Subject: [PATCH 03/13] fix: keep Cloud attention indicators in the leading slot --- Sources/Cloud/CloudTreeCellView.swift | 2 +- Sources/Cloud/CloudTreeRowGrid.swift | 2 + .../Sidebar/CloudSidebarRowDecoration.swift | 38 +++++++++---------- cmuxTests/CloudSidebarAcceptanceTests.swift | 2 +- .../CloudSidebarAttentionLayoutTests.swift | 11 ++---- cmuxTests/CloudSidebarPinGeometryTests.swift | 8 ++-- 6 files changed, 30 insertions(+), 33 deletions(-) diff --git a/Sources/Cloud/CloudTreeCellView.swift b/Sources/Cloud/CloudTreeCellView.swift index a294e4d43f66..65cb74066be0 100644 --- a/Sources/Cloud/CloudTreeCellView.swift +++ b/Sources/Cloud/CloudTreeCellView.swift @@ -75,7 +75,7 @@ final class CloudTreeCellView: NSTableCellView { isPinned: node.isPinned, showsAttentionSlot: node.showsAttentionSlot, hasUnreadNotification: node.hasUnreadAttention, - trailingPadding: style.rowGrid.trailingPadding + attentionSlot: style.rowGrid.attentionSlot )) .frame(maxWidth: .infinity, alignment: .leading) ) diff --git a/Sources/Cloud/CloudTreeRowGrid.swift b/Sources/Cloud/CloudTreeRowGrid.swift index 2a60294fa2b7..4cb00984d877 100644 --- a/Sources/Cloud/CloudTreeRowGrid.swift +++ b/Sources/Cloud/CloudTreeRowGrid.swift @@ -4,6 +4,8 @@ import CoreGraphics struct CloudTreeRowGrid: Equatable, Sendable { var disclosureSlot: CGFloat = 16 var disclosureGap: CGFloat = 2 + /// Width of the leading unread-indicator column on rows that can carry attention. + var attentionSlot: CGFloat = 12 var dotGap: CGFloat = 4 var detailGap: CGFloat = 5 var trailingGap: CGFloat = 10 diff --git a/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift b/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift index f39fc793ecd8..d5d6b05b2225 100644 --- a/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift +++ b/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift @@ -1,17 +1,31 @@ import SwiftUI -/// An optional leading pin and an unread badge in the row's trailing padding. +/// An unread badge in the leading identity column, followed by an optional pin. /// Read/unread changes never move the row's icon or title. /// Immutable input keeps AppKit cell reuse independent of observable stores. struct CloudSidebarRowDecoration: ViewModifier { let isPinned: Bool let showsAttentionSlot: Bool let hasUnreadNotification: Bool - var trailingPadding: CGFloat = CloudTreeStyle.compact.rowGrid.trailingPadding + var attentionSlot: CGFloat = CloudTreeStyle.compact.rowGrid.attentionSlot func body(content: Content) -> some View { - // Keep the pin in the same compact leading cluster as the row icon. + // Reserve the attention column even when the row is read. This keeps + // the icon and title stable across unread transitions while limiting + // the gutter to rows that can actually carry notifications. HStack(spacing: 2) { + if showsAttentionSlot { + Circle() + .fill(Color.accentColor) + .frame(width: 6, height: 6) + .opacity(hasUnreadNotification ? 1 : 0) + .accessibilityHidden(!hasUnreadNotification) + .accessibilityLabel(String(localized: "cloudTree.organization.unread", defaultValue: "Unread notification")) + .help(hasUnreadNotification + ? String(localized: "cloudTree.organization.unread", defaultValue: "Unread notification") : "") + .frame(width: attentionSlot) + .allowsHitTesting(false) + } if isPinned { Image(systemName: "pin.fill") .cmuxFont(size: 9, weight: .semibold) @@ -20,24 +34,6 @@ struct CloudSidebarRowDecoration: ViewModifier { .accessibilityLabel(String(localized: "taskManager.row.pinned", defaultValue: "Pinned")) } content - .overlay(alignment: .trailing) { - if showsAttentionSlot { - Circle() - .fill(Color.accentColor) - .frame(width: 6, height: 6) - .opacity(hasUnreadNotification ? 1 : 0) - .accessibilityHidden(!hasUnreadNotification) - .accessibilityLabel(String(localized: "cloudTree.organization.unread", defaultValue: "Unread notification")) - .help(hasUnreadNotification - ? String(localized: "cloudTree.organization.unread", defaultValue: "Unread notification") : "") - // The leaf row already reserves trailing padding for - // its edge. Center the indicator in that space so it - // stays separate from text without consuming a new - // layout slot or colliding with hover controls. - .frame(width: trailingPadding) - .allowsHitTesting(false) - } - } } } } diff --git a/cmuxTests/CloudSidebarAcceptanceTests.swift b/cmuxTests/CloudSidebarAcceptanceTests.swift index edf1e231c70d..2ae07489b6f6 100644 --- a/cmuxTests/CloudSidebarAcceptanceTests.swift +++ b/cmuxTests/CloudSidebarAcceptanceTests.swift @@ -26,7 +26,7 @@ struct CloudSidebarAcceptanceTests { let pins = CloudSidebarPinGeometryTests() for width in [220.0, 380.0] { try pins.machinePinRepaintsImmediately(width: width) } for percent in [75, 100, 150, 200] { - try pins.noEmptyAttentionGutter(percent: percent) + try pins.attentionSlotPrecedesContent(percent: percent) for width in [100.0, 320.0] { try pins.leadingPin(width: width, percent: percent) } } try pins.pinMagnification() diff --git a/cmuxTests/CloudSidebarAttentionLayoutTests.swift b/cmuxTests/CloudSidebarAttentionLayoutTests.swift index e1605f5fac4c..a84364dc033d 100644 --- a/cmuxTests/CloudSidebarAttentionLayoutTests.swift +++ b/cmuxTests/CloudSidebarAttentionLayoutTests.swift @@ -76,13 +76,10 @@ struct CloudSidebarAttentionLayoutTests { } try #require(!changed.isNull, "The unread indicator must actually render") let scale = CGFloat(unread.pixelsWide) / cell.bounds.width - // The native cell also owns hover buttons. The display host stops - // before those controls, so measure its actual edge inside the cell. - let host = try #require(cell.subviews.first { $0 is CloudTreePassthroughHostingView }) - let content = cell.convert(host.bounds, from: host) - #expect(changed.minX / scale >= content.maxX - CloudTreeStyle.compact.rowGrid.trailingPadding, - "Only the existing trailing padding may change; icons, pins and titles must stay put") - #expect(changed.maxX < CGFloat(unread.pixelsWide), "The dot must not clip at the sidebar edge") + _ = try #require(cell.subviews.first { $0 is CloudTreePassthroughHostingView }) + let leadingSlot = CloudTreeStyle.compact.rowGrid.attentionSlot + #expect(changed.minX / scale >= 0 && changed.maxX / scale <= leadingSlot + 1, + "Only the leading attention slot may change; icons, pins and titles must stay put") #expect(abs(changed.midY - CGFloat(unread.pixelsHigh) / 2) <= scale, "Center the notification on the row, not on the icon's upper corner") #expect(changed.width / scale >= 5 && changed.width / scale <= 7 diff --git a/cmuxTests/CloudSidebarPinGeometryTests.swift b/cmuxTests/CloudSidebarPinGeometryTests.swift index 5af6771dfeda..4dba3c750ed7 100644 --- a/cmuxTests/CloudSidebarPinGeometryTests.swift +++ b/cmuxTests/CloudSidebarPinGeometryTests.swift @@ -81,11 +81,13 @@ struct CloudSidebarPinGeometryTests { #expect(abs(pinned.maxX - unpinned.maxX) <= 1, "Trailing alignment must not move when pinning") } - @Test("Read rows start at the caret content edge without an invisible attention column", + @Test("Read rows reserve the leading attention column without shifting on unread changes", arguments: [75, 100, 150, 200]) - func noEmptyAttentionGutter(percent: Int) throws { + func attentionSlotPrecedesContent(percent: Int) throws { let bounds = try contentBounds(width: 220, pinned: false, percent: percent) - #expect(bounds.minX <= 1, "Hidden unread decoration must not indent the folder or terminal: \(bounds.minX)") + let slot = CloudTreeStyle.compact.rowGrid.attentionSlot + #expect(bounds.minX >= slot && bounds.minX <= slot + 4, + "The unread slot must stay in the leading identity grid: \(bounds.minX)") } @Test("Pin geometry follows the same magnification as row text") From 15b4b5d4db3d8670defa508fe05e1e951576f0b9 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 21 Sep 2026 19:00:24 -0700 Subject: [PATCH 04/13] ci: keep Cloud layout suites in the strict macOS gate --- .github/workflows/ci-macos.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index 2af92a21834a..1a63ade5964f 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -1385,6 +1385,9 @@ jobs: -destination "platform=macOS" \ CMUX_SKIP_ZIG_BUILD=1 \ -only-testing:cmuxTests/CloudNotificationDismissParityTests \ + -only-testing:cmuxTests/CloudSidebarAttentionLayoutTests \ + -only-testing:cmuxTests/CloudSidebarPinGeometryTests \ + -only-testing:cmuxTests/CloudTreeCompactLayoutTests \ test-without-building - name: Run agent restore admission regression From 20d4e694ba154472fac14e5d4eecd2012eb14f65 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 21 Sep 2026 19:07:27 -0700 Subject: [PATCH 05/13] test: align compact Cloud spacing with attention slots --- Sources/Cloud/Sidebar/CloudTreeNode+Organization.swift | 6 +++--- cmuxTests/CloudTreeCompactLayoutTests.swift | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Sources/Cloud/Sidebar/CloudTreeNode+Organization.swift b/Sources/Cloud/Sidebar/CloudTreeNode+Organization.swift index d7ee7cf67f12..ab8e5d9b6e04 100644 --- a/Sources/Cloud/Sidebar/CloudTreeNode+Organization.swift +++ b/Sources/Cloud/Sidebar/CloudTreeNode+Organization.swift @@ -10,9 +10,9 @@ extension CloudTreeNode { var showsAttentionSlot: Bool { switch kind { - // Surface, resource, and empty-state rows share a leading attention - // column so every nested row uses the same horizontal rhythm. - case .workspace, .localWorkspace, .terminal, .display, .browser, .port, .resource, .placeholder: return true + // Only rows that can carry the Cloud unread projection reserve the + // leading slot. Other nested rows keep their compact identity edge. + case .workspace, .terminal: return true default: return false } } diff --git a/cmuxTests/CloudTreeCompactLayoutTests.swift b/cmuxTests/CloudTreeCompactLayoutTests.swift index 5ddb8816cc9c..cf297ea756e9 100644 --- a/cmuxTests/CloudTreeCompactLayoutTests.swift +++ b/cmuxTests/CloudTreeCompactLayoutTests.swift @@ -147,9 +147,11 @@ struct CloudTreeCompactLayoutTests { let scale = Double(percent) / 100 let folderGap = try leadingGap(folder, in: outline) let sectionGap = try leadingGap(section, in: outline) - #expect(abs(folderGap - sectionGap) <= 4 * scale, - "Folder and header use the same close spacing, allowing glyph side bearings: \(folderGap), \(sectionGap)") - #expect(folderGap <= 6 * scale, "No reserved unread column between caret and folder") + let attentionSlot = CloudTreeStyle.compact.rowGrid.attentionSlot * scale + #expect(folderGap >= attentionSlot, + "Notification-capable folders reserve the leading attention slot: \(folderGap), \(attentionSlot)") + #expect(abs((folderGap - sectionGap) - attentionSlot) <= 6 * scale, + "The attention slot is the only intentional folder/header offset: \(folderGap), \(sectionGap)") for row in 0.. Date: Mon, 21 Sep 2026 20:54:58 -0700 Subject: [PATCH 06/13] test: account for clipped narrow Cloud rows --- cmuxTests/CloudTreeCompactLayoutTests.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cmuxTests/CloudTreeCompactLayoutTests.swift b/cmuxTests/CloudTreeCompactLayoutTests.swift index cf297ea756e9..e328f94540a9 100644 --- a/cmuxTests/CloudTreeCompactLayoutTests.swift +++ b/cmuxTests/CloudTreeCompactLayoutTests.swift @@ -53,13 +53,13 @@ struct CloudTreeCompactLayoutTests { #expect(cell.accessibilityLabel()?.contains(node.searchableTitle) == true) return cell } - if width == 220, percent == 200 { - // The unchanged leaf rows cannot fit title ink at this width - // and zoom, even before #13072. Capture the clipping and check - // full accessible identities; there is no visible gap to measure. + if width == 220, percent >= 150 { + // The narrow rows cannot fit title ink at this width and zoom. + // Capture the clipping and check full accessible identities; + // there is no visible gap to measure. #if compiler(>=6.2) - Attachment.record("Leaf titles are clipped at 220pt/200%; spacing is not measurable. Accessible identities checked.", - named: "icon-spacing-220-200-pinned-\(pinned).txt") + Attachment.record("Leaf titles are clipped at 220pt/\(percent)%; spacing is not measurable. Accessible identities checked.", + named: "icon-spacing-220-\(percent)-pinned-\(pinned).txt") #endif continue } From 84303f01c70d0d91965fc2655a379d157a732c37 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 22 Sep 2026 17:49:46 -0700 Subject: [PATCH 07/13] fix: avoid empty Cloud attention space until unread --- .../Sidebar/CloudSidebarRowDecoration.swift | 3 +- .../CloudSidebarAttentionLayoutTests.swift | 50 +++++++++++++------ cmuxTests/CloudSidebarPinGeometryTests.swift | 30 ++++++----- cmuxTests/CloudTreeCompactLayoutTests.swift | 8 ++- 4 files changed, 56 insertions(+), 35 deletions(-) diff --git a/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift b/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift index 62207b9ffa08..bc07e1e9f075 100644 --- a/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift +++ b/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift @@ -8,6 +8,7 @@ struct CloudSidebarRowDecoration: ViewModifier { let showsAttentionSlot: Bool let hasUnreadNotification: Bool var attentionSlot: CGFloat = CloudTreeStyle.compact.rowGrid.attentionSlot + @Environment(\.cmuxGlobalFontMagnificationPercent) private var magnification func body(content: Content) -> some View { // Reserve the attention column even when the row is read. This keeps @@ -23,7 +24,7 @@ struct CloudSidebarRowDecoration: ViewModifier { .accessibilityLabel(String(localized: "cloudTree.organization.unread", defaultValue: "Unread notification")) .help(hasUnreadNotification ? String(localized: "cloudTree.organization.unread", defaultValue: "Unread notification") : "") - .frame(width: attentionSlot) + .frame(width: GlobalFontMagnification.scaledSize(attentionSlot, percent: magnification)) .allowsHitTesting(false) } if isPinned { diff --git a/cmuxTests/CloudSidebarAttentionLayoutTests.swift b/cmuxTests/CloudSidebarAttentionLayoutTests.swift index a84364dc033d..18f5a5229ef2 100644 --- a/cmuxTests/CloudSidebarAttentionLayoutTests.swift +++ b/cmuxTests/CloudSidebarAttentionLayoutTests.swift @@ -52,7 +52,7 @@ struct CloudSidebarAttentionLayoutTests { Attachment.record(try #require(read.representation(using: .png, properties: [:])), named: name + "-read.png") Attachment.record(try #require(unread.representation(using: .png, properties: [:])), named: name + "-unread.png") #endif - try expectSeparateIndicator(read: read, unread: unread, in: cell) + try expectSeparateIndicator(read: read, unread: unread, in: cell, percent: percent) cell.prepareForReuse() let cleared = try render(cell, node: readNode, fixture: fixture) #expect(cleared.tiffRepresentation == read.tiffRepresentation, @@ -60,7 +60,7 @@ struct CloudSidebarAttentionLayoutTests { } private func expectSeparateIndicator( - read: NSBitmapImageRep, unread: NSBitmapImageRep, in cell: CloudTreeCellView + read: NSBitmapImageRep, unread: NSBitmapImageRep, in cell: CloudTreeCellView, percent: Int ) throws { #expect(read.pixelsWide == unread.pixelsWide) #expect(read.pixelsHigh == unread.pixelsHigh) @@ -77,21 +77,39 @@ struct CloudSidebarAttentionLayoutTests { try #require(!changed.isNull, "The unread indicator must actually render") let scale = CGFloat(unread.pixelsWide) / cell.bounds.width _ = try #require(cell.subviews.first { $0 is CloudTreePassthroughHostingView }) - let leadingSlot = CloudTreeStyle.compact.rowGrid.attentionSlot - #expect(changed.minX / scale >= 0 && changed.maxX / scale <= leadingSlot + 1, - "Only the leading attention slot may change; icons, pins and titles must stay put") - #expect(abs(changed.midY - CGFloat(unread.pixelsHigh) / 2) <= scale, - "Center the notification on the row, not on the icon's upper corner") - #expect(changed.width / scale >= 5 && changed.width / scale <= 7 - && changed.height / scale >= 5 && changed.height / scale <= 7, - "The complete six-point indicator remains visible at every supported font scale") - var overlappingInk = 0 - for y in max(0, Int(changed.minY) - 1).. 0.15 { overlappingInk += 1 } + let readRuns = occupiedRuns(in: read) + let unreadRuns = occupiedRuns(in: unread) + let leadingSlot = GlobalFontMagnification.scaledSize( + CloudTreeStyle.compact.rowGrid.attentionSlot, percent: percent + ) + let readStart = CGFloat(try #require(readRuns.first?.lowerBound)) / scale + let unreadDot = try #require(unreadRuns.first) + let unreadContent = try #require(unreadRuns.dropFirst().first) + #expect(readStart <= 8, "Read rows keep their compact leading edge: \(readStart)") + #expect(CGFloat(unreadDot.lowerBound) / scale <= 8, + "The unread dot remains on the left side") + #expect(CGFloat(unreadDot.count) / scale >= 5 && CGFloat(unreadDot.count) / scale <= 7, + "The unread dot remains six points wide") + #expect(CGFloat(unreadContent.lowerBound) / scale >= leadingSlot - 2, + "Unread content follows the reserved leading attention slot") + } + + private func occupiedRuns(in bitmap: NSBitmapImageRep) -> [Range] { + var runs: [Range] = [] + var start: Int? + for x in 0.. 0.2 + } + if occupied { + if start == nil { start = x } + } else if let first = start { + runs.append(first.. unpinned.minX + 4, "The pin must precede the identity instead of consuming its trailing edge") #expect(abs(pinned.maxX - unpinned.maxX) <= 1, "Trailing alignment must not move when pinning") } - @Test("Read rows reserve the leading attention column without shifting on unread changes", + @Test("Read rows stay compact and unread rows reserve the leading attention column", arguments: [75, 100, 150, 200]) func attentionSlotPrecedesContent(percent: Int) throws { - let bounds = try contentBounds(width: 220, pinned: false, percent: percent) - let slot = CloudTreeStyle.compact.rowGrid.attentionSlot - #expect(bounds.minX >= slot && bounds.minX <= slot + 4, - "The unread slot must stay in the leading identity grid: \(bounds.minX)") + let read = try contentBounds(width: 220, pinned: false, percent: percent, unread: false) + let unread = try contentBounds(width: 220, pinned: false, percent: percent, unread: true) + let slot = GlobalFontMagnification.scaledSize(CloudTreeStyle.compact.rowGrid.attentionSlot, percent: percent) + #expect(read.minX <= 1, "Read rows must not reserve an empty leading gutter: \(read.minX)") + #expect(unread.minX >= slot + 1, + "Unread content must follow the leading attention slot: \(unread.minX), \(slot)") } @Test("Pin geometry follows the same magnification as row text") func pinMagnification() throws { - let small = try contentBounds(width: 140, pinned: true, percent: 75) - let large = try contentBounds(width: 140, pinned: true, percent: 200) + let small = try contentBounds(width: 140, pinned: true, percent: 75, unread: false) + let large = try contentBounds(width: 140, pinned: true, percent: 200, unread: false) #expect(large.minX > small.minX + 4) #expect(abs(large.maxX - small.maxX) <= 1) } @@ -219,9 +221,10 @@ struct CloudSidebarPinGeometryTests { return bounds } - private func contentBounds(width: Double, pinned: Bool, percent: Int) throws -> CGRect { - let host = NSHostingView(rootView: Color.blue - .modifier(CloudSidebarRowDecoration(isPinned: pinned, showsAttentionSlot: true, hasUnreadNotification: false)) + private func contentBounds(width: Double, pinned: Bool, percent: Int, unread: Bool) throws -> CGRect { + let host = NSHostingView(rootView: Text("content") + .foregroundStyle(.green) + .modifier(CloudSidebarRowDecoration(isPinned: pinned, showsAttentionSlot: true, hasUnreadNotification: unread)) .environment(\.cmuxGlobalFontMagnificationPercent, percent)) host.frame = NSRect(x: 0, y: 0, width: width, height: 28) let window = NSWindow(contentRect: host.frame, styleMask: [], backing: .buffered, defer: false) @@ -233,7 +236,8 @@ struct CloudSidebarPinGeometryTests { var xs: [Int] = [] for x in 0.. color.redComponent + 0.3 { xs.append(x) } + if color.greenComponent > color.redComponent + 0.15, + color.greenComponent > color.blueComponent + 0.15 { xs.append(x) } } let scale = Double(bitmap.pixelsWide) / width let left = Double(try #require(xs.min())) / scale diff --git a/cmuxTests/CloudTreeCompactLayoutTests.swift b/cmuxTests/CloudTreeCompactLayoutTests.swift index 6a1eabecb853..0beef66a1239 100644 --- a/cmuxTests/CloudTreeCompactLayoutTests.swift +++ b/cmuxTests/CloudTreeCompactLayoutTests.swift @@ -180,11 +180,9 @@ struct CloudTreeCompactLayoutTests { let scale = Double(percent) / 100 let folderGap = try leadingGap(folder, in: outline) let sectionGap = try leadingGap(section, in: outline) - let attentionSlot = CloudTreeStyle.compact.rowGrid.attentionSlot * scale - #expect(folderGap >= attentionSlot, - "Notification-capable folders reserve the leading attention slot: \(folderGap), \(attentionSlot)") - #expect(abs((folderGap - sectionGap) - attentionSlot) <= 6 * scale, - "The attention slot is the only intentional folder/header offset: \(folderGap), \(sectionGap)") + #expect(abs(folderGap - sectionGap) <= 4 * scale, + "Folder and header use the same close spacing, allowing glyph side bearings: \(folderGap), \(sectionGap)") + #expect(folderGap <= 6 * scale, "Read rows do not reserve an empty unread column") for row in 0.. Date: Tue, 22 Sep 2026 17:52:17 -0700 Subject: [PATCH 08/13] fix: import Cloud font scaling support --- Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift b/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift index bc07e1e9f075..02d540c6ea56 100644 --- a/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift +++ b/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift @@ -1,3 +1,4 @@ +import CmuxFoundation import SwiftUI /// An unread badge in the leading identity column, followed by an optional pin. From ab0d82a95b474635f565c56577424de7986d3a41 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 22 Sep 2026 18:18:35 -0700 Subject: [PATCH 09/13] fix: remove empty Cloud notification gutter --- .../Sidebar/CloudSidebarRowDecoration.swift | 15 +++----- .../CloudSidebarAttentionLayoutTests.swift | 37 ++++++++++--------- 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift b/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift index 02d540c6ea56..6245732f421d 100644 --- a/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift +++ b/Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift @@ -2,7 +2,7 @@ import CmuxFoundation import SwiftUI /// An unread badge in the leading identity column, followed by an optional pin. -/// Read/unread changes never move the row's icon or title. +/// Read rows keep the compact identity edge; unread rows reserve the badge slot. /// Immutable input keeps AppKit cell reuse independent of observable stores. struct CloudSidebarRowDecoration: ViewModifier { let isPinned: Bool @@ -12,19 +12,16 @@ struct CloudSidebarRowDecoration: ViewModifier { @Environment(\.cmuxGlobalFontMagnificationPercent) private var magnification func body(content: Content) -> some View { - // Reserve the attention column even when the row is read. This keeps - // the icon and title stable across unread transitions while limiting - // the gutter to rows that can actually carry notifications. + // Keep read rows flush with the outline's content edge. A row earns the + // leading slot only while it has unread attention, so the compact tree + // does not carry an empty gutter between the caret and its identity. HStack(spacing: 2) { - if showsAttentionSlot { + if showsAttentionSlot && hasUnreadNotification { Circle() .fill(Color.accentColor) .frame(width: 6, height: 6) - .opacity(hasUnreadNotification ? 1 : 0) - .accessibilityHidden(!hasUnreadNotification) .accessibilityLabel(String(localized: "cloudTree.organization.unread", defaultValue: "Unread notification")) - .help(hasUnreadNotification - ? String(localized: "cloudTree.organization.unread", defaultValue: "Unread notification") : "") + .help(String(localized: "cloudTree.organization.unread", defaultValue: "Unread notification")) .frame(width: GlobalFontMagnification.scaledSize(attentionSlot, percent: magnification)) .allowsHitTesting(false) } diff --git a/cmuxTests/CloudSidebarAttentionLayoutTests.swift b/cmuxTests/CloudSidebarAttentionLayoutTests.swift index 18f5a5229ef2..c00a2d93f8d3 100644 --- a/cmuxTests/CloudSidebarAttentionLayoutTests.swift +++ b/cmuxTests/CloudSidebarAttentionLayoutTests.swift @@ -10,7 +10,7 @@ import Testing @MainActor @Suite("Cloud sidebar attention layout", .serialized) struct CloudSidebarAttentionLayoutTests { - @Test("Unread indicators remain separate from row ink without moving the identity", + @Test("Unread indicators use a leading slot while read rows stay compact", arguments: [140.0, 300.0], ["workspace", "terminal"]) func attentionPlacement(width: Double, kind: String) throws { for percent in [75, 100, 150, 200] { @@ -56,7 +56,7 @@ struct CloudSidebarAttentionLayoutTests { cell.prepareForReuse() let cleared = try render(cell, node: readNode, fixture: fixture) #expect(cleared.tiffRepresentation == read.tiffRepresentation, - "A reused cell must remove the dot without shifting the pin, icon or title") + "A reused cell must remove the dot and restore the compact read layout") } private func expectSeparateIndicator( @@ -64,19 +64,7 @@ struct CloudSidebarAttentionLayoutTests { ) throws { #expect(read.pixelsWide == unread.pixelsWide) #expect(read.pixelsHigh == unread.pixelsHigh) - var changed = CGRect.null - for y in 0.. 0.15 { changed = changed.union(CGRect(x: x, y: y, width: 1, height: 1)) } - } - } - try #require(!changed.isNull, "The unread indicator must actually render") let scale = CGFloat(unread.pixelsWide) / cell.bounds.width - _ = try #require(cell.subviews.first { $0 is CloudTreePassthroughHostingView }) let readRuns = occupiedRuns(in: read) let unreadRuns = occupiedRuns(in: unread) let leadingSlot = GlobalFontMagnification.scaledSize( @@ -86,12 +74,25 @@ struct CloudSidebarAttentionLayoutTests { let unreadDot = try #require(unreadRuns.first) let unreadContent = try #require(unreadRuns.dropFirst().first) #expect(readStart <= 8, "Read rows keep their compact leading edge: \(readStart)") - #expect(CGFloat(unreadDot.lowerBound) / scale <= 8, - "The unread dot remains on the left side") + let dotCenter = CGFloat(unreadDot.lowerBound + unreadDot.upperBound) / (2 * scale) + #expect(abs(dotCenter - leadingSlot / 2) <= 1, + "The unread dot is centered in the leading slot at every font scale") #expect(CGFloat(unreadDot.count) / scale >= 5 && CGFloat(unreadDot.count) / scale <= 7, "The unread dot remains six points wide") - #expect(CGFloat(unreadContent.lowerBound) / scale >= leadingSlot - 2, - "Unread content follows the reserved leading attention slot") + let contentShift = CGFloat(unreadContent.lowerBound) / scale - readStart + #expect(abs(contentShift - leadingSlot - 2) <= 1, + "Only unread rows add the leading slot and its gap: \(contentShift)") + #expect(CGFloat(unreadContent.lowerBound - unreadDot.upperBound) / scale >= 2, + "The dot stays separate from the pin or icon") + let dotRows = (0.. 0.2 } + } + let top = try #require(dotRows.first) + let bottom = try #require(dotRows.last) + 1 + #expect(abs(CGFloat(top + bottom - unread.pixelsHigh) / 2) <= scale, + "The dot stays vertically centered instead of overlapping the icon's upper corner") + #expect(CGFloat(bottom - top) / scale >= 5 && CGFloat(bottom - top) / scale <= 7, + "The complete six-point dot remains visible") } private func occupiedRuns(in bitmap: NSBitmapImageRep) -> [Range] { From 8b710619dfb6261f4fce0edd60a29a1e6408d782 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 22 Sep 2026 19:32:01 -0700 Subject: [PATCH 10/13] test: measure Cloud machine title columns robustly --- cmuxTests/CloudTreeCompactLayoutTests.swift | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/cmuxTests/CloudTreeCompactLayoutTests.swift b/cmuxTests/CloudTreeCompactLayoutTests.swift index 0beef66a1239..131f58bcf560 100644 --- a/cmuxTests/CloudTreeCompactLayoutTests.swift +++ b/cmuxTests/CloudTreeCompactLayoutTests.swift @@ -147,7 +147,7 @@ struct CloudTreeCompactLayoutTests { let cell = try #require(outline.view(atColumn: 0, row: outline.row(forItem: node), makeIfNecessary: true)) let ink = try inkColumns(in: cell) try #require(ink.runs.count >= 2) - return CGFloat(ink.runs[1].lowerBound) / ink.scale + return try titleStart(in: ink, style: style, percent: percent) } // Sections insets the whole machine identity 6pt inside its band. // Preserve that decoration while comparing the shared icon column. @@ -241,6 +241,22 @@ struct CloudTreeCompactLayoutTests { return CGFloat(ink.runs[icon + 1].lowerBound - ink.runs[icon].upperBound) / ink.scale } + /// Resolved AppKit symbols can create extra ink runs before the title (for + /// example, the monochrome ledger glyph at 75%). Select the first run at + /// the layout's expected title column instead of assuming it is always + /// the second run in the raster. + private func titleStart( + in ink: (runs: [Range], scale: CGFloat), style: CloudTreeStyle, percent: Int + ) throws -> CGFloat { + let scale = CGFloat(percent) / 100 + let iconColumn = GlobalFontMagnification.scaledSize( + max(style.iconSlot, style.iconSize) + style.iconGap, percent: percent + ) + (style.machineBand ? 6 : 0) + let minimum = max(0, iconColumn - (1.5 * scale)) + let run = try #require(ink.runs.first { CGFloat($0.lowerBound) / ink.scale >= minimum }) + return CGFloat(run.lowerBound) / ink.scale + } + private func inkColumns(in view: NSView) throws -> (runs: [Range], scale: CGFloat) { view.layoutSubtreeIfNeeded() let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) From 598551157eeb8b65b20befc5b28246f8282a8e14 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 22 Sep 2026 19:58:41 -0700 Subject: [PATCH 11/13] test: scope title raster workaround to ledger rows --- cmuxTests/CloudTreeCompactLayoutTests.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/cmuxTests/CloudTreeCompactLayoutTests.swift b/cmuxTests/CloudTreeCompactLayoutTests.swift index 131f58bcf560..d070165da579 100644 --- a/cmuxTests/CloudTreeCompactLayoutTests.swift +++ b/cmuxTests/CloudTreeCompactLayoutTests.swift @@ -147,7 +147,10 @@ struct CloudTreeCompactLayoutTests { let cell = try #require(outline.view(atColumn: 0, row: outline.row(forItem: node), makeIfNecessary: true)) let ink = try inkColumns(in: cell) try #require(ink.runs.count >= 2) - return try titleStart(in: ink, style: style, percent: percent) + if style.id == "ledger" { + return try titleStart(in: ink, style: style, percent: percent) + } + return CGFloat(ink.runs[1].lowerBound) / ink.scale } // Sections insets the whole machine identity 6pt inside its band. // Preserve that decoration while comparing the shared icon column. @@ -241,17 +244,16 @@ struct CloudTreeCompactLayoutTests { return CGFloat(ink.runs[icon + 1].lowerBound - ink.runs[icon].upperBound) / ink.scale } - /// Resolved AppKit symbols can create extra ink runs before the title (for - /// example, the monochrome ledger glyph at 75%). Select the first run at - /// the layout's expected title column instead of assuming it is always - /// the second run in the raster. + /// The resolved monochrome ledger symbol can create an extra ink run before + /// the title at small magnifications. Select the expected title run rather + /// than assuming it is always the second run in that raster. private func titleStart( in ink: (runs: [Range], scale: CGFloat), style: CloudTreeStyle, percent: Int ) throws -> CGFloat { let scale = CGFloat(percent) / 100 let iconColumn = GlobalFontMagnification.scaledSize( max(style.iconSlot, style.iconSize) + style.iconGap, percent: percent - ) + (style.machineBand ? 6 : 0) + ) let minimum = max(0, iconColumn - (1.5 * scale)) let run = try #require(ink.runs.first { CGFloat($0.lowerBound) / ink.scale >= minimum }) return CGFloat(run.lowerBound) / ink.scale From efe16fa1ea50067d5a35d95a5bd3be2244667957 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 22 Sep 2026 20:20:00 -0700 Subject: [PATCH 12/13] test: measure rendered Cloud row geometry without style heuristics --- cmuxTests/CloudSidebarPinGeometryTests.swift | 8 +++-- cmuxTests/CloudTreeCompactLayoutTests.swift | 32 +++++++++----------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/cmuxTests/CloudSidebarPinGeometryTests.swift b/cmuxTests/CloudSidebarPinGeometryTests.swift index 4c26e069c85a..084305d32c77 100644 --- a/cmuxTests/CloudSidebarPinGeometryTests.swift +++ b/cmuxTests/CloudSidebarPinGeometryTests.swift @@ -222,9 +222,11 @@ struct CloudSidebarPinGeometryTests { } private func contentBounds(width: Double, pinned: Bool, percent: Int, unread: Bool) throws -> CGRect { - let host = NSHostingView(rootView: Text("content") - .foregroundStyle(.green) + // Fill the proposed content area so both edges measure layout, not + // the intrinsic width and side bearings of a centered text glyph. + let host = NSHostingView(rootView: Color.green .modifier(CloudSidebarRowDecoration(isPinned: pinned, showsAttentionSlot: true, hasUnreadNotification: unread)) + .accentColor(.blue) .environment(\.cmuxGlobalFontMagnificationPercent, percent)) host.frame = NSRect(x: 0, y: 0, width: width, height: 28) let window = NSWindow(contentRect: host.frame, styleMask: [], backing: .buffered, defer: false) @@ -243,7 +245,7 @@ struct CloudSidebarPinGeometryTests { let left = Double(try #require(xs.min())) / scale let right = Double(try #require(xs.max())) / scale #if compiler(>=6.2) - Attachment.record(try #require(bitmap.representation(using: .png, properties: [:])), named: "pin-\(pinned)-\(Int(width))-\(percent).png") + Attachment.record(try #require(bitmap.representation(using: .png, properties: [:])), named: "pin-\(pinned)-\(Int(width))-\(percent)-unread-\(unread).png") #endif return CGRect(x: left, y: 0, width: right - left, height: 28) } diff --git a/cmuxTests/CloudTreeCompactLayoutTests.swift b/cmuxTests/CloudTreeCompactLayoutTests.swift index d070165da579..83ceece71bcc 100644 --- a/cmuxTests/CloudTreeCompactLayoutTests.swift +++ b/cmuxTests/CloudTreeCompactLayoutTests.swift @@ -145,12 +145,7 @@ struct CloudTreeCompactLayoutTests { let outline = try #require(fixture.coordinator.outlineView) let starts = try nodes.map { node in let cell = try #require(outline.view(atColumn: 0, row: outline.row(forItem: node), makeIfNecessary: true)) - let ink = try inkColumns(in: cell) - try #require(ink.runs.count >= 2) - if style.id == "ledger" { - return try titleStart(in: ink, style: style, percent: percent) - } - return CGFloat(ink.runs[1].lowerBound) / ink.scale + return try titleStart(in: cell) } // Sections insets the whole machine identity 6pt inside its band. // Preserve that decoration while comparing the shared icon column. @@ -244,18 +239,19 @@ struct CloudTreeCompactLayoutTests { return CGFloat(ink.runs[icon + 1].lowerBound - ink.runs[icon].upperBound) / ink.scale } - /// The resolved monochrome ledger symbol can create an extra ink run before - /// the title at small magnifications. Select the expected title run rather - /// than assuming it is always the second run in that raster. - private func titleStart( - in ink: (runs: [Range], scale: CGFloat), style: CloudTreeStyle, percent: Int - ) throws -> CGFloat { - let scale = CGFloat(percent) / 100 - let iconColumn = GlobalFontMagnification.scaledSize( - max(style.iconSlot, style.iconSize) + style.iconGap, percent: percent - ) - let minimum = max(0, iconColumn - (1.5 * scale)) - let run = try #require(ink.runs.first { CGFloat($0.lowerBound) / ink.scale >= minimum }) + /// A glyph may contain several disconnected ink runs. Locate title ink + /// beyond the actual leading AppKit icon, independent of the expected + /// title position, symbol shape, font scale, or machine-band inset. + private func titleStart(in view: NSView) throws -> CGFloat { + let ink = try inkColumns(in: view) + let host = try #require(view.subviews.first { $0 is CloudTreePassthroughHostingView }) + let icons = descendants(of: host).compactMap { $0 as? CmuxResolvedIconImageView } + let icon = try #require(icons.min { + view.convert($0.bounds, from: $0).minX < view.convert($1.bounds, from: $1).minX + }) + let iconFrame = view.convert(icon.bounds, from: icon) + #expect(iconFrame.width > 0) + let run = try #require(ink.runs.first { CGFloat($0.lowerBound) / ink.scale >= iconFrame.maxX }) return CGFloat(run.lowerBound) / ink.scale } From f64ccb6f4d0f52c7b5413dfa4d591d99a5ac55ad Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 22 Sep 2026 21:17:46 -0700 Subject: [PATCH 13/13] test: cover Cloud notification layout at minimum font magnification --- cmuxTests/CloudSidebarAcceptanceTests.swift | 4 ++-- cmuxTests/CloudSidebarAttentionLayoutTests.swift | 2 +- cmuxTests/CloudSidebarPinGeometryTests.swift | 4 ++-- cmuxTests/CloudTreeCompactLayoutTests.swift | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cmuxTests/CloudSidebarAcceptanceTests.swift b/cmuxTests/CloudSidebarAcceptanceTests.swift index 2ae07489b6f6..938180d58927 100644 --- a/cmuxTests/CloudSidebarAcceptanceTests.swift +++ b/cmuxTests/CloudSidebarAcceptanceTests.swift @@ -14,7 +14,7 @@ struct CloudSidebarAcceptanceTests { @Test func compactGeometry() throws { let geometry = CloudTreeCompactLayoutTests() for width in [220.0, 360.0] { - for percent in [100, 150] { try geometry.compactRows(width: width, percent: percent) } + for percent in [50, 100, 150] { try geometry.compactRows(width: width, percent: percent) } } let layout = CloudTreeWorkspaceTitleLayoutTests() try layout.disclosureSpacing(style: .compact) @@ -25,7 +25,7 @@ struct CloudSidebarAcceptanceTests { @Test func pinsAndUnreadState() throws { let pins = CloudSidebarPinGeometryTests() for width in [220.0, 380.0] { try pins.machinePinRepaintsImmediately(width: width) } - for percent in [75, 100, 150, 200] { + for percent in [50, 75, 100, 150, 200] { try pins.attentionSlotPrecedesContent(percent: percent) for width in [100.0, 320.0] { try pins.leadingPin(width: width, percent: percent) } } diff --git a/cmuxTests/CloudSidebarAttentionLayoutTests.swift b/cmuxTests/CloudSidebarAttentionLayoutTests.swift index c00a2d93f8d3..6447aaafd7f3 100644 --- a/cmuxTests/CloudSidebarAttentionLayoutTests.swift +++ b/cmuxTests/CloudSidebarAttentionLayoutTests.swift @@ -13,7 +13,7 @@ struct CloudSidebarAttentionLayoutTests { @Test("Unread indicators use a leading slot while read rows stay compact", arguments: [140.0, 300.0], ["workspace", "terminal"]) func attentionPlacement(width: Double, kind: String) throws { - for percent in [75, 100, 150, 200] { + for percent in [50, 75, 100, 150, 200] { for pinned in [false, true] { try attentionPlacement(width: width, kind: kind, percent: percent, pinned: pinned) } diff --git a/cmuxTests/CloudSidebarPinGeometryTests.swift b/cmuxTests/CloudSidebarPinGeometryTests.swift index 084305d32c77..10648ac9ad8e 100644 --- a/cmuxTests/CloudSidebarPinGeometryTests.swift +++ b/cmuxTests/CloudSidebarPinGeometryTests.swift @@ -73,7 +73,7 @@ struct CloudSidebarPinGeometryTests { #expect(restored.tiffRepresentation == unpinned.tiffRepresentation) } - @Test("Pin reserves space before content at narrow and wide widths", arguments: [100.0, 320.0], [75, 100, 150, 200]) + @Test("Pin reserves space before content at narrow and wide widths", arguments: [100.0, 320.0], [50, 75, 100, 150, 200]) func leadingPin(width: Double, percent: Int) throws { let unpinned = try contentBounds(width: width, pinned: false, percent: percent, unread: false) let pinned = try contentBounds(width: width, pinned: true, percent: percent, unread: false) @@ -82,7 +82,7 @@ struct CloudSidebarPinGeometryTests { } @Test("Read rows stay compact and unread rows reserve the leading attention column", - arguments: [75, 100, 150, 200]) + arguments: [50, 75, 100, 150, 200]) func attentionSlotPrecedesContent(percent: Int) throws { let read = try contentBounds(width: 220, pinned: false, percent: percent, unread: false) let unread = try contentBounds(width: 220, pinned: false, percent: percent, unread: true) diff --git a/cmuxTests/CloudTreeCompactLayoutTests.swift b/cmuxTests/CloudTreeCompactLayoutTests.swift index 6d859fddcf71..70925c7fc515 100644 --- a/cmuxTests/CloudTreeCompactLayoutTests.swift +++ b/cmuxTests/CloudTreeCompactLayoutTests.swift @@ -173,7 +173,7 @@ struct CloudTreeCompactLayoutTests { } @Test("Folders start as close to their carets as plain section headings", - arguments: [220.0, 360.0], [100, 150]) + arguments: [220.0, 360.0], [50, 100, 150]) func compactRows(width: Double, percent: Int) throws { let oldPercent = UserDefaults.standard.object(forKey: GlobalFontMagnification.percentKey) UserDefaults.standard.set(percent, forKey: GlobalFontMagnification.percentKey)