Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import AppKit
import CmuxTheme
@testable import CmuxNextDesign
import Testing

/// The accent is never orange: with the macOS accent set to orange (System Settings > Appearance),
/// cmux's menus, selections and focus rings still draw the theme's own colors, which are neutral
/// grays by default. AppKit paints the system accent into an NSMenu's highlight, a table's
/// selection, a bezel and a focus ring unless the view draws its own; pane-toolbar found a menu
/// highlighted in it (2026-10-20). The web side is webviews/test/accent-glass-invariants.test.ts.
///
/// The suite sets the accent to orange for this process (`AppleAccentColor` 1 is orange) and also
/// compares against `NSColor.systemOrange`, so the check holds where AppKit keeps the host's accent.
@MainActor @Suite(.serialized) final class SystemAccentOrangeTests {
init() {
// The argument domain, as `-AppleAccentColor 1` on the command line: in memory only, so the
// test host's own defaults keep their accent.
var arguments = UserDefaults.standard.volatileDomain(forName: UserDefaults.argumentDomain)
arguments["AppleAccentColor"] = 1
arguments["AppleHighlightColor"] = "1.000000 0.874510 0.701961 Orange"
UserDefaults.standard.setVolatileDomain(arguments, forName: UserDefaults.argumentDomain)
_ = NSApplication.shared
}

/// Other suites in this process see the host's accent again.
deinit {
var arguments = UserDefaults.standard.volatileDomain(forName: UserDefaults.argumentDomain)
arguments["AppleAccentColor"] = nil
arguments["AppleHighlightColor"] = nil
UserDefaults.standard.setVolatileDomain(arguments, forName: UserDefaults.argumentDomain)
}

/// The app's default look: a dark theme with no accent of its own and `appearance.accent` neutral.
private static func neutralScope() -> ThemeScope {
let input = ThemeInput(background: ThemeRGB(hex: 0x1E1E1E), foreground: ThemeRGB(hex: 0xFFFFFF))
let scope = ThemeScope(level: .room)
scope.setOverride(ThemeSpec("Neutral"), input: input, animated: false)
return scope
}

/// The colors macOS draws for its accent, orange included whatever the host's accent is.
private static var systemAccents: [(String, NSColor)] {
[("controlAccentColor", .controlAccentColor), ("selectedContentBackgroundColor", .selectedContentBackgroundColor),
("keyboardFocusIndicatorColor", .keyboardFocusIndicatorColor), ("selectedTextBackgroundColor", .selectedTextBackgroundColor),
("systemOrange", .systemOrange)]
}

/// Case (a): the default theme shows no orange anywhere.
@Test func theDefaultThemesAccentColorsAreNeitherOrangeNorTheSystemAccent() {
for (name, color) in Self.accentColors(in: Self.neutralScope()) { Self.expectNotAccent(color, name) }
}

/// Case (b): a theme on schemas/theme/orange-accent-allowlist.json (Clay Orange: the person
/// picked an orange theme) keeps its own orange accent, and still never the system accent.
@Test func anAllowlistedOrangeThemeKeepsItsAccentButNeverTheSystemAccent() throws {
let themes = try Self.orangeAllowlist()
for theme in themes {
let scope = try Self.scope(themeFile: theme)
for (name, color) in Self.accentColors(in: scope) {
Self.expectNotAccent(color, "\(theme) \(name)", orangeAllowed: true)
}
}
}

@Test func aThemedMenuHighlightIsNotTheSystemAccent() throws {
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Rename", action: nil, keyEquivalent: ""))
// Held here: the registry and a scope's parent hold scopes weakly.
let scope = Self.neutralScope()
menu.themeRows(in: scope)
let row = try #require(menu.items[0].view as? ThemedMenuRowView, "the row is cmux-drawn, not AppKit's highlight")
row.highlightOverride = true
// Left of the title, where only the highlight paints.
Self.expectNotAccent(try #require(Self.sample(row, x: 8)), "highlighted menu row")
withExtendedLifetime(scope) {}
}

@Test func aSelectedTableRowIsNotTheSystemAccent() throws {
let row = ThemedTableRowView(frame: NSRect(x: 0, y: 0, width: 200, height: 24))
let scope = Self.neutralScope()
ThemeScopeRegistry.setScope(scope, of: row)
row.isSelected = true
#expect(row.isEmphasized == false, "an emphasized row turns its text white on the accent")
Self.expectNotAccent(try #require(Self.sample(row, x: 100)), "selected table row")
withExtendedLifetime(scope) {}
}

@Test func aBezelButtonIsNotTheSystemAccent() throws {
let button = ThemedBezelButton(frame: NSRect(x: 0, y: 0, width: 120, height: 28))
Self.expectNotAccent(try #require(button.bezelColor), "bezel button")
}

/// AppKit's focus ring is the system accent; cmux's fields draw their own.
@Test func textFieldsDrawNoSystemFocusRing() {
#expect(ThemedTextField(frame: .zero).focusRingType == .none)
#expect(ThemedSecureTextField(frame: .zero).focusRingType == .none)
}

// MARK: Helpers

private static let repoRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()

/// Every theme the allowlist names (one entry may name a dark and light pair).
private static func orangeAllowlist() throws -> [String] {
struct Allowlist: Decodable { struct Entry: Decodable { let themes: [String] }; let allowed: [Entry] }
let data = try Data(contentsOf: repoRoot.appending(path: "schemas/theme/orange-accent-allowlist.json"))
return try JSONDecoder().decode(Allowlist.self, from: data).allowed.flatMap(\.themes)
}

/// A shipped cmux theme as the app reads it: background, foreground and its `# cmux-accent`.
private static func scope(themeFile name: String) throws -> ThemeScope {
let text = try String(contentsOf: repoRoot.appending(path: "Resources/cmux-themes/\(name)"), encoding: .utf8)
func value(_ key: String) -> ThemeRGB? {
text.split(separator: "\n").lazy.compactMap { line -> ThemeRGB? in
let parts = line.split(separator: "=", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) }
return parts.count == 2 && parts[0] == key ? ThemeRGB(cssHex: parts[1]) : nil
}.first
}
var input = ThemeInput(background: try #require(value("background")), foreground: try #require(value("foreground")))
let accent: ThemeRGB = try #require(value("# cmux-accent"), "\(name) names its accent")
input.accentColor = accent
let scope = ThemeScope(level: .room)
scope.setOverride(ThemeSpec(name), input: input, animated: false)
return scope
}

/// The palette's accent colors in `scope`, resolved there (a dynamic color would resolve against
/// the app theme once the scope is popped).
private static func accentColors(in scope: ThemeScope) -> [(String, NSColor)] {
ThemeContext.push(scope)
defer { ThemeContext.pop() }
let colors = [("accent", Palette.accent), ("accentFill", Palette.accentFill), ("focusRing", Palette.focusRing),
("menuHighlight", Palette.menuHighlight), ("selectionFill", Palette.selectionFill)]
return colors.map { ($0.0, $0.1.usingColorSpace(.sRGB) ?? $0.1) }
}

private static func expectNotAccent(_ color: NSColor, _ name: String, orangeAllowed: Bool = false,
sourceLocation: SourceLocation = #_sourceLocation) {
guard let rgb = color.usingColorSpace(.sRGB), rgb.alphaComponent > 0.02 else { return }
if !orangeAllowed { #expect(!isOrange(rgb), "\(name) \(rgb) is orange", sourceLocation: sourceLocation) }
for (systemName, system) in systemAccents {
guard let system = system.usingColorSpace(.sRGB), chroma(system) >= 0.04 else { continue }
#expect(distance(rgb, system) > 0.08, "\(name) \(rgb) is \(systemName) \(system)", sourceLocation: sourceLocation)
}
}

/// OKLCH chroma and hue in degrees.
private static func lch(_ color: NSColor) -> (chroma: CGFloat, hue: CGFloat) {
func linear(_ c: CGFloat) -> CGFloat { c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) }
let (r, g, b) = (linear(color.redComponent), linear(color.greenComponent), linear(color.blueComponent))
let l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
let m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
let s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
let a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s
let bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
let hue = atan2(bb, a) * 180 / .pi
return (hypot(a, bb), hue < 0 ? hue + 360 : hue)
}

private static func chroma(_ color: NSColor) -> CGFloat { lch(color).chroma }

/// Past red (system red is about 29°) and short of yellow (about 95°); macOS orange is 64–68°,
/// Clay Orange's clay 38°.
private static func isOrange(_ color: NSColor) -> Bool {
let (chroma, hue) = lch(color)
return chroma >= 0.1 && hue >= 35 && hue <= 78
}

private static func sample(_ view: NSView, x: CGFloat) -> NSColor? {
guard let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return nil }
view.cacheDisplay(in: view.bounds, to: rep)
let scale = CGFloat(rep.pixelsWide) / view.bounds.width
return rep.colorAt(x: Int(x * scale), y: rep.pixelsHigh / 2)?.usingColorSpace(.sRGB)
}

private static func distance(_ lhs: NSColor, _ rhs: NSColor) -> CGFloat {
max(abs(lhs.redComponent - rhs.redComponent), abs(lhs.greenComponent - rhs.greenComponent),
abs(lhs.blueComponent - rhs.blueComponent))
}
}
10 changes: 10 additions & 0 deletions schemas/theme/orange-accent-allowlist.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"$comment": "Themes whose own accent may be orange: the person picked an orange theme, so the 'the accent is never orange' invariant (webviews/test/accent-glass-invariants.test.ts, CmuxNextDesignTests/SystemAccentOrangeTests.swift) skips its orange-hue rule for them. The system-accent and opaque-glass rules still hold. Remove an entry to make that theme fail the rule too.",
"allowed": [
{
"name": "Clay Orange",
"themes": ["Clay Orange", "Clay Orange Light"],
"why": "cmux's own warm theme with a clay accent (#d87657); allowed for now, coord-deputy 2026-10-21, pending Leo"
}
]
}
14 changes: 14 additions & 0 deletions webviews/src/agent-session/acpmux/composerStates.css
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@
--acpmux-menu-press: color-mix(in srgb, var(--agent-text) 20%, var(--acpmux-base));
}

/* A solid card blurs nothing it can show (glass is never opaque, accent-glass-invariants.test.ts):
the blur is only for the see-through glass below. */
:root:not([data-cmux-see-through]) .acpmux-composer-box,
:root[data-reduce-transparency] .acpmux-composer-box {
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
@media (prefers-reduced-transparency: reduce) {
.acpmux-composer-box {
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
}

/* Over art or a see-through window (data-cmux-see-through, desktop.ts) the composer is glass, like
iOS's (Leo 2026-10-18, replacing the solid surface of 10-14): its surface at 60% so the art shows
through the window's tint, and a top-lit rim. The prompt is primary text, which the window's tint
Expand Down
2 changes: 2 additions & 0 deletions webviews/src/gallery/theme/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export function themeInput(theme: GhosttyTheme, accent: AppAccent = "neutral"):
selectionForeground: color(theme.selectionForeground) ?? undefined,
backgroundOpacity: 1,
accent,
// The theme's own `# cmux-accent`, which wins over `accent` as ThemeBridge's does in the app.
accentColor: color(theme.accent),
};
}

Expand Down
8 changes: 8 additions & 0 deletions webviews/src/ui/popupSurface.css
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@
}
}

/* A popup takes focus when it opens so its keys work; it never draws the engine's `outline: auto`
ring, which WebKit paints in the macOS accent (orange when the person picks orange). Its rows show
the highlight instead. */
:where(.ui-popup):focus,
:where(.ui-popup):focus-visible {
outline: none;
}

/* A popup fades out when it closes (POLISH: no flash; a closed menu vanished in one frame). Base UI
keeps it mounted while it carries data-ending-style, until the fade ends. */
:where(.ui-popup) {
Expand Down
3 changes: 3 additions & 0 deletions webviews/test/accent-glass-baseline.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Known violations of the accent and glass invariants (test/accent-glass-invariants.test.ts), one
# finding key per line: `<entry>/<variant> <rule> <element>`, then `# <owner or PR fixing it>`.
# Only remove lines: a fix drops its line, and a new finding is fixed, not added here.
Loading