From 86316da761b723634f87849026d79235db11f079 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 05:10:21 -0700 Subject: [PATCH 1/2] test(design): every bundled theme keeps its card glass translucent and its card text AA CardMaterial draws a card's fill on its glass at the least alpha below 1 that keeps primary and secondary text at 4.5:1 over black and white, and at exactly 1 when none does, which makes the glass solid. Check every bundled theme (Resources/ghostty/themes and Resources/cmux-themes) at the shipped lift and minimum opacity: the card stays below alpha 1, its primary and secondary text keep 4.5:1 on the card and on the pressed row (Grass exempted at its measured 4.41:1, where no single ink reads on both its background and its pressed rows), and only Grass and Hot Dog Stand derive other inks or card fills than the previous rule. Grass should take the light ink, Hot Dog Stand the dark ink with a white-lifted card. The fixture proves the checker reports an opaque card. Red on this commit: Grass and Hot Dog Stand fall back to an opaque card. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15bf1-fdc6-7623-8ee3-fa94b3900ccd&host=cosmic-gray --- .../CardNeverOpaqueTests.swift | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/CardNeverOpaqueTests.swift diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/CardNeverOpaqueTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/CardNeverOpaqueTests.swift new file mode 100644 index 000000000000..bc122a608e27 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/CardNeverOpaqueTests.swift @@ -0,0 +1,207 @@ +import CmuxTheme +@testable import CmuxNextDesign +import Foundation +import Testing + +/// Glass is never opaque, and card text keeps WCAG AA (Lawrence, 2026-10-20). Every floating +/// glass card (`Glass.makeOverlayPanel`) lays its ``CardMaterial`` fill on the glass at +/// `opacity`: the least alpha below 1 at which primary and secondary text keep 4.5:1 over black +/// and over white. When no alpha below 1 does, `opacity` falls back to exactly 1 and the glass +/// turns solid. This guards every theme cmux ships (Ghostty's bundled themes and cmux's own), +/// each in the mode (light or dark) its colors give it, against that fallback. Reduce +/// Transparency (`OverlayMaterial.opaque`) is allowed to be solid and is not checked here. +@MainActor @Suite struct CardNeverOpaqueTests { + static let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + + /// The theme folders the app bundles (`scripts/cmux-next/bundle-ghostty-resources.sh`). + static let themeFolders = ["Resources/ghostty/themes", "Resources/cmux-themes"] + + static let lift = ChromeTunables.opaqueOverlayLift.defaultValue + + struct BundledTheme { + let name: String + let label: String + let input: ThemeInput + let tokens: ThemeTokens + let card: CardMaterial + } + + /// Grass (#13773D on #FFF0A5) is the one theme whose pressed and selected rows miss 4.5:1. + /// No single ink reads on both its background and its pressed rows: white gets 5.62:1 on the + /// background (and the card) but 4.41:1 on a pressed row; black gets 4.76:1 on a pressed row + /// but 3.73:1 on the background, so the card would have to turn opaque. The ink choice takes + /// white, the higher least contrast; the pressed row keeps 4.41:1. + static let pressedRowExemptions: [String: Double] = ["Grass": 4.41] + + /// Every bundled theme with its tokens and card material at the shipped defaults. + static func bundledThemes() throws -> [BundledTheme] { + var themes: [BundledTheme] = [] + for folder in themeFolders { + let directory = repoRoot.appending(path: folder) + let names = try FileManager.default.contentsOfDirectory(atPath: directory.path) + .filter { !$0.hasPrefix(".") }.sorted() + for name in names { + let text = try String(contentsOf: directory.appending(path: name), encoding: .utf8) + guard let colors = ThemeFileColors(name: name, themeFile: text) else { continue } + let defaults = ThemeInput.ghosttyDefault.palette + let palette = colors.palette.enumerated().map { $0.element ?? defaults[$0.offset] } + var input = ThemeInput(background: colors.background, foreground: colors.foreground, palette: palette, + selectionBackground: colors.selectionBackground, + selectionForeground: colors.selectionForeground, cursor: colors.cursorColor) + input.accentColor = colors.accent + let tokens = ThemeTokens.derive(from: input) + let card = CardMaterial(tokens, lift: lift, minimumOpacity: CardMaterial.defaultMinimumOpacity) + themes.append(BundledTheme(name: name, label: "\(folder)/\(name) [\(tokens.isDark ? "dark" : "light")]", + input: input, tokens: tokens, card: card)) + } + } + return themes + } + + /// The labels of the cards that draw their fill at alpha 1: an opaque card on glass. + static func opaqueCards(_ cards: [(label: String, card: CardMaterial)]) -> [String] { + cards.filter { $0.card.opacity >= 1.0 }.map { "\($0.label) (fill \($0.card.fill), opacity \($0.card.opacity))" } + } + + /// The strongest row fill text sits on (pressed or selected) over the background, as `derive` finds it. + static func worstRowSurface(_ theme: BundledTheme) -> ThemeRGB { + let bg = theme.input.background + let fg = theme.input.foreground + let rows = [theme.tokens.pressedFill, theme.tokens.selectionFill].map { $0.composited(over: bg) } + return rows.min { fg.contrast(with: $0) < fg.contrast(with: $1) } ?? bg + } + + static func close(_ a: ThemeRGB, _ b: ThemeRGB) -> Bool { + abs(a.red - b.red) < 1e-9 && abs(a.green - b.green) < 1e-9 && abs(a.blue - b.blue) < 1e-9 + } + + /// The inks and card fill the rule before this change gave: the foreground walked toward + /// white below 0.18 luminance of the row surface (black above) until it read on that row + /// surface only, and a dark card fill always lifted toward the primary ink. + static func previousRule(_ theme: BundledTheme) -> (primary: ThemeRGB, secondary: ThemeRGB, fill: ThemeRGB) { + let bg = theme.input.background + let fg = theme.input.foreground + let row = worstRowSurface(theme) + let minimum = ThemeTokens.minimumTextContrast + var primary = fg + if fg.contrast(with: row) < minimum { + let pole: ThemeRGB = row.relativeLuminance < 0.18 ? .white : .black + var step = 0.0 + while step < 1, primary.contrast(with: row) < minimum { + step += 0.02 + primary = fg.mixed(toward: pole, step) + } + } + var secondary = primary + var fraction = 0.38 + while fraction > 0 { + let candidate = primary.mixed(toward: bg, fraction) + if candidate.contrast(with: row) >= minimum { + secondary = candidate + break + } + fraction -= 0.01 + } + let page = theme.tokens.windowBackground.withAlpha(1) + guard theme.tokens.isDark else { return (primary, secondary, page.mixed(toward: .white, lift)) } + var step = (lift * 100).rounded() / 100 + while step > 0 { + let fill = page.mixed(toward: primary.withAlpha(1), step) + if secondary.withAlpha(1).contrast(with: theme.tokens.hoverFill.composited(over: fill)) >= minimum { + return (primary, secondary, fill) + } + step -= 0.01 + } + return (primary, secondary, page) + } + + @Test func everyBundledThemeKeepsItsCardGlassTranslucentAndItsCardTextAA() throws { + let themes = try Self.bundledThemes() + // Hundreds of Ghostty themes plus cmux's own: an empty read would check nothing. + #expect(themes.count > 500, "read only \(themes.count) bundled themes") + let opaque = Self.opaqueCards(themes.map { (label: $0.label, card: $0.card) }) + #expect(opaque.isEmpty, "card glass is opaque (no alpha below 1 keeps AA) in \(opaque.count) themes:\n\(opaque.joined(separator: "\n"))") + var cardInk: [String] = [] + var rowInk: [String] = [] + for theme in themes { + let row = Self.worstRowSurface(theme) + for (role, ink) in [("primary", theme.tokens.textPrimary), ("secondary", theme.tokens.textSecondary)] { + let onCard = TooltipSurface.worstContrast(ink: ink.withAlpha(1), tint: theme.card.tint) + if onCard < ThemeTokens.minimumTextContrast { cardInk.append("\(theme.label) \(role) \(onCard)") } + let onRow = ink.withAlpha(1).contrast(with: row) + if let floor = Self.pressedRowExemptions[theme.name] { + #expect(abs(onRow - floor) < 0.01, "\(theme.label) \(role): pressed row \(onRow), exempted at \(floor)") + } else if onRow < ThemeTokens.minimumTextContrast { + rowInk.append("\(theme.label) \(role) \(onRow)") + } + } + } + #expect(cardInk.isEmpty, "card text under 4.5:1 in \(cardInk.count) cases:\n\(cardInk.joined(separator: "\n"))") + #expect(rowInk.isEmpty, "pressed-row text under 4.5:1 in \(rowInk.count) cases:\n\(rowInk.joined(separator: "\n"))") + } + + /// The ink choice changes exactly Grass (ink, and so its lifted card fill) and Hot Dog Stand + /// (ink and the white-lifted card fill); every other bundled theme keeps what it had. + @Test func onlyGrassAndHotDogStandChange() throws { + var changed: [String: [String]] = [:] + for theme in try Self.bundledThemes() { + let before = Self.previousRule(theme) + var parts: [String] = [] + if !Self.close(before.primary, theme.tokens.textPrimary) || !Self.close(before.secondary, theme.tokens.textSecondary) { + parts.append("ink") + } + if !Self.close(before.fill, theme.tokens.opaqueOverlayFill(lift: Self.lift)) { parts.append("fill") } + if !parts.isEmpty { changed[theme.name] = parts } + } + #expect(changed == ["Grass": ["ink", "fill"], "Hot Dog Stand": ["ink", "fill"]], "changed: \(changed)") + } + + /// The ink choice takes whichever of the light and the dark ink keeps the higher least + /// contrast over the background and the pressed row: light on Grass, dark on Hot Dog Stand. + @Test func theInkChoiceTakesTheHigherLeastContrast() { + let grassBackground = ThemeRGB(hex: 0x13773D) + let grass = ThemeTokens.derive(from: ThemeInput(background: grassBackground, foreground: ThemeRGB(hex: 0xFFF0A5))) + #expect(grass.textPrimary.relativeLuminance > grassBackground.relativeLuminance, "Grass: \(grass.textPrimary) is not the light ink") + let grassRatio = grass.textPrimary.contrast(with: grassBackground) + #expect(abs(grassRatio - 5.62) < 0.01, "Grass: primary \(grassRatio) on the background") + + let hotDogBackground = ThemeRGB(hex: 0xEA3323) + let hotDog = ThemeTokens.derive(from: ThemeInput(background: hotDogBackground, foreground: ThemeRGB(hex: 0xFFFFFF))) + #expect(hotDog.textPrimary.relativeLuminance < hotDogBackground.relativeLuminance, "Hot Dog Stand: \(hotDog.textPrimary) is not the dark ink") + for (role, ink) in [("primary", hotDog.textPrimary), ("secondary", hotDog.textSecondary)] { + let ratio = ink.contrast(with: hotDogBackground) + #expect(ratio >= ThemeTokens.minimumTextContrast, "Hot Dog Stand: \(role) \(ratio) on the background") + } + // White, the theme's own foreground, only reaches 4.20:1 on the background. + #expect(abs(ThemeRGB.white.contrast(with: hotDogBackground) - 4.20) < 0.01) + } + + /// A dark theme whose chosen ink is darker than its page lifts the card fill toward white, + /// as a light theme does: lifting toward the dark ink would darken the card under it. + @Test func aDarkInkOnADarkThemeLiftsTheCardTowardWhite() { + let hotDog = ThemeTokens.derive(from: ThemeInput(background: ThemeRGB(hex: 0xEA3323), foreground: ThemeRGB(hex: 0xFFFFFF))) + #expect(hotDog.isDark) + let page = hotDog.windowBackground.withAlpha(1) + #expect(Self.close(hotDog.opaqueOverlayFill(lift: Self.lift), page.mixed(toward: .white, Self.lift))) + let card = CardMaterial(hotDog, lift: Self.lift) + #expect(card.opacity < 1.0) + } + + /// The checker reports a card whose text cannot reach AA below alpha 1, so the theme sweep + /// cannot pass by checking nothing. + @Test func theCheckerReportsACardThatOnlyAnOpaqueFillKeepsLegible() { + // Grey ink on a grey fill: no alpha keeps 4.5:1, so the fill falls back to 1. + let stuck = CardMaterial(fill: ThemeRGB(hex: 0x808080), inks: [ThemeRGB(hex: 0x8C8C8C)], + minimumOpacity: CardMaterial.defaultMinimumOpacity, isDark: false) + // Near-black ink on white keeps AA at the least opacity. + let fine = CardMaterial(fill: ThemeRGB(hex: 0xFFFFFF), inks: [ThemeRGB(hex: 0x111111)], + minimumOpacity: CardMaterial.defaultMinimumOpacity, isDark: false) + #expect(stuck.opacity == 1.0) + #expect(fine.opacity < 1.0) + let opaque = Self.opaqueCards([(label: "stuck", card: stuck), (label: "fine", card: fine)]) + #expect(opaque.count == 1) + #expect(opaque.first?.hasPrefix("stuck") == true) + } +} From 50ea1b1f6c6dcaf71daf5cc2180c90d5fbd1551b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 06:33:26 -0700 Subject: [PATCH 2/2] fix(theme): text takes the ink with the higher least contrast; a dark ink lifts the card toward white ThemeTokens.derive walked an unreadable foreground toward white or black by a 0.18 luminance cutoff of the pressed row and checked it on that row only. On Grass (#13773D) that gave near-black text at 3.73:1 on the background, and on Hot Dog Stand (#EA3323) a dark grey that read on the lighter pressed row but not on the red, so both cards fell back to an opaque fill. Walk the foreground toward both poles until it reads on the pressed row and on the background, and keep the candidate whose primary and secondary keep the higher least contrast (capped at 4.5:1, so a reading ink toward the pole that reads better on the row stays first). Grass now takes white, Hot Dog Stand dark text. A dark theme's card fill lifted toward its text; with Hot Dog Stand's dark text that darkened the card under it. opaqueOverlayFill lifts toward white, as on a light theme, when the text is darker than the page. Only Grass and Hot Dog Stand change among the 622 bundled themes. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15bf1-fdc6-7623-8ee3-fa94b3900ccd&host=cosmic-gray --- .../Sources/CmuxTheme/ThemeTokens.swift | 43 ++++++++++++++++--- .../CmuxNextDesign/OverlayMaterial.swift | 4 +- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/Packages/Shared/CmuxTheme/Sources/CmuxTheme/ThemeTokens.swift b/Packages/Shared/CmuxTheme/Sources/CmuxTheme/ThemeTokens.swift index 6a59d97f8861..6de39a375ea5 100644 --- a/Packages/Shared/CmuxTheme/Sources/CmuxTheme/ThemeTokens.swift +++ b/Packages/Shared/CmuxTheme/Sources/CmuxTheme/ThemeTokens.swift @@ -155,14 +155,10 @@ public struct ThemeTokens: Hashable, Sendable { // Text must hold its contrast on the strongest fill it can sit on. let worstSurface = [pressed, selection].map { $0.composited(over: bg) } .min { fg.contrast(with: $0) < fg.contrast(with: $1) }! - let primary = readable(fg, over: worstSurface, minimum: minimumTextContrast) // In a see-through window the quieter inks also hold over the tint on the worst desktop for // the theme (sidebar sweep 2026-10-21); the tint stays the opacity asked for (#22566). - let inkSurface = ([worstSurface] + seeThroughSurfaces(bg: bg, opacity: input.backgroundOpacity, isDark: isDark, - fills: [pressed, selection])) - .min { primary.contrast(with: $0) < primary.contrast(with: $1) } ?? worstSurface - let secondary = muted(primary, toward: bg, upTo: 0.38, over: inkSurface, minimum: minimumTextContrast) - let tertiary = muted(primary, toward: bg, upTo: 0.55, over: inkSurface, minimum: minimumMarkContrast) + let seeThrough = seeThroughSurfaces(bg: bg, opacity: input.backgroundOpacity, isDark: isDark, fills: [pressed, selection]) + let (primary, secondary, tertiary) = inks(fg, rows: [worstSurface, bg], seeThrough: seeThrough, background: bg) // The config's selection fill, when text reads on it. Many themes select by inverse video // (the foreground as the fill, with a dark `selection-foreground`); chrome and pages draw @@ -241,6 +237,41 @@ public struct ThemeTokens: Hashable, Sendable { return candidate } + /// The primary, secondary and tertiary text for `foreground`, read on every surface in `rows` + /// (the strongest row fill and the bare background). A foreground that already reads keeps + /// its color. Otherwise it is walked toward white and toward black, and the candidate whose + /// primary and secondary keep the higher least contrast over `rows` wins (capped at 4.5:1, so + /// of two candidates that both read, the one toward the pole that reads better on the row + /// fill wins). Hot Dog Stand's white on red reaches only 4.20:1, so it takes the dark ink; + /// Grass's dark ink drops to 3.73:1 on its background, so it takes white. + static func inks(_ foreground: ThemeRGB, rows: [ThemeRGB], seeThrough: [ThemeRGB], background bg: ThemeRGB) + -> (ThemeRGB, ThemeRGB, ThemeRGB) { + func least(_ ink: ThemeRGB) -> Double { rows.map { ink.contrast(with: $0) }.min() ?? 1 } + let poles = [ThemeRGB.white, .black].sorted { $0.contrast(with: rows[0]) > $1.contrast(with: rows[0]) } + let candidates = poles.map { pole -> (inks: (ThemeRGB, ThemeRGB, ThemeRGB), score: Double) in + let primary = readable(foreground, over: rows, toward: pole, minimum: minimumTextContrast) + let inkSurface = (rows + seeThrough).min { primary.contrast(with: $0) < primary.contrast(with: $1) } ?? bg + let secondary = muted(primary, toward: bg, upTo: 0.38, over: inkSurface, minimum: minimumTextContrast) + let tertiary = muted(primary, toward: bg, upTo: 0.55, over: inkSurface, minimum: minimumMarkContrast) + return ((primary, secondary, tertiary), min(least(primary), least(secondary), minimumTextContrast)) + } + var chosen = candidates[0] + for candidate in candidates.dropFirst() where candidate.score > chosen.score { chosen = candidate } + return chosen.inks + } + + /// `color` walked toward `pole` until it reaches `minimum` contrast over every surface (or `pole`). + static func readable(_ color: ThemeRGB, over surfaces: [ThemeRGB], toward pole: ThemeRGB, minimum: Double) -> ThemeRGB { + func reads(_ ink: ThemeRGB) -> Bool { surfaces.allSatisfy { ink.contrast(with: $0) >= minimum } } + var step = 0.0 + var candidate = color + while step < 1, !reads(candidate) { + step += 0.02 + candidate = color.mixed(toward: pole, step) + } + return candidate + } + /// What text sits on in a window of `opacity` below 1: the background at that opacity over /// the worst desktop for the theme (black behind a light theme, white behind a dark one), /// alone and under each of `fills`. Empty for an opaque window. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlayMaterial.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlayMaterial.swift index d3413a943292..a37a7e1a7f0c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlayMaterial.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/OverlayMaterial.swift @@ -109,8 +109,10 @@ extension ThemeTokens { /// on a hovered row. nonisolated public func opaqueOverlayFill(lift: Double) -> ThemeRGB { let page = windowBackground.withAlpha(1) - guard isDark else { return page.mixed(toward: .white, lift) } let text = textPrimary.withAlpha(1) + // A dark theme whose text is darker than its page (Hot Dog Stand's red takes dark text) + // lifts toward white too: lifting toward the text would darken the card under it. + guard isDark, text.relativeLuminance >= page.relativeLuminance else { return page.mixed(toward: .white, lift) } let secondary = textSecondary.withAlpha(1) var step = (lift * 100).rounded() / 100 while step > 0 {