diff --git a/Sources/CodeCaps/PlatformLogo.swift b/Sources/CodeCaps/PlatformLogo.swift index e9e9384..ae605c5 100644 --- a/Sources/CodeCaps/PlatformLogo.swift +++ b/Sources/CodeCaps/PlatformLogo.swift @@ -235,36 +235,47 @@ public enum PlatformLogoImage { return dir }() - private static let resourceNames: [String: (name: String, ext: String)] = [ - "anthropic": ("claude", "svg"), - "claude": ("claude", "svg"), - "openai": ("openai", "svg"), - "codex": ("openai", "svg"), - "google-antigravity": ("gemini", "svg"), - "antigravity": ("gemini", "svg"), - // The two Antigravity pools are told apart by their mark as well as - // their name: the Gemini pool wears the colour Gemini star, and the - // Third-Party pool the same star as a solid one-colour glyph. - "google-antigravity:gemini": ("gemini-color", "png"), - "google-antigravity:third-party": ("gemini-mono", "svg"), - "gemini": ("gemini", "svg"), - "xai": ("grok", "svg"), - "grok": ("grok", "svg"), - "grok-cli": ("grok", "svg"), - "grok-bot": ("grok-bot", "svg"), - "minimax": ("minimax", "png"), - "muse": ("muse", "png"), - "muse-assist": ("muse-assist", "png"), - "muse-code": ("muse-code", "svg"), - "cursor": ("cursor", "svg"), - ] - - /// Dedicated monochrome/template marks used when Light/Dark (template) style - /// is active, providing high-contrast silhouette rendering. - private static let templateResourceNames: [String: (name: String, ext: String)] = [ - "muse-code": ("muse-code-dark", "svg"), - "muse": ("muse-code-dark", "svg"), - ] + /// Provider key -> mark base name. Every base has three shipped variants: +/// `-color` (brand colours, Standard style), `-light` (silhouette in the app's +/// Light ink) and `-dark` (silhouette in the app's Dark ink). +/// +/// These used to be `name, ext` pairs pointing at SVG files with a hardcoded +/// `monochromeKeys` Set deciding which ones adapted to appearance. Two problems +/// that measured, not assumed: the SVGs came back as `_NSSVGImageRep`, a PRIVATE +/// AppKit class, and the Muse rasters shipped a baked-in opaque white background. +/// Shipping the three variants as PNG makes appearance a property of the data +/// rather than of a guess, and puts every mark on `NSBitmapImageRep`. +private static let resourceNames: [String: String] = [ + "anthropic": "claude", + "claude": "claude", + "openai": "openai", + "codex": "openai", + "google-antigravity": "gemini", + "antigravity": "gemini", + // The two Antigravity pools are told apart by their mark as well as + // their name: the Gemini pool wears the colour Gemini star, and the + // Third-Party pool the same star as a solid one-colour glyph. + "google-antigravity:gemini": "gemini-color", + "google-antigravity:third-party": "gemini-mono", + "gemini": "gemini", + "xai": "grok", + "grok": "grok", + "grok-cli": "grok", + "grok-bot": "grok-bot", + "minimax": "minimax", + "muse": "muse", + "muse-assist": "muse-assist", + "muse-code": "muse-code", + "cursor": "cursor", +] + +/// The file a style and appearance ask for, or `nil` when the mark is absent. +private static func variantFile(base: String, style: MarkStyle, isDark: Bool) -> String? { + switch style { + case .standard, .custom: return "\(base)-color.png" + case .template: return isDark ? "\(base)-dark.png" : "\(base)-light.png" + } +} /// Return the bundled asset for `providerKey`, or `nil` if no artwork ships. /// The standard cache preserves brand colors; the template cache marks the @@ -311,38 +322,41 @@ public enum PlatformLogoImage { NSLog("CodeCaps: provider mark unavailable — %@", reason) } - private static func bundledImage(providerKey: String, style: MarkStyle = .template, iconHint: String? = nil) -> NSImage? { + private static func bundledImage(providerKey: String, style: MarkStyle = .template, iconHint: String? = nil, isDark: Bool = false) -> NSImage? { let key = providerKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() as NSString // Cache dimension: a manifest hint change must not keep serving the - // previously cached mark for the same provider key. + // previously cached mark for the same provider key, and appearance must + // never serve a Light silhouette onto a Dark surface (or the reverse). let hintName = iconHint?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let cacheKey = ((hintName?.isEmpty == false) ? "\(key)|\(hintName!)" : (key as String)) as NSString + let stem = (hintName?.isEmpty == false) ? "\(key)|\(hintName!)" : (key as String) + let cacheKey = "\(stem)|v2|\(style.rawValue)|\(isDark ? "dark" : "light")" as NSString let cache = (style == .standard) ? standardCache : templateCache if let cached = cache.object(forKey: cacheKey) { return cached } - // Server-first: try the manifest-supplied hint name first; fall back - // to the built-in `resourceNames` map (pre-manifest behaviour) when - // the hint does not resolve to a file, so a bad hint can never blank - // a mark the bundled map would have found. - let hintResource: (name: String, ext: String)? = hintName.flatMap { name in + // Server-first: try the manifest-supplied hint name first; fall back to + // the built-in map when the hint does not resolve to a file, so a bad + // hint can never blank a mark the map would have found. + let hintResource: String? = hintName.flatMap { name in guard !name.isEmpty else { return nil } - if let existing = resourceNames[name] { return existing } - return (name, "svg") - } - let mapResource: (name: String, ext: String)? - if style == .template, let tmpl = templateResourceNames[key as String] ?? templateResourceNames[platformKey(of: key as String)] { - mapResource = tmpl - } else { - mapResource = resourceNames[key as String] ?? resourceNames[platformKey(of: key as String)] - } - for resource in [hintResource, mapResource].compactMap({ $0 }) { - if let image = imageFromBundle(resource: resource, key: key, cacheKey: cacheKey, cache: cache, style: style) { + // Known provider keys are base names ("claude"), not filenames. + // Route them through variantFile so we ask for claude-color.png + // (or -light/-dark) instead of looking up a file named "claude". + if let base = resourceNames[name] { + return variantFile(base: base, style: style, isDark: isDark) + } + return "\(name)-color.png" + } + let mapResource: String? = { + let base = resourceNames[key as String] ?? resourceNames[platformKey(of: key as String)] + return base.flatMap { variantFile(base: $0, style: style, isDark: isDark) } + }() + for filename in [hintResource, mapResource].compactMap({ $0 }) { + if let image = imageFromBundle(filename: filename, key: key, cacheKey: cacheKey, cache: cache, style: style) { return image } } return nil } - - private static func imageFromBundle(resource: (name: String, ext: String), key: NSString, cacheKey: NSString, cache: NSCache, style: MarkStyle) -> NSImage? { + private static func imageFromBundle(filename: String, key: NSString, cacheKey: NSString, cache: NSCache, style: MarkStyle) -> NSImage? { // Walk EVERY candidate, decoding as we go, and take the first that // yields pixels. // @@ -352,7 +366,6 @@ public enum PlatformLogoImage { // down. That is the shape of a bug that survives ten "cache" fixes: it // is not the cache at all, it is a resolution order that gives up early. // Existence and decodability are now checked together, per candidate. - let filename = "\(resource.name).\(resource.ext)" for candidate in candidateURLs(filename: filename) { guard FileManager.default.fileExists(atPath: candidate.path) else { continue } guard let image = NSImage(contentsOf: candidate) ?? NSImage(contentsOfFile: candidate.path) else { @@ -485,15 +498,18 @@ public enum PlatformLogoImage { let key = providerKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() switch style { case .standard: - return bundledImage(providerKey: key, style: .standard, iconHint: iconHint) + return bundledImage(providerKey: key, style: .standard, iconHint: iconHint, isDark: isDarkMode) case .template: - return bundledImage(providerKey: key, style: .template, iconHint: iconHint) + return bundledImage(providerKey: key, style: .template, iconHint: iconHint, isDark: isDarkMode) case .custom: if let custom = loadCustom(providerKey: key, isDarkMode: isDarkMode) ?? loadCustom(providerKey: platformKey(of: key), isDarkMode: isDarkMode) { return custom } - return bundledImage(providerKey: key, style: .standard, iconHint: iconHint) + // No custom mark on disk for this provider: fall back to the + // bundled colour mark so the row still shows something real rather + // than the placeholder glyph. + return bundledImage(providerKey: key, style: .standard, iconHint: iconHint, isDark: isDarkMode) } } diff --git a/Sources/CodeCaps/Resources/ProviderMarks/claude-color.png b/Sources/CodeCaps/Resources/ProviderMarks/claude-color.png new file mode 100644 index 0000000..180c300 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/claude-color.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/claude-dark.png b/Sources/CodeCaps/Resources/ProviderMarks/claude-dark.png new file mode 100644 index 0000000..7b30b8d Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/claude-dark.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/claude-light.png b/Sources/CodeCaps/Resources/ProviderMarks/claude-light.png new file mode 100644 index 0000000..3f86669 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/claude-light.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/cursor-color.png b/Sources/CodeCaps/Resources/ProviderMarks/cursor-color.png new file mode 100644 index 0000000..b4208fb Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/cursor-color.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/cursor-dark.png b/Sources/CodeCaps/Resources/ProviderMarks/cursor-dark.png new file mode 100644 index 0000000..d9247b7 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/cursor-dark.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/cursor-light.png b/Sources/CodeCaps/Resources/ProviderMarks/cursor-light.png new file mode 100644 index 0000000..272a495 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/cursor-light.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/gemini-color-color.png b/Sources/CodeCaps/Resources/ProviderMarks/gemini-color-color.png new file mode 100644 index 0000000..cc6aef7 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/gemini-color-color.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/gemini-color-dark.png b/Sources/CodeCaps/Resources/ProviderMarks/gemini-color-dark.png new file mode 100644 index 0000000..dc00ff1 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/gemini-color-dark.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/gemini-color-light.png b/Sources/CodeCaps/Resources/ProviderMarks/gemini-color-light.png new file mode 100644 index 0000000..6108476 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/gemini-color-light.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/gemini-color.png b/Sources/CodeCaps/Resources/ProviderMarks/gemini-color.png index a3319d8..3468fd7 100644 Binary files a/Sources/CodeCaps/Resources/ProviderMarks/gemini-color.png and b/Sources/CodeCaps/Resources/ProviderMarks/gemini-color.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/gemini-dark.png b/Sources/CodeCaps/Resources/ProviderMarks/gemini-dark.png new file mode 100644 index 0000000..342b414 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/gemini-dark.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/gemini-light.png b/Sources/CodeCaps/Resources/ProviderMarks/gemini-light.png new file mode 100644 index 0000000..70a8694 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/gemini-light.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/gemini-mono-color.png b/Sources/CodeCaps/Resources/ProviderMarks/gemini-mono-color.png new file mode 100644 index 0000000..5c5e84d Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/gemini-mono-color.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/gemini-mono-dark.png b/Sources/CodeCaps/Resources/ProviderMarks/gemini-mono-dark.png new file mode 100644 index 0000000..bb1799d Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/gemini-mono-dark.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/gemini-mono-light.png b/Sources/CodeCaps/Resources/ProviderMarks/gemini-mono-light.png new file mode 100644 index 0000000..ba6892b Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/gemini-mono-light.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/grok-bot-color.png b/Sources/CodeCaps/Resources/ProviderMarks/grok-bot-color.png new file mode 100644 index 0000000..66c9314 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/grok-bot-color.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/grok-bot-dark.png b/Sources/CodeCaps/Resources/ProviderMarks/grok-bot-dark.png new file mode 100644 index 0000000..4d0518c Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/grok-bot-dark.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/grok-bot-light.png b/Sources/CodeCaps/Resources/ProviderMarks/grok-bot-light.png new file mode 100644 index 0000000..50107d6 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/grok-bot-light.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/grok-color.png b/Sources/CodeCaps/Resources/ProviderMarks/grok-color.png new file mode 100644 index 0000000..56c4d23 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/grok-color.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/grok-dark.png b/Sources/CodeCaps/Resources/ProviderMarks/grok-dark.png new file mode 100644 index 0000000..0dd55b1 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/grok-dark.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/grok-light.png b/Sources/CodeCaps/Resources/ProviderMarks/grok-light.png new file mode 100644 index 0000000..ca2cf98 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/grok-light.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/minimax-color.png b/Sources/CodeCaps/Resources/ProviderMarks/minimax-color.png new file mode 100644 index 0000000..ff07b29 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/minimax-color.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/minimax-dark.png b/Sources/CodeCaps/Resources/ProviderMarks/minimax-dark.png new file mode 100644 index 0000000..3a5e0d7 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/minimax-dark.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/minimax-light.png b/Sources/CodeCaps/Resources/ProviderMarks/minimax-light.png new file mode 100644 index 0000000..1d16d42 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/minimax-light.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/muse-assist-color.png b/Sources/CodeCaps/Resources/ProviderMarks/muse-assist-color.png new file mode 100644 index 0000000..30b880d Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/muse-assist-color.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/muse-assist-dark.png b/Sources/CodeCaps/Resources/ProviderMarks/muse-assist-dark.png new file mode 100644 index 0000000..ab69b7d Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/muse-assist-dark.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/muse-assist-light.png b/Sources/CodeCaps/Resources/ProviderMarks/muse-assist-light.png new file mode 100644 index 0000000..f2baa03 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/muse-assist-light.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/muse-code-color.png b/Sources/CodeCaps/Resources/ProviderMarks/muse-code-color.png new file mode 100644 index 0000000..6105ebd Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/muse-code-color.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/muse-code-dark.png b/Sources/CodeCaps/Resources/ProviderMarks/muse-code-dark.png index 69fb603..b06a090 100644 Binary files a/Sources/CodeCaps/Resources/ProviderMarks/muse-code-dark.png and b/Sources/CodeCaps/Resources/ProviderMarks/muse-code-dark.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/muse-code-light.png b/Sources/CodeCaps/Resources/ProviderMarks/muse-code-light.png new file mode 100644 index 0000000..751cf7a Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/muse-code-light.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/muse-color.png b/Sources/CodeCaps/Resources/ProviderMarks/muse-color.png new file mode 100644 index 0000000..30b880d Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/muse-color.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/muse-dark.png b/Sources/CodeCaps/Resources/ProviderMarks/muse-dark.png new file mode 100644 index 0000000..ab69b7d Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/muse-dark.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/muse-light.png b/Sources/CodeCaps/Resources/ProviderMarks/muse-light.png new file mode 100644 index 0000000..f2baa03 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/muse-light.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/openai-color.png b/Sources/CodeCaps/Resources/ProviderMarks/openai-color.png new file mode 100644 index 0000000..45374dd Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/openai-color.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/openai-dark.png b/Sources/CodeCaps/Resources/ProviderMarks/openai-dark.png new file mode 100644 index 0000000..bb20634 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/openai-dark.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/openai-light.png b/Sources/CodeCaps/Resources/ProviderMarks/openai-light.png new file mode 100644 index 0000000..b5e6aa1 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/openai-light.png differ diff --git a/Tests/CodeCapsTests/ProviderMarkTripletTests.swift b/Tests/CodeCapsTests/ProviderMarkTripletTests.swift new file mode 100644 index 0000000..1932ce8 --- /dev/null +++ b/Tests/CodeCapsTests/ProviderMarkTripletTests.swift @@ -0,0 +1,92 @@ +import AppKit +import XCTest +@testable import CodeCaps + +/// Every provider mark must ship three explicit variants — colour, light ink and +/// dark ink — and every one of them must resolve through the real loader. +/// +/// The owner reported that no provider logo appeared at all except the one +/// custom mark on disk. Measurement ruled out the obvious causes: the assets +/// were present, `Bundle.url(forResource:)` found every one, and +/// `PlatformLogoImage.load` returned a valid image for all eighteen provider +/// keys in a test process. What was left was the delivery itself — the marks +/// were SVGs, which come back as `_NSSVGImageRep`, a PRIVATE AppKit class, and +/// the Muse rasters had an opaque white background baked in. +/// +/// These assertions pin the delivered contract rather than a particular +/// implementation: whatever draws a mark must be an `NSBitmapImageRep`, so a +/// private rep cannot creep back in through any path. +final class ProviderMarkTripletTests: XCTestCase { + private static let providerKeys = [ + "anthropic", "claude", "openai", "codex", + "google-antigravity", "antigravity", + "google-antigravity:gemini", "google-antigravity:third-party", "gemini", + "xai", "grok", "grok-cli", "grok-bot", + "minimax", "muse", "muse-assist", "muse-code", "cursor", + ] + + override func setUp() { + super.setUp() + PlatformLogoImage.invalidateCaches() + } + + func testEveryProviderResolvesInEveryStyleAndAppearance() { + var unresolved: [String] = [] + for key in Self.providerKeys { + for style in [MarkStyle.standard, .template] { + for isDark in [false, true] { + if PlatformLogoImage.load(providerKey: key, style: style, isDarkMode: isDark) == nil { + unresolved.append("\(key) \(style.rawValue) \(isDark ? "dark" : "light")") + } + } + } + } + XCTAssertTrue(unresolved.isEmpty, + "these marks drew the placeholder glyph: \(unresolved.joined(separator: ", "))") + } + + /// The failure that started this was an SVG resolving to `_NSSVGImageRep`. + /// Whatever the artwork, a delivered mark must be a bitmap. + func testEveryDeliveredMarkIsABitmapNotAPrivateRepresentation() { + for key in Self.providerKeys { + for style in [MarkStyle.standard, .template] { + for isDark in [false, true] { + guard let image = PlatformLogoImage.load(providerKey: key, style: style, isDarkMode: isDark) else { continue } + XCTAssertTrue(image.representations.first is NSBitmapImageRep, + "\(key) \(style.rawValue) came back as " + + "\(image.representations.first.map { String(describing: type(of: $0)) } ?? "no representation")" + + ", which is the private-rep path the owner reported as invisible.") + } + } + } + } + + /// A mark must not carry an opaque background, which is what made the Muse + /// PNGs read as a white box on any non-white surface. + func testNoDeliveredMarkHasAnOpaqueBackground() { + for key in Self.providerKeys { + for style in [MarkStyle.standard, .template] { + guard let image = PlatformLogoImage.load(providerKey: key, style: style), + let rep = image.representations.first as? NSBitmapImageRep, + rep.pixelsWide > 0, rep.pixelsHigh > 0 else { continue } + let corner = rep.colorAt(x: 0, y: 0) + XCTAssertNotEqual(corner?.alphaComponent ?? 0, 1.0, + "\(key) \(style.rawValue) has an opaque background; it would draw as a white box.") + } + } + } + + /// Light and Dark are different ink, not the same file twice, so a Dark + /// surface never gets a black silhouette that vanishes into it. + func testLightAndDarkVariantsDiffer() { + for key in Self.providerKeys { + guard let light = PlatformLogoImage.load(providerKey: key, style: .template, isDarkMode: false), + let dark = PlatformLogoImage.load(providerKey: key, style: .template, isDarkMode: true) else { + continue + } + XCTAssertNotEqual(light.representations.first as? NSBitmapImageRep, + dark.representations.first as? NSBitmapImageRep, + "\(key) served one identical silhouette for both appearances.") + } + } +} \ No newline at end of file