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
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 @@ -28,18 +28,19 @@ public struct Glass {
}()

/// 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.
/// hover cards, find and prompt bars, page info, popovers) and hosts
/// `content` edge to edge: Liquid Glass tinted with the theme, with its
/// own refracting rim, like iOS's menus and folders (Leo, 2026-10-18,
/// replacing the solid surface of 2026-10-12); the frosted vibrancy
/// material before macOS 26, and the solid popup surface under Reduce
/// Transparency. 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(material: .opaque, interactive: interactive, cornerRadius: cornerRadius)
let surface = OverlaySurfaceView(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 @@ -8,10 +8,10 @@ public import AppKit
/// Content goes in `contentView`, above the material, and may size the
/// 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.
/// 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): themed Liquid Glass, whose tint
/// (`glassOverlayTintStrength`) keeps its text over busy content.
@MainActor
public final class OverlaySurfaceView: NSView {
public private(set) var material: OverlayMaterial
Expand Down Expand Up @@ -82,6 +82,8 @@ public final class OverlaySurfaceView: NSView {
}

private func rebuild() {
// Moving the content out of the window resigns a focused field in it; it takes the keys back after.
let focused = (window?.firstResponder as? NSView).flatMap { $0.isDescendant(of: contentView) ? $0 : nil }
materialView?.removeFromSuperview()
contentView.removeFromSuperview()
tintView = nil
Expand Down Expand Up @@ -121,8 +123,10 @@ public final class OverlaySurfaceView: NSView {
tintView?.frame = view.bounds
contentView.frame = view.bounds
}
addSubview(view)
// Below any view a caller added on the surface itself (the floating chat's grip).
addSubview(view, positioned: .below, relativeTo: nil)
materialView = view
if let focused, let window = focused.window { window.makeFirstResponder(focused) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Rebuilding drops field focus

When Reduce Transparency changes during an NSTextField edit, window.firstResponder is the shared field editor, not the field. Removing the content ends editing and can detach that editor. This makes focused.window nil, so this line skips restoring focus and typing stops until the user clicks the field again.

Save the editor’s owning field and selection before removal, then restore them through the original window, as WindowOverlayHost already does.

applyShape()
applyTheme()
// Content colors may depend on the material (a veil that only
Expand Down Expand Up @@ -161,7 +165,8 @@ public final class OverlaySurfaceView: NSView {
}

/// The theme's glass tint, its alpha scaled by the overlay tint
/// strength tunable (1, the default, is the theme tint unchanged).
/// strength tunable (default 1.25, a little denser than the theme
/// tint so text over busy content stays readable; 1 is unchanged).
private var overlayTint: NSColor {
let tint = Palette.glassTint
let strength = ChromeTunables.glassOverlayTintStrength.value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ public nonisolated struct TooltipSurface: Hashable, Sendable {
public static let blurRadius: Double = 24
/// The least tint alpha (pages' fallback without the host's tint:
/// `--tooltip-tint`): dense enough that the glass reads as a surface,
/// light enough that the backdrop's color shows.
public static let minimumTintAlpha: Double = 0.86
/// light enough that the backdrop's color shows. The 4.5:1 rule decides
/// above it (about 0.7 dark, 0.6 light; Leo 2026-10-18: tooltips were too opaque at 0.86).
public static let minimumTintAlpha: Double = 0.6
/// The shadow: elevation 1, small and tight (`--elevation-1` in pages).
@MainActor public static var shadow: Elevation { PopupStyle.elevation1 }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public nonisolated enum ChromeTunables {
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).",
default: 1, range: 0...3, step: 0.05, unit: .multiplier, code: "ChromeTunables.glassOverlayTintStrength")
default: 1.25, range: 0...3, step: 0.05, unit: .multiplier, code: "ChromeTunables.glassOverlayTintStrength")

// MARK: Scroll fade

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import CmuxNextDesign

/// The update card's highlights popup (Leo 2026-10-13): the popup every cmux popover is, not
/// AppKit's NSPopover with its stock arrow. A borderless child window of the sidebar's window
/// holding the solid popup surface with its hairline and shadow (`Glass.makeOverlayPanel`,
/// holding the popup surface (themed glass, or the solid card under Reduce Transparency) with its hairline and shadow (`Glass.makeOverlayPanel`,
/// `PopupStyle`), beside the card toward the content (left of it for a sidebar on the right, or
/// with no room on the right) and top-aligned with it, in the card's theme. It takes clicks
/// ("See all changes") and never becomes key, so the keys stay with the window. The app going
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import AppKit
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. (The omnibar's suggestion card
/// has its own color since cx-d0d.102; the refusal pill is a toast now, #19759.)
/// Leo, 2026-10-12: every overlay that carries text was the solid popup surface; on 2026-10-18 the
/// overlay panels went back to this Mac's material (themed glass). The Share cmux dialog keeps the
/// solid surface it pins itself. (The omnibar's suggestion card has its own color since cx-d0d.102;
/// the refusal pill is a toast now, #19759.)
@MainActor @Suite(.serialized) struct PopupSurfaceCallersTests {
@Test func theShareDialogIsThePopupSurface() {
#expect(ShareCmuxView(writeToPasteboard: { _ in }).surface.material == .opaque)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,19 @@ import Testing
/// ink once, while the dialog was built outside any window, so a dialog shown in a light
/// window (or one whose theme turned light while it showed) kept another theme's text over the
/// light surface. The labels must re-read their ink in the scope they draw in, in an inactive
/// (never key) window too, and the surface must stay a light card, not a grey slab.
/// (never key) window too, and the surface must stay a light card, not a grey slab. These
/// check the solid card a dialog draws under Reduce Transparency; on glass the tint carries it.
@MainActor @Suite(.serialized) struct CmuxDialogLightContrastTests {
private static let light = ThemeInput(background: ThemeRGB(hex: 0xFFFFFF), foreground: ThemeRGB(hex: 0x1D1D1F))
private static let dark = ThemeInput(background: ThemeRGB(hex: 0x1E1E1E), foreground: ThemeRGB(hex: 0xE6E6E6))

/// Pins the solid popup surface for one test; returns the setting to restore.
private static func pinOpaque() -> Bool? {
let pinned = ReduceTransparency.shared.override
ReduceTransparency.shared.override = true
return pinned
}

private static func scope(_ input: ThemeInput, _ name: String) throws -> ThemeScope {
let scope = ThemeScope(level: .room)
scope.setOverride(ThemeSpec(name), input: input, animated: false)
Expand Down Expand Up @@ -42,6 +50,8 @@ import Testing
}

@Test func aDialogMovedIntoALightWindowDrawsReadableText() throws {
let pinned = Self.pinOpaque()
defer { ReduceTransparency.shared.override = pinned }
let darkWindow = try Self.window(in: try Self.scope(Self.dark, "Contrast Dark"))
let lightScope = try Self.scope(Self.light, "Contrast Light")
let lightWindow = try Self.window(in: lightScope)
Expand All @@ -56,6 +66,8 @@ import Testing
/// The mirror case: whichever theme the app scope has when the dialog is built, one of the
/// two moves lands it in the other one.
@Test func aDialogMovedIntoADarkWindowDrawsReadableText() throws {
let pinned = Self.pinOpaque()
defer { ReduceTransparency.shared.override = pinned }
let lightWindow = try Self.window(in: try Self.scope(Self.light, "Contrast Light First"))
let darkScope = try Self.scope(Self.dark, "Contrast Dark Second")
let darkWindow = try Self.window(in: darkScope)
Expand All @@ -67,6 +79,8 @@ import Testing
}

@Test func aDialogWhoseThemeTurnsLightDrawsReadableText() throws {
let pinned = Self.pinOpaque()
defer { ReduceTransparency.shared.override = pinned }
let scope = try Self.scope(Self.dark, "Contrast Switch")
let window = try Self.window(in: scope)
defer { window.close() }
Expand All @@ -78,6 +92,8 @@ import Testing
}

@Test func aDarkDialogDrawsItsWindowsInk() throws {
let pinned = Self.pinOpaque()
defer { ReduceTransparency.shared.override = pinned }
let scope = try Self.scope(Self.dark, "Contrast Dark Only")
let window = try Self.window(in: scope)
defer { window.close() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,27 @@ 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.
/// Leo, 2026-10-18: overlays that carry text (popovers, hover cards, the palette, the find bars)
/// are themed Liquid Glass, like iOS's menus, replacing the solid surface of 2026-10-12. Under
/// Reduce Transparency they are the one solid popup surface (the theme's opaque overlay fill), 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 {
@Test func overlayPanelsFollowThisMacsMaterial() {
let panel = Glass.makeOverlayPanel(content: NSTextField(labelWithString: "Find"))
#expect(panel.materialOverride == nil)
#expect(panel.material == OverlayMaterial.select(liquidGlassAvailable: Glass.isLiquidGlassAvailable,
reduceTransparency: ReduceTransparency.shared.isEnabled))
}

@Test func overlayPanelsAreTheOpaquePopupSurfaceUnderReduceTransparency() throws {
let pinned = ReduceTransparency.shared.override
ReduceTransparency.shared.override = true
defer { ReduceTransparency.shared.override = pinned }
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],
Expand Down
2 changes: 1 addition & 1 deletion plans/cmux-next/spec-proposals/visuals/design-tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -2040,7 +2040,7 @@
"source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlayMaterial.swift (ThemeTokens.opaqueOverlayFill); Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlaySurface.swift (OverlaySurfaceView.applyTheme); Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/ChromeTunables.swift:40-42 (ChromeTunables.opaqueOverlayLift)"
},
"glassTintStrength": {
"value": 1,
"value": 1.25,
"source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/ChromeTunables.swift:43-45 (ChromeTunables.glassOverlayTintStrength)",
"appliesTo": "OverlaySurface surfaces (materials.overlay.appliesTo). Raw Glass.makePanel panels use token:glassTint unscaled."
},
Expand Down
2 changes: 1 addition & 1 deletion plans/cmux-next/spec-proposals/visuals/design-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ Every listed surface draws through `OverlaySurfaceView`: the drop overlay, drop

The "older macOS" column cannot run in the macOS app today: `Packages/macOS/CmuxNext/Package.swift` requires macOS 26. Ports on a platform without Liquid Glass use the vibrancy row where the platform has a blur, else the opaque fallback.

The overlay tint strength Debug tunable (default 1, `ChromeTunables.swift:43-45 (ChromeTunables.glassOverlayTintStrength)`) scales the tint of `OverlaySurface` only. `Glass.makePanel` panels use `glassTint` unscaled.
The overlay tint strength Debug tunable (default 1.25, `ChromeTunables.swift:43-45 (ChromeTunables.glassOverlayTintStrength)`) scales the tint of `OverlaySurface` only. `Glass.makePanel` panels use `glassTint` unscaled.

## 5. Motion

Expand Down
2 changes: 2 additions & 0 deletions scripts/cmux-next/media/tours/glass.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
{"title": "Split right", "cmux": ["action", "run", "splitRight"]},
{"title": "Agent chat beside the terminal", "cmux": ["agent", "new-chat"], "wait": 4,
"optional": "agent chat views are not ported yet"},
{"title": "Command palette on glass over art", "cmux": ["action", "run", "commandPalette"], "wait": 2},
{"title": "Close the palette", "cmux": ["action", "run", "commandPalette"], "wait": 1},
{"title": "Soft Blue over art", "cmux": ["settings", "set", "appearance.theme", "Soft Blue"], "wait": 3},
{"title": "Soft Blue Light over art", "cmux": ["settings", "set", "appearance.theme", "Soft Blue Light"], "wait": 3},
{"title": "Clay Orange Light over art", "cmux": ["settings", "set", "appearance.theme", "Clay Orange Light"], "wait": 3},
Expand Down
4 changes: 2 additions & 2 deletions webviews/src/pages/shared/desktop.css
Original file line number Diff line number Diff line change
Expand Up @@ -355,10 +355,10 @@ input[type="submit"] {
/* TOOLTIP SURFACE (CmuxNextDesign/Tooltip/TooltipSurface.swift; tooltip-surface.test.ts holds the
two in step). Frosted glass: the host's tint (--cmux-tooltip-tint: the popup surface at the
least alpha that keeps the text 4.5:1 over pure black and pure white, whatever the blur
averages), else the popup surface at 86%; a 24px blur; the separator rim one device pixel
averages), else the popup surface at 60%; a 24px blur; the separator rim one device pixel
(--cmux-hairline); elevation 1. It fades in with the popup open and out with the popup close (the native
fadeIn and fadeOut tokens), rising 2px. Reduce Transparency: the opaque popup surface. */
--tooltip-tint: var(--cmux-tooltip-tint, color-mix(in srgb, var(--surface-popup) 86%, transparent));
--tooltip-tint: var(--cmux-tooltip-tint, color-mix(in srgb, var(--surface-popup) 60%, transparent));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Fallback tooltips lose contrast

Without --cmux-tooltip-tint, the page now uses a fixed 60% tint. Unlike TooltipSurface.tintAlpha, it never raises that alpha to keep the text readable. The existing dark fixture’s light text over a white backdrop falls below 4.5:1, breaking the fallback contrast test in tooltip-surface.test.ts.

Keep a safe fallback alpha separate from the native minimum, or calculate the fallback tint from the page’s colors.

--tooltip-blur: blur(24px) saturate(1.6);
--tooltip-rim-width: var(--cmux-hairline);
--tooltip-shadow: var(--elevation-1);
Expand Down
5 changes: 3 additions & 2 deletions webviews/src/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,10 @@ Rules:
- Popups, menus, pickers and tooltips render in the portal container (`usePortalContainer`), never
inside a component, so no parent's stacking context (`transform`, `filter`, `opacity`, `isolation`)
or `overflow` traps or clips them. Inside a component use only base, raised and sticky.
- Menus, popovers, pickers and tooltips are the solid popup surface, `background: var(--surface-popup)`
- Menus, popovers, pickers and tooltips are the popup surface, `background: var(--surface-popup)`
(desktop.css: the host's `--cmux-popup-surface`, the native overlays' `Palette.popupSurface`), with
no `backdrop-filter`. A translucent surface that carries text keeps at least
no `backdrop-filter` (a page cannot blur the native glass behind it; native overlays are themed glass,
solid under Reduce Transparency). A translucent surface that carries text keeps at least
`--surface-text-min` of its fill: `color-mix(in srgb, <fill> var(--surface-text-min), transparent)`.
- A blur uses `backdrop-filter: var(--surface-blur)`, or the file has its own
`@media (prefers-reduced-transparency: reduce)` rule that makes the surface opaque. Both tokens turn
Expand Down