From fe38f0be6126d040c6c8a8d5645a45cb35281efd Mon Sep 17 00:00:00 2001 From: Arul Agarwal Date: Sun, 21 Jun 2026 20:36:01 -0500 Subject: [PATCH 1/3] test(Color): add hex, float, and 8-bit .colorset fixtures for #146 Adds three asset-catalog fixtures that encode the same color (#04F188) via each of Xcode's numeric "Input Method" variants: HexColor (8-bit hexadecimal, 0x04/0xF1/0x88), FloatColor (floating point), and IntColor (8-bit 0-255, 4/241/136). Used by the issue #146 regression tests and reachable via Bundle.module in SkipUITests. Co-Authored-By: Claude Opus 4.8 --- .../FloatColor.colorset/Contents.json | 20 +++++++++++++++++++ .../HexColor.colorset/Contents.json | 20 +++++++++++++++++++ .../IntColor.colorset/Contents.json | 20 +++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 Tests/SkipUITests/Resources/Assets.xcassets/FloatColor.colorset/Contents.json create mode 100644 Tests/SkipUITests/Resources/Assets.xcassets/HexColor.colorset/Contents.json create mode 100644 Tests/SkipUITests/Resources/Assets.xcassets/IntColor.colorset/Contents.json diff --git a/Tests/SkipUITests/Resources/Assets.xcassets/FloatColor.colorset/Contents.json b/Tests/SkipUITests/Resources/Assets.xcassets/FloatColor.colorset/Contents.json new file mode 100644 index 00000000..984bc288 --- /dev/null +++ b/Tests/SkipUITests/Resources/Assets.xcassets/FloatColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.533333", + "green" : "0.945098", + "red" : "0.015686" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/SkipUITests/Resources/Assets.xcassets/HexColor.colorset/Contents.json b/Tests/SkipUITests/Resources/Assets.xcassets/HexColor.colorset/Contents.json new file mode 100644 index 00000000..eaa75e78 --- /dev/null +++ b/Tests/SkipUITests/Resources/Assets.xcassets/HexColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x88", + "green" : "0xF1", + "red" : "0x04" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/SkipUITests/Resources/Assets.xcassets/IntColor.colorset/Contents.json b/Tests/SkipUITests/Resources/Assets.xcassets/IntColor.colorset/Contents.json new file mode 100644 index 00000000..84281869 --- /dev/null +++ b/Tests/SkipUITests/Resources/Assets.xcassets/IntColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "255", + "blue" : "136", + "green" : "241", + "red" : "4" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} From 651af9b840d6771d64b2fa8e37a2282c917b4711 Mon Sep 17 00:00:00 2001 From: Arul Agarwal Date: Sun, 21 Jun 2026 20:36:01 -0500 Subject: [PATCH 2/3] fix(Color): parse hexadecimal and 8-bit .colorset color components (#146) Custom colors only parsed correctly when the .colorset "Input Method" was "Floating Point". With "8-bit Hexadecimal" (e.g. "0xF1") the transpiled Kotlin read channels via toDoubleOrNull -> null, so every channel fell back to 0.0 and the color rendered pure black; "8-bit (0-255)" (e.g. "148") parsed as 148.0 and clamped to white. The issue asks to handle all Input Method variants. Channels now route through a new parseColorComponent helper that covers all three: an 0x/0X/# prefix is parsed as an 8-bit hex byte (via Kotlin's toIntOrNull(radix:), since Swift's Int(_:radix:) does not transpile); a bare integer with no decimal point is treated as an 8-bit (0-255) value; both are normalized by /255. Anything else is parsed as floating point, so existing decimal colorsets are unchanged. Hex is detected by prefix rather than by Double(_:) failing, because Swift parses Double("0xF1") as 241.0 while Kotlin's toDoubleOrNull is null -- the prefix check keeps both platforms identical (and is why #146 was Android-only). Malformed/empty input preserves the previous defaults. parseColorComponent is a free function rather than a static method on the Decodable ColorComponents struct: a static member breaks Skip's reflection-based JSON decoding of ColorSet. Co-Authored-By: Claude Opus 4.8 --- Sources/SkipUI/SkipUI/Color/Color.swift | 39 ++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/Sources/SkipUI/SkipUI/Color/Color.swift b/Sources/SkipUI/SkipUI/Color/Color.swift index daacfcf7..5b764934 100644 --- a/Sources/SkipUI/SkipUI/Color/Color.swift +++ b/Sources/SkipUI/SkipUI/Color/Color.swift @@ -623,15 +623,46 @@ private struct ColorSet : Decodable { let alpha: String? var color: Color { - let redValue = Double(red ?? "") ?? 0.0 - let greenValue = Double(green ?? "") ?? 0.0 - let blueValue = Double(blue ?? "") ?? 0.0 - let alphaValue = Double(alpha ?? "") ?? 1.0 + let redValue = parseColorComponent(red, defaultValue: 0.0) + let greenValue = parseColorComponent(green, defaultValue: 0.0) + let blueValue = parseColorComponent(blue, defaultValue: 0.0) + let alphaValue = parseColorComponent(alpha, defaultValue: 1.0) return Color(red: redValue, green: greenValue, blue: blueValue, opacity: alphaValue) } } } +/// Parse a single `.colorset` channel, covering all of Xcode's numeric "Input Method" variants: +/// Floating Point ("0.945", passed through unchanged), 8-bit Hexadecimal ("0xF1" / "#F1"), and +/// 8-bit (0-255) ("241"). Hex bytes and 0-255 integers are normalized to 0...1 by dividing by 255. +/// Malformed or empty input falls back to `defaultValue`. See https://github.com/skiptools/skip-ui/issues/146 +/// +/// The hex prefix is detected explicitly rather than relying on `Double(_:)` to reject it: Swift +/// parses `Double("0xF1")` as `241.0`, whereas the transpiled Kotlin `"0xF1".toDoubleOrNull()` is +/// `null`. A "try Double first" approach would therefore behave differently once transpiled (and is +/// the reason #146 manifests only on Android). Checking the prefix keeps both platforms identical. +/// Hex digits are parsed with Kotlin's `toIntOrNull(radix:)` because Swift's `Int(_:radix:)` does not +/// transpile. This is a free function rather than a method on `ColorComponents` because adding a +/// `static` member to that `Decodable` type breaks Skip's reflection-based JSON decoding of `ColorSet`. +private func parseColorComponent(_ string: String?, defaultValue: Double) -> Double { + guard let string, !string.isEmpty else { return defaultValue } + if string.hasPrefix("0x") || string.hasPrefix("0X") { + let byte: Int? = String(string.dropFirst(2)).toIntOrNull(radix: 16) + if let byte { return Double(byte) / 255.0 } + return defaultValue + } + if string.hasPrefix("#") { + let byte: Int? = String(string.dropFirst(1)).toIntOrNull(radix: 16) + if let byte { return Double(byte) / 255.0 } + return defaultValue + } + // Floating Point emits a decimal ("0.580"); the "8-bit (0-255)" method emits a bare integer ("148"). + if !string.contains("."), let intValue = Int(string) { + return Double(intValue) / 255.0 + } + return Double(string) ?? defaultValue +} + #endif /* From 79460210d3990cc581c29c6a137a8744d4f572be Mon Sep 17 00:00:00 2001 From: Arul Agarwal Date: Sun, 21 Jun 2026 20:36:01 -0500 Subject: [PATCH 3/3] test(Color): add render tests for hex/float/8-bit colorsets (#146) Adds DISABLEDtestHexColorset, DISABLEDtestFloatColorset, and DISABLEDtestIntColorset, which render the three fixtures and assert each produces #04F188 (all Input Methods agree, and none is the pre-fix black). They are DISABLED-prefixed because decoding a bundled component .colorset throws a kotlin-reflect IllegalAccessException under the Robolectric unit runner (it works on a real device, where #146 was reported); run them on an emulator/device. Co-Authored-By: Claude Opus 4.8 --- Tests/SkipUITests/ColorTests.swift | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Tests/SkipUITests/ColorTests.swift b/Tests/SkipUITests/ColorTests.swift index 36fe0f7b..0aa1e4e1 100644 --- a/Tests/SkipUITests/ColorTests.swift +++ b/Tests/SkipUITests/ColorTests.swift @@ -14,6 +14,42 @@ final class ColorTests: XCSnapshotTestCase { XCTAssertEqual("F", try render(compact: 1, view: Color.white.frame(width: 1.0, height: 1.0)).pixmap) } + // Issue #146: a custom .colorset authored with Xcode's "8-bit Hexadecimal" input method (channels + // like "0xF1") rendered as pure black on Android, because Double("0xF1") returned nil and every + // channel fell back to 0.0. These tests render fixtures from the test bundle and are guarded to the + // transpiled (SKIP) side: on Apple platforms Color(_:bundle:) defers to SwiftUI's own asset loader, + // so the buggy parsing path only exists under SKIP. + + // The two tests below validate the issue #146 fix end-to-end by rendering custom `.colorset` assets. + // They are DISABLED-prefixed (this file's convention for tests that don't run in the standard pass) + // because decoding a bundled component `.colorset` currently fails under the Robolectric *unit* runner: + // `JSONDecoder().decode(ColorSet.self, …)` throws a kotlin-reflect `IllegalAccessException` on the JVM. + // It works on a real Android device — which is where #146 was reported (hex colorsets rendered *black*, + // i.e. decode succeeded and only the parse was wrong). The failure is independent of this fix: it occurs + // in `ColorSet` decoding, before `parseColorComponent` is ever reached. Run these on an emulator/device + // to validate; the parsing algorithm itself is covered by the contribution's standalone logic harness. + + func DISABLEDtestHexColorset() throws { + // 0x04/0xF1/0x88 must render the real color (#04F188), not black (pre-fix it rendered "0"). + #if SKIP + XCTAssertEqual("04F188", try render(compact: 1, view: Color("HexColor", bundle: .module).frame(width: 1.0, height: 1.0)).pixmap) + #endif + } + + func DISABLEDtestFloatColorset() throws { + // The identical color authored as floats must render the same #04F188 (hex path == float path). + #if SKIP + XCTAssertEqual("04F188", try render(compact: 1, view: Color("FloatColor", bundle: .module).frame(width: 1.0, height: 1.0)).pixmap) + #endif + } + + func DISABLEDtestIntColorset() throws { + // The identical color authored with the "8-bit (0-255)" method (4/241/136) must render #04F188. + #if SKIP + XCTAssertEqual("04F188", try render(compact: 1, view: Color("IntColor", bundle: .module).frame(width: 1.0, height: 1.0)).pixmap) + #endif + } + // Disabled tests (due to slow performance when running against emulator) func DISABLEDtestColorClearDark() throws {