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..b50e9a9710d1 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/FloatingSurfacesStayGlassTests.swift @@ -0,0 +1,125 @@ +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, 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 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 + } + + /// 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") + } + } +}