diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/IconPicker/IconPickerPanel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/IconPicker/IconPickerPanel.swift index f1541de5ac76..0794db06e945 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/IconPicker/IconPickerPanel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/IconPicker/IconPickerPanel.swift @@ -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. @@ -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?() } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBookmarks/UI/BookmarkEditBubble.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBookmarks/UI/BookmarkEditBubble.swift index c2e5b3d37614..aaad680e53ca 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBookmarks/UI/BookmarkEditBubble.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBookmarks/UI/BookmarkEditBubble.swift @@ -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, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/OmnibarStyle.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/OmnibarStyle.swift index 3edeaf439395..91346dd195ab 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/OmnibarStyle.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/OmnibarStyle.swift @@ -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 diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Glass.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Glass.swift index ccda79281fb2..7fd4ce5ed5e2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Glass.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Glass.swift @@ -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 diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlayMaterial.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlayMaterial.swift index 7d4d012c6518..2eb5d9e160e1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlayMaterial.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlayMaterial.swift @@ -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 } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlaySurface.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlaySurface.swift index 3102c687b506..bdb8e2d227e8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlaySurface.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlaySurface.swift @@ -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 @@ -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 } } @@ -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. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Palette.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Palette.swift index 43b4fb8ba683..9772579caa89 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Palette.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Palette.swift @@ -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. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaletteDynamic.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaletteDynamic.swift index b91a0afef242..7cd50108de66 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaletteDynamic.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaletteDynamic.swift @@ -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 & Sendable, opaque: Bool = false) -> NSColor { NSColor(name: nil) { _ in diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tooltip/TooltipPanel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tooltip/TooltipPanel.swift index 23a6e31e1ae8..b47436f06fd8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tooltip/TooltipPanel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tooltip/TooltipPanel.swift @@ -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)) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/ChromeTunables.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/ChromeTunables.swift index 549838fd6203..bc2b133fdcd2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/ChromeTunables.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/ChromeTunables.swift @@ -38,7 +38,7 @@ public nonisolated enum ChromeTunables { // MARK: Glass and overlays public static let opaqueOverlayLift = Tunable.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.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).", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/WebTheme.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/WebTheme.swift index 2c5b5ee45fcf..0831026e529b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/WebTheme.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/WebTheme.swift @@ -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), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarUpdateCardView+Popover.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarUpdateCardView+Popover.swift index 1773ffb388da..1729af6053cb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarUpdateCardView+Popover.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarUpdateCardView+Popover.swift @@ -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) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/Share/ShareCmuxView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/Share/ShareCmuxView.swift index 0f6380313618..8885704ab421 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/Share/ShareCmuxView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/Share/ShareCmuxView.swift @@ -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), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateSheetController.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateSheetController.swift index a00b138bdffb..afdcc3ee0e15 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateSheetController.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextUpdater/UpdateSheetController.swift @@ -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([ diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PopupSurfaceCallersTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PopupSurfaceCallersTests.swift new file mode 100644 index 000000000000..a31b1f6f102c --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PopupSurfaceCallersTests.swift @@ -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 })) + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ThemedTooltipTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ThemedTooltipTests.swift index f6a9f8589ad7..f98e50fa6b0b 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ThemedTooltipTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ThemedTooltipTests.swift @@ -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. diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/PopupSurfaceTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/PopupSurfaceTests.swift new file mode 100644 index 000000000000..96ed53915988 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/PopupSurfaceTests.swift @@ -0,0 +1,87 @@ +import AppKit +import CmuxTheme +@testable import CmuxNextDesign +import Testing + +/// Leo, 2026-10-12: popovers, hover cards, the palette and the find bars were glass, so the text +/// under them showed through and read as text on text. Every overlay that carries text is the +/// one solid popup surface (the theme's opaque overlay fill), whatever this Mac's transparency +/// setting, and pages get the same color (`--cmux-popup-surface`) for their menus and tooltips. +@MainActor @Suite(.serialized) struct PopupSurfaceTests { + /// A see-through theme (60% background opacity): the window under a popup is translucent, the + /// popup surface must not be. + private let input = ThemeInput(background: ThemeRGB(hex: 0x2B1650), foreground: ThemeRGB(hex: 0xF4E3FF), + backgroundOpacity: 0.6) + + @Test func overlayPanelsAreTheOpaquePopupSurface() throws { + let scope = ThemeScope(level: .room) + scope.setOverride(try #require(ThemeSpec("Stress Purple")), input: input, animated: false) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), styleMask: [.titled], + backing: .buffered, defer: false) + let root = try #require(window.contentView) + scope.root(root) + let expected = scope.tokens.opaqueOverlayFill(lift: ChromeTunables.opaqueOverlayLift.value).nsColor + #expect(Self.same(scope.perform { Palette.popupSurface }, expected)) + + // The palette's, a hover card's or popover's, and a find bar's. + for radius in [Metrics.panelCornerRadius, PopupStyle.standard.cornerRadius, 6] { + let panel = Glass.makeOverlayPanel(content: NSTextField(labelWithString: "Find"), cornerRadius: radius) + panel.frame = NSRect(x: 10, y: 10, width: 200, height: 80) + root.addSubview(panel) + panel.applyTheme() + #expect(panel.material == .opaque, "radius \(radius)") + let fill = try #require(panel.subviews.first?.layer?.backgroundColor.flatMap(NSColor.init(cgColor:)), + "radius \(radius)") + #expect(Self.same(fill, expected), "radius \(radius): \(fill) vs \(expected)") + #expect(panel.subviews.first?.layer?.cornerRadius == radius) + panel.removeFromSuperview() + } + } + + @Test func pagesGetTheSamePopupSurface() throws { + let tokens = ThemeTokens.derive(from: input) + let fill = tokens.opaqueOverlayFill(lift: ChromeTunables.opaqueOverlayLift.value) + #expect(fill.alpha == 1) + let popup = try #require(WebTheme(tokens, surface: .internalPage, backgrounds: .none).variables["--cmux-popup-surface"]) + #expect(popup == WebTheme.css(fill)) + } + + /// Leo, 2026-10-12: on a light theme a popup 14% toward the text read as a dark gray box next to + /// native near-white menus. Light themes lift toward white (a white page gets a white popup, + /// set apart by its hairline and shadow), dark themes toward the text, and either way secondary + /// text keeps 4.5:1 on a hovered row. + @Test func popupsAreLighterThanThePageAndKeepSecondaryTextReadable() { + let themes = [ + ThemeInput(background: ThemeRGB(hex: 0xFFFFFF), foreground: ThemeRGB(hex: 0x000000)), + ThemeInput(background: ThemeRGB(hex: 0xFFFFFF), foreground: ThemeRGB(hex: 0x24292F)), + ThemeInput(background: ThemeRGB(hex: 0xFDF6E3), foreground: ThemeRGB(hex: 0x657B83)), + ThemeInput(background: ThemeRGB(hex: 0xEEEEEE), foreground: ThemeRGB(hex: 0x333333)), + ThemeInput(background: ThemeRGB(hex: 0x1E1E1E), foreground: ThemeRGB(hex: 0xD4D4D4)), + ThemeInput(background: ThemeRGB(hex: 0x282A36), foreground: ThemeRGB(hex: 0xF8F8F2)), + ThemeInput(background: ThemeRGB(hex: 0x002B36), foreground: ThemeRGB(hex: 0x839496)), + ThemeInput(background: ThemeRGB(hex: 0x2B1650), foreground: ThemeRGB(hex: 0xF4E3FF)), + ] + for input in themes { + let tokens = ThemeTokens.derive(from: input) + let fill = tokens.opaqueOverlayFill(lift: ChromeTunables.opaqueOverlayLift.value) + let page = tokens.windowBackground.withAlpha(1) + #expect(fill.alpha == 1, "\(input.background)") + if tokens.isDark { + #expect(fill.relativeLuminance > page.relativeLuminance, "\(input.background): a lighter card") + } else { + #expect(fill.relativeLuminance >= page.relativeLuminance, "\(input.background): never a gray box") + } + let hovered = tokens.hoverFill.composited(over: fill) + let contrast = tokens.textSecondary.withAlpha(1).contrast(with: hovered) + #expect(contrast >= ThemeTokens.minimumTextContrast, "\(input.background): \(contrast)") + } + let white = ThemeTokens.derive(from: themes[0]) + #expect(white.opaqueOverlayFill(lift: ChromeTunables.opaqueOverlayLift.value) == ThemeRGB(hex: 0xFFFFFF)) + } + + static func same(_ lhs: NSColor, _ rhs: NSColor) -> Bool { + guard let lhs = lhs.usingColorSpace(.sRGB), let rhs = rhs.usingColorSpace(.sRGB) else { return false } + return abs(lhs.redComponent - rhs.redComponent) < 0.01 && abs(lhs.greenComponent - rhs.greenComponent) < 0.01 + && abs(lhs.blueComponent - rhs.blueComponent) < 0.01 && abs(lhs.alphaComponent - rhs.alphaComponent) < 0.01 + } +} diff --git a/scripts/cmux-next/layers-baseline.tsv b/scripts/cmux-next/layers-baseline.tsv index 255ee0afe81f..a17473a30183 100644 --- a/scripts/cmux-next/layers-baseline.tsv +++ b/scripts/cmux-next/layers-baseline.tsv @@ -7,7 +7,7 @@ webviews/src/agent-session/acpmux/composerLocation.css z 1 webviews/src/agent-session/acpmux/handoff/styles.css z 1 webviews/src/agent-session/acpmux/modelPicker.css z 1 webviews/src/agent-session/acpmux/prototype/prototype.css z 5 -webviews/src/agent-session/acpmux/styles.css z 7 +webviews/src/agent-session/acpmux/styles.css z 6 webviews/src/agent-session/acpmux/summary/summary.css backdrop 2 webviews/src/agent-session/acpmux/threadMinimap/threadMinimap.css z 1 webviews/src/gallery/fixtures/VisualMediaBoard.css backdrop 1 diff --git a/webviews/src/agent-session/acpmux-preview/main.tsx b/webviews/src/agent-session/acpmux-preview/main.tsx index 31bed8da527d..a2fc82356f01 100644 --- a/webviews/src/agent-session/acpmux-preview/main.tsx +++ b/webviews/src/agent-session/acpmux-preview/main.tsx @@ -1,3 +1,5 @@ +// desktop.css first, as in the bundle: it defines the popup surface (--surface-popup) and type scale. +import "../../pages/shared/desktop.css"; import "../../ui/popupSurface.css"; import "../shared/styles.css"; import "../acpmux/styles.css"; diff --git a/webviews/src/agent-session/acpmux/DictationButton.tsx b/webviews/src/agent-session/acpmux/DictationButton.tsx index 98af6f304072..c3553a5815fd 100644 --- a/webviews/src/agent-session/acpmux/DictationButton.tsx +++ b/webviews/src/agent-session/acpmux/DictationButton.tsx @@ -4,7 +4,7 @@ import { MicIcon } from "./ComposerPickers"; import { DictationLevelMeter } from "./DictationLevelMeter"; import { isActive, type Dictation } from "./dictation"; import { useT } from "./i18n"; -import { SHORTCUT_ACTIONS, useShortcut, withShortcut } from "./shortcuts"; +import { SHORTCUT_ACTIONS, tooltipProps, useShortcut } from "./shortcuts"; export function DictationButton({ dictation }: { dictation: Dictation }) { const t = useT(); @@ -19,7 +19,7 @@ export function DictationButton({ dictation }: { dictation: Dictation }) { data-state={state} aria-label={label} aria-pressed={isActive(state)} - title={withShortcut(label, shortcut)} + {...tooltipProps(label, shortcut)} // Keep focus and the selection in the prompt: that is where the words go. onMouseDown={(event) => event.preventDefault()} onClick={dictation.toggle} diff --git a/webviews/src/agent-session/acpmux/ModelPicker.gallery.tsx b/webviews/src/agent-session/acpmux/ModelPicker.gallery.tsx index 52935961c69f..9eefb7d77fca 100644 --- a/webviews/src/agent-session/acpmux/ModelPicker.gallery.tsx +++ b/webviews/src/agent-session/acpmux/ModelPicker.gallery.tsx @@ -113,5 +113,28 @@ export default componentEntry({ props: { ...base }, play: numericSearch, }, + // A folder profile whose icon names no brand: the chip draws no mark (never a placeholder + // square), and its rail tab its initial. + "profile-without-mark": { + props: { + ...base, + catalog: [ + { + id: "vercel-ai-gateway", + name: "Vercel AI Gateway", + icon: "/icons/vercel-ai-gateway.svg", + folder: { folder: "/Users/me/app", state: "enabled" }, + models: [{ id: "apex", name: "Apex" }], + }, + ...catalog, + ], + harness: "vercel-ai-gateway", + model: "apex", + label: "Apex", + }, + play: async (ctx) => { + await ctx.click({ role: "button", name: "Model" }); + }, + }, }, }); diff --git a/webviews/src/agent-session/acpmux/ModelPicker.tsx b/webviews/src/agent-session/acpmux/ModelPicker.tsx index db2e214f7452..47e71ba89bc5 100644 --- a/webviews/src/agent-session/acpmux/ModelPicker.tsx +++ b/webviews/src/agent-session/acpmux/ModelPicker.tsx @@ -8,7 +8,7 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent, } from "react"; -import { AgentMark } from "../shared/AgentMark"; +import { AgentMark, agentBrand, knownMark } from "../shared/AgentMark"; import { agentName } from "./agents"; import { isDefaultChoice } from "./defaultChoice"; import { CheckIcon, ChevronIcon, PICKER_LABELS, SearchIcon } from "./ComposerPickers"; @@ -134,7 +134,7 @@ function uniqueHarnesses(catalog: ModelPickerProps["catalog"]): HarnessChoice[] acpmuxHarness: entry.id, pickable: entry.pickable !== false, folder: entry.folder, - mark: entry.icon ?? entry.family ?? entry.id, + mark: knownMark(entry.icon, entry.family, entry.id), })); // Terminal and unknown harnesses are routing entries, not installed choices. const entries: HarnessChoice[] = catalog @@ -554,7 +554,14 @@ export function ModelPicker(props: ModelPickerProps) { // The chip draws one harness: the one a switch to another harness is starting, else the running // one. Browsing another harness in the open menu changes neither its mark nor its name. const switchingTo = switching && !current?.ids.includes(switching.harness) ? switching : undefined; - const chipHarness = switchingTo?.harness ?? current?.id ?? harness; + // A folder profile draws its own mark (its icon or family), as its rail tab does, not its id's. + // A switch draws its target's mark: the one it names, else that harness's own. + const switchingMark = switchingTo + ? (switchingTo.mark ?? + harnesses.find((entry) => entry.ids.includes(switchingTo.harness))?.mark ?? + switchingTo.harness) + : undefined; + const chipHarness = switchingMark ?? current?.mark ?? current?.id ?? harness; const chipLabel = switchingTo?.name ?? label; const enableProfile = (entry: HarnessChoice) => { if (entry.folder?.state !== "needs-enable" || entry.ids.includes(harness ?? "")) return false; @@ -683,7 +690,14 @@ export function ModelPicker(props: ModelPickerProps) { }} active={index === activeHarness} > - + {agentBrand(entry.mark ?? entry.id) ? ( + + ) : ( + // A harness without a mark: its initial, never an empty tab. + + )} {entry.name} , ])} @@ -795,7 +809,7 @@ export function ModelPicker(props: ModelPickerProps) { active={index === active} > {(starredView || searching) && ( - + )} {model.name} {searching && (parsed.effort || parsed.fast) && ( diff --git a/webviews/src/agent-session/acpmux/changes/changes.css b/webviews/src/agent-session/acpmux/changes/changes.css index 6d02f9b99ab3..62fd23553c7b 100644 --- a/webviews/src/agent-session/acpmux/changes/changes.css +++ b/webviews/src/agent-session/acpmux/changes/changes.css @@ -1,7 +1,7 @@ /* The changes view's per-file menu. Colors come from the pane's theme tokens only. */ @import "./git.css"; .acpmux-file-menu{position:relative;display:inline-flex} -.acpmux-file-menu-list{position:absolute;z-index:var(--layer-dropdown);top:calc(100% + 4px);right:0;display:flex;flex-direction:column;min-width:180px;padding:var(--ui-popup-padding);box-sizing:border-box;border-radius:var(--ui-popup-radius);background:var(--acpmux-menu-bg);box-shadow:inset 0 0 0 .5px var(--acpmux-composer-edge),var(--ui-popup-shadow);transform-origin:top right;color:var(--agent-text);font-size:13px;line-height:18px} +.acpmux-file-menu-list{position:absolute;z-index:var(--layer-dropdown);top:calc(100% + 4px);right:0;display:flex;flex-direction:column;min-width:180px;padding:var(--ui-popup-padding);box-sizing:border-box;border-radius:var(--ui-popup-radius);background:var(--surface-popup);box-shadow:inset 0 0 0 .5px var(--acpmux-composer-edge),var(--ui-popup-shadow);transform-origin:top right;color:var(--agent-text);font-size:13px;line-height:18px} .ui-positioner>.acpmux-file-menu-list{position:static;top:auto;right:auto} .acpmux-file-menu-item{display:flex;align-items:center;min-height:var(--ui-row-height);padding:4px var(--ui-row-padding-inline);border:0;border-radius:var(--ui-row-radius);background:none;color:inherit;font:inherit;text-align:left;white-space:nowrap;cursor:default} .acpmux-file-menu-item:hover,.acpmux-file-menu-item:focus-visible{background:var(--agent-control-hover,color-mix(in srgb,var(--agent-text) 10%,transparent));outline:none} diff --git a/webviews/src/agent-session/acpmux/composerControls.css b/webviews/src/agent-session/acpmux/composerControls.css index a4501223b3e1..f0eb77ca6d25 100644 --- a/webviews/src/agent-session/acpmux/composerControls.css +++ b/webviews/src/agent-session/acpmux/composerControls.css @@ -7,24 +7,10 @@ padding: 5px; border: 1px solid var(--ui-line, var(--acpmux-composer-edge)); border-radius: 10px; - background-color: color-mix(in srgb, var(--acpmux-menu-bg) var(--surface-text-min, 92%), transparent); - -webkit-backdrop-filter: var(--surface-blur, blur(20px) saturate(1.4)); - backdrop-filter: var(--surface-blur, blur(20px) saturate(1.4)); + background-color: var(--surface-popup); box-shadow: var(--ui-popup-shadow); color: var(--agent-text); } -@media (prefers-reduced-transparency: reduce) { - .acpmux-effort-menu { - background-color: var(--acpmux-menu-bg); - -webkit-backdrop-filter: none; - backdrop-filter: none; - } -} -:root[data-reduce-transparency] .acpmux-effort-menu { - background-color: var(--acpmux-menu-bg); - -webkit-backdrop-filter: none; - backdrop-filter: none; -} .ui-menu-item.acpmux-effort-item { display: flex; align-items: center; diff --git a/webviews/src/agent-session/acpmux/composerLocation.css b/webviews/src/agent-session/acpmux/composerLocation.css index 6b1d4fd72e78..dc73bfb99f4b 100644 --- a/webviews/src/agent-session/acpmux/composerLocation.css +++ b/webviews/src/agent-session/acpmux/composerLocation.css @@ -140,25 +140,11 @@ padding: var(--ui-popup-padding); border: 0; border-radius: var(--ui-popup-radius); - background-color: color-mix(in srgb, var(--acpmux-menu-bg) var(--surface-text-min, 92%), transparent); - -webkit-backdrop-filter: var(--surface-blur, blur(20px) saturate(1.4)); - backdrop-filter: var(--surface-blur, blur(20px) saturate(1.4)); + background-color: var(--surface-popup); box-shadow: var(--ui-popup-shadow); color: var(--agent-text); transform-origin: var(--transform-origin); } -@media (prefers-reduced-transparency: reduce) { - .acpmux-access-menu { - background-color: var(--acpmux-menu-bg); - -webkit-backdrop-filter: none; - backdrop-filter: none; - } -} -:root[data-reduce-transparency] .acpmux-access-menu { - background-color: var(--acpmux-menu-bg); - -webkit-backdrop-filter: none; - backdrop-filter: none; -} .ui-menu-item.acpmux-access-item { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; diff --git a/webviews/src/agent-session/acpmux/dev.tsx b/webviews/src/agent-session/acpmux/dev.tsx index 479c39f183fb..d27c1a377871 100644 --- a/webviews/src/agent-session/acpmux/dev.tsx +++ b/webviews/src/agent-session/acpmux/dev.tsx @@ -1,6 +1,8 @@ // Dev server entry (vite.config.acpmux-pane.mjs): the bundled pane inlines // these stylesheets (scripts/cmux-next/build-agent-pane-web.sh); here Vite // serves them with hot reload. +// desktop.css first, as in the bundle: it defines the popup surface (--surface-popup) and type scale. +import "../../pages/shared/desktop.css"; import "../../ui/popupSurface.css"; import "../shared/styles.css"; import "./styles.css"; diff --git a/webviews/src/agent-session/acpmux/handoff/styles.css b/webviews/src/agent-session/acpmux/handoff/styles.css index 36fc3eeeabdf..620c526f0a0b 100644 --- a/webviews/src/agent-session/acpmux/handoff/styles.css +++ b/webviews/src/agent-session/acpmux/handoff/styles.css @@ -52,7 +52,7 @@ padding: 6px; min-width: 160px; border-radius: 10px; - background: var(--acpmux-menu-bg); + background: var(--surface-popup); box-shadow: 0 4px 16px color-mix(in srgb, var(--agent-text) 12%, transparent); } .acpmux-handoff-targets button { display: block; width: 100%; text-align: left; border: 0; border-radius: 6px; background: none; color: var(--agent-text); padding: 8px; font: inherit; cursor: pointer; } diff --git a/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx b/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx index ae7b82f9ea60..e77ed80413b8 100644 --- a/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx +++ b/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx @@ -7,7 +7,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { useT, type StringKey } from "../i18n"; import { Icon } from "../icons/Icon"; -import { useShortcut, withShortcut } from "../shortcuts"; +import { tooltipProps, useShortcut } from "../shortcuts"; import { registerPicker } from "../pickerOpeners"; import { Menu, MenuButton, MenuItem, MenuPopup, MenuSeparator, Submenu } from "../../../ui/Menu"; import { ContextMenu, type ContextMenuItem } from "../../../ui/ContextMenu"; @@ -183,7 +183,7 @@ export function ChatHeaderTools({ className="acpmux-header-tool" aria-label={t("header.changes")} aria-pressed={on(HEADER_ACTIONS.changes, mode)} - title={withShortcut(t("header.changes"), changesKey)} + {...tooltipProps(t("header.changes"), changesKey)} onClick={() => onChanges(mode)} > @@ -230,7 +230,7 @@ export function ChatHeaderTools({ className="acpmux-header-tool" aria-label={t("header.terminal")} aria-pressed={on(HEADER_ACTIONS.terminal, mode)} - title={withShortcut(t("header.terminal"), terminalKey)} + {...tooltipProps(t("header.terminal"), terminalKey)} onClick={() => onTerminal(mode)} > @@ -243,7 +243,7 @@ export function ChatHeaderTools({ className="acpmux-header-tool" aria-label={t("header.browser")} aria-pressed={on(HEADER_ACTIONS.browser, mode)} - title={withShortcut(t("header.browser"), browserKey)} + {...tooltipProps(t("header.browser"), browserKey)} onClick={() => onBrowser(mode)} > diff --git a/webviews/src/agent-session/acpmux/header/header.css b/webviews/src/agent-session/acpmux/header/header.css index 47f2d943dbf4..e0abd9296ff1 100644 --- a/webviews/src/agent-session/acpmux/header/header.css +++ b/webviews/src/agent-session/acpmux/header/header.css @@ -9,7 +9,7 @@ /* On: a toggle whose panel is open (aria-pressed, cx-00n1), tinted like the composer's Plan toggle. */ .acpmux-header-tool[aria-pressed=true]{background:color-mix(in srgb,var(--agent-accent,var(--agent-text)) 16%,transparent);color:var(--agent-accent,var(--agent-text))} .acpmux-header-tool[aria-pressed=true]:not(:disabled):hover{background:color-mix(in srgb,var(--agent-accent,var(--agent-text)) 22%,transparent)} -.ui-popup.acpmux-chat-menu-popover{width:256px;max-height:min(540px,70vh);overflow:auto;padding:var(--ui-popup-padding);box-sizing:border-box;border:0;border-radius:var(--ui-popup-radius);background:var(--acpmux-menu-bg);box-shadow:inset 0 0 0 .5px var(--acpmux-composer-edge),var(--ui-popup-shadow);transform-origin:var(--transform-origin);color:var(--agent-text);font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",system-ui,sans-serif;font-size:var(--text-control);line-height:var(--text-control--line-height);-webkit-user-select:none;user-select:none;cursor:default;outline:none} +.ui-popup.acpmux-chat-menu-popover{width:256px;max-height:min(540px,70vh);overflow:auto;padding:var(--ui-popup-padding);box-sizing:border-box;border:0;border-radius:var(--ui-popup-radius);background:var(--surface-popup);box-shadow:inset 0 0 0 .5px var(--acpmux-composer-edge),var(--ui-popup-shadow);transform-origin:var(--transform-origin);color:var(--agent-text);font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",system-ui,sans-serif;font-size:var(--text-control);line-height:var(--text-control--line-height);-webkit-user-select:none;user-select:none;cursor:default;outline:none} .ui-menu-item.acpmux-chat-menu-item{display:flex;align-items:center;gap:8px;width:100%;min-height:var(--ui-row-height);height:var(--ui-row-height);padding:0 var(--ui-row-padding-inline);box-sizing:border-box;border-radius:var(--ui-row-radius);color:inherit;cursor:default;transition:background-color var(--acpmux-motion-hover,80ms) var(--acpmux-motion-ease,ease-out)} .acpmux-chat-menu-item>svg{flex:none;color:var(--agent-muted)} .ui-menu-item.acpmux-chat-menu-item[data-highlighted],.ui-menu-item.acpmux-chat-menu-item[data-popup-open]{background:var(--acpmux-menu-hover);outline:none} @@ -68,7 +68,7 @@ .acpmux-worktree-apply button.acpmux-worktree-remove-discard{color:var(--agent-danger)} /* The chip's status (cx-ncc.77.1): the title linking the pull request, its state and review, then each check with a dot by its outcome, failing first. Compact rows, the summary popover's surface. */ -.acpmux-pr-status{width:300px;max-height:min(480px,70vh);overflow:auto;padding:8px 6px 6px;box-sizing:border-box;border:0;border-radius:var(--ui-popup-radius);background:var(--acpmux-menu-bg);box-shadow:inset 0 0 0 .5px var(--acpmux-composer-edge),var(--ui-popup-shadow);transform-origin:var(--transform-origin);color:var(--agent-text);font-size:var(--text-control);line-height:18px;outline:none} +.acpmux-pr-status{width:300px;max-height:min(480px,70vh);overflow:auto;padding:8px 6px 6px;box-sizing:border-box;border:0;border-radius:var(--ui-popup-radius);background:var(--surface-popup);box-shadow:inset 0 0 0 .5px var(--acpmux-composer-edge),var(--ui-popup-shadow);transform-origin:var(--transform-origin);color:var(--agent-text);font-size:var(--text-control);line-height:18px;outline:none} @media (prefers-reduced-motion:no-preference){.acpmux-pr-status{animation:ui-popup-open var(--ui-popup-open) ease-out}} .acpmux-pr-title{display:block;padding:0 6px;color:var(--agent-text);font-weight:600;text-decoration:none;overflow-wrap:anywhere} .acpmux-pr-title:hover{text-decoration:underline} @@ -88,7 +88,7 @@ .acpmux-pr-none{padding:4px 6px;border-top:.5px solid var(--acpmux-composer-edge);color:var(--agent-muted)} /* Merge on the PR chip's status (cx-ncc.77.4): asks once more in place, then says it merged or why not. */ /* Sticky, so Merge stays in reach under a long check list. */ -.acpmux-pr-merge{position:sticky;bottom:-6px;display:flex;flex-direction:column;gap:6px;margin:6px 0 -6px;padding:8px 6px 8px;background:var(--acpmux-menu-bg);border-top:.5px solid var(--acpmux-composer-edge);font-size:var(--text-detail)} +.acpmux-pr-merge{position:sticky;bottom:-6px;display:flex;flex-direction:column;gap:6px;margin:6px 0 -6px;padding:8px 6px 8px;background:var(--surface-popup);border-top:.5px solid var(--acpmux-composer-edge);font-size:var(--text-detail)} .acpmux-pr-merge-buttons{display:flex;justify-content:flex-end;gap:6px} .acpmux-pr-merge button{height:24px;padding:0 10px;border:0;border-radius:6px;background:var(--agent-control,color-mix(in srgb,var(--agent-text) 10%,var(--agent-page-bg)));color:var(--agent-text);font:inherit;cursor:default;transition:background-color var(--acpmux-motion-hover) var(--acpmux-motion-ease)} .acpmux-pr-merge button:hover{background:var(--agent-control-hover,color-mix(in srgb,var(--agent-text) 14%,var(--agent-page-bg)))} diff --git a/webviews/src/agent-session/acpmux/modelPicker.css b/webviews/src/agent-session/acpmux/modelPicker.css index 6572531e0b8c..b9309d7d2818 100644 --- a/webviews/src/agent-session/acpmux/modelPicker.css +++ b/webviews/src/agent-session/acpmux/modelPicker.css @@ -141,7 +141,7 @@ padding: 6px; box-sizing: border-box; border-radius: 12px; - background: var(--acpmux-menu-bg); + background: var(--surface-popup); box-shadow: inset 0 0 0 0.5px var(--acpmux-composer-edge), 0 10px 30px var(--agent-shadow, rgb(0 0 0 / 0.35)); @@ -233,29 +233,13 @@ } /* Nested cascade levels are popups too. Keep their bespoke placement and scrolling, but use the - same material as the root model menu and Base UI surfaces. */ + same solid popup surface as the root model menu and Base UI surfaces. */ .acpmux-mp-cascade .acpmux-mp-sub { border: 1px solid var(--ui-line, color-mix(in srgb, var(--agent-text) 12%, transparent)); - background-color: color-mix(in srgb, var(--acpmux-menu-bg, var(--ui-surface, Canvas)) var(--surface-text-min, 92%), transparent); - -webkit-backdrop-filter: var(--surface-blur, blur(20px) saturate(1.4)); - backdrop-filter: var(--surface-blur, blur(20px) saturate(1.4)); + background-color: var(--surface-popup); box-shadow: var(--ui-popup-shadow); } -@media (prefers-reduced-transparency: reduce) { - .acpmux-mp-cascade .acpmux-mp-sub { - background-color: var(--acpmux-menu-bg, var(--ui-surface, Canvas)); - -webkit-backdrop-filter: none; - backdrop-filter: none; - } -} - -:root[data-reduce-transparency] .acpmux-mp-cascade .acpmux-mp-sub { - background-color: var(--acpmux-menu-bg, var(--ui-surface, Canvas)); - -webkit-backdrop-filter: none; - backdrop-filter: none; -} - /* Under Increase Contrast the search row's text step does not show (muted is the text color): the row takes the shared focus ring when its field has focus (cx-ncc.57). */ @media (prefers-contrast: more) { diff --git a/webviews/src/agent-session/acpmux/modelPickerLayout.ts b/webviews/src/agent-session/acpmux/modelPickerLayout.ts index e5322dff3647..860ae6c2466d 100644 --- a/webviews/src/agent-session/acpmux/modelPickerLayout.ts +++ b/webviews/src/agent-session/acpmux/modelPickerLayout.ts @@ -15,7 +15,7 @@ export type ModelPickerProps = { /// The chip's text: the current model's name, or its id when the catalog doesn't list it. label: string; /// A harness switch in flight: the chip draws this harness's mark and name until it starts. - switching?: { harness: string; name: string }; + switching?: { harness: string; name: string; mark?: string }; /// The chip's secondary text after the model: the chosen effort, when it is not the default. detail?: string; /// The name of the model the agent's default resolves to, when known: the "Default" row draws diff --git a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx index 4d1d60206e05..c00f49388072 100644 --- a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx +++ b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.tsx @@ -6,6 +6,7 @@ import type { AcpmuxSnapshot } from "../model"; import { EMPTY_OMNIBAR, type OmnibarContext } from "../omnibar"; import { type Project, ProjectChooser } from "../ProjectChooser"; import { isAgentHome, projectLabel } from "../sessionList"; +import { tooltipProps } from "../shortcuts"; import { AllChatsList, type LoadChatsPage } from "./AllChatsList"; import { ChatCards } from "./ChatCards"; import { ClosedList } from "./ClosedList"; @@ -523,8 +524,8 @@ function ToolsRow({ `); + const button = doc.getElementById("new")!; + button.getBoundingClientRect = () => ({ left: 10, top: 10, width: 24, height: 24 }) as DOMRect; + await hover(button); + const tip = doc.querySelector(".ui-title-tooltip")!; + expect(tip.hidden).toBe(false); + expect(tip.style.maxWidth).toBe("320px"); + expect(tip.querySelector(".ui-tooltip-text")?.textContent).toBe("New Tab"); + expect(tip.querySelector(".ui-tooltip-hint")?.textContent).toBe("⌘T"); +}); diff --git a/webviews/src/ui/titleTooltips.ts b/webviews/src/ui/titleTooltips.ts index 02ab0101ff24..6eb62bd901f0 100644 --- a/webviews/src/ui/titleTooltips.ts +++ b/webviews/src/ui/titleTooltips.ts @@ -4,6 +4,8 @@ // once when the pointer moves on from a tooltip just shown (along a rail or a toolbar), and kept // inside the window. The text moves to `data-tooltip`, so the native tooltip never also appears; // a control with no other name keeps it as its `aria-label`. Controls built on ./Tooltip keep theirs. +// A `data-tooltip-shortcut` shows after the text in the secondary ink, like the native tooltip's. +// Text the element cuts off shows in full, as wide as the element (`tooltipWidth`). /// Hover time before the first tooltip. export const TOOLTIP_DELAY = 500; @@ -15,6 +17,43 @@ const GAP = 6; type Box = { left: number; top: number; width: number; height: number }; +/// The standard tooltip's widest, the native one's (TooltipPanel.maxWidth). +const MAX_WIDTH = 320; +/// The narrowest a truncated element's tooltip may wrap at, so a cut-off chip's text is not a column. +const TRUNCATED_MIN_MAX = 480; + +/// The tooltip's min and max width for an element `width` px wide in a `view` px window. A tooltip +/// for text the element cuts off shows it in full, wrapping, up to the element's width (at least +/// 480 px) and never narrower than the element's line, unless the full text (`text`, its width on +/// one line) is shorter: then it is just the text's width. Any other tooltip wraps at 320 px. Both +/// stay inside the window's edges. +export function tooltipWidth({ + width, + truncated, + view, + text, +}: { + width: number; + truncated: boolean; + view: number; + text?: number; +}) { + const room = Math.max(0, view - 2 * EDGE); + if (!truncated) return { min: 0, max: Math.min(MAX_WIDTH, room) }; + const line = Math.ceil(width); + const min = text === undefined ? line : Math.min(line, Math.ceil(text)); + return { min: Math.min(min, room), max: Math.min(Math.max(line, TRUNCATED_MIN_MAX), room) }; +} + +/// Whether `element` or anything inside it cuts its text off: a row can carry the title while its +/// label clips (BranchBasePicker's rows). +function clips(element: Element): boolean { + const cut = (node: Element) => node.scrollWidth > node.clientWidth + 1; + if (cut(element)) return true; + for (const child of element.querySelectorAll("*")) if (cut(child)) return true; + return false; +} + /// Where a `size` tooltip for `anchor` goes in a `view` window: below and centered, above when /// there is no room below, and shifted to stay inside the edges. export function tooltipPosition( @@ -60,9 +99,37 @@ export function installTooltips(doc: Document): () => void { if (!target?.isConnected) return; const text = textOf(target); if (!text) return; - tip.textContent = text; + const shortcut = target.getAttribute("data-tooltip-shortcut"); + const label = doc.createElement("span"); + label.className = "ui-tooltip-text"; + label.textContent = text; + // The hint floats to the top right, so it comes first. + if (shortcut) { + const hint = doc.createElement("span"); + hint.className = "ui-tooltip-hint"; + hint.textContent = shortcut; + tip.replaceChildren(hint, label); + } else tip.replaceChildren(label); + const anchor = target.getBoundingClientRect(); + const truncated = clips(target); tip.hidden = false; - const position = tooltipPosition(target.getBoundingClientRect(), tip.getBoundingClientRect(), { + let natural: number | undefined; + if (truncated) { + // The full text's width on one line, so a short title gets no wide empty box. + tip.style.minWidth = "0"; + tip.style.maxWidth = "none"; + // Zero means no layout to measure (a detached or unrendered page): keep the line's width. + natural = tip.getBoundingClientRect().width || undefined; + } + const width = tooltipWidth({ + width: anchor.width, + truncated, + view: doc.documentElement.clientWidth, + text: natural, + }); + tip.style.minWidth = `${width.min}px`; + tip.style.maxWidth = `${width.max}px`; + const position = tooltipPosition(anchor, tip.getBoundingClientRect(), { width: doc.documentElement.clientWidth, height: doc.documentElement.clientHeight, }); diff --git a/webviews/src/ui/ui.css b/webviews/src/ui/ui.css index 21fc3c476136..791ce2d3fbfb 100644 --- a/webviews/src/ui/ui.css +++ b/webviews/src/ui/ui.css @@ -49,7 +49,7 @@ padding: var(--ui-popup-padding); border: 1px solid var(--ui-line); border-radius: var(--ui-popup-radius); - background: var(--ui-surface); + background: var(--surface-popup); color: var(--ui-fg); font-size: var(--text-control); line-height: var(--text-control--line-height); @@ -225,18 +225,7 @@ margin: 0 4px; } -.ui-tooltip { - max-width: 320px; - padding: 3px 7px; - border-radius: 5px; - background: color-mix(in srgb, var(--ui-surface) 96%, var(--ui-fg)); - border: 1px solid var(--ui-line); - color: var(--ui-fg); - font-size: var(--text-caption); - line-height: var(--text-caption--line-height); - box-shadow: 0 2px 10px color-mix(in srgb, black 14%, transparent); - pointer-events: none; -} +/* .ui-tooltip's look is the title tooltip's (pages/shared/desktop.css), the native tooltip's. */ .ui-toolbar, .ui-toggle-group, diff --git a/webviews/test/chat-header-tooltips.test.tsx b/webviews/test/chat-header-tooltips.test.tsx index 076ebfa1066b..3b04ad5e49e0 100644 --- a/webviews/test/chat-header-tooltips.test.tsx +++ b/webviews/test/chat-header-tooltips.test.tsx @@ -1,5 +1,5 @@ // The chat header's top right (cx-qom0): every button names itself in a tooltip, with its keycap -// when the action has one, as in ChatGPT; [+] New tab reads Hide tabs in the same spot once the +// as the tooltip's shortcut hint when the action has one, as in ChatGPT; [+] New tab reads Hide tabs in the same spot once the // tabs beside the chat show. The header renders in jsdom with the host's keycaps; the // pane's tooltip layer (ui/titleTooltips.ts) shows each `title`. import { afterAll, beforeAll, expect, test } from "bun:test"; @@ -58,18 +58,20 @@ test("every header button has a tooltip, with its shortcut when it has one; New [...host.querySelectorAll(".acpmux-header-tools button")].map((button) => [ button.getAttribute("aria-label"), button.getAttribute("title"), + button.getAttribute("data-tooltip-shortcut"), ]); render(false); + // The keycap is the tooltip's shortcut hint (secondary ink, like native), not part of the title. expect(named()).toEqual([ - ["Terminal", "Terminal (⌘D)"], - ["Browser", "Browser (⇧⌘L)"], - ["New tab", "New tab"], - ["Chat actions", "Chat actions"], + ["Terminal", "Terminal", "⌘D"], + ["Browser", "Browser", "⇧⌘L"], + ["New tab", "New tab", null], + ["Chat actions", "Chat actions", null], ]); // [+] New tab becomes Hide tabs on the same button, so the next click lands on it again. const newTab = host.querySelectorAll(".acpmux-header-tools button")[2]; render(true); expect(host.querySelectorAll(".acpmux-header-tools button")[2]).toBe(newTab); - expect(named()[2]).toEqual(["Hide tabs", "Hide tabs"]); + expect(named()[2]).toEqual(["Hide tabs", "Hide tabs", null]); expect(newTab.getAttribute("aria-pressed")).toBe("true"); }); diff --git a/webviews/test/model-picker-mark.test.tsx b/webviews/test/model-picker-mark.test.tsx new file mode 100644 index 000000000000..5b55d8ab02c7 --- /dev/null +++ b/webviews/test/model-picker-mark.test.tsx @@ -0,0 +1,71 @@ +// Leo, 2026-10-12: the model chip of a folder profile ("Vercel AI Gateway/Apex") drew a broken +// square for its icon: the chip looked up the profile's own id, which names no brand, and an +// unknown mark drew a placeholder square. The chip draws the profile's mark (its icon or family, +// as the rail does), and a mark nobody knows draws nothing. +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { AgentMark } from "../src/agent-session/shared/AgentMark"; +import { ModelPicker } from "../src/agent-session/acpmux/ModelPicker"; + +type Props = Parameters[0]; +const chip = (catalog: Props["catalog"], harness: string, label: string, switching?: Props["switching"]) => + renderToStaticMarkup( + undefined} + onEffort={() => undefined} + />, + ); + +const profile = (extra: { icon?: string; family?: string }) => ({ + id: "vercel-ai-gateway", + name: "Vercel AI Gateway", + models: [{ id: "apex", name: "Apex" }], + folder: { folder: "/Users/me/app", state: "enabled" as const }, + ...extra, +}); + +test("an unknown mark draws nothing, never a placeholder glyph", () => { + for (const agent of ["vercel-ai-gateway", "/icons/vercel.svg", "", undefined]) + expect(renderToStaticMarkup()).toBe(""); + expect(renderToStaticMarkup()).toContain('data-agent="claude"'); +}); + +test("a folder profile's chip with an unknown icon draws no mark", () => { + const html = chip([profile({ icon: "/icons/vercel.svg" })], "vercel-ai-gateway", "Apex"); + expect(html).toContain("Apex"); + expect(html).not.toContain("agent-mark"); +}); + +test("a folder profile's chip draws the mark of its family, as its rail tab does", () => { + const html = chip([profile({ family: "claude" })], "vercel-ai-gateway", "Apex"); + expect(html).toContain('data-agent="claude"'); +}); + +test("a profile whose icon names no brand falls back to its family's mark", () => { + const html = chip([profile({ icon: "/icons/vercel.svg", family: "claude" })], "vercel-ai-gateway", "Apex"); + expect(html).toContain('data-agent="claude"'); +}); + +test("while a switch to a profile starts, the chip draws that profile's mark", () => { + const claude = { id: "claude", name: "Claude Code", models: [{ id: "claude-opus-5-5", name: "Opus 5.5" }] }; + const catalog = [claude, profile({ icon: "/icons/vercel.svg", family: "openai" })]; + const looked = chip(catalog, "claude", "Opus 5.5", { harness: "vercel-ai-gateway", name: "Apex" }); + expect(looked).toContain('data-agent="openai"'); + const given = chip(catalog, "claude", "Opus 5.5", { harness: "vercel-ai-gateway", name: "Apex", mark: "gemini" }); + expect(given).toContain('data-agent="gemini"'); +}); + +test("a row's unknown mark keeps its slot, so names line up", () => { + const html = renderToStaticMarkup(); + expect(html).not.toContain(" { + const missing = ["agent-session/acpmux/dev.tsx", "agent-session/acpmux-preview/main.tsx"].filter((file) => { + const text = fs.readFileSync(path.join(source, file), "utf8"); + const popup = text.indexOf("ui/popupSurface.css"); + const desktop = text.indexOf("pages/shared/desktop.css"); + return popup >= 0 && (desktop < 0 || desktop > popup); + }); + expect(missing).toEqual([]); +}); diff --git a/webviews/test/popup-surface.test.ts b/webviews/test/popup-surface.test.ts new file mode 100644 index 000000000000..1adf5bcf6a2b --- /dev/null +++ b/webviews/test/popup-surface.test.ts @@ -0,0 +1,213 @@ +// Leo, 2026-10-12: popovers, menus and tooltips were glass at 92% with a blur, so the text under +// them showed through (the New Tab folder popover over the Chats list read as text on text). Every +// one is now the solid popup surface, `--surface-popup`: the host's `--cmux-popup-surface`, the +// same opaque fill as the native overlays (`Palette.popupSurface`), with no backdrop blur. The two +// web tooltips (Base UI's `.ui-tooltip`, the title tooltip) also match the native tooltip: popup +// surface, primary ink, 5 px radius, 4×8 padding, a separator hairline and the caption font. +// +// The pages are the real stylesheets with the variables the host sets, in a see-through window +// (a translucent elevated background). The engines come from `playwright install chromium +// webkit`; where they are not installed, the cases are skipped and say so. +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import fs from "node:fs"; +import path from "node:path"; +import { chromium, webkit, type Browser, type BrowserType, type Page } from "playwright"; +import { requireBrowserLane } from "./support/requireBrowserLane"; + +await requireBrowserLane("popup-surface.test.ts", async () => { + setDefaultTimeout(120_000); + const source = path.resolve(import.meta.dir, "../src"); + const read = (...files: string[]) => + files + .map((file) => fs.readFileSync(path.join(source, file), "utf8")) + .join("\n") + // A page's bundler resolves these; the fixture inlines the sheet itself. + .replace(/^@import .*$/gm, ""); + + const engines: [string, BrowserType][] = []; + for (const engine of [ + ["chromium", chromium], + ["webkit", webkit], + ] as [string, BrowserType][]) { + try { + await (await engine[1].launch({ headless: true })).close(); + engines.push(engine); + } catch { + console.warn(`popup-surface: skipping ${engine[0]} (run \`bunx playwright install ${engine[0]}\`)`); + } + } + + // The host's theme (WebTheme.swift) in a see-through window: the elevated background is translucent. + const POPUP = "rgb(52, 49, 68)"; + const TEXT = "rgb(224, 222, 244)"; + const SECONDARY = "rgb(144, 140, 170)"; + const SEPARATOR = "rgba(224, 222, 244, 0.12)"; + const theme = [ + `--cmux-popup-surface:${POPUP}`, + `--cmux-text:${TEXT}`, + `--cmux-text-secondary:${SECONDARY}`, + `--cmux-separator:${SEPARATOR}`, + "--cmux-elevated-background:rgba(38, 35, 58, 0.6)", + "--cmux-surface-solid:rgb(25, 23, 36)", + "--cmux-surface-background:rgba(25, 23, 36, 0)", + `--agent-text:${TEXT}`, + "--agent-accent-text:rgb(25, 23, 36)", + "--agent-page-bg:rgba(25, 23, 36, 0)", + "--page-elevated:rgba(38, 35, 58, 0.6)", + ].join(";"); + const LONG = "A tooltip whose text runs on for a good while so that it has to wrap onto more lines. ".repeat(3); + const tooltips = `
${LONG}
+
${LONG}⌘T
`; + + // First-party pages: desktop.css, then ui.css and its popup surface. + const pages = { + css: read( + "pages/shared/desktop.css", + "pages/shared/pageBase.css", + "ui/popupSurface.css", + "ui/ui.css", + "icon-picker/styles.css", + "pages/markdown/styles.css", + "pages/home-channels/styles.css", + "pages/editor/styles.css", + ), + body: `
+${tooltips} + +
Icons
+ + +
React
+
Switch channel
+
Suggest
`, + surfaces: [ + "menu", + "ui-tooltip", + "title-tooltip", + "page-menu", + "icon-bar-pill", + "md-link-card", + "md-link-popover", + "hc-msg-toolbar", + "hc-switcher", + "editor-widget", + ], + }; + // The agent pane: desktop.css, the popup surface, then the pane's sheets (build-agent-pane-web.sh). + const pane = { + css: read( + "pages/shared/desktop.css", + "ui/popupSurface.css", + "agent-session/acpmux/styles.css", + "agent-session/acpmux/changes/changes.css", + "agent-session/acpmux/composerControls.css", + "agent-session/acpmux/composerLocation.css", + "agent-session/acpmux/modelPicker.css", + "agent-session/acpmux/summary/summary.css", + "agent-session/acpmux/header/header.css", + ), + body: `
+
Model
+
Context
+
/compact
+
Models
+
Older
+
Open
+
+
+
Copy
+
High
+
Full access
+
Rename
+
Checks
+
Summary
+${tooltips}`, + surfaces: [ + "cascade", + "context-pop", + "slash", + "model-picker", + "model-sub", + "file-menu", + "popup", + "context-menu", + "effort", + "access", + "chat-menu", + "pr-status", + "summary", + "title-tooltip", + ], + }; + + const open = async (browser: Browser, fixture: { css: string; body: string }) => { + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + await page.setContent( + `${fixture.body}`, + ); + return page; + }; + const material = (page: Page, id: string) => + page.$eval(`#${id}`, (node) => { + const style = getComputedStyle(node); + const blur = [style.getPropertyValue("backdrop-filter"), style.getPropertyValue("-webkit-backdrop-filter")]; + return { background: style.backgroundColor, blurred: blur.some((value) => value !== "" && value !== "none") }; + }); + + for (const [name, engine] of engines) { + describe(`popup surfaces in ${name}`, () => { + let browser: Browser; + beforeAll(async () => { + browser = await engine.launch({ headless: true }); + }); + afterAll(async () => browser?.close()); + + for (const [where, fixture] of [ + ["pages", pages], + ["the agent pane", pane], + ] as const) { + test(`every menu, popover and tooltip in ${where} is the opaque popup surface, unblurred`, async () => { + const page = await open(browser, fixture); + const seen: Record = {}; + for (const id of fixture.surfaces) seen[id] = await material(page, id); + const want = Object.fromEntries(fixture.surfaces.map((id) => [id, { background: POPUP, blurred: false }])); + expect(seen).toEqual(want); + await page.close(); + }); + } + + test("the web tooltips match the native tooltip", async () => { + for (const fixture of [pages, pane]) { + const page = await open(browser, fixture); + for (const id of fixture === pages ? ["ui-tooltip", "title-tooltip"] : ["title-tooltip"]) { + const look = await page.$eval(`#${id}`, (node) => { + const style = getComputedStyle(node); + return { + ink: style.color, + radius: style.borderTopLeftRadius, + padding: `${style.paddingTop} ${style.paddingLeft}`, + border: `${style.borderTopWidth} ${style.borderTopStyle} ${style.borderTopColor}`, + font: `${style.fontSize}/${style.lineHeight}`, + shadow: style.boxShadow !== "none", + width: Math.round(node.getBoundingClientRect().width), + }; + }); + expect({ id, ...look }).toEqual({ + id, + ink: TEXT, + radius: "5px", + padding: "4px 8px", + border: `1px solid ${SEPARATOR}`, + font: "11px/14px", + shadow: true, + width: 320, + }); + } + const hint = await page.$eval("#hint", (node) => getComputedStyle(node).color); + expect(hint).toBe(SECONDARY); + await page.close(); + } + }); + }); + } +});