From b010f9c05e7d279a4886e6b3d8399d33730fbc75 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 14:04:52 -0700 Subject: [PATCH 1/2] cmux NEXT: hover cards and the What's New popover are glass again, not a solid card Leo 2026-10-21 on his nightly: "WHY IS THIS SOLID COLOR AGAIN". The app's one hover card window (session and tab hover cards, and the Update Ready card's What's New highlights popover) was themed Liquid Glass after #21785; ccde782cb51 then laid the mostly opaque CardMaterial fill (at least 90%) over every theme-tone overlay, this window included, so these cards read as solid again. Glass.makeOverlayPanel takes cardFill (default true, so the palette, popovers and bars keep the card fill); HoverCardPanel and the hover card gallery host pass false and draw the shared themed overlay glass alone. Reduce Transparency stays solid. FloatingSurfacesStayGlassTests fails when a fill between the hover card's or the Update Ready card's material and its content is denser than the shared glass (FloatingGlassStyle veils, the themed overlay tint), on Liquid Glass and on the pre-26 blur, and proves the check catches the card fill. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16027-a80b-79c0-ab31-500e58883d27&host=cosmic-gray --- .../Sources/CmuxNextDesign/CardMaterial.swift | 6 +- .../Sources/CmuxNextDesign/Glass.swift | 11 +- .../HoverCards/HoverCardPanel.swift | 3 +- .../HoverCards/HoverCardRow.swift | 2 +- .../CmuxNextDesign/OverlaySurface.swift | 3 +- .../Tunables/ChromeTunables.swift | 2 +- .../FloatingSurfacesStayGlassTests.swift | 111 ++++++++++++++++++ 7 files changed, 129 insertions(+), 9 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/FloatingSurfacesStayGlassTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/CardMaterial.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/CardMaterial.swift index 2ada601b7fc0..ddf6c82814c8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/CardMaterial.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/CardMaterial.swift @@ -1,8 +1,10 @@ public import CmuxTheme public import Foundation -/// The one material of every floating card that carries text (hover cards, -/// the update card, popovers, the palette, dialogs, find and prompt bars): +/// The one material of every floating card that carries text (popovers, the +/// palette, dialogs, find and prompt bars; the hover card window, which shows +/// the workspace and tab cards and the Update Ready highlights, stays bare +/// glass, Leo 2026-10-21): /// the opaque popup surface laid on the glass or blur at `opacity`, with a /// visible rim. /// diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Glass.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Glass.swift index 893e879193d1..2c0ed6a1c241 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Glass.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Glass.swift @@ -39,15 +39,20 @@ public struct Glass { /// live. Use this for every overlay; it takes clicks unless `interactive` /// is false. `tone` `.neutral` draws the shared neutral glass with its rim /// (``FloatingGlassStyle``: dialogs, prompts, toasts) instead of the theme - /// tint and card fill. + /// tint and card fill. `cardFill` false keeps the theme tone's bare glass + /// with no ``CardMaterial`` fill: the hover card window (workspace and tab + /// cards, the Update Ready highlights), which is glass, never a solid card + /// (Leo 2026-10-19 "it's solid gray?", 2026-10-21 "WHY IS THIS SOLID COLOR + /// AGAIN"; `FloatingSurfacesStayGlassTests`). public static func makeOverlayPanel( content: NSView? = nil, cornerRadius: CGFloat = Metrics.panelCornerRadius, interactive: Bool = true, - tone: OverlayTone = .theme + tone: OverlayTone = .theme, + cardFill: Bool = true ) -> OverlaySurfaceView { let surface = OverlaySurfaceView(interactive: interactive, cornerRadius: cornerRadius, - card: tone.style == nil, tone: tone) + card: tone.style == nil && cardFill, tone: tone) surface.translatesAutoresizingMaskIntoConstraints = false if let content { content.translatesAutoresizingMaskIntoConstraints = true diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/HoverCards/HoverCardPanel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/HoverCards/HoverCardPanel.swift index fe2113fd5006..c7ff515094e8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/HoverCards/HoverCardPanel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/HoverCards/HoverCardPanel.swift @@ -86,7 +86,8 @@ final class HoverCardPanel: NSPanel { init() { // `PopupHostView` resets an overlay card to the popup radius; the host's radius below sets it back. - glass = Glass.makeOverlayPanel(cornerRadius: Self.cornerRadius) + // Bare themed glass, no solid card fill (`Glass.makeOverlayPanel`'s `cardFill`). + glass = Glass.makeOverlayPanel(cornerRadius: Self.cornerRadius, cardFill: false) // The card is its clear window's whole content: before macOS 26 its blur samples what is // behind the window (the sidebar, a terminal), as its Liquid Glass refracts it. glass.samplesBehindWindow = true diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/HoverCards/HoverCardRow.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/HoverCards/HoverCardRow.swift index 5221170d19ed..cfd4b3a2258e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/HoverCards/HoverCardRow.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/HoverCards/HoverCardRow.swift @@ -336,7 +336,7 @@ public final class HoverCardGalleryHost: NSView, ThemeResponsive { self.recolor = recolor super.init(frame: .zero) // The real card's shape (`HoverCardPanel.cornerRadius`), so the gallery shows what a hover shows. - let glass = Glass.makeOverlayPanel(cornerRadius: HoverCardPanel.cornerRadius, interactive: false) + let glass = Glass.makeOverlayPanel(cornerRadius: HoverCardPanel.cornerRadius, interactive: false, cardFill: false) body.translatesAutoresizingMaskIntoConstraints = false glass.contentView.addSubview(body) addSubview(glass) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlaySurface.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlaySurface.swift index c0fd5948135a..fb00f1d7aedd 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlaySurface.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlaySurface.swift @@ -11,7 +11,8 @@ import CmuxTheme /// (palette, dialogs, hover cards, find and prompt bars, page info, /// popovers) is one of these, made with `Glass.makeOverlayPanel`, and /// follows this Mac's material like the overlays without text (the drop -/// overlay, dock backdrop). A card (`isCard`: every theme-tone `makeOverlayPanel`) +/// overlay, dock backdrop). A card (`isCard`: every theme-tone `makeOverlayPanel` +/// but the hover card window's, which stays bare glass) /// also lays the ``CardMaterial`` on its glass or blur: the popup surface, /// mostly opaque, with a visible rim, so its text keeps 4.5:1 over any /// wallpaper or page (Lawrence 2026-10-20: hover cards were "too liquid diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/ChromeTunables.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/ChromeTunables.swift index 5246cd3dbad8..fb428d9f6b70 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/ChromeTunables.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/ChromeTunables.swift @@ -63,7 +63,7 @@ public nonisolated enum ChromeTunables { "glass.overlayTintStrength", .glass, "Overlay tint strength", help: "Multiplier on the theme glass tint's alpha for overlay surfaces (palette, hover cards, browser bars, drop overlay).", default: 1.25, range: 0...3, step: 0.05, unit: .multiplier, code: "ChromeTunables.glassOverlayTintStrength") public static let cardOpacity = Tunable.number( - "glass.cardOpacity", .glass, "Card opacity", help: "Least opacity of the solid fill every floating card (hover cards, popovers, palette, update card) lays on its glass; text keeps 4.5:1 over any backdrop above it.", + "glass.cardOpacity", .glass, "Card opacity", help: "Least opacity of the solid fill every floating card (popovers, palette, find and prompt bars; hover cards stay bare glass) lays on its glass; text keeps 4.5:1 over any backdrop above it.", default: CardMaterial.defaultMinimumOpacity, range: 0.5...1, step: 0.01, unit: .fraction, code: "ChromeTunables.cardOpacity") // MARK: Scroll fade diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/FloatingSurfacesStayGlassTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/FloatingSurfacesStayGlassTests.swift new file mode 100644 index 000000000000..bd63aba0bf04 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/FloatingSurfacesStayGlassTests.swift @@ -0,0 +1,111 @@ +import AppKit +import CmuxTheme +@testable import CmuxNextDesign +import Testing + +/// Leo, 2026-10-21, on his nightly: "WHY IS THIS SOLID COLOR AGAIN". The What's New popover beside +/// the Update Ready card, the session hover cards and the tab cards all show in the app's one hover +/// card window (`HoverCardPanel`), and the Update Ready card itself draws `CardSurfaceView`. Each is +/// glass in the shared material: the themed overlay glass (`OverlaySurfaceView`, Leo 2026-10-19, +/// #21785) or the shared neutral veil (`FloatingGlassStyle`). ccde782cb51 laid the mostly opaque +/// ``CardMaterial`` fill (at least 90%) over every theme-tone overlay, the hover card window's too, +/// which turned these surfaces back into solid cards. This fails when any layer between their +/// material and their content paints denser than the shared glass. Reduce Transparency (the +/// `.opaque` material) may be solid and is not checked here. +@MainActor @Suite(.serialized) struct FloatingSurfacesStayGlassTests { + enum Look: String, CaseIterable { case light, dark } + + private func scope(_ look: Look) throws -> ThemeScope { + let scope = ThemeScope(level: .room) + let input = switch look { + case .light: ThemeInput(background: ThemeRGB(hex: 0xFFFFFF), foreground: ThemeRGB(hex: 0x24292F)) + case .dark: ThemeInput(background: ThemeRGB(hex: 0x1E1E1E), foreground: ThemeRGB(hex: 0xD4D4D4)) + } + scope.setOverride(try #require(ThemeSpec("Stay Glass \(look.rawValue)")), input: input, animated: false) + return scope + } + + /// The densest shared glass: the neutral veils (`FloatingGlassStyle`) and the themed overlay + /// tint (`Palette.glassTint` at `glassOverlayTintStrength`). Anything denser reads as a card + /// painted over the glass. + private static func sharedGlassCeiling(in scope: ThemeScope) -> CGFloat { + let veils = [FloatingGlassStyle.toast, .dialog, .card, .agentTag].flatMap { [$0.darkVeilAlpha, $0.lightVeilAlpha] } + let tint = scope.perform { Palette.glassTint }.alphaComponent + let overlay = min(max(tint * ChromeTunables.glassOverlayTintStrength.value, 0), 1) + return max(CGFloat(veils.max() ?? 0), overlay) + 0.01 + } + + /// Every fill drawn between `surface`'s material and its content: the layer fills and glass + /// tints of the views that hold the content (its ancestors up to the surface), so the system + /// material's own private views are never read. + private static func fills(over surface: OverlaySurfaceView) -> [(String, CGFloat)] { + var out: [(String, CGFloat)] = [] + var view = surface.contentView.superview + while let current = view, current !== surface { + if let color = current.layer?.backgroundColor, color.alpha > 0 { out.append(("\(type(of: current)) fill", color.alpha)) } + if let glass = current as? GlassPanelView, let tint = glass.tintColor { out.append(("glass tint", tint.alphaComponent)) } + view = current.superview + } + return out + } + + /// The hover card window (session and tab cards, the Update Ready highlights) is the themed + /// glass alone: no ``CardMaterial`` fill on it, on Liquid Glass or on the blur before macOS 26. + @Test(arguments: Look.allCases) + func theHoverCardWindowIsGlassNotASolidCard(look: Look) throws { + let scope = try scope(look) + let panel = HoverCardPanel() + scope.adopt(panel) + #expect(!panel.glass.isCard, "the hover card lays the opaque card fill on its glass") + let ceiling = Self.sharedGlassCeiling(in: scope) + for material in [OverlayMaterial.liquidGlass, .vibrancy] { + panel.glass.materialOverride = material + panel.themeDidChange() + let fills = Self.fills(over: panel.glass) + #expect(!fills.isEmpty, "\(look) \(material): no glass tint drawn") + for (name, alpha) in fills { + #expect(alpha <= ceiling, "\(look) \(material): \(name) at \(alpha), denser than the shared glass (\(ceiling))") + } + } + panel.glass.materialOverride = nil + } + + /// The hover card gallery draws the card the way a hover shows it. + @Test func theGalleryCardIsTheSameGlass() throws { + let host = HoverCardGalleryHost(body: NSView(), recolor: {}) + let surface = try #require(host.subviews.compactMap { $0 as? OverlaySurfaceView }.first) + #expect(!surface.isCard) + } + + /// The sidebar's Update Ready card (and every sidebar card) draws the shared neutral veil, well + /// below a solid fill, in light and dark. + @Test(arguments: Look.allCases) + func theUpdateReadyCardIsTheSharedVeil(look: Look) throws { + let scope = try scope(look) + let card = CardSurfaceView(reduceTransparency: ReduceTransparency(system: { false }, changes: NotificationCenter())) + card.frame = NSRect(x: 0, y: 0, width: 200, height: 60) + scope.root(card) + card.updateLayer() + let fill = try #require(card.layer?.backgroundColor) + let veil = FloatingGlassStyle.card.look(isDark: card.themeTokens.isDark).veil + #expect(abs(fill.alpha - veil.alpha) < 0.02, "\(look): the card is not the shared veil (\(fill.alpha))") + #expect(fill.alpha <= Self.sharedGlassCeiling(in: scope), "\(look): the card's fill is solid (\(fill.alpha))") + } + + /// The check is not vacuous: a theme-tone overlay that keeps the card fill is denser than the + /// shared glass on both materials, so the hover card test would catch it coming back. + @Test(arguments: Look.allCases) + func theCardFillTheHoverCardDropsIsSolid(look: Look) throws { + let scope = try scope(look) + let card = Glass.makeOverlayPanel(cornerRadius: HoverCardPanel.cornerRadius) + scope.root(card) + #expect(card.isCard) + let ceiling = Self.sharedGlassCeiling(in: scope) + for material in [OverlayMaterial.liquidGlass, .vibrancy] { + card.materialOverride = material + card.applyTheme() + let densest = Self.fills(over: card).map(\.1).max() ?? 0 + #expect(densest > ceiling, "\(look) \(material): the card fill (\(densest)) is no denser than the shared glass") + } + } +} From 763689a704891daf379ac42928976ed721188029 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 14:23:17 -0700 Subject: [PATCH 2/2] test(next): read the hover card's fills the way CardMaterialLegibilityTests does Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16027-a80b-79c0-ab31-500e58883d27&host=cosmic-gray --- .../FloatingSurfacesStayGlassTests.swift | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/FloatingSurfacesStayGlassTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/FloatingSurfacesStayGlassTests.swift index bd63aba0bf04..b50e9a9710d1 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/FloatingSurfacesStayGlassTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/FloatingSurfacesStayGlassTests.swift @@ -35,16 +35,30 @@ import Testing return max(CGFloat(veils.max() ?? 0), overlay) + 0.01 } - /// Every fill drawn between `surface`'s material and its content: the layer fills and glass - /// tints of the views that hold the content (its ancestors up to the surface), so the system - /// material's own private views are never read. + /// Every fill drawn between `surface`'s material and its content, outermost first: a glass + /// panel's tint (its private internals stand for it) and every layer fill on the way down, + /// as `CardMaterialLegibilityTests` reads them. private static func fills(over surface: OverlaySurfaceView) -> [(String, CGFloat)] { + var chain: [NSView] = [] + var current: NSView? = surface.contentView + while let view = current, view !== surface { + chain.append(view) + current = view.superview + } var out: [(String, CGFloat)] = [] - var view = surface.contentView.superview - while let current = view, current !== surface { - if let color = current.layer?.backgroundColor, color.alpha > 0 { out.append(("\(type(of: current)) fill", color.alpha)) } - if let glass = current as? GlassPanelView, let tint = glass.tintColor { out.append(("glass tint", tint.alphaComponent)) } - view = current.superview + var skipUntil: NSView? + for view in chain.reversed() { + if let skip = skipUntil { + guard view === skip else { continue } + skipUntil = nil + } + if view === surface.contentView { break } + if let glass = view as? GlassPanelView { + if let tint = glass.tintColor { out.append(("glass tint", tint.alphaComponent)) } + skipUntil = glass.contentView + continue + } + if let color = view.layer?.backgroundColor, color.alpha > 0 { out.append(("\(type(of: view)) fill", color.alpha)) } } return out }