Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
///
Expand Down
11 changes: 8 additions & 3 deletions Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Glass.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Double>.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
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
}
}