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
Expand Up @@ -2,8 +2,8 @@ import AppKit
import CmuxNextDesign

/// The icon picker's floating panel: a child of the cmux window it opens over,
/// beside its anchor (the workspace's sidebar row), with the system popover
/// material in the popup style (`PopupStyle`). It takes the keys only while the app is active (``ActiveAppKeyPanel``),
/// beside its anchor (the workspace's sidebar row), on the solid popup surface
/// (`Glass.makeOverlayPanel`) in the popup style (`PopupStyle`). It takes the keys only while the app is active (``ActiveAppKeyPanel``),
/// so a picker opened by a socket or CLI call never captures another app's typing.
/// A panel, not an NSPopover: a transient popover closed itself when the app was
/// not active (nxdog34), and `debug.popups` lists panels.
Expand All @@ -19,18 +19,9 @@ final class IconPickerPanel: ActiveAppKeyPanel {
level = .floating
hidesOnDeactivate = false
isReleasedWhenClosed = false
let material = NSVisualEffectView(frame: NSRect(origin: .zero, size: size))
material.material = .popover
material.blendingMode = .behindWindow
material.state = .active
material.wantsLayer = true
material.layer?.cornerRadius = PopupStyle.standard.cornerRadius
material.layer?.cornerCurve = .continuous
material.layer?.masksToBounds = true
content.frame = material.bounds
content.autoresizingMask = [.width, .height]
material.addSubview(content)
adoptPopupStyle(card: material)
let card = Glass.makeOverlayPanel(content: content, cornerRadius: PopupStyle.standard.cornerRadius)
card.frame = NSRect(origin: .zero, size: size)
adoptPopupStyle(card: card)
onKeyElsewhere = { [weak self] in self?.onDismiss?() }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ public final class BookmarkEditBubble: NSObject, NSPopoverDelegate {
}
let hosting = NSHostingController(rootView: BookmarkEditView(model: model))
hosting.sizingOptions = [.preferredContentSize]
hosting.view.fillWithPopupSurface(themeOf: anchor)
popover.contentViewController = hosting
popover.animates = Motion.animatesMovement
popover.show(relativeTo: CmuxPopoverAnchor.rect(in: anchor), of: anchor,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,9 @@ enum OmnibarStyle {
// `performWithTheme` (theme-scoped).
static var barFill: NSColor { Palette.chromeBackground } // theme-scoped
static var barHoverFill: NSColor { Palette.elevatedBackground } // theme-scoped
/// Editing fill and popup card.
static var cardFill: NSColor { Palette.elevatedBackground } // theme-scoped
/// Editing fill and popup card: the solid popup surface, so no page text
/// shows through the suggestions (the field's editing card joins it).
static var cardFill: NSColor { Palette.popupSurface } // theme-scoped
/// Neutral ring while editing with the popup closed.
static var ring: NSColor { Palette.focusRing } // theme-scoped
static var rowSelectedFill: NSColor { Palette.selectionFill } // theme-scoped
Expand Down
15 changes: 8 additions & 7 deletions Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Glass.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,19 @@ public struct Glass {
OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 0))
}()

/// A floating panel over content (palette, hover card, find and prompt
/// bars) that hosts `content` edge to edge: Liquid Glass with the theme
/// tint, or under Reduce Transparency an opaque theme fill
/// (`ThemeTokens.opaqueOverlayFill`) with a hairline, switched live
/// when the setting or the theme changes. Use this for every overlay;
/// it takes clicks unless `interactive` is false.
/// A floating panel over content that carries text (palette, dialogs,
/// hover cards, find and prompt bars, page info, popovers, toasts) and
/// hosts `content` edge to edge: the solid popup surface
/// (`Palette.popupSurface`) with a hairline, never glass, so the text
/// under it never shows through (Leo, 2026-10-12). It follows the theme
/// live. Use this for every overlay; it takes clicks unless
/// `interactive` is false.
public static func makeOverlayPanel(
content: NSView? = nil,
cornerRadius: CGFloat = Metrics.panelCornerRadius,
interactive: Bool = true
) -> OverlaySurfaceView {
let surface = OverlaySurfaceView(interactive: interactive, cornerRadius: cornerRadius)
let surface = OverlaySurfaceView(material: .opaque, interactive: interactive, cornerRadius: cornerRadius)
surface.translatesAutoresizingMaskIntoConstraints = false
if let content {
content.translatesAutoresizingMaskIntoConstraints = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,23 @@ public final class ReduceTransparency {
}

extension ThemeTokens {
/// The Reduce Transparency overlay fill: the window background moved
/// `lift` toward the primary text, both opaque.
/// The popup surface (`Palette.popupSurface`): the window background,
/// opaque, lifted by up to `lift` toward white on a light theme (a white
/// page gets a white popup, set apart by its hairline and shadow, like
/// native light menus) and toward the text on a dark one (a lighter
/// card). A dark lift stops where secondary text would drop below 4.5:1
/// on a hovered row.
nonisolated public func opaqueOverlayFill(lift: Double) -> ThemeRGB {
windowBackground.withAlpha(1).mixed(toward: textPrimary.withAlpha(1), lift)
let page = windowBackground.withAlpha(1)
guard isDark else { return page.mixed(toward: .white, lift) }
let text = textPrimary.withAlpha(1)
let secondary = textSecondary.withAlpha(1)
var step = (lift * 100).rounded() / 100
while step > 0 {
let fill = page.mixed(toward: text, step)
if secondary.contrast(with: hoverFill.composited(over: fill)) >= ThemeTokens.minimumTextContrast { return fill }
step -= 0.01
}
return page
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ public import AppKit
/// follows Reduce Transparency live (`ReduceTransparency`) and re-reads
/// theme colors whenever its effective appearance or theme scope changes.
/// Content goes in `contentView`, above the material, and may size the
/// surface through constraints. Every floating glass panel (palette, hover
/// card, find and prompt bars, drop overlay) is one of these, made with
/// `Glass.makeOverlayPanel`; raw `Glass.makePanel` has no fallback.
/// surface through constraints. Every floating panel that carries text
/// (palette, dialogs, hover cards, find and prompt bars, page info,
/// popovers) is one of these, made with `Glass.makeOverlayPanel`, which pins
/// it to the solid popup surface (`Palette.popupSurface`); glass would let
/// the text under it show through. Overlays without text (the drop overlay,
/// dock backdrop) follow this Mac's material.
@MainActor
public final class OverlaySurfaceView: NSView {
public private(set) var material: OverlayMaterial
Expand Down Expand Up @@ -149,8 +152,7 @@ public final class OverlaySurfaceView: NSView {
tintView?.layer?.backgroundColor = overlayTint.cgColor
materialView?.layer?.borderColor = Palette.separator.cgColor
case .opaque:
let fill = themeTokens.opaqueOverlayFill(lift: ChromeTunables.opaqueOverlayLift.value)
materialView?.layer?.backgroundColor = fill.cgColor
materialView?.layer?.backgroundColor = Palette.popupSurface.cgColor
materialView?.layer?.borderColor = Borders.color(Palette.separator.withAlphaComponent(1)).cgColor
}
}
Expand All @@ -168,6 +170,15 @@ public final class OverlaySurfaceView: NSView {
}

extension NSView {
/// Paints this view, a system popover's content, with the solid popup
/// surface of `scope`'s theme, so no text under the popover's vibrancy
/// shows through. Call it each time the popover opens (it follows the
/// theme then).
public func fillWithPopupSurface(themeOf scope: NSView) {
wantsLayer = true
layer?.backgroundColor = scope.performWithTheme { Palette.popupSurface }.cgColor
}

/// The material of the overlay surface this view sits in, or nil
/// outside one. Content that only glass needs (a legibility veil)
/// checks it in its color hook, which reruns when the material changes.
Expand Down
10 changes: 10 additions & 0 deletions Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Palette.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ public struct Palette {
public static var chromeBackground: NSColor { color(\.chromeBackground, dynamic: PaletteDynamic.chromeBackground) }
/// Floating cards: palette, hover card, editors.
public static var elevatedBackground: NSColor { color(\.elevatedBackground, dynamic: PaletteDynamic.elevatedBackground) }
/// The one solid surface of every overlay that carries text (palette,
/// dialogs, hover cards, find and prompt bars, page info, popovers,
/// tooltips): the window background lifted toward the text
/// (``ThemeTokens/opaqueOverlayFill(lift:)``), always opaque, so no text
/// under it shows through. Pages get the same color as
/// `--cmux-popup-surface` (`WebTheme`).
public static var popupSurface: NSColor {
guard let tokens = ThemeContext.active else { return PaletteDynamic.popupSurface }
return tokens.opaqueOverlayFill(lift: ChromeTunables.opaqueOverlayLift.value).nsColor
}
/// Every pane's tab strip: a shade darker than the window.
public static var stripBackground: NSColor { color(\.stripBackground, dynamic: PaletteDynamic.stripBackground) }
/// The sidebar's tonal step over the window backdrop.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ enum PaletteDynamic {
static let highlight = token(\.highlight)
static let highlightText = token(\.highlightText)
static let textOnPrimary = token(\.contentBackground, opaque: true)
static let popupSurface = overlayFill()

/// `Palette.popupSurface` outside a scope: the app theme's opaque overlay fill.
private static func overlayFill() -> NSColor {
NSColor(name: nil) { _ in
ThemeSnapshot.tokens.opaqueOverlayFill(lift: ChromeTunables.opaqueOverlayLift.value).nsColor
}
}

private static func token(_ keyPath: any KeyPath<ThemeTokens, ThemeRGB> & Sendable, opaque: Bool = false) -> NSColor {
NSColor(name: nil) { _ in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ struct TooltipStyle {
/// The scope's colors, frozen (the panel itself sits in no scope). theme-scoped
@MainActor static func current() -> TooltipStyle {
func frozen(_ color: NSColor) -> NSColor { color.usingColorSpace(.sRGB) ?? color }
return TooltipStyle(fill: frozen(Palette.elevatedBackground), border: frozen(Palette.separator),
return TooltipStyle(fill: frozen(Palette.popupSurface), border: frozen(Palette.separator),
ink: frozen(Palette.textPrimary), hint: frozen(Palette.textSecondary))
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public nonisolated enum ChromeTunables {
// MARK: Glass and overlays

public static let opaqueOverlayLift = Tunable<Double>.number(
"glass.opaqueOverlayLift", .glass, "Opaque overlay lift", help: "Reduce Transparency overlays: how far the fill moves from the background toward the text color.",
"glass.opaqueOverlayLift", .glass, "Opaque overlay lift", help: "Popup surface (overlays, popovers, tooltips): how far its solid fill moves from the background toward the text color.",
default: 0.14, range: 0...0.6, step: 0.01, unit: .fraction, code: "ChromeTunables.opaqueOverlayLift")
public static let glassOverlayTintStrength = Tunable<Double>.number(
"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).",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ public nonisolated struct WebTheme: Equatable, Sendable {
backgrounds.opaqueFill(for: $0, tokens: tokens, base: surface.withAlpha(1))
} ?? surface.withAlpha(1)),
"--cmux-elevated-background": Self.css(tokens.elevatedBackground),
// The solid surface of menus, popovers and tooltips (`--surface-popup`), the same
// opaque fill as native overlays (`Palette.popupSurface`).
"--cmux-popup-surface": Self.css(tokens.opaqueOverlayFill(lift: ChromeTunables.opaqueOverlayLift.value)),
"--cmux-text": Self.css(tokens.textPrimary),
"--cmux-text-secondary": Self.css(tokens.textSecondary),
"--cmux-text-tertiary": Self.css(tokens.textTertiary),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ extension SidebarUpdateCardView {
self.popover = popover
(popover.contentViewController?.view as? SidebarPopoverIslandHost)?.adopt(highlights)
popover.appearance = effectiveAppearance
popover.contentViewController?.view.fillWithPopupSurface(themeOf: self)
// The page's own height: a zero first size would show nothing.
popover.contentSize = NSSize(width: Self.popoverWidth, height: highlights.contentHeight)
popover.show(relativeTo: bounds, of: self, preferredEdge: .maxX)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public final class ShareCmuxView: NSView {
/// (tests record it instead).
public init(writeToPasteboard: @escaping @MainActor (String) -> Void = ShareCmuxView.writeToGeneralPasteboard) {
self.writeToPasteboard = writeToPasteboard
surface = OverlaySurfaceView(interactive: true)
surface = OverlaySurfaceView(material: .opaque, interactive: true)
copyButton = CmuxDialogButtonView(CmuxDialogButton(id: "copy-link", title: ShareCmuxStrings.copyLink, role: .default),
target: nil, action: #selector(ShareCmuxView.copyMessage))
closeButton = NSButton(image: NSImage.icon(.actionClose, size: 12),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,16 @@ public final class UpdateSheetController {
panel.onCancel = { [weak self] in self?.cancel() }
let hosting = NSHostingView(rootView: UpdateSheetView(source: source, dismiss: { [weak self] in self?.dismiss() }))
hosting.translatesAutoresizingMaskIntoConstraints = false
let glass = Glass.makePanel(content: hosting)
// The solid popup surface, never glass: the update notes are text. The hosting view keeps
// its constraints, so its fitting size still sizes the sheet.
let glass = Glass.makeOverlayPanel()
glass.contentView.addSubview(hosting)
NSLayoutConstraint.activate([
hosting.leadingAnchor.constraint(equalTo: glass.contentView.leadingAnchor),
hosting.trailingAnchor.constraint(equalTo: glass.contentView.trailingAnchor),
hosting.topAnchor.constraint(equalTo: glass.contentView.topAnchor),
hosting.bottomAnchor.constraint(equalTo: glass.contentView.bottomAnchor),
])
let root = NSView()
root.addSubview(glass)
NSLayoutConstraint.activate([
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import AppKit
@testable import CmuxNextApp
@testable import CmuxNextBrowser
@testable import CmuxNextDesign
@testable import CmuxNextUpdater
import CmuxTheme
import Testing

/// Leo, 2026-10-12: every overlay that carries text is the solid popup surface. These had their own
/// glass, vibrancy or a translucent card fill: the Share cmux dialog and the omnibar's suggestion
/// card, which joins the field's editing card. (The refusal pill is a toast now, #19759.)
@MainActor @Suite(.serialized) struct PopupSurfaceCallersTests {
@Test func theShareDialogIsThePopupSurface() {
#expect(ShareCmuxView(writeToPasteboard: { _ in }).surface.material == .opaque)
}

@Test func omnibarSuggestionsAreThePopupSurface() throws {
let scope = ThemeScope(level: .room)
scope.setOverride(try #require(ThemeSpec("Stress Purple")),
input: ThemeInput(background: ThemeRGB(hex: 0x2B1650), foreground: ThemeRGB(hex: 0xF4E3FF)),
animated: false)
let card = scope.perform { OmnibarStyle.cardFill }
#expect(ThemedTooltipTests.same(card, scope.perform { Palette.popupSurface }))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ import Testing
#expect(Self.same(try #require(panel.text.textColor), scope.perform { Palette.textPrimary }))
#expect(Self.same(try #require(panel.hint.textColor), scope.perform { Palette.textSecondary }))
let fill = try #require(panel.contentView?.layer?.backgroundColor.flatMap(NSColor.init(cgColor:)))
#expect(Self.same(fill, scope.perform { Palette.elevatedBackground }))
// The solid popup surface (`Palette.popupSurface`), like every overlay that carries text.
#expect(Self.same(fill, scope.tokens.opaqueOverlayFill(lift: ChromeTunables.opaqueOverlayLift.value).nsColor))
#expect(panel.contentView?.layer?.cornerRadius == TooltipPanel.radius)

// Below the button, centered on it.
Expand Down
Loading
Loading