Skip to content

fix: parse hexadecimal and 8-bit .colorset color components (#146)#468

Open
arulagarwal wants to merge 3 commits into
skiptools:mainfrom
arulagarwal:fix-issue-146-hex-colors
Open

fix: parse hexadecimal and 8-bit .colorset color components (#146)#468
arulagarwal wants to merge 3 commits into
skiptools:mainfrom
arulagarwal:fix-issue-146-hex-colors

Conversation

@arulagarwal

@arulagarwal arulagarwal commented Jun 22, 2026

Copy link
Copy Markdown

Why

Xcode's Asset Catalog lets each .colorset channel use a different "Input Method". SkipUI's Android colorset parser assumed the Floating Point method, where each channel is a 0–1 decimal string like "0.945". When a color is authored with the 8-bit Hexadecimal method, Xcode emits the same JSON shape but encodes each channel as a hex byte string like "0xF1". On the transpiled (Android) path these are read with toDoubleOrNull, which returns null, so every channel falls back to 0.0 and the color renders pure black (#146). It is not a crash — JSON decoding succeeds and nothing is logged; the only symptom is wrong pixels.

This affects Android only: on Apple platforms Color(_:bundle:) defers to SwiftUI's own asset loader, so the defect lives entirely in the #if SKIP colorset code in Color.swift.

What

ColorComponents now routes each channel (red/green/blue/alpha) through a new file-level parseColorComponent(_:defaultValue:) helper that covers all three of Xcode's numeric Input Methods: 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; anything else is parsed as a floating-point string, so existing decimal colorsets are byte-for-byte unchanged. Hex and 0–255 values are normalized to 0–1 by /255. Malformed or empty input preserves the previous defaults (0.0 for RGB, 1.0 for alpha), so nothing can regress. It is a free function rather than a static method on the Decodable ColorComponents struct, because adding a static member breaks Skip's reflection-based JSON decoding of ColorSet.

The hex prefix is detected explicitly rather than relying on Double(_:) to reject hex, because Swift parses Double("0xF1") as 241.0 while the transpiled Kotlin "0xF1".toDoubleOrNull() is null. A "try Double first" approach would therefore behave differently across platforms (and is precisely why this bug is Android-only); prefix detection keeps Swift and Kotlin identical.

The 8-bit (0–255) decimal method (e.g. "148") is distinguished from floating point by the absence of a decimal point — Xcode writes floats as "1.000" and 8-bit as bare integers — so it's handled too, since the issue asks to cover all Input Method variants. (Grayscale colorsets, which use white/alpha components instead of RGB, are a separate pre-existing gap not addressed here.)

Before / After evidence

parseColorComponent was exercised over 13 cases spanning all three Input Methods (floating point, 0x/0X/# hex, lowercase hex, 8-bit 148/255/4, empty/nil, malformed):

PASS | float passthrough: 0.945
PASS | hex 0x lowercase: 0.9451    (before: toDoubleOrNull -> null -> 0.0 -> black on Android)
PASS | hex # prefix: 0.5333
PASS | 8-bit (0-255): 0.5804       (148/255; before: 148.0 -> clamped to white)
PASS | 8-bit (0-255) max: 1.0
PASS | malformed -> default: 0.0
...
13/13 cases passed
hex 0x04/0xF1/0x88, 8-bit 4/241/136, and float 0.0157/0.9451/0.5333 all resolve to the same color

swift test --filter ColorTests2 tests, 0 failures (native build clean; existing tests unaffected — the template's required check). skip test --filter ColorTests (transpiled Kotlin + Robolectric) → succeeded, confirming the fix compiles/transpiles correctly and the suite stays green. The three new render tests (DISABLEDtestHexColorset / DISABLEDtestFloatColorset / DISABLEDtestIntColorset) assert all three encodings render #04F188; they are DISABLED-prefixed because decoding a bundled component .colorset throws a kotlin-reflect error under the Robolectric unit runner (it works on a real device) — run them on an emulator/device.

Acceptance criteria

  • Tests added that target the fix (DISABLEDtestHexColorset/DISABLEDtestFloatColorset/DISABLEDtestIntColorset, one per Input Method); the parsing algorithm is also covered by a standalone 13-case logic check
  • Existing test suite passes (swift test 2/2; skip test on Robolectric succeeded)
  • Follows project style (matches surrounding Color.swift / ColorTests conventions)
  • No breaking changes (float path unchanged; defaults preserved)

Closes #146


Skip Pull Request Checklist:

  • REQUIRED: I have signed the Contributor Agreement
  • REQUIRED: I have tested my change locally with swift test
  • OPTIONAL: I have tested my change on an iOS simulator or device
  • OPTIONAL: I have tested my change on an Android emulator or device
  • REQUIRED: I have checked whether this change requires a corresponding update in Skip Fuse UI — none needed (internal colorset parsing; no public API change)
  • OPTIONAL: I have added an example of any UI changes to the Showcase sample app

  • AI was used to assist with generating this PR. How: I used Claude Code as a pair-programmer to locate the root cause in ColorComponents, draft the parseColorComponent helper, and scaffold the fixtures and tests. Manual verification: I read and understood every line, empirically confirmed the Double("0xF1") Swift-vs-Kotlin parsing difference that drove the prefix-first design, traced the #if SKIP code path, and ran swift test and skip test (Robolectric) plus a standalone 10-case logic check to confirm the before/after behavior. The local skip test run also surfaced two transpilation issues I then fixed: Swift's Int(_:radix:) does not transpile (switched to Kotlin's toIntOrNull(radix:)), and a static helper on the Decodable struct broke ColorSet decoding (moved it to a free function).

arulagarwal and others added 3 commits June 21, 2026 20:36
…ls#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 skiptools#146 regression tests and
reachable via Bundle.module in SkipUITests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…kiptools#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 skiptools#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 <noreply@anthropic.com>
…s#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 skiptools#146 was reported); run them on
an emulator/device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cla-bot

cla-bot Bot commented Jun 22, 2026

Copy link
Copy Markdown

Thank you for your pull request and welcome to the Skip community. We require contributors to sign our contributor license agreement (CLA), and we don't seem to have the user(s) @arulagarwal on file. In order for us to review and merge your code, for each noted user please add your GitHub username to Skip's .clabot file

@arulagarwal

Copy link
Copy Markdown
Author

@cla-bot check

@cla-bot cla-bot Bot added the cla-signed label Jun 23, 2026
@cla-bot

cla-bot Bot commented Jun 23, 2026

Copy link
Copy Markdown

recheck

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cannot parse custom colors whose .colorset uses Hex values

1 participant