diff --git a/apps/android/app/src/main/java/com/litter/android/ui/LitterThemeManager.kt b/apps/android/app/src/main/java/com/litter/android/ui/LitterThemeManager.kt index ff84ba40e..0f0820659 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/LitterThemeManager.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/LitterThemeManager.kt @@ -145,35 +145,35 @@ data class LitterResolvedTheme( ): LitterResolvedTheme { val colors = definition.colors val background = - colorFromHex( + tokenColorFromHex( colors["editor.background"], fallback = if (definition.type == LitterColorThemeType.DARK) Color(0xFF111111) else Color.White, ) val foreground = - colorFromHex( + tokenColorFromHex( colors["editor.foreground"], fallback = if (definition.type == LitterColorThemeType.DARK) Color(0xFFFCFCFC) else Color(0xFF0D0D0D), ) val surface = - colors["sideBar.background"]?.let(::colorFromHex) + colors["sideBar.background"]?.let(::tokenColorFromHex) ?: adjustBrightness(background, if (definition.type == LitterColorThemeType.DARK) 0.03f else -0.02f) val surfaceLight = - colors["activityBar.background"]?.let(::colorFromHex) + colors["activityBar.background"]?.let(::tokenColorFromHex) ?: adjustBrightness(surface, if (definition.type == LitterColorThemeType.DARK) 0.04f else -0.03f) val accent = - colors["textLink.foreground"]?.let(::colorFromHex) - ?: colors["button.background"]?.let(::colorFromHex) + colors["textLink.foreground"]?.let(::tokenColorFromHex) + ?: colors["button.background"]?.let(::tokenColorFromHex) ?: if (definition.type == LitterColorThemeType.DARK) Color(0xFFB0B0B0) else Color(0xFF4A4A4A) val accentStrong = - colors["button.background"]?.let(::colorFromHex) - ?: colors["textLink.foreground"]?.let(::colorFromHex) + colors["button.background"]?.let(::tokenColorFromHex) + ?: colors["textLink.foreground"]?.let(::tokenColorFromHex) ?: accent val border = - colors["editorGroup.border"]?.let(::colorFromHex) - ?: colors["sideBar.border"]?.let(::colorFromHex) + colors["editorGroup.border"]?.let(::tokenColorFromHex) + ?: colors["sideBar.border"]?.let(::tokenColorFromHex) ?: adjustBrightness(surface, if (definition.type == LitterColorThemeType.DARK) 0.05f else -0.05f) val separator = - colors["panel.border"]?.let(::colorFromHex) + colors["panel.border"]?.let(::tokenColorFromHex) ?: adjustBrightness(background, if (definition.type == LitterColorThemeType.DARK) 0.04f else -0.04f) return LitterResolvedTheme( @@ -184,8 +184,8 @@ data class LitterResolvedTheme( surface = surface, surfaceLight = surfaceLight, textPrimary = foreground, - textSecondary = colors["sideBar.foreground"]?.let(::colorFromHex) ?: dimColor(foreground, 0.55f), - textMuted = colors["editorLineNumber.foreground"]?.let(::colorFromHex) ?: dimColor(foreground, 0.35f), + textSecondary = colors["sideBar.foreground"]?.let(::tokenColorFromHex) ?: dimColor(foreground, 0.55f), + textMuted = colors["editorLineNumber.foreground"]?.let(::tokenColorFromHex) ?: dimColor(foreground, 0.35f), textBody = dimColor(foreground, 0.88f), textSystem = dimColor(foreground, 0.7f), accent = accent, @@ -239,18 +239,40 @@ data class LitterResolvedTheme( internal fun colorFromHex( hex: String?, fallback: Color = Color.Transparent, -): Color { - val normalized = hex?.trim()?.takeIf { it.isNotEmpty() } ?: return fallback - // Theme JSON uses CSS/VS Code #RRGGBBAA, not Android #AARRGGBB. - // App theme tokens are solid colors, matching the Material3 generator. - val rgb = when { - Regex("#[0-9a-fA-F]{3}").matches(normalized) -> - normalized.drop(1).map { "$it$it" }.joinToString("") - Regex("#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?").matches(normalized) -> - normalized.substring(1, 7) - else -> return fallback +): Color = parseColorFromHex(hex) ?: fallback + +/// Parses theme tokens as opaque colors: CSS hex may carry alpha, but app +/// theme tokens must stay solid so they match iOS and the generated Material +/// schemes, which both drop alpha when a theme loads. +internal fun tokenColorFromHex( + hex: String?, + fallback: Color = Color.Transparent, +): Color = parseColorFromHex(hex)?.copy(alpha = 1f) ?: fallback + +private fun parseColorFromHex(hex: String?): Color? { + val normalized = hex?.trim()?.takeIf { it.isNotEmpty() } ?: return null + // Theme JSON uses CSS/VS Code hex with alpha last: #RGB, #RGBA, + // #RRGGBB, #RRGGBBAA. Android's Color(Long) is ARGB (alpha first), + // so an alpha byte must be moved to the front, not dropped. + var digits = normalized.removePrefix("#").lowercase().toList() + if (digits.any { it !in '0'..'9' && it !in 'a'..'f' }) { + return null } - return Color(0xFF000000 or rgb.toLong(16)) + if (digits.size == 3 || digits.size == 4) { + digits = digits.flatMap { listOf(it, it) } + } + val value = + when (digits.size) { + 6, 8 -> digits.joinToString("").toLongOrNull(16) ?: return null + else -> return null + } + return Color( + if (digits.size == 8) { + ((value and 0xFF) shl 24) or (value ushr 8) + } else { + 0xFF000000L or value + }, + ) } object LitterThemeManager { diff --git a/apps/android/app/src/test/java/com/litter/android/ui/ThemeColorTest.kt b/apps/android/app/src/test/java/com/litter/android/ui/ThemeColorTest.kt index cafab7c36..1dfa766d4 100644 --- a/apps/android/app/src/test/java/com/litter/android/ui/ThemeColorTest.kt +++ b/apps/android/app/src/test/java/com/litter/android/ui/ThemeColorTest.kt @@ -6,10 +6,23 @@ import org.junit.Test class ThemeColorTest { @Test - fun alphaLastThemeColorsMatchGeneratedMaterialRoles() { - val accent = colorFromHex("#45858880") - assertEquals(Color(0xFF458588), accent) - assertEquals(accent, LitterMaterialSchemes.rolesFor("gruvbox-dark-medium", true)?.primary) + fun alphaLastThemeColorsPreserveCssAlpha() { + // Theme JSON is CSS hex with alpha last; Android ARGB is alpha first. + assertEquals(Color(0x80458588), colorFromHex("#45858880")) + assertEquals(Color(0x804585AA), colorFromHex("#4585AA80")) + // #RGBA shorthand expands each nibble. + assertEquals(Color(0xAAAA88AA), colorFromHex("#A8AA")) + } + + @Test + fun themeTokensStripAlphaToMatchGeneratedMaterialRoles() { + assertEquals(Color(0xFF458588), tokenColorFromHex("#45858880")) + assertEquals(Color(0xFF000000), tokenColorFromHex("#0000")) + assertEquals(Color(0xFF458588), tokenColorFromHex("#458588")) + assertEquals( + tokenColorFromHex("#45858880"), + LitterMaterialSchemes.rolesFor("gruvbox-dark-medium", true)?.primary, + ) } @Test @@ -17,6 +30,11 @@ class ThemeColorTest { assertEquals(Color(0xFFAABBCC), colorFromHex(" #abc ")) assertEquals(Color(0xFF123456), colorFromHex("#123456")) assertEquals(Color.Red, colorFromHex("#invalid", Color.Red)) + assertEquals(Color.Red, colorFromHex("#1234567", Color.Red)) assertEquals(Color.Red, colorFromHex(null, Color.Red)) + // A leading sign is not a hex digit and must fall back instead of + // letting toLongOrNull(16) parse it. + assertEquals(Color.Red, colorFromHex("#-12345", Color.Red)) + assertEquals(Color.Red, colorFromHex("#+12345", Color.Red)) } } diff --git a/apps/android/docs/qa-matrix.md b/apps/android/docs/qa-matrix.md index c6139a20c..d942dadab 100644 --- a/apps/android/docs/qa-matrix.md +++ b/apps/android/docs/qa-matrix.md @@ -284,6 +284,7 @@ Replaces the prior WebSocket + base64-PCM audio pump with a platform-native WebR | Known non-blockers | Per-frame input/output meter animation no longer drives — requires `RTCRtpReceiver.stats` polling to restore (follow-up) | Same flat meter behavior; speaker toggle currently stubbed to a boolean — follow-up to honor runtime routing | | Regression: custom AEC path | Retired — `codex-ios-audio` crate + `AecBridge.swift` / `VoiceSessionAudioCodec.swift` were deleted; libwebrtc AEC3 handles echo cancellation natively | Retired — `AecBridge.kt` deleted; `JavaAudioDeviceModule` enables the hardware AEC + NS | | Regression: SSH-tunneled codex server | RPC still flows through SSH; WebRTC peer goes direct to OpenAI edge from device. If client runs in fully air-gapped network, realtime voice will not establish | Same | +| Theme hex colors preserve CSS alpha | `litterHexRGBA` parses #RGB/#RGBA/#RRGGBB/#RRGGBBAA with alpha last; swatch/index colors keep the alpha byte; theme tokens strip alpha at decode (`ThemeDefinition.sanitizeHex`) so app colors stay opaque (`HexColorTests`) | `colorFromHex` moves the alpha byte to the ARGB front instead of dropping it; theme tokens strip alpha via `tokenColorFromHex` to match iOS and the generated Material schemes (`ThemeColorTest`) | ## Message link menus (iOS PR #322) diff --git a/apps/ios/Litter.xcodeproj/project.pbxproj b/apps/ios/Litter.xcodeproj/project.pbxproj index b88dd99b5..9a6f511a7 100644 --- a/apps/ios/Litter.xcodeproj/project.pbxproj +++ b/apps/ios/Litter.xcodeproj/project.pbxproj @@ -232,6 +232,7 @@ 56F93B9C3848A3D983526A2F /* CodexVoiceCallAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACB2B7BC833F0EB556D8401A /* CodexVoiceCallAttributes.swift */; }; 5784473E9D446D638DE2EEB2 /* CodexVoiceCallAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACB2B7BC833F0EB556D8401A /* CodexVoiceCallAttributes.swift */; }; 57F6703288282A1CC4A0B216 /* SavedThreadsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F1E4447C779CA8B66B950F7 /* SavedThreadsStore.swift */; }; + 58547E503761BE6E1C2086C9 /* HexColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A6C1D1C48E7406BAD30F901 /* HexColorTests.swift */; }; 5895E5C25C200FC3FF747049 /* slack-dark.json in Resources */ = {isa = PBXBuildFile; fileRef = 5DF2C84DD77E7A8282AE9831 /* slack-dark.json */; }; 5935F15B5E258E3222780528 /* github-light-high-contrast.json in Resources */ = {isa = PBXBuildFile; fileRef = 70AB8E0BCCF3384EE9B4A0BF /* github-light-high-contrast.json */; }; 595604C3346A5A3C0B4DBBEC /* SessionLaunchSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2EAAADB209274FFFB94CC0 /* SessionLaunchSupport.swift */; }; @@ -959,6 +960,7 @@ 88B886829FDF299DFFCC1A86 /* everforest-light.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "everforest-light.json"; sourceTree = ""; }; 89172C83E4194291FF873A88 /* houston.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = houston.json; sourceTree = ""; }; 892D583790A9BD90CFD89701 /* LockScreenCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenCardView.swift; sourceTree = ""; }; + 8A6C1D1C48E7406BAD30F901 /* HexColorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HexColorTests.swift; sourceTree = ""; }; 8BE99A02238FD8184F77DD49 /* WallpaperAdjustView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WallpaperAdjustView.swift; sourceTree = ""; }; 8D9B7168247D5201C4795A69 /* AlleycatPairingModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlleycatPairingModeTests.swift; sourceTree = ""; }; 8F98D2E0622F1A4DADC1E888 /* cat_transmission_01.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = cat_transmission_01.png; sourceTree = ""; }; @@ -1231,6 +1233,7 @@ 77969543FA36FEA8B621A3B9 /* ConversationDisplayPreferenceTests.swift */, B0C1A0569B4424123A37B0DA /* ConversationPlanSemanticsTests.swift */, B66383694E80CDCBCF582C67 /* ConversationScreenModelTests.swift */, + 8A6C1D1C48E7406BAD30F901 /* HexColorTests.swift */, 018779F7529B6CAAC3A2B896 /* HomeDashboardSupportTests.swift */, F5F26ED2D3C223D3FD12ACB9 /* InteractionTimingTests.swift */, 73F04FAE655DA80B0585F866 /* LitterAppearanceModeTests.swift */, @@ -2258,6 +2261,7 @@ 2F28DA245F84E87318A4D7E4 /* ConversationDisplayPreferenceTests.swift in Sources */, 9C44ABDBA2399DD2C0B1E9E2 /* ConversationPlanSemanticsTests.swift in Sources */, F1535DE46C8E5A530A8C131E /* ConversationScreenModelTests.swift in Sources */, + 58547E503761BE6E1C2086C9 /* HexColorTests.swift in Sources */, 6EF7AFD103A68CDEA1A94612 /* HomeDashboardSupportTests.swift in Sources */, 7881749EF05190C237EFF36B /* InteractionTimingTests.swift in Sources */, 4BEFD1E7129A6B33D965029D /* LitterAppearanceModeTests.swift in Sources */, diff --git a/apps/ios/Sources/Litter/Extensions.swift b/apps/ios/Sources/Litter/Extensions.swift index 029ad52c3..89b6222d2 100644 --- a/apps/ios/Sources/Litter/Extensions.swift +++ b/apps/ios/Sources/Litter/Extensions.swift @@ -4,25 +4,21 @@ import Observation extension Color { init(hex: String) { - let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) - var int: UInt64 = 0 - Scanner(string: hex).scanHexInt64(&int) - let r = Double((int >> 16) & 0xFF) / 255 - let g = Double((int >> 8) & 0xFF) / 255 - let b = Double(int & 0xFF) / 255 - self.init(red: r, green: g, blue: b) + if let rgba = litterHexRGBA(hex) { + self.init(red: rgba.red, green: rgba.green, blue: rgba.blue, opacity: rgba.alpha) + } else { + self.init(red: 0, green: 0, blue: 0) + } } } extension UIColor { convenience init(hex: String) { - let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) - var int: UInt64 = 0 - Scanner(string: hex).scanHexInt64(&int) - let r = CGFloat((int >> 16) & 0xFF) / 255 - let g = CGFloat((int >> 8) & 0xFF) / 255 - let b = CGFloat(int & 0xFF) / 255 - self.init(red: r, green: g, blue: b, alpha: 1) + if let rgba = litterHexRGBA(hex) { + self.init(red: rgba.red, green: rgba.green, blue: rgba.blue, alpha: rgba.alpha) + } else { + self.init(red: 0, green: 0, blue: 0, alpha: 1) + } } } diff --git a/apps/ios/Sources/Litter/Models/LitterPalette.swift b/apps/ios/Sources/Litter/Models/LitterPalette.swift index c569965f4..e36f0a224 100644 --- a/apps/ios/Sources/Litter/Models/LitterPalette.swift +++ b/apps/ios/Sources/Litter/Models/LitterPalette.swift @@ -1,5 +1,33 @@ import SwiftUI +/// Parses CSS hex with alpha last (`#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`) +/// into normalized RGBA components, or nil for anything else. +/// +/// Lives in this file because both the main app target and the +/// `LitterLiveActivity` extension compile `LitterPalette.swift`. +func litterHexRGBA(_ hex: String) -> (red: Double, green: Double, blue: Double, alpha: Double)? { + var value = hex.trimmingCharacters(in: .whitespacesAndNewlines) + if value.hasPrefix("#") { value.removeFirst() } + var digits = Array(value.lowercased()) + // A leading sign is not a hex digit; UInt64(_:radix:) would still + // accept one, so require hex digits before parsing. + guard digits.allSatisfy(\.isHexDigit) else { return nil } + if digits.count == 3 || digits.count == 4 { + digits = digits.flatMap { [$0, $0] } + } + guard digits.count == 6 || digits.count == 8, + let int = UInt64(String(digits), radix: 16) + else { return nil } + let alpha = digits.count == 8 ? Double(int & 0xFF) / 255 : 1 + let rgb = digits.count == 8 ? int >> 8 : int + return ( + Double((rgb >> 16) & 0xFF) / 255, + Double((rgb >> 8) & 0xFF) / 255, + Double(rgb & 0xFF) / 255, + alpha + ) +} + /// Shared color palette used by both the main app (LitterTheme) and the /// Live Activity widget extension. Reads from the shared App Group /// UserDefaults (written by ThemeManager) with hardcoded fallbacks. @@ -63,12 +91,9 @@ extension LitterPalette.Pair { } static func colorFromHex(_ hex: String) -> Color { - let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) - var int: UInt64 = 0 - Scanner(string: hex).scanHexInt64(&int) - let r = Double((int >> 16) & 0xFF) / 255 - let g = Double((int >> 8) & 0xFF) / 255 - let b = Double(int & 0xFF) / 255 - return Color(red: r, green: g, blue: b) + if let rgba = litterHexRGBA(hex) { + return Color(red: rgba.red, green: rgba.green, blue: rgba.blue, opacity: rgba.alpha) + } + return Color(red: 0, green: 0, blue: 0) } } diff --git a/apps/ios/Sources/Litter/Models/ThemeDefinition.swift b/apps/ios/Sources/Litter/Models/ThemeDefinition.swift index 46eca5bf2..5567174ce 100644 --- a/apps/ios/Sources/Litter/Models/ThemeDefinition.swift +++ b/apps/ios/Sources/Litter/Models/ThemeDefinition.swift @@ -38,11 +38,19 @@ struct ThemeDefinition: Codable { } } - // VS Code allows #RRGGBBAA. Downstream color helpers assume 6-digit - // RGB, so strip the trailing alpha pair at the decode boundary. + // VS Code allows #RRGGBBAA and #RGBA. Downstream color helpers assume + // 6-digit RGB and theme tokens must stay opaque (Android strips alpha + // the same way), so drop the trailing pair at the decode boundary and + // expand 4-digit shorthand to solid #RRGGBB. private static func sanitizeHex(_ raw: String) -> String { - guard raw.hasPrefix("#"), raw.count == 9 else { return raw } - return String(raw.prefix(7)) + guard raw.hasPrefix("#") else { return raw } + let hex = String(raw.dropFirst()) + if hex.count == 8 { return "#" + hex.prefix(6) } + if hex.count == 4 { + let digits = Array(hex.lowercased()).prefix(3) + return "#" + digits.map { "\($0)\($0)" }.joined() + } + return raw } // tokenColors are ignored — syntax highlighting is handled by Hairball @@ -162,13 +170,10 @@ struct ResolvedTheme { } static func hexToRGB(_ hex: String) -> (Double, Double, Double) { - let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) - var int: UInt64 = 0 - Scanner(string: cleaned).scanHexInt64(&int) - let r = Double((int >> 16) & 0xFF) / 255 - let g = Double((int >> 8) & 0xFF) / 255 - let b = Double(int & 0xFF) / 255 - return (r, g, b) + // Theme hex is CSS with alpha last; the dimming math uses the RGB + // channels only. + guard let rgba = litterHexRGBA(hex) else { return (0, 0, 0) } + return (rgba.red, rgba.green, rgba.blue) } static func rgbToHex(_ r: Double, _ g: Double, _ b: Double) -> String { diff --git a/apps/ios/Sources/LitterWatch/Models/WatchThemeStore.swift b/apps/ios/Sources/LitterWatch/Models/WatchThemeStore.swift index bb384e4ff..645fdcac0 100644 --- a/apps/ios/Sources/LitterWatch/Models/WatchThemeStore.swift +++ b/apps/ios/Sources/LitterWatch/Models/WatchThemeStore.swift @@ -48,12 +48,35 @@ final class WatchThemeStore: ObservableObject { extension Color { init(themeHex string: String) { - let cleaned = string.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) - var v: UInt64 = 0 - Scanner(string: cleaned).scanHexInt64(&v) - let r = Double((v >> 16) & 0xFF) / 255 - let g = Double((v >> 8) & 0xFF) / 255 - let b = Double(v & 0xFF) / 255 - self.init(.sRGB, red: r, green: g, blue: b, opacity: 1) + // Theme hex is CSS with alpha last: #RGB/#RGBA/#RRGGBB/#RRGGBBAA. + // The watch target doesn't compile the app's litterHexRGBA helper, + // so keep this parse in step with it. + var value = string.trimmingCharacters(in: .whitespacesAndNewlines) + if value.hasPrefix("#") { value.removeFirst() } + var digits = Array(value.lowercased()) + // A leading sign is not a hex digit; UInt64(_:radix:) would still + // accept one, so require hex digits before parsing. + guard digits.allSatisfy(\.isHexDigit) else { + self.init(.sRGB, red: 0, green: 0, blue: 0, opacity: 1) + return + } + if digits.count == 3 || digits.count == 4 { + digits = digits.flatMap { [$0, $0] } + } + guard digits.count == 6 || digits.count == 8, + let v = UInt64(String(digits), radix: 16) + else { + self.init(.sRGB, red: 0, green: 0, blue: 0, opacity: 1) + return + } + let opacity = digits.count == 8 ? Double(v & 0xFF) / 255 : 1 + let rgb = digits.count == 8 ? v >> 8 : v + self.init( + .sRGB, + red: Double((rgb >> 16) & 0xFF) / 255, + green: Double((rgb >> 8) & 0xFF) / 255, + blue: Double(rgb & 0xFF) / 255, + opacity: opacity + ) } } diff --git a/apps/ios/Tests/LitterTests/HexColorTests.swift b/apps/ios/Tests/LitterTests/HexColorTests.swift new file mode 100644 index 000000000..d19d54dfb --- /dev/null +++ b/apps/ios/Tests/LitterTests/HexColorTests.swift @@ -0,0 +1,76 @@ +import SwiftUI +import UIKit +import XCTest +@testable import Litter + +final class HexColorTests: XCTestCase { + func testAlphaLastCssHexPreservesAlpha() throws { + let rgba = try XCTUnwrap(litterHexRGBA("#45858880")) + XCTAssertEqual(rgba.red, 0x45 / 255, accuracy: 0.0001) + XCTAssertEqual(rgba.green, 0x85 / 255, accuracy: 0.0001) + XCTAssertEqual(rgba.blue, 0x88 / 255, accuracy: 0.0001) + XCTAssertEqual(rgba.alpha, 0x80 / 255, accuracy: 0.0001) + } + + func testOpaqueAndShorthandForms() throws { + let opaque = try XCTUnwrap(litterHexRGBA("#458588")) + XCTAssertEqual(opaque.alpha, 1, accuracy: 0.0001) + XCTAssertEqual(opaque.red, 0x45 / 255, accuracy: 0.0001) + + // #RGB and #RGBA shorthand expand each nibble. + let rgb = try XCTUnwrap(litterHexRGBA("#f0a")) + XCTAssertEqual(rgb.red, 0xFF / 255, accuracy: 0.0001) + XCTAssertEqual(rgb.green, 0x00 / 255, accuracy: 0.0001) + XCTAssertEqual(rgb.blue, 0xAA / 255, accuracy: 0.0001) + let rgba = try XCTUnwrap(litterHexRGBA("#f0a5")) + XCTAssertEqual(rgba.blue, 0xAA / 255, accuracy: 0.0001) + XCTAssertEqual(rgba.alpha, 0x55 / 255, accuracy: 0.0001) + } + + func testUppercaseAndPaddedInput() throws { + let rgba = try XCTUnwrap(litterHexRGBA(" #4585AA80 ")) + XCTAssertEqual(rgba.blue, 0xAA / 255, accuracy: 0.0001) + XCTAssertEqual(rgba.alpha, 0x80 / 255, accuracy: 0.0001) + } + + func testInvalidInputReturnsNil() { + XCTAssertNil(litterHexRGBA("#invalid")) + XCTAssertNil(litterHexRGBA("#1234567")) + XCTAssertNil(litterHexRGBA("")) + XCTAssertNil(litterHexRGBA("#zzzzzz")) + // A leading sign is not a hex digit and must not parse. + XCTAssertNil(litterHexRGBA("#-12345")) + XCTAssertNil(litterHexRGBA("#+12345")) + } + + func testThemeDecodeStripsAlphaFromTokens() throws { + // Theme tokens must stay opaque so they match Android's + // tokenColorFromHex and the generated Material schemes. + let json = """ + {"name":"T","type":"dark","colors":{ + "button.background":"#45858880", + "editorGroup.border":"#0000", + "editor.background":"#111111" + }} + """ + let theme = try JSONDecoder().decode(ThemeDefinition.self, from: Data(json.utf8)) + XCTAssertEqual(theme.colors["button.background"], "#458588") + XCTAssertEqual(theme.colors["editorGroup.border"], "#000000") + } + + func testUIColorInitializerCarriesAlpha() { + let color = UIColor(hex: "#45858880") + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + XCTAssertEqual(alpha, 0x80 / 255, accuracy: 0.0001) + XCTAssertEqual(red, 0x45 / 255, accuracy: 0.0001) + } + + func testLitterPaletteColorFromHexKeepsAlpha() { + let color = LitterPalette.Pair.colorFromHex("#45858880") + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + UIColor(color).getRed(&red, green: &green, blue: &blue, alpha: &alpha) + XCTAssertEqual(alpha, 0x80 / 255, accuracy: 0.0001) + XCTAssertEqual(red, 0x45 / 255, accuracy: 0.0001) + } +} diff --git a/tools/scripts/generate-material-schemes.mjs b/tools/scripts/generate-material-schemes.mjs index 29281bb54..5d6dcf56d 100644 --- a/tools/scripts/generate-material-schemes.mjs +++ b/tools/scripts/generate-material-schemes.mjs @@ -96,7 +96,8 @@ export function parseColor(hex, fallback) { return parseInt(s.slice(1), 16) | 0xff000000; } // VS Code themes carry #RRGGBBAA (alpha last). App tokens use the solid - // RGB color, matching Android colorFromHex rather than #AARRGGBB. + // RGB color, matching Android tokenColorFromHex / iOS sanitizeHex rather + // than #AARRGGBB. if (/^#[0-9a-fA-F]{8}$/.test(s)) { return parseInt(s.slice(1, 7), 16) | 0xff000000; }