From c4abc7851f7df0963703e80bbf5e8d2248cf623e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 12 Oct 2026 00:09:29 -0700 Subject: [PATCH 1/4] test: popups, menus and tooltips are the solid popup surface Failing behavioral tests first: - native overlay panels and the themed tooltip fill with the theme's opaque overlay fill; - WebTheme exports it as --cmux-popup-surface; - every web menu, popover and tooltip computes to that color with no backdrop blur, and both web tooltips match the native tooltip's look; - tooltipWidth sizes a truncated row's tooltip to the row; - an unknown model mark draws nothing, and a folder profile's chip draws its family's mark. Co-Authored-By: Claude Opus 5.5 --- .../CmuxNextAppTests/ThemedTooltipTests.swift | 3 +- .../PopupSurfaceTests.swift | 52 +++++ webviews/src/ui/titleTooltips.test.ts | 67 +++++++ webviews/test/model-picker-mark.test.tsx | 47 +++++ webviews/test/popup-surface.test.ts | 187 ++++++++++++++++++ 5 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/PopupSurfaceTests.swift create mode 100644 webviews/src/ui/titleTooltips.test.ts create mode 100644 webviews/test/model-picker-mark.test.tsx create mode 100644 webviews/test/popup-surface.test.ts 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..e53ceda02eda --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/PopupSurfaceTests.swift @@ -0,0 +1,52 @@ +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: its elevated background 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 + + // 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)) + } + + 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/webviews/src/ui/titleTooltips.test.ts b/webviews/src/ui/titleTooltips.test.ts new file mode 100644 index 000000000000..b628b170a166 --- /dev/null +++ b/webviews/src/ui/titleTooltips.test.ts @@ -0,0 +1,67 @@ +// Leo, 2026-10-12: a tooltip for truncated text was narrower than the row it came from, so the +// full text wrapped into a tall thin column. A tooltip for truncated text shows the full text, +// wrapping, up to the source row's width (at least ~480 px), and never narrower than the line. +// Tooltips also carry a shortcut hint in the secondary ink, like the native one. +import { afterEach, expect, test } from "bun:test"; +import { JSDOM } from "jsdom"; +import { installTooltips, TOOLTIP_DELAY, tooltipWidth } from "./titleTooltips"; + +test("a tooltip for truncated text wraps up to its source's width, never narrower than the line", () => { + // Untruncated: the standard tooltip width. + expect(tooltipWidth({ width: 120, truncated: false, view: 1000 })).toEqual({ min: 0, max: 320 }); + // A truncated 600 px line: at least the line, wrapping up to it. + expect(tooltipWidth({ width: 600, truncated: true, view: 1000 })).toEqual({ min: 600, max: 600 }); + // A narrow truncated chip: up to 480 px. + expect(tooltipWidth({ width: 140, truncated: true, view: 1000 })).toEqual({ min: 140, max: 480 }); + // Never wider than the window's room. + expect(tooltipWidth({ width: 900, truncated: true, view: 700 })).toEqual({ min: 688, max: 688 }); +}); + +let dom: JSDOM | undefined; +let uninstall: (() => void) | undefined; +afterEach(() => { + uninstall?.(); + dom?.window.close(); + uninstall = undefined; + dom = undefined; +}); + +/** Hovers `element` and waits out the first tooltip's delay. */ +async function hover(element: Element) { + element.dispatchEvent(new dom!.window.MouseEvent("pointerover", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, TOOLTIP_DELAY + 50)); +} + +function page(body: string) { + dom = new JSDOM(`${body}`, { pretendToBeVisual: true }); + Object.defineProperty(dom.window.document.documentElement, "clientWidth", { value: 1000 }); + Object.defineProperty(dom.window.document.documentElement, "clientHeight", { value: 800 }); + uninstall = installTooltips(dom.window.document); + return dom.window.document; +} + +test("hovering a truncated row shows its full text as wide as the row", async () => { + const doc = page(`
A long…
`); + const row = doc.getElementById("row")!; + Object.defineProperty(row, "scrollWidth", { value: 900 }); + Object.defineProperty(row, "clientWidth", { value: 600 }); + row.getBoundingClientRect = () => ({ left: 10, top: 10, width: 600, height: 24 }) as DOMRect; + await hover(row); + const tip = doc.querySelector(".ui-title-tooltip")!; + expect(tip.hidden).toBe(false); + expect(tip.textContent).toBe("A long chat title that the sidebar row cuts off with an ellipsis"); + expect(tip.style.minWidth).toBe("600px"); + expect(tip.style.maxWidth).toBe("600px"); +}); + +test("an untruncated control's tooltip keeps the standard width and shows its shortcut hint", async () => { + const doc = page(``); + 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/test/model-picker-mark.test.tsx b/webviews/test/model-picker-mark.test.tsx new file mode 100644 index 000000000000..637f0c07b875 --- /dev/null +++ b/webviews/test/model-picker-mark.test.tsx @@ -0,0 +1,47 @@ +// 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"; + +const chip = (catalog: Parameters[0]["catalog"], harness: string, label: string) => + 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"'); +}); diff --git a/webviews/test/popup-surface.test.ts b/webviews/test/popup-surface.test.ts new file mode 100644 index 000000000000..e1dfee73d9c4 --- /dev/null +++ b/webviews/test/popup-surface.test.ts @@ -0,0 +1,187 @@ +// 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", "ui/popupSurface.css", "ui/ui.css", "icon-picker/styles.css"), + body: `
+${tooltips} +
Icons
`, + surfaces: ["menu", "ui-tooltip", "title-tooltip", "icon-bar-pill"], + }; + // 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(); + } + }); + }); + } +}); From b10e6983fbbf1f3af770861bfb7ef6f3d0a1a1cb Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 12 Oct 2026 00:20:06 -0700 Subject: [PATCH 2/4] fix: popups, menus and tooltips are one solid popup surface Glass popovers let the text under them show through (the New Tab folder popover over the Chats list read as text on text). Every overlay that carries text is now the theme's opaque overlay fill, unblurred: - native: Palette.popupSurface; Glass.makeOverlayPanel pins OverlaySurfaceView to it (palette, dialogs, hover cards, find and prompt bars, page info, editors, toasts); the themed tooltip and the icon picker panel use it too; - web: --surface-popup (desktop.css), the host's --cmux-popup-surface (WebTheme), for .ui-popup, the page menu, every acpmux menu and popover and the icon picker pill; no backdrop-filter; - the web tooltips (.ui-tooltip, the title tooltip) share the native tooltip's look and take a shortcut hint; a truncated row's title tooltip is as wide as the row (tooltipWidth); - the model chip draws a folder profile's own mark, and an unknown mark draws nothing instead of a placeholder square. Co-Authored-By: Claude Opus 5.5 --- .../IconPicker/IconPickerPanel.swift | 19 ++---- .../Sources/CmuxNextDesign/Glass.swift | 15 +++-- .../CmuxNextDesign/OverlaySurface.swift | 12 ++-- .../Sources/CmuxNextDesign/Palette.swift | 10 +++ .../CmuxNextDesign/PaletteDynamic.swift | 8 +++ .../CmuxNextDesign/Tooltip/TooltipPanel.swift | 2 +- .../Tunables/ChromeTunables.swift | 2 +- .../CmuxNextDesign/Windows/WebTheme.swift | 3 + .../PopupSurfaceTests.swift | 1 + scripts/cmux-next/layers-baseline.tsv | 2 +- .../acpmux/ModelPicker.gallery.tsx | 23 +++++++ .../src/agent-session/acpmux/ModelPicker.tsx | 14 +++- .../agent-session/acpmux/changes/changes.css | 2 +- .../agent-session/acpmux/composerControls.css | 16 +---- .../agent-session/acpmux/composerLocation.css | 16 +---- .../agent-session/acpmux/handoff/styles.css | 2 +- .../agent-session/acpmux/header/header.css | 6 +- .../src/agent-session/acpmux/modelPicker.css | 22 +------ webviews/src/agent-session/acpmux/styles.css | 65 ++++--------------- .../agent-session/acpmux/summary/summary.css | 6 +- .../src/agent-session/shared/AgentMark.tsx | 24 ++----- webviews/src/icon-picker/styles.css | 13 +--- webviews/src/pages/shared/desktop.css | 41 +++++++++--- webviews/src/pages/shared/pageBase.css | 2 +- webviews/src/ui/README.md | 4 +- webviews/src/ui/Tooltip.tsx | 12 +++- webviews/src/ui/popupSurface.css | 33 ++-------- webviews/src/ui/titleTooltips.test.ts | 4 +- webviews/src/ui/titleTooltips.ts | 40 +++++++++++- webviews/src/ui/ui.css | 15 +---- 30 files changed, 207 insertions(+), 227 deletions(-) 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/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/OverlaySurface.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlaySurface.swift index 3102c687b506..91399cd2a3c0 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 } } 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/Tests/CmuxNextDesignTests/PopupSurfaceTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/PopupSurfaceTests.swift index e53ceda02eda..0e040bf2a18c 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/PopupSurfaceTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/PopupSurfaceTests.swift @@ -20,6 +20,7 @@ import Testing 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] { 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/ModelPicker.gallery.tsx b/webviews/src/agent-session/acpmux/ModelPicker.gallery.tsx index d52bf51a4610..7688cdc657ab 100644 --- a/webviews/src/agent-session/acpmux/ModelPicker.gallery.tsx +++ b/webviews/src/agent-session/acpmux/ModelPicker.gallery.tsx @@ -84,5 +84,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 3f142936e13d..67ca2575511c 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 } from "../shared/AgentMark"; import { agentName } from "./agents"; import { isDefaultChoice } from "./defaultChoice"; import { CheckIcon, ChevronIcon, PICKER_LABELS, SearchIcon } from "./ComposerPickers"; @@ -527,7 +527,8 @@ 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. + const chipHarness = switchingTo?.harness ?? 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; @@ -658,7 +659,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} , ])} 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 20fd3d528be2..43934a612c69 100644 --- a/webviews/src/agent-session/acpmux/composerLocation.css +++ b/webviews/src/agent-session/acpmux/composerLocation.css @@ -134,25 +134,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/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/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/styles.css b/webviews/src/agent-session/acpmux/styles.css index f238920ed434..339fd0ffa51d 100644 --- a/webviews/src/agent-session/acpmux/styles.css +++ b/webviews/src/agent-session/acpmux/styles.css @@ -2,7 +2,7 @@ .acpmux-stage{position:relative;flex:1;display:flex;flex-direction:column;min-height:0}.acpmux-reviewing>.acpmux-header,.acpmux-reviewing>.acpmux-scroll{visibility:hidden} .acpmux-diff-add{color:var(--acpmux-add)}.acpmux-diff-del{color:var(--acpmux-del)}.acpmux-diff-main{flex:1;display:flex;min-height:0}.acpmux-diff-body{flex:1;min-width:0;overflow-y:auto;overflow-x:hidden}.acpmux-diff-file{border-bottom:1px solid var(--agent-border)}.acpmux-fh-spacer{flex:1} -.acpmux-diff-empty-edit{padding:8px 16px;color:var(--agent-muted);font-size:12px}.acpmux-main{position:relative;flex:1;min-width:0;display:flex;flex-direction:column}.acpmux-header>div{display:flex;align-items:center;min-width:0}.acpmux-sidebar-toggle{position:relative;flex:none;width:28px;height:28px;margin-right:6px;border:0;border-radius:7px;background:none;cursor:default}.acpmux-sidebar-toggle:hover{background:var(--agent-control-hover)}.acpmux-sidebar-toggle:focus-visible{outline:2px solid color-mix(in srgb,var(--agent-text) 40%,transparent);outline-offset:0}.acpmux-sidebar-toggle::before{content:"";position:absolute;left:6px;top:7px;width:13px;height:11px;border:1.5px solid var(--agent-soft);border-radius:3px}.acpmux-sidebar-toggle::after{content:"";position:absolute;left:11px;top:8px;width:1.5px;height:11.5px;background:var(--agent-soft)}.acpmux-sidebar{flex:none;display:flex;width:292px;box-sizing:border-box;overflow:hidden;border-right:0.5px solid var(--agent-border);background:var(--agent-page-bg);font-size:13px;letter-spacing:0.04px}.acpmux-shell[data-sidebar=closed] .acpmux-sidebar{display:none}@media not all and (min-width:640px){.acpmux-shell[data-sidebar=auto] .acpmux-sidebar{display:none}.acpmux-shell[data-sidebar=open] .acpmux-sidebar{position:absolute;z-index:2;inset:0 auto 0 0;width:min(332px,90%);background:var(--acpmux-base);-webkit-backdrop-filter:blur(24px);backdrop-filter:blur(24px);box-shadow:0 8px 24px var(--agent-shadow,rgb(0 0 0 / 35%))}.acpmux-shell[data-sidebar=open] .acpmux-sidebar-scrim{display:block;position:absolute;z-index:1;inset:0;border:0;padding:0;background:var(--cmux-scrim, rgb(0 0 0 / 12%));cursor:default}}.acpmux-sidebar-scrim{display:none}.acpmux-sidebar-group+.acpmux-sidebar-group{margin-top:10px}.acpmux-sidebar-project{display:flex;align-items:center;gap:8px;height:30px;margin-bottom:1px;padding:0 8px 0 9px;color:var(--agent-text);white-space:nowrap}.acpmux-sidebar ul{margin:0;padding:0;list-style:none}.acpmux-session-row{display:flex;align-items:center;gap:8px;width:100%;height:30px;margin-bottom:1px;padding:0 8px 0 33px;border:0;border-radius:8px;background:none;color:var(--agent-text);font:inherit;letter-spacing:inherit;text-align:left;cursor:pointer}.acpmux-session-row:hover,.acpmux-sidebar-more:hover{background:color-mix(in srgb,var(--agent-text) 5%,transparent)}.acpmux-session-row.is-selected{background:color-mix(in srgb,var(--agent-accent,var(--agent-text)) 9%,transparent)}.acpmux-sidebar-more:focus-visible{outline:2px solid var(--agent-accent);outline-offset:-2px}.acpmux-session-row.is-closed{color:var(--agent-muted)}.acpmux-session-row-title{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.acpmux-session-place{flex:none;display:inline-flex;color:var(--agent-muted);opacity:.75}.acpmux-session-place-branch{opacity:.5}.acpmux-session-row:hover .acpmux-session-place,.acpmux-session-row.is-selected .acpmux-session-place{opacity:1}.acpmux-session-mark{flex:none;display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;color:var(--agent-muted)}.acpmux-session-mark-input{color:var(--agent-warning,var(--agent-accent))}.acpmux-session-mark-unread::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--agent-accent)}.acpmux-session-mark-running{color:var(--agent-accent)}.acpmux-sidebar-more{display:block;width:100%;height:30px;margin-bottom:1px;padding:0 8px 0 33px;border:0;border-radius:8px;background:none;color:var(--agent-muted);font:inherit;letter-spacing:inherit;text-align:left;cursor:pointer}.acpmux-sidebar-section{height:30px;padding-left:8px;line-height:30px;color:var(--agent-muted);font-weight:500}.acpmux-sidebar-pinned .acpmux-session-row{padding-left:9px}.acpmux-sidebar-pinned+.acpmux-sidebar-projects{margin-top:12px}.acpmux-sidebar-host{flex:none;max-width:45%;overflow:hidden;text-overflow:ellipsis;color:var(--agent-muted);font-size:12px}.acpmux-sidebar-search{display:flex;align-items:center;gap:8px;height:30px;margin:0 0 10px;padding:0 8px 0 9px;border-radius:8px;background:color-mix(in srgb,var(--agent-text) 5%,transparent);color:var(--agent-muted)}.acpmux-sidebar-search:focus-within{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--agent-accent) 55%,transparent)}.acpmux-sidebar-search>label{display:contents}.acpmux-sidebar-search svg{flex:none}.acpmux-sidebar-search input{flex:1;min-width:0;height:100%;padding:0;border:0;outline:none;background:none;color:var(--agent-text);font:inherit;letter-spacing:inherit;-webkit-appearance:none;appearance:none}.acpmux-sidebar-search input::placeholder{color:var(--agent-muted)}.acpmux-sidebar-search input::-webkit-search-cancel-button{-webkit-appearance:none;appearance:none}.acpmux-sidebar-empty:empty{padding:0}.acpmux-sidebar-empty{padding:6px 9px;color:var(--agent-muted)} +.acpmux-diff-empty-edit{padding:8px 16px;color:var(--agent-muted);font-size:12px}.acpmux-main{position:relative;flex:1;min-width:0;display:flex;flex-direction:column}.acpmux-header>div{display:flex;align-items:center;min-width:0}.acpmux-sidebar-toggle{position:relative;flex:none;width:28px;height:28px;margin-right:6px;border:0;border-radius:7px;background:none;cursor:default}.acpmux-sidebar-toggle:hover{background:var(--agent-control-hover)}.acpmux-sidebar-toggle:focus-visible{outline:2px solid color-mix(in srgb,var(--agent-text) 40%,transparent);outline-offset:0}.acpmux-sidebar-toggle::before{content:"";position:absolute;left:6px;top:7px;width:13px;height:11px;border:1.5px solid var(--agent-soft);border-radius:3px}.acpmux-sidebar-toggle::after{content:"";position:absolute;left:11px;top:8px;width:1.5px;height:11.5px;background:var(--agent-soft)}.acpmux-sidebar{flex:none;display:flex;width:292px;box-sizing:border-box;overflow:hidden;border-right:0.5px solid var(--agent-border);background:var(--agent-page-bg);font-size:13px;letter-spacing:0.04px}.acpmux-shell[data-sidebar=closed] .acpmux-sidebar{display:none}@media not all and (min-width:640px){.acpmux-shell[data-sidebar=auto] .acpmux-sidebar{display:none}.acpmux-shell[data-sidebar=open] .acpmux-sidebar{position:absolute;z-index:2;inset:0 auto 0 0;width:min(332px,90%);background:var(--acpmux-base);-webkit-backdrop-filter:var(--surface-blur);backdrop-filter:var(--surface-blur);box-shadow:0 8px 24px var(--agent-shadow,rgb(0 0 0 / 35%))}.acpmux-shell[data-sidebar=open] .acpmux-sidebar-scrim{display:block;position:absolute;z-index:1;inset:0;border:0;padding:0;background:var(--cmux-scrim, rgb(0 0 0 / 12%));cursor:default}}.acpmux-sidebar-scrim{display:none}.acpmux-sidebar-group+.acpmux-sidebar-group{margin-top:10px}.acpmux-sidebar-project{display:flex;align-items:center;gap:8px;height:30px;margin-bottom:1px;padding:0 8px 0 9px;color:var(--agent-text);white-space:nowrap}.acpmux-sidebar ul{margin:0;padding:0;list-style:none}.acpmux-session-row{display:flex;align-items:center;gap:8px;width:100%;height:30px;margin-bottom:1px;padding:0 8px 0 33px;border:0;border-radius:8px;background:none;color:var(--agent-text);font:inherit;letter-spacing:inherit;text-align:left;cursor:pointer}.acpmux-session-row:hover,.acpmux-sidebar-more:hover{background:color-mix(in srgb,var(--agent-text) 5%,transparent)}.acpmux-session-row.is-selected{background:color-mix(in srgb,var(--agent-accent,var(--agent-text)) 9%,transparent)}.acpmux-sidebar-more:focus-visible{outline:2px solid var(--agent-accent);outline-offset:-2px}.acpmux-session-row.is-closed{color:var(--agent-muted)}.acpmux-session-row-title{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.acpmux-session-place{flex:none;display:inline-flex;color:var(--agent-muted);opacity:.75}.acpmux-session-place-branch{opacity:.5}.acpmux-session-row:hover .acpmux-session-place,.acpmux-session-row.is-selected .acpmux-session-place{opacity:1}.acpmux-session-mark{flex:none;display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;color:var(--agent-muted)}.acpmux-session-mark-input{color:var(--agent-warning,var(--agent-accent))}.acpmux-session-mark-unread::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--agent-accent)}.acpmux-session-mark-running{color:var(--agent-accent)}.acpmux-sidebar-more{display:block;width:100%;height:30px;margin-bottom:1px;padding:0 8px 0 33px;border:0;border-radius:8px;background:none;color:var(--agent-muted);font:inherit;letter-spacing:inherit;text-align:left;cursor:pointer}.acpmux-sidebar-section{height:30px;padding-left:8px;line-height:30px;color:var(--agent-muted);font-weight:500}.acpmux-sidebar-pinned .acpmux-session-row{padding-left:9px}.acpmux-sidebar-pinned+.acpmux-sidebar-projects{margin-top:12px}.acpmux-sidebar-host{flex:none;max-width:45%;overflow:hidden;text-overflow:ellipsis;color:var(--agent-muted);font-size:12px}.acpmux-sidebar-search{display:flex;align-items:center;gap:8px;height:30px;margin:0 0 10px;padding:0 8px 0 9px;border-radius:8px;background:color-mix(in srgb,var(--agent-text) 5%,transparent);color:var(--agent-muted)}.acpmux-sidebar-search:focus-within{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--agent-accent) 55%,transparent)}.acpmux-sidebar-search>label{display:contents}.acpmux-sidebar-search svg{flex:none}.acpmux-sidebar-search input{flex:1;min-width:0;height:100%;padding:0;border:0;outline:none;background:none;color:var(--agent-text);font:inherit;letter-spacing:inherit;-webkit-appearance:none;appearance:none}.acpmux-sidebar-search input::placeholder{color:var(--agent-muted)}.acpmux-sidebar-search input::-webkit-search-cancel-button{-webkit-appearance:none;appearance:none}.acpmux-sidebar-empty:empty{padding:0}.acpmux-sidebar-empty{padding:6px 9px;color:var(--agent-muted)} /* Composer (Composer.tsx, ComposerPickers.tsx), with metrics and font from the reference prototype (src/shell Composer): the prompt over a bar with +, the mode and model chips, and a round Send. Every color derives from the theme bridge: each surface is the page @@ -15,10 +15,11 @@ opaque (the accent is the text color). A host that sends a different accent text must change this base too; without the bridge the base is the system Canvas. */ .acpmux-shell,.ui-positioner{--acpmux-base:var(--agent-accent-text,Canvas);--acpmux-composer-bg:color-mix(in srgb,var(--agent-text) 13%,var(--agent-page-bg,var(--acpmux-base)));--acpmux-composer-edge:color-mix(in srgb,var(--agent-text) 18%,var(--agent-page-bg,var(--acpmux-base)));--acpmux-composer-placeholder:color-mix(in srgb,var(--agent-text) 34%,var(--acpmux-base));--acpmux-composer-dim:color-mix(in srgb,var(--agent-text) 56%,var(--acpmux-base));--acpmux-menu-bg:color-mix(in srgb,var(--agent-text) 9%,var(--acpmux-base));--acpmux-menu-hover:color-mix(in srgb,var(--agent-text) 14%,var(--acpmux-base));--acpmux-composer-hover:color-mix(in srgb,var(--agent-text) 14%,var(--agent-page-bg,var(--acpmux-base)));--acpmux-composer-tray:color-mix(in srgb,var(--agent-text) 9%,var(--agent-page-bg,var(--acpmux-base)));--acpmux-warning:var(--agent-warning,var(--agent-danger))} -/* The shared popup surface (ui/popupSurface.css) takes the pane's menu colors, so every popup, nested submenus included, is opaque in the theme. */ -.ui-positioner{--ui-popup-bg:var(--acpmux-menu-bg);--ui-popup-fg:var(--agent-text)} +/* The shared popup surface (ui/popupSurface.css) takes the pane's ink; every popup, nested submenus included, is the solid popup surface (--surface-popup, the host's --cmux-popup-surface, else the pane's menu color). */ +.acpmux-shell,.ui-positioner{--surface-popup:var(--cmux-popup-surface,var(--acpmux-menu-bg))} +.ui-positioner{--ui-popup-fg:var(--agent-text)} /* The point-anchored context menu (ui/ContextMenu.tsx; New Tab All chats rows, cx-j4sd): the pane does not load ui.css, so it draws the menu in its own menu look. */ -.ui-popup.ui-context-menu{box-sizing:border-box;padding:var(--ui-popup-padding);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);color:var(--agent-text);font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",system-ui,sans-serif;font-size:var(--text-control);line-height:18px;outline:none} +.ui-popup.ui-context-menu{box-sizing:border-box;padding:var(--ui-popup-padding);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);color:var(--agent-text);font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",system-ui,sans-serif;font-size:var(--text-control);line-height:18px;outline:none} .ui-context-menu .ui-menu-item{display:flex;align-items:center;gap:8px;width:100%;height:var(--ui-row-height);padding:0 var(--ui-row-padding-inline);border:0;border-radius:var(--ui-row-radius);background:transparent;color:inherit;font:inherit;text-align:start;white-space:nowrap;cursor:default} .ui-context-menu .ui-menu-item[data-highlighted]{background:var(--acpmux-menu-hover);outline:none} .ui-context-menu .ui-menu-item:disabled{color:var(--agent-muted)} @@ -95,7 +96,7 @@ .acpmux-context-ring:disabled{background:none;color:var(--agent-muted);opacity:.5} .acpmux-context-ring:focus-visible{outline:1.5px solid var(--agent-text);outline-offset:2px} .acpmux-context-full .acpmux-context-ring,.acpmux-context-full .acpmux-context-ring:hover{color:var(--acpmux-warning)} -.acpmux-context-pop{width:240px;box-sizing:border-box;padding:12px;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,bottom);color:var(--agent-text);font:13px/18px -apple-system,BlinkMacSystemFont,"SF Pro Text",system-ui,sans-serif} +.acpmux-context-pop{width:240px;box-sizing:border-box;padding:12px;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,bottom);color:var(--agent-text);font:13px/18px -apple-system,BlinkMacSystemFont,"SF Pro Text",system-ui,sans-serif} .acpmux-context-title{font-size:12px;color:var(--agent-muted)} .acpmux-context-percent{margin-top:2px;font-size:14px;font-weight:500} .acpmux-context-bar{height:4px;margin-top:8px;border-radius:2px;background:color-mix(in srgb,var(--agent-text) 14%,transparent);overflow:hidden} @@ -120,7 +121,7 @@ .acpmux-send .acpmux-icon{width:16px;height:16px} .acpmux-send:not(.acpmux-send-ready):not(.acpmux-cancel):not(:hover){background:color-mix(in srgb,var(--agent-accent,var(--agent-text)) 34%,var(--acpmux-base))} /* The picker menus open above the field at their chip (useUiAnchor). The chip is their containing block, so the width comes from the rows and the viewport, never the chip. */ -.acpmux-menu{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",system-ui,sans-serif;font-size:var(--text-control);position:absolute;bottom:calc(100% + 6px);z-index:3;width:max-content;min-width:min(220px,calc(100vw - 16px));max-width:min(420px,calc(100vw - 16px));height:auto;max-height:min(360px,50vh);overflow-x:hidden;overflow-y:auto;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);color:var(--agent-text);line-height:18px;transform-origin:var(--transform-origin,bottom left)} +.acpmux-menu{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",system-ui,sans-serif;font-size:var(--text-control);position:absolute;bottom:calc(100% + 6px);z-index:3;width:max-content;min-width:min(220px,calc(100vw - 16px));max-width:min(420px,calc(100vw - 16px));height:auto;max-height:min(360px,50vh);overflow-x:hidden;overflow-y:auto;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);color:var(--agent-text);line-height:18px;transform-origin:var(--transform-origin,bottom left)} /* The picker menus open above the field, aligned with its edges. */ .acpmux-menu-start{left:0} .acpmux-menu-end{right:0;transform-origin:var(--transform-origin,bottom right)} @@ -135,7 +136,7 @@ .acpmux-menu-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .acpmux-menu-description{color:var(--acpmux-composer-dim);font-size:var(--text-detail)} .acpmux-unrestricted .acpmux-menu-description{color:inherit} -.acpmux-slash-menu{position:absolute;bottom:calc(100% + 6px);left:0;right:0;z-index:3;max-height:min(296px,50vh);overflow:auto;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);font-size:13px;transform-origin:bottom} +.acpmux-slash-menu{position:absolute;bottom:calc(100% + 6px);left:0;right:0;z-index:3;max-height:min(296px,50vh);overflow:auto;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);font-size:13px;transform-origin:bottom} .acpmux-slash-row{display:flex;align-items:center;gap:8px;height:31px;padding:0 8px;border-radius:8px;cursor:default;white-space:nowrap} .acpmux-slash-active{background:var(--acpmux-menu-hover)} .acpmux-slash-name{flex:none;color:var(--agent-text)} @@ -230,7 +231,7 @@ /* The folder trust ask (TrustAsk.tsx): one quiet line among the chat's permission asks, its actions text buttons that step on hover and press. */ .acpmux-trust-ask{display:flex;align-items:center;gap:4px;min-height:34px;margin-bottom:8px;padding:4px 4px 4px 12px;box-sizing:border-box;border-radius:12px;background:var(--acpmux-composer-tray);color:var(--acpmux-composer-dim);font-size:13px}.acpmux-trust-ask-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.acpmux-trust-ask-action{flex:none;height:26px;padding:0 10px;border:0;border-radius:13px;background:none;color:var(--agent-text);font:inherit;cursor:pointer}.acpmux-trust-ask-action:hover{background:var(--acpmux-composer-hover)}.acpmux-trust-ask-action:active{background:color-mix(in srgb,var(--agent-text) 20%,var(--agent-page-bg,var(--acpmux-base)))}.acpmux-trust-ask-action:focus-visible{outline:1.5px solid var(--agent-text);outline-offset:1px}@media (prefers-reduced-motion:no-preference){.acpmux-trust-ask-action{transition:background-color var(--agent-motion-hover,80ms) var(--agent-ease-out,ease-out)}} /* Search files: a quick-open palette, centered over the transcript's upper part (main column). */ -.acpmux-file-search{position:absolute;z-index:20;top:max(48px,18%);left:50%;right:auto;margin:0;border:0;width:min(600px,calc(100vw - 32px));transform:translateX(-50%);padding:6px;box-sizing:border-box;border-radius:14px;background:var(--acpmux-menu-bg);box-shadow:inset 0 0 0 .5px var(--acpmux-composer-edge),0 16px 40px var(--agent-shadow,rgb(0 0 0/.35));color:var(--agent-text);font-size:14px} +.acpmux-file-search{position:absolute;z-index:20;top:max(48px,18%);left:50%;right:auto;margin:0;border:0;width:min(600px,calc(100vw - 32px));transform:translateX(-50%);padding:6px;box-sizing:border-box;border-radius:14px;background:var(--surface-popup);box-shadow:inset 0 0 0 .5px var(--acpmux-composer-edge),0 16px 40px var(--agent-shadow,rgb(0 0 0/.35));color:var(--agent-text);font-size:14px} .acpmux-file-search input{display:block;width:100%;height:34px;padding:0 10px;box-sizing:border-box;border:0;background:none;color:var(--agent-text);font:inherit;outline:none} .acpmux-file-search input::placeholder{color:var(--acpmux-composer-dim)} .acpmux-file-search [role=listbox]{max-height:min(360px,50vh);overflow:auto} @@ -434,8 +435,6 @@ button.acpmux-edited-file:hover{background:color-mix(in srgb,var(--agent-text) 4 .acpmux-instructions-notice button[data-notice-action=dismiss]{background:transparent;color:var(--agent-muted)} .acpmux-composer{flex-wrap:wrap}.acpmux-composer-dropping .acpmux-composer-box{border-color:var(--agent-accent)} .acpmux-switch-notice{display:block;padding:0 max(18px,calc((100% - 760px)/2)) 6px;color:var(--agent-muted);font-size:12px;line-height:16px} -/* ui/titleTooltips.ts: the pane's one tooltip, placed inside the window. */ -.ui-title-tooltip{position:fixed;z-index:1000;max-width:min(320px,calc(100vw - 12px));padding:4px 8px;box-sizing:border-box;border-radius:6px;background:color-mix(in srgb,var(--agent-text) 14%,var(--agent-page-bg,Canvas));box-shadow:inset 0 0 0 .5px color-mix(in srgb,var(--agent-text) 18%,transparent),0 4px 14px var(--agent-shadow,rgb(0 0 0/.3));color:var(--agent-text);font-family:system-ui,-apple-system,sans-serif;font-size:var(--text-caption);line-height:var(--text-caption--line-height);white-space:pre-line;overflow-wrap:anywhere;pointer-events:none} .acpmux-folder-choice-button{padding:0;border:0;background:none;color:var(--agent-accent,inherit);font:inherit;text-decoration:underline;cursor:pointer} .acpmux-side-question{margin:0 max(18px,calc((100% - 760px)/2)) 8px;padding:8px 10px;border:1px solid var(--agent-border);border-radius:8px;background:var(--agent-input-bg);color:var(--agent-text);font-size:var(--text-body);line-height:var(--text-body--line-height)} .acpmux-side-question-asked{margin:0 0 4px} @@ -503,57 +502,19 @@ fieldset.acpmux-question{display:flex;flex-direction:column;gap:10px;margin:0;mi /* Menus and popovers open from their trigger (ui/popupSurface.css), only when motion is allowed. */ @media (prefers-reduced-motion:no-preference){.acpmux-menu,.acpmux-context-pop,.acpmux-slash-menu{animation:ui-popup-open var(--ui-popup-open) ease-out}} -/* Keep the legacy cascade and slash surfaces on the same material contract as Base UI's Menu and - Popover. These menus keep their bespoke layout, but never their own glass, border, shadow or - motion. */ +/* Keep the legacy cascade and slash surfaces on the same surface as Base UI's Menu and Popover: the + solid popup surface, unblurred (Leo, 2026-10-12: glass let the transcript read through). These + menus keep their bespoke layout, but never their own material, border, shadow or motion. */ .acpmux-menu, .acpmux-context-pop, .acpmux-slash-menu, .acpmux-mp { 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, var(--ui-popup-glass-alpha, 92%)), transparent); - -webkit-backdrop-filter: var(--surface-blur, var(--ui-popup-blur, blur(20px) saturate(1.4))); - backdrop-filter: var(--surface-blur, var(--ui-popup-blur, blur(20px) saturate(1.4))); + background-color: var(--surface-popup); box-shadow: var(--ui-popup-shadow); scrollbar-width: thin; } -@media (prefers-reduced-transparency: reduce) { - .acpmux-menu, - .acpmux-context-pop, - .acpmux-slash-menu, - .acpmux-mp { - background-color: var(--acpmux-menu-bg, var(--ui-surface, Canvas)); - -webkit-backdrop-filter: none; - backdrop-filter: none; - } -} - -:root[data-reduce-transparency] .acpmux-menu, -:root[data-reduce-transparency] .acpmux-context-pop, -:root[data-reduce-transparency] .acpmux-slash-menu, -:root[data-reduce-transparency] .acpmux-mp { - background-color: var(--acpmux-menu-bg, var(--ui-surface, Canvas)); - -webkit-backdrop-filter: none; - backdrop-filter: none; -} - -@media (prefers-reduced-transparency: reduce) { - .acpmux-effort-menu, - .acpmux-access-menu { - background-color: var(--acpmux-menu-bg, var(--ui-surface, Canvas)); - -webkit-backdrop-filter: none; - backdrop-filter: none; - } -} - -:root[data-reduce-transparency] .acpmux-effort-menu, -:root[data-reduce-transparency] .acpmux-access-menu { - background-color: var(--acpmux-menu-bg, var(--ui-surface, Canvas)); - -webkit-backdrop-filter: none; - backdrop-filter: none; -} - .acpmux-menu, .acpmux-slash-menu, .acpmux-mp { diff --git a/webviews/src/agent-session/acpmux/summary/summary.css b/webviews/src/agent-session/acpmux/summary/summary.css index de086c9eb7d9..cb347951543f 100644 --- a/webviews/src/agent-session/acpmux/summary/summary.css +++ b/webviews/src/agent-session/acpmux/summary/summary.css @@ -4,7 +4,7 @@ .acpmux-summary-button:hover,.acpmux-summary-button[aria-expanded="true"]{background:color-mix(in srgb,currentColor 8%,transparent)} .acpmux-summary-button:active{background:color-mix(in srgb,var(--agent-text) 20%,transparent)} .acpmux-summary-button:focus-visible{outline:1.5px solid var(--agent-text);outline-offset:1px} -.acpmux-summary-popover{width:288px;max-height:min(540px,70vh);overflow:auto;padding:4px 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-summary-popover{width:288px;max-height:min(540px,70vh);overflow:auto;padding:4px 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} .acpmux-summary-section+.acpmux-summary-section{margin-top:4px;padding-top:4px;border-top:.5px solid var(--agent-border)} .acpmux-summary-title{margin:0;padding:8px 8px 4px;color:var(--agent-muted);font-size:var(--text-detail);font-weight:500;line-height:16px} .acpmux-summary-list{margin:0;padding:0;list-style:none} @@ -22,7 +22,7 @@ .acpmux-summary-link[data-state=closed]>svg{color:var(--agent-danger)} .acpmux-summary-more{display:block;width:100%;height:26px;padding:0 8px;border:0;border-radius:7px;background:none;color:var(--agent-muted);font:inherit;font-size:var(--text-detail);text-align:left;cursor:default} .acpmux-summary-stack{flex:none;display:flex;padding-left:4px} -.acpmux-summary-dot{width:14px;height:14px;margin-left:-4px;box-sizing:border-box;border-radius:50%;border:2px solid var(--acpmux-menu-bg);background:var(--agent-ansi-4,#58a6ff)} +.acpmux-summary-dot{width:14px;height:14px;margin-left:-4px;box-sizing:border-box;border-radius:50%;border:2px solid var(--surface-popup);background:var(--agent-ansi-4,#58a6ff)} .acpmux-summary-dot[data-tone="1"]{background:var(--agent-ansi-2,#3fb950)} .acpmux-summary-dot[data-tone="2"]{background:var(--agent-ansi-6,#39c5cf)} .acpmux-summary-dot[data-tone="3"]{background:var(--agent-ansi-5,#a371f7)} @@ -31,7 +31,7 @@ .acpmux-summary-none{color:var(--agent-muted)} /* Lineage (subagents/Lineage.tsx): a status dot on each agent's icon, its state in muted text. */ .acpmux-lineage__icon{position:relative;flex:none;display:inline-flex;color:var(--agent-muted)} -.acpmux-lineage__icon::after{content:"";position:absolute;right:-2px;bottom:-2px;width:6px;height:6px;border-radius:50%;box-shadow:0 0 0 1.5px var(--acpmux-menu-bg);background:var(--agent-muted)} +.acpmux-lineage__icon::after{content:"";position:absolute;right:-2px;bottom:-2px;width:6px;height:6px;border-radius:50%;box-shadow:0 0 0 1.5px var(--surface-popup);background:var(--agent-muted)} .acpmux-lineage__icon[data-state=running]::after{background:var(--agent-accent)} .acpmux-lineage__icon[data-state=completed]::after{background:var(--agent-success,var(--acpmux-add,#3fb950))} .acpmux-lineage__icon[data-state=failed]::after{background:var(--agent-danger)} diff --git a/webviews/src/agent-session/shared/AgentMark.tsx b/webviews/src/agent-session/shared/AgentMark.tsx index 497b050fa1c0..b94a8b70d3bb 100644 --- a/webviews/src/agent-session/shared/AgentMark.tsx +++ b/webviews/src/agent-session/shared/AgentMark.tsx @@ -56,7 +56,7 @@ const box = (v: readonly number[]) => v.join(" "); /// and new-tab cards. With `label` it is an image named for the agent; without, it /// is decoration beside text that already names it. Marks pick their colors for the /// page's theme; on a surface of the other lightness (a selected row on the accent), -/// `onDark` says which side the surface is on. The marks and their sources live in +/// `onDark` says which side the surface is on. An agent without a mark draws nothing. The marks and their sources live in /// design/agent-icons (manifest.json); agentBrands.generated.ts is generated from it. export function AgentMark({ agent, @@ -82,25 +82,9 @@ export function AgentMark({ ? { ...full, viewBox: full.small.viewBox, paths: full.small.paths, overlays: undefined, small: undefined } : full; const a11y = label ? { role: "img", "aria-label": label } : { "aria-hidden": true as const }; - if (!spec) - return ( - - - - - ); + // A mark nobody knows (a profile's own id, a file icon) draws nothing: a placeholder square read + // as a broken image (Leo, 2026-10-12, the "Vercel AI Gateway" chip). The name beside it says who. + if (!spec) return null; const tile = brand ? spec.tile : undefined; const fill = brand ? tone(spec.tone, dark) : dark ? "#fff" : "#000"; const drawPath = (path: AgentBrandSpec["paths"][number], index: number, paint?: string) => { diff --git a/webviews/src/icon-picker/styles.css b/webviews/src/icon-picker/styles.css index 667a94d5fa25..c5aa289daad9 100644 --- a/webviews/src/icon-picker/styles.css +++ b/webviews/src/icon-picker/styles.css @@ -309,21 +309,12 @@ padding: 0 14px; border-radius: calc(var(--icon-bar-height) / 2); border: 1px solid var(--page-separator); - background: color-mix(in srgb, var(--page-elevated) 90%, transparent); - -webkit-backdrop-filter: blur(20px) saturate(1.5); - backdrop-filter: blur(20px) saturate(1.5); + /* The solid popup surface: the grid's icons never show through the pill's labels. */ + background: var(--surface-popup); box-shadow: 0 4px 18px color-mix(in srgb, black 16%, transparent); pointer-events: auto; } -@media (prefers-reduced-transparency: reduce) { - .icon-bar-pill { - background: var(--page-elevated); - -webkit-backdrop-filter: none; - backdrop-filter: none; - } -} - .icon-bar-item { flex: 0 1 auto; min-width: 0; diff --git a/webviews/src/pages/shared/desktop.css b/webviews/src/pages/shared/desktop.css index f06c0dc2e42e..b05ed391c38e 100644 --- a/webviews/src/pages/shared/desktop.css +++ b/webviews/src/pages/shared/desktop.css @@ -56,25 +56,42 @@ select { cursor: pointer; } -/* The shared title tooltip replaces WebKit's native title bubble on every first-party page. */ +/* One tooltip, the native one's (CmuxNextDesign/Tooltip/TooltipPanel.swift): Base UI's tooltip + (ui/Tooltip.tsx) and the title tooltip that replaces WebKit's bubble on every first-party page + (ui/titleTooltips.ts) both sit on the solid popup surface in the primary ink, with a separator + hairline, the caption font and a subtle shadow. A truncated row's title tooltip is as wide as + the row (tooltipWidth sets its min and max width). */ +.ui-tooltip, .ui-title-tooltip { - position: fixed; - z-index: var(--layer-tooltip); + box-sizing: border-box; max-width: min(320px, calc(100vw - 12px)); padding: 4px 8px; - border: 1px solid color-mix(in srgb, var(--cmux-text, CanvasText) 18%, transparent); - border-radius: 6px; - background: color-mix(in srgb, var(--cmux-elevated-background, Canvas) 96%, var(--cmux-text, CanvasText)); - box-shadow: 0 4px 14px color-mix(in srgb, black 30%, transparent); + border: 1px solid var(--cmux-separator, color-mix(in srgb, var(--cmux-text, CanvasText) 12%, transparent)); + border-radius: 5px; + background: var(--surface-popup); + box-shadow: 0 2px 8px color-mix(in srgb, black 16%, transparent); color: var(--cmux-text, CanvasText); font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; font-size: var(--text-caption); line-height: var(--text-caption--line-height); - white-space: pre-line; overflow-wrap: anywhere; pointer-events: none; } +.ui-title-tooltip { + position: fixed; + z-index: var(--layer-tooltip); + white-space: pre-line; +} + +/* A shortcut hint after the text, in the secondary ink, at the tooltip's top right. */ +.ui-tooltip-hint { + float: inline-end; + margin-inline-start: 8px; + color: var(--cmux-text-secondary, color-mix(in srgb, var(--cmux-text, CanvasText) 64%, transparent)); + white-space: nowrap; +} + img, svg, a { @@ -239,6 +256,14 @@ input[type="submit"] { tooltips stay opaque. Reduce Transparency makes every translucent surface opaque. */ --surface-text-min: 92%; --surface-blur: blur(20px) saturate(1.4); + /* The one solid surface of every menu, popover, picker and tooltip, never translucent and never + blurred, so text under a popup never shows through (Leo, 2026-10-12): the host's popup fill + (WebTheme.swift, the same color as native overlays' Palette.popupSurface), else the page + color lifted toward the text. */ + --surface-popup: var( + --cmux-popup-surface, + color-mix(in srgb, var(--cmux-text, CanvasText) 14%, var(--cmux-surface-solid, Canvas)) + ); } @media (prefers-reduced-transparency: reduce) { diff --git a/webviews/src/pages/shared/pageBase.css b/webviews/src/pages/shared/pageBase.css index 104b0bc36fdc..bcd0584cb797 100644 --- a/webviews/src/pages/shared/pageBase.css +++ b/webviews/src/pages/shared/pageBase.css @@ -42,7 +42,7 @@ input { max-width: 280px; padding: 4px; border-radius: 8px; - background: var(--page-elevated); + background: var(--surface-popup); border: 1px solid var(--page-separator); box-shadow: 0 6px 24px rgb(0 0 0 / 0.18); outline: none; diff --git a/webviews/src/ui/README.md b/webviews/src/ui/README.md index 656984dc3749..7fc87c36f9de 100644 --- a/webviews/src/ui/README.md +++ b/webviews/src/ui/README.md @@ -96,7 +96,9 @@ 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 and tooltips are opaque. A translucent surface that carries text keeps at least +- Menus, popovers, pickers and tooltips are the solid 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 `--surface-text-min` of its fill: `color-mix(in srgb, 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 diff --git a/webviews/src/ui/Tooltip.tsx b/webviews/src/ui/Tooltip.tsx index 8d978703f6a1..862a22374337 100644 --- a/webviews/src/ui/Tooltip.tsx +++ b/webviews/src/ui/Tooltip.tsx @@ -1,6 +1,7 @@ // Tooltips over Base UI Tooltip. A tooltip only repeats a control's accessible name (rule 4): the // control carries `aria-label`, the tooltip is the sighted hint. It opens on hover and on keyboard -// focus, and Escape closes it. +// focus, and Escape closes it. It looks like the native tooltip and the title tooltip +// (pages/shared/desktop.css): the solid popup surface, primary ink, an optional shortcut hint. import type { ReactElement, ReactNode } from "react"; import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip"; import { usePortalContainer } from "./UiProvider"; @@ -21,16 +22,21 @@ export interface TooltipProps { /** The control it describes (its `render` target). */ children: ReactElement; side?: "top" | "bottom"; + /** A keyboard shortcut shown after the label in the secondary ink (`⌘T`), as on native tooltips. */ + shortcut?: string; } -export function Tooltip({ label, children, side = "bottom" }: TooltipProps) { +export function Tooltip({ label, children, side = "bottom", shortcut }: TooltipProps) { const container = usePortalContainer(); return ( - {label} + + {shortcut && {shortcut}} + {label} + diff --git a/webviews/src/ui/popupSurface.css b/webviews/src/ui/popupSurface.css index c2d7cfeb189f..33c1e1ddbde0 100644 --- a/webviews/src/ui/popupSurface.css +++ b/webviews/src/ui/popupSurface.css @@ -5,11 +5,6 @@ :root { --ui-popup-radius: 8px; --ui-popup-padding: 4px; - /* A popup is a glass surface above the page, rather than a second opaque panel. Hosts may - provide --ui-popup-bg, but the shared material keeps enough of it transparent for the page - to read through while the border and shadow preserve the edge at either theme. */ - --ui-popup-glass-alpha: var(--surface-text-min, 92%); - --ui-popup-blur: var(--surface-blur, blur(20px) saturate(1.4)); --ui-popup-shadow: 0 8px 24px color-mix(in srgb, black 18%, transparent), 0 1px 3px color-mix(in srgb, black 14%, transparent), @@ -28,18 +23,18 @@ } } -/* Every popup is a legible glass surface on the dropdown layer by default (webviews/src/ui/README.md, - "Layers and transparency"). Zero-specificity defaults: a feature's own surface class still wins. A - host maps --ui-popup-bg/--ui-popup-fg to its theme (the agent pane: acpmux/styles.css), so a popup - that names no surface (a nested submenu, a new menu) still gets the same material. */ +/* Every popup is the solid popup surface (--surface-popup, pages/shared/desktop.css) on the + dropdown layer by default (webviews/src/ui/README.md, "Layers and transparency"): opaque and + unblurred, so the page under it never shows through. Zero-specificity defaults: a feature's + own surface class still wins, and draws on the same token. A host maps --ui-popup-fg to its + ink (the agent pane: acpmux/styles.css), so a popup that names no surface (a nested submenu, + a new menu) still gets the same look. */ :where(.ui-positioner) { z-index: var(--layer-dropdown); } :where(.ui-popup) { - background-color: color-mix(in srgb, var(--ui-popup-bg, var(--ui-surface, Canvas)) var(--ui-popup-glass-alpha), transparent); + background-color: var(--surface-popup); color: var(--ui-popup-fg, var(--ui-fg, CanvasText)); - -webkit-backdrop-filter: var(--ui-popup-blur); - backdrop-filter: var(--ui-popup-blur); } /* The shipped agent pane loads this shared surface sheet without ui.css. Keep modal layers here @@ -61,20 +56,6 @@ outline: none; } -@media (prefers-reduced-transparency: reduce) { - :where(.ui-popup) { - background-color: var(--ui-popup-bg, var(--ui-surface, Canvas)); - -webkit-backdrop-filter: none; - backdrop-filter: none; - } -} - -:root[data-reduce-transparency] :where(.ui-popup) { - background-color: var(--ui-popup-bg, var(--ui-surface, Canvas)); - -webkit-backdrop-filter: none; - backdrop-filter: none; -} - /* Menu sections and separators look the same in every host (dogfood 2026-10-09: the agent pane's reasoning menu drew "Reasoning" and "Service Tier" as large unstyled titles at another indent, with no line between the sections, because the pane does not load ui.css). Zero-specificity defaults: a quiet caption aligned with the rows' text, and a diff --git a/webviews/src/ui/titleTooltips.test.ts b/webviews/src/ui/titleTooltips.test.ts index b628b170a166..3c9f4d156209 100644 --- a/webviews/src/ui/titleTooltips.test.ts +++ b/webviews/src/ui/titleTooltips.test.ts @@ -41,7 +41,9 @@ function page(body: string) { } test("hovering a truncated row shows its full text as wide as the row", async () => { - const doc = page(`
A long…
`); + const doc = page( + `
A long…
`, + ); const row = doc.getElementById("row")!; Object.defineProperty(row, "scrollWidth", { value: 900 }); Object.defineProperty(row, "clientWidth", { value: 600 }); diff --git a/webviews/src/ui/titleTooltips.ts b/webviews/src/ui/titleTooltips.ts index 02ab0101ff24..50b8a8eac52e 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,22 @@ 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; any other tooltip wraps at 320 px. Both stay +/// inside the window's edges. +export function tooltipWidth({ width, truncated, view }: { width: number; truncated: boolean; view: 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); + return { min: Math.min(line, room), max: Math.min(Math.max(line, TRUNCATED_MIN_MAX), room) }; +} + /// 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 +78,27 @@ 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 width = tooltipWidth({ + width: anchor.width, + truncated: target.scrollWidth > target.clientWidth, + view: doc.documentElement.clientWidth, + }); + tip.style.minWidth = `${width.min}px`; + tip.style.maxWidth = `${width.max}px`; tip.hidden = false; - const position = tooltipPosition(target.getBoundingClientRect(), tip.getBoundingClientRect(), { + 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 b45cb9203587..2e0801c76b97 100644 --- a/webviews/src/ui/ui.css +++ b/webviews/src/ui/ui.css @@ -48,7 +48,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); @@ -224,18 +224,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, From 11aa6b0f0c5786ba70776cc033cefd8969ef13f8 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 12 Oct 2026 00:38:21 -0700 Subject: [PATCH 3/4] test: review gaps in the solid popup surface Failing tests first, for the review of #19770: - a title tooltip is truncated when any descendant clips, and a short title's tooltip is only as wide as its text; - markdown link cards, the editor's widgets and the home-channels toolbar and switcher are the popup surface; - a profile's chip falls back to a known family mark, a switch draws the target's mark, and an unknown row mark keeps its slot; - header buttons pass their keycap as the tooltip's shortcut hint, not in the title; - dev previews load the popup surface token; - light themes lift popups toward white, and secondary text keeps 4.5:1 on hovered rows; - the Share dialog, the refusal pill and the omnibar suggestion card are the popup surface. Co-Authored-By: Claude Opus 5.5 --- .../PopupSurfaceCallersTests.swift | 28 +++++++++++++++ .../PopupSurfaceTests.swift | 36 ++++++++++++++++++- webviews/src/ui/titleTooltips.test.ts | 33 +++++++++++++++++ webviews/test/chat-header-tooltips.test.tsx | 14 ++++---- webviews/test/model-picker-mark.test.tsx | 26 +++++++++++++- webviews/test/popup-surface-dev.test.ts | 18 ++++++++++ webviews/test/popup-surface.test.ts | 32 +++++++++++++++-- 7 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PopupSurfaceCallersTests.swift create mode 100644 webviews/test/popup-surface-dev.test.ts diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PopupSurfaceCallersTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PopupSurfaceCallersTests.swift new file mode 100644 index 000000000000..92f659c4985d --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PopupSurfaceCallersTests.swift @@ -0,0 +1,28 @@ +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, the refusal pill and the +/// omnibar's suggestion card, which joins the field's editing card. +@MainActor @Suite(.serialized) struct PopupSurfaceCallersTests { + @Test func shareAndRefusalOverlaysAreThePopupSurface() { + #expect(ShareCmuxView(writeToPasteboard: { _ in }).surface.material == .opaque) + let hud = RefusalHUDView(frame: .zero) + let surface = hud.subviews.compactMap { $0 as? OverlaySurfaceView }.first + #expect(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/CmuxNextDesignTests/PopupSurfaceTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/PopupSurfaceTests.swift index 0e040bf2a18c..96ed53915988 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/PopupSurfaceTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/PopupSurfaceTests.swift @@ -8,7 +8,8 @@ import Testing /// 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: its elevated background is translucent, the popup surface must not be. + /// 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) @@ -45,6 +46,39 @@ import Testing #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 diff --git a/webviews/src/ui/titleTooltips.test.ts b/webviews/src/ui/titleTooltips.test.ts index 3c9f4d156209..6c2174826782 100644 --- a/webviews/src/ui/titleTooltips.test.ts +++ b/webviews/src/ui/titleTooltips.test.ts @@ -56,6 +56,39 @@ test("hovering a truncated row shows its full text as wide as the row", async () expect(tip.style.maxWidth).toBe("600px"); }); +test("a short title's tooltip for truncated text is only as wide as its text", () => { + // The line is 600 px but the full text measures 250 px: no wide empty box. + expect(tooltipWidth({ width: 600, truncated: true, view: 1000, text: 250 })).toEqual({ min: 250, max: 600 }); + // Text longer than the line: at least the line, as before. + expect(tooltipWidth({ width: 600, truncated: true, view: 1000, text: 2000 })).toEqual({ min: 600, max: 600 }); +}); + +test("a row whose child clips its text gets the truncated tooltip, sized to the text", async () => { + // BranchBasePicker: the title sits on the row, the primary label inside it clips. + const doc = page( + `
feature/a-bra…2d
`, + ); + const row = doc.getElementById("row")!; + Object.defineProperty(row, "scrollWidth", { value: 600 }); + Object.defineProperty(row, "clientWidth", { value: 600 }); + const primary = doc.getElementById("primary")!; + Object.defineProperty(primary, "scrollWidth", { value: 900 }); + Object.defineProperty(primary, "clientWidth", { value: 400 }); + row.getBoundingClientRect = () => ({ left: 10, top: 10, width: 600, height: 28 }) as DOMRect; + // The tooltip's own text, laid out on one line, measures 330 px. + const proto = dom!.window.HTMLElement.prototype; + const original = proto.getBoundingClientRect; + proto.getBoundingClientRect = function (this: HTMLElement) { + if (this.classList.contains("ui-title-tooltip")) return { left: 0, top: 0, width: 330, height: 16 } as DOMRect; + return original.call(this); + }; + await hover(row); + const tip = doc.querySelector(".ui-title-tooltip")!; + expect(tip.hidden).toBe(false); + expect(tip.style.minWidth).toBe("330px"); + expect(tip.style.maxWidth).toBe("600px"); +}); + test("an untruncated control's tooltip keeps the standard width and shows its shortcut hint", async () => { const doc = page(``); const button = doc.getElementById("new")!; 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 index 637f0c07b875..5b55d8ab02c7 100644 --- a/webviews/test/model-picker-mark.test.tsx +++ b/webviews/test/model-picker-mark.test.tsx @@ -7,11 +7,13 @@ import { renderToStaticMarkup } from "react-dom/server"; import { AgentMark } from "../src/agent-session/shared/AgentMark"; import { ModelPicker } from "../src/agent-session/acpmux/ModelPicker"; -const chip = (catalog: Parameters[0]["catalog"], harness: string, label: string) => +type Props = Parameters[0]; +const chip = (catalog: Props["catalog"], harness: string, label: string, switching?: Props["switching"]) => renderToStaticMarkup( { + 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 index e1dfee73d9c4..1adf5bcf6a2b 100644 --- a/webviews/test/popup-surface.test.ts +++ b/webviews/test/popup-surface.test.ts @@ -61,11 +61,37 @@ await requireBrowserLane("popup-surface.test.ts", async () => { // First-party pages: desktop.css, then ui.css and its popup surface. const pages = { - css: read("pages/shared/desktop.css", "ui/popupSurface.css", "ui/ui.css", "icon-picker/styles.css"), + 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
`, - surfaces: ["menu", "ui-tooltip", "title-tooltip", "icon-bar-pill"], + +
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 = { From 76afbfef590f04240f215ac1659ea8629e227c9e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 12 Oct 2026 00:43:39 -0700 Subject: [PATCH 4/4] fix: close the review gaps in the solid popup surface - Title tooltips count a row as truncated when any descendant clips (BranchBasePicker rows), and a short title's tooltip is only as wide as its text. - Markdown link cards, the editor's widgets, home-channels' message toolbar and switcher draw on --surface-popup. - Light themes lift popups toward white; dark themes toward the text, stopping where secondary text would drop below 4.5:1 on a hovered row. - Native: the Share dialog, the refusal pill and the update sheet are the popup surface; the omnibar's suggestion card and editing fill use Palette.popupSurface; the bookmark bubble and the update card's highlights popover fill their content with it. - Model marks: the first known of icon, family, id; a switch draws its target's mark; rows keep an empty slot for an unknown mark. - Header, dictation and New Tab tool tooltips pass their keycaps as the shortcut hint. - The agent pane's dev previews load desktop.css for the token. Co-Authored-By: Claude Opus 5.5 --- .../CmuxNextApp/Refusals/RefusalHUD.swift | 4 +- .../UI/BookmarkEditBubble.swift | 1 + .../CmuxNextBrowser/UI/OmnibarStyle.swift | 5 ++- .../CmuxNextDesign/OverlayMaterial.swift | 20 +++++++-- .../CmuxNextDesign/OverlaySurface.swift | 9 ++++ .../Views/SidebarUpdateCardView+Popover.swift | 1 + .../CmuxNextUpdater/Share/ShareCmuxView.swift | 2 +- .../UpdateSheetController.swift | 11 ++++- .../src/agent-session/acpmux-preview/main.tsx | 2 + .../agent-session/acpmux/DictationButton.tsx | 4 +- .../src/agent-session/acpmux/ModelPicker.tsx | 14 ++++-- webviews/src/agent-session/acpmux/dev.tsx | 2 + .../acpmux/header/ChatHeaderTools.tsx | 8 ++-- .../agent-session/acpmux/modelPickerLayout.ts | 2 +- .../acpmux/newtab/NewTabScreen.tsx | 5 ++- .../src/agent-session/acpmux/shortcuts.ts | 7 +++ .../src/agent-session/shared/AgentMark.tsx | 18 +++++++- webviews/src/pages/editor/settings.ts | 2 +- webviews/src/pages/editor/styles.css | 2 +- webviews/src/pages/home-channels/styles.css | 4 +- webviews/src/pages/markdown/settings.ts | 2 +- webviews/src/pages/markdown/styles.css | 2 +- .../settings/components/AgentHarnesses.tsx | 4 +- webviews/src/ui/titleTooltips.ts | 43 ++++++++++++++++--- 24 files changed, 137 insertions(+), 37 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Refusals/RefusalHUD.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Refusals/RefusalHUD.swift index 229ddb144676..ccd6615fe412 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Refusals/RefusalHUD.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Refusals/RefusalHUD.swift @@ -42,9 +42,9 @@ final class RefusalHUD { } } -/// The glass pill and its label. +/// The pill (the solid popup surface) and its label. final class RefusalHUDView: NSView { - private let surface = OverlaySurfaceView() + private let surface = OverlaySurfaceView(material: .opaque) private let label = NSTextField(labelWithString: "") private(set) var isShowing = false var text: String { label.stringValue } 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/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 91399cd2a3c0..bdb8e2d227e8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlaySurface.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlaySurface.swift @@ -170,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/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/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.tsx b/webviews/src/agent-session/acpmux/ModelPicker.tsx index 2875328718e4..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, agentBrand } 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 @@ -555,7 +555,13 @@ export function ModelPicker(props: ModelPickerProps) { // 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; // A folder profile draws its own mark (its icon or family), as its rail tab does, not its id's. - const chipHarness = switchingTo?.harness ?? current?.mark ?? current?.id ?? harness; + // 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; @@ -803,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/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/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/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 125fb75afadd..1ca846c4904b 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({