Android support - #18
Open
rozd wants to merge 20 commits into
Open
Conversation
Make ThemeKit cross-compile for Android via Skip (skip.dev) in native (Fuse) mode: - Package.swift: skip + skip-fuse-ui dependencies, skipstone plugin on the ThemeKit target, .dynamic library product (required for JNI), and a SKIP_ZERO=1 block that strips all Skip machinery for Apple-only consumers. Skip/skip.yml declares the module as native mode. - Gate Apple-only APIs with #if !os(Android): ShapeStyle conformances (SkipFuseUI has no resolve(in:) customization point), MeshGradient, ShadowStyle, and hex encoding of colors (no UIColor/NSColor). - Work around SkipSwiftUI's non-public Gradient.Stop initializer when decoding gradients on Android. - Add resolved(colorScheme:sizeClass:) as the cross-platform explicit resolution path (environment values cannot be read outside @Environment on Android). - Generator: emit a classic EnvironmentKey instead of @entry (SkipFuseUI ships no macros) and #if !os(Android) guards in ThemeShapeStyle, ThemeShadowedStyle, ShapeStyle extension, and preview templates so generated files compile in shared Skip app modules. - Document the Android support surface in README and CLAUDE.md. Verified: swift test (225 tests), SKIP_ZERO=1 swift build, skip android build --target ThemeKit, and an end-to-end Skip app probe consuming the package plus generated theme files, built for both macOS and Android. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #18 +/- ##
==========================================
+ Coverage 98.92% 99.00% +0.07%
==========================================
Files 35 40 +5
Lines 2229 3200 +971
==========================================
+ Hits 2205 3168 +963
- Misses 24 32 +8 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Contributor
There was a problem hiding this comment.
Pull request overview
This PR updates ThemeKit to support cross-compiling the core library (and generated theme files) for Android when used inside Skip (native/Fuse mode), primarily by gating Apple-only SwiftUI APIs and adding an explicit, environment-free token resolution API.
Changes:
- Add Android/Skip compatibility gates (
#if !os(Android)) around ShapeStyle-based sugar, previews, shadows, and MeshGradient. - Introduce explicit resolution API
resolved(colorScheme:sizeClass:)for resolving tokens withoutEnvironmentValues. - Update generator templates and tests to emit Skip-safe code (e.g.,
EnvironmentKeyinstead of@Entry) and assert Android guards.
Reviewed changes
Copilot reviewed 20 out of 21 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| Tests/ThemeKitTests/ThemeAdaptiveStyleTests.swift | Adds test coverage for explicit resolved(colorScheme:sizeClass:) behavior. |
| Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift | Asserts preview output is guarded for Android. |
| Tests/ThemeKitGeneratorTests/ThemeFileGeneratorTests.swift | Asserts generated files include Android guards and EnvironmentKey-based theme environment plumbing. |
| Sources/ThemeKitGenerator/ThemeShapeStyleGenerator.swift | Wraps generated ThemeShapeStyle (custom resolve(in:)) behind #if !os(Android). |
| Sources/ThemeKitGenerator/ThemeShadowedStyleGenerator.swift | Wraps generated shadow ShapeStyle composition behind #if !os(Android). |
| Sources/ThemeKitGenerator/ThemePreviewGenerator.swift | Wraps generated #Preview content behind #if !os(Android). |
| Sources/ThemeKitGenerator/ShapeStyleExtensionGenerator.swift | Guards generated ShapeStyle extensions for Android and closes the conditional compilation block. |
| Sources/ThemeKitGenerator/EnvironmentThemeGenerator.swift | Replaces @Entry usage with an EnvironmentKey-based implementation. |
| Sources/ThemeKit/ThemeAdaptiveStyle+ShapeStyle.swift | Disables ThemeAdaptiveStyle: ShapeStyle conformance on Android (Skip Fuse). |
| Sources/ThemeKit/ThemeAdaptiveStyle+Resolver.swift | Adds Foundation import (UUID usage) for resolver ID creation. |
| Sources/ThemeKit/ThemeAdaptiveStyle+Resolved.swift | Adds the new explicit, environment-free resolution API (but currently with a visibility issue). |
| Sources/ThemeKit/ThemeAdaptiveStyle+Defaults.swift | Adds Foundation import (JSONEncoder usage) for defaults hashing. |
| Sources/ThemeKit/Skip/skip.yml | Adds Skip configuration to enable native (Fuse) mode module processing. |
| Sources/ThemeKit/Shadow.swift | Gates ShadowStyle/ShapeStyle sugar to Apple platforms while keeping Shadow data available on Android. |
| Sources/ThemeKit/MeshGradient+Codable.swift | Gates MeshGradient Codable support to non-Android platforms. |
| Sources/ThemeKit/Gradient+Codable.swift | Adds Android-specific decoding path for gradients due to Stop initializer constraints. |
| Sources/ThemeKit/Color+Hex.swift | Disables hex encoding on platforms without RGB extraction (Android/Skip), while keeping decoding paths intact. |
| README.md | Documents Skip/Android support matrix and explicit-resolution usage. |
| Package.swift | Adds Skip dependencies/plugins, sets ThemeKit library to dynamic, and introduces SKIP_ZERO=1 stripping logic. |
| Package.resolved | Pins Skip-related dependency versions. |
| CLAUDE.md | Adds maintenance notes for Skip/Android compatibility and verification commands. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+22
to
+25
| nonisolated func resolved( | ||
| colorScheme: ColorScheme? = nil, | ||
| sizeClass: UserInterfaceSizeClass? = nil | ||
| ) -> Style? { |
Comment on lines
+16
to
+18
| /// @Environment(\.colorScheme) private var colorScheme | ||
| /// ... | ||
| /// let color = theme.colors.primary.resolved(colorScheme: colorScheme) |
Previously, `Color` encoding used `try? self.hexString`, which would silently encode `null` if `hexString` failed (e.g., for non-RGB color spaces on certain platforms). This change removes the optional `try?`, ensuring that `Color` encoding now explicitly propagates any errors from `self.hexString`. This provides a clearer contract: either a valid hex string is encoded, or the encoding fails, removing the ambiguity of `null` serialization for colors.
Adds two jobs alongside the existing macOS test job: - android-build: cross-compiles the ThemeKit target for Android via the skip CLI. --target scoping is required to skip the Darwin-only GeneratedCodeSwift* verification targets. The host Swift version is left unpinned so setup-skip can keep it consistent with the Android SDK artifactbundle it installs. - skip-zero-build: proves SKIP_ZERO=1 still strips every Skip dependency and plugin, leaving a plain SwiftPM package for Apple-only consumers. Compile-only by design; rendering claims need an emulator tier and land with the demo app later. Triggers now also cover feat/skip-android-compatibility, the long-lived integration branch for the Android work, since nothing reaches main until the roadmap closes out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rozd
marked this pull request as draft
August 12, 2026 13:48
Three Android data-layer gaps close here, none of which need anything from upstream Skip. Color encoding. `Color.hexString` throws unconditionally on Android — SkipFuseUI exposes no colour components — so `JSONEncoder().encode(theme)` hard-threw and remote-theme round-trips were impossible. Colours built through `Color(hex:)` now record their canonical `#RRGGBB` at construction and read it back on every platform. This is the design `785c83b`'s test comments already described but never implemented; on Apple it also makes encoding exact instead of colour-space-rounded. Keying the cache by `Color` is sound because `Color` is `Hashable` by value everywhere, including SkipFuseUI, whose `Color` hashes over a value-typed component spec — so copies still hit. Entries are capped and evicted oldest-first, since an app decoding a stream of remote themes would otherwise grow the table forever. Colours built as `Color(red:green:blue:)` remain unencodable on Android; the decode → copyWith → encode loop that remote themes actually use is covered. The new tests also caught a pre-existing crash: on macOS, `Color.red.hexString` aborted the process instead of throwing, because catalog colours have no RGB components and `NSColor.getRed` raises an ObjC exception on one. It now converts to sRGB first and throws if that is not possible. MeshGradient. A `meshGradients` config could not compile on Android at all — the generated root `Theme` names a type SkipFuseUI does not have. ThemeKit now ships a shim there, so such configs stay portable and render in a deliberately degraded form until real mesh support exists. To stop the two conformances drifting, the wire format moves into a shared `MeshGradientCoding` layer that both route through: one JSON shape, one uniform-grid rule. `MeshGradient.pointsFrom` moves there as `uniformPoints`, and its tests follow it — they were always testing the rule, not the type. Rendering seam. `AndroidRenderableStyle` maps a resolved token value to the primitive SkipFuseUI can draw with. It lives in the library rather than in generated code because the mapping depends only on the value type, never on an app's theme configuration. The generated modifier overloads (P1-2) consume it. Verified: swift test (237 passing), SKIP_ZERO=1 swift build, and skip android build --plain --target ThemeKit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restores the ergonomic call site on Android. `.foregroundStyle(.primaryColor)` now compiles and renders there, spelled exactly as it is on Apple — previously every generated ShapeStyle extension was gated off and Android users had to hand-resolve every token through `resolved(colorScheme:)`. How it works. SkipFuseUI's ShapeStyle has no `resolve(in:)` customization point, so a theme style cannot render itself there. Instead the generator emits overloads of the style-taking modifiers that wrap the content in a view which reads the theme from the environment and applies the resolved value. That indirection exists because there is no environment at Java_view bridging time — reading it inside a view body is the only place it is available. `ThemeStyleResolving` is the load-bearing piece: it plays exactly the role ShapeStyle plays on Apple, as both the namespace the token accessors hang off and the constraint on the overloads. Both properties matter. Hanging the accessors off ThemeShapeStyle instead looks equivalent and is not: a chain like `.surface.card` needs a protocol-constrained generic so the parameter can re-bind from a style to a shadowed style, and without one it fails to compile. And because only ThemeKit's own types conform, no argument can satisfy both this constraint and SkipFuseUI's `S: ShapeStyle` — the overloads cannot be ambiguous with the built-in modifiers, by construction rather than by luck. The verified overload set is foregroundStyle, background(_:ignoresSafeAreaEdges:), background(_:in:), border(_:width:), Shape.fill and Shape.stroke(_:lineWidth:). Deliberately absent: tint. `.tint(.primaryColor)` does not compile on *Apple* — Swift cannot infer an implicit member's base through tint's optional generic — so emitting it on Android alone would produce code that builds on one platform and not the other, which is the asymmetry this whole design exists to prevent. A test pins that omission so it is not silently re-added. Structural changes this forced: ThemeShapeStyle and ThemeShadowedStyle are now emitted unconditionally with their ShapeStyle conformances moved into Apple-only conditional extensions, because the old generic constraints were unsatisfiable on Android — Shadow's ShapeStyle conformance is Apple-gated and SkipFuseUI's Gradient is not a ShapeStyle at all. Both must state Sendable up front: a conditional conformance does not imply the Sendable that ShapeStyle inherits. AndroidStyleRendering becomes a struct carrying an optional fill and an optional shadow, rather than an enum of one or the other, because a shadowed style contributes both at once. The fixtures plugin predicts the generated file list and fails the build when it drifts, so View+ThemeStyles.swift is registered there too. Verified: swift test (246 passing), SKIP_ZERO=1 swift build, and skip android build --plain --target ThemeKit. End-to-end, the generator's own output — byte-identical to the hand-verified spike files apart from comments — builds and renders on an Android emulator, including shadow composition and a meshGradients config, which previously could not compile there at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The section described the pre-P1 state and now says things that are simply false: that ShapeStyle sugar is gated off on Android, that hex encoding is Apple-only, and that the meshGradients category generates Apple-only code by design. Leaving those in place is worse than a gap, since this file is the repo's standing briefing — the next reader would design against a world that no longer exists. Scoped to accuracy. The full README rewrite and the per-feature support matrix are P1-4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings the A′ Android render path onto the integration branch: theme data that encodes and a meshGradients category that compiles there (P1-1), and a generator that emits the modifier overloads restoring the exact .foregroundStyle(.primaryColor) spelling (P1-2), gated on the SP-2 spike passing on a device. Not merged to main — that happens once, at P4-6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only colours constructed through Color(hex:) carry a recorded hex spelling, and that recording is the sole reason encoding works on Android at all — the platform has no colour introspection. Keeping the initializer internal meant app devs could not author defaults that survive JSONEncoder there; the demo app had already worked around it by hand-copying the initializer. init(hex: Int), init(hex: String), hexString and HexCodingError become public. canonicalHex and the cache itself stay internal — they are mechanism, not API. The four GeneratedCodeSwift* fixtures now author their colours with Color(hex:). Those targets import ThemeKit without @testable, so building them is what proves the initializer is genuinely public, across all four Swift language modes; a @testable test could not show that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README's Skip / Android section contradicted the code on three points: it said the ShapeStyle sugar was Apple-only, that meshGradients configs had to be omitted from Android themes, and that colour encoding did not work there. All three stopped being true when the render path landed. The "How It Works" snippet was stale too — it showed the old ShapeStyle-constrained ThemeShapeStyle rather than the relaxed constraint and conditional conformance. Replaces it with the actual story (identical call sites), a per-feature support matrix, and the caveats that genuinely bite: Color(hex:) for encodable defaults, no alpha in the hex format, custom Resolver tokens resolving to nil, `some View` return types, and why no tint overload exists on either platform. docs/android-rendering.md carries the drafted release notes. They are not tagged: the single merge into main and the release tag happen together at the end of the Android roadmap, not per phase. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes Phase 1 of the Android roadmap. P1-3 — theme-kit-demo converted from an iOS-only Xcode project into a dual-platform Skip app. It is the render path's regression harness, not a showcase: View+ThemeStyles.swift is entirely #if os(Android), so the Darwin fixture targets compile it to nothing and this app is its only real compile coverage. 20 parity screenshots committed as the baseline. P1-4 — Color(hex:) becomes public so app devs can author Android-encodable defaults, verified by the non-@testable fixture targets across all four Swift language modes. README's Skip/Android section rewritten (it contradicted the shipped code on three counts), and docs/android-rendering.md drafted. Verified: swift test 247/15 suites, SKIP_ZERO=1 build, Android cross-compile, both demo apps running on device. Not tagged and not merged to main — both happen once, at P4-6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ROOT (P2-1) Completes the fork workbench: ThemeKit can be built against unreleased Skip changes without the manifest ever naming a fork URL. Phase 3's mesh gradient work needs this to consume a forked skip-ui. Setting SKIP_DEPENDENCY_ROOT to a directory of local checkouts redirects every dependency whose name starts with "skip" to a path dependency there. The rewrite is all-or-nothing because skip-fuse-ui's own manifest reads the same variable: redirecting only some packages leaves two declarations of one identity and fails resolution outright. skip-model is pinned explicitly, since a root package's path dependencies override transitive identities. Placement matters. It runs after the SKIP_ZERO block, which matches on .sourceControl and would stop recognising these dependencies once they became .fileSystem — silently leaving fork paths in a build meant to contain no Skip machinery at all. `SKIP_ZERO=1 swift build` is the check that catches a swap. CLAUDE.md also records that both SKIP_DEPENDENCY_ROOT and SKIP_ZERO leave Package.resolved dirty, the latter by deleting it outright (zero dependencies, so no lockfile). That is pre-existing behaviour, normally hidden by manifest caching and surfacing after any manifest edit. Verified: swift test (247 tests / 15 suites), SKIP_ZERO=1 swift build, and skip android build --plain --target ThemeKit both with and without the variable. Resolution against the forks reports no identity conflicts, and no fork URL can reach Package.resolved — path dependencies produce no pin at all. This is all that remains of Phase 2. U-2 (ColorSchemeShapeStyle / SizeClassShapeStyle upstream) and P2-2 (bridging tokens to them) were built, tested green on Robolectric, run on an emulator, and then dropped. The premise was false: A′ already honours a subtree .colorScheme() override on Android, because EnvironmentValues.colorScheme's getter reads MaterialTheme, which is exactly what .colorScheme(_:) swaps — only its setter is a no-op. Builds with and without the feature rendered identically on device. Do not retry it without new evidence; see tmp/phase2-evidence.md and theme-kit-demo's Screenshots/phase2/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
THEMEKIT_MESH_UPSTREAM=1 compiles the Android side against the real MeshGradient built on the skip-ui/skip-fuse-ui forks (U-5) instead of the bundled degraded shim: the shim file re-gates to !THEMEKIT_MESH_UPSTREAM, and MeshGradient+AndroidUpstream.swift gives the upstream type the same ThemeKit surface (uniform-grid init, MeshGradientCoding wire format, AndroidRenderableStyle). Unset, consumers keep the shim — flipping the default, deleting the shim, and bumping the skip-fuse-ui minimum wait for the upstream release (Direction #5 follow-up cycle). Verified: negative control fails against released deps; fork build green; demo renders real meshes on an API 36 emulator (light/dark/runtime switch) and the two-stop fallback on API 32; Darwin suite, SKIP_ZERO, and the no-env Android build unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ThemeBackgroundShapeView resolved its token to an AndroidStyleRendering and then used only the shapeStyle, discarding rendering.shadow. Every call site spelled `.background(.cardSurface.card, in: shape)` therefore rendered flat on Android while iOS was correct, because there the shadow rides inside the style value via ThemeShadowedStyle's resolve(in:) and no wrapper has to re-apply it. The shadow now goes on the filled shape rather than on the content. SkipUI's `.shadow` is Shadowed.kt, which re-renders the whole subtree through a colour matrix and blurs it — it silhouettes every non-transparent pixel, so shadowing the content would halo the text inside a card and compose the subtree twice. Applying it to the fill is what Apple's base.shadow(_:) does. The no-fill branch falls back to shadowing the content so the shadow is never dropped silently on any path. Centralize the application while here: AndroidShadow.resolvedColor and View.themeShadow(_:) move the default-colour literal and the nil handling into ThemeKit, so the template no longer repeats them per wrapper and a fix does not require apps to regenerate. Add ThemeViewModifiersGeneratorTests, which had no test file at all — that is why this shipped. The generated Android render path is entirely #if os(Android), so the Darwin fixture targets compile it to nothing and only an emulator could have caught it. The suite asserts the structural invariant that every emitted wrapper applies the shadow, derived from the emitted source so a wrapper added later is covered automatically. Verified: swift test green (258); skip android build clean in theme-kit-demo; shadows render on emulator-5556 with no halo on the card labels. Reverting the ThemeBackgroundShapeView change turns the two new tests red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- theme.json gains config.androidSupport (default false); without it the generated output matches main (pure Apple SwiftUI: @entry environment, ungated ShapeStyle conformances, no #if os(Android) anywhere) - rename the Android workaround surface to say what it is: ThemeStyleResolving -> AndroidShapeStyleAdapter, AndroidStyleRendering -> AndroidResolvedStyle, AndroidRenderableStyle -> AndroidResolvableStyle, themeRendering -> androidThemeRendering, View.themeShadow -> View.androidThemeShadow; generated View+ThemeStyles.swift -> Android/View+AndroidThemeStyles.swift (emitted by the renamed AndroidViewModifiersGenerator) - group library Android code under Sources/ThemeKit/Android/ - strip generated comments down to the header and MARK markers - CLI creates output subdirectories; fixtures plugin declares the Android file only when the flag is on - fixture targets split two flag-on / two flag-off so both output variants compile across both language modes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous commit staged the deletions of the old paths but not the files they were renamed to, leaving the tree unbuildable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The manifest declared `from: "1.0.0"` while the Android render path has only ever been built and verified against 1.18.1 (what Package.resolved pins). A `from:` bound is a claim about what has been tested, and SwiftPM only enforces the lower one — so the old floor let a consumer resolve a skip-fuse-ui that ThemeKit's Android side has never compiled against, with the failure landing in their build rather than ours. Bumps the floor to 1.18.1 so it matches README's new version-requirements table, and notes the sync obligation in the manifest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three Phase 4 documentation items. Rendering fidelity (P4-3): a new README table for Skip/Compose bridge behaviours that surface in ThemeKit-shaped screenshots but are not ThemeKit's doing — perceptual colour space is a no-op, angular gradient angles are ignored, elliptical approximates to radial, gradients on Image/Button tint fall back to Color.primary, materials do not exist, unmapped SF Symbols draw a placeholder and drop the tint, and subtree .colorScheme() is honoured by ThemeKit tokens but not by SwiftUI's built-in palette. Kept separate from the support matrix, which answers a different question. Version requirements (P4-4): a table of what each capability needs, quoting the manifest floors. The plan's ShapeStyle-conformance row is struck — it belonged to U-2, which was dropped. Unreleased rows are covered by shipped workarounds, so they bound fidelity rather than compilation. A' close-out (P4-5): records in CLAUDE.md that the generated Android surface is permanent. The alternative (ShapeStyle conformance + upstream adaptive style types + a ThemeRegistry) was built, measured to render identically, and reverted; no registry exists in Sources/, and the upstream RFC was never opened for the same reason. Also flags the androidSupport opt-in on the feature bullet, which advertised Android readiness unconditionally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rozd
marked this pull request as ready for review
August 14, 2026 18:20
Brings in the retroactive-KeyPath removal (#19) ahead of the close-out merge, so the conflicts resolve here rather than in the umbrella PR. Both changes edit the same two generator templates for different reasons — main swaps the generated styles from synthesized `Sendable` (which needed the now-deleted blanket `KeyPath: Sendable` conformance) to `@unchecked Sendable`, while this branch splits each template into Apple and Android variants. Git only flagged the Android arm, because main's single hunk matched the first of the two copies; the Apple arm merged silently and still carried the old spelling. Applied main's `@unchecked Sendable` and dropped the per-property `nonisolated` in *both* arms, and updated the two branch-side assertions that pinned the old declarations. `Sources/ThemeKit/KeyPath+Sendable.swift` is gone; nothing on this branch referenced it outside the generated styles it existed to serve. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The suite only exercised encode(to:) through meshes that had been decoded first — always [Color], always a 2x2 grid — which left several branches unvisited. Adds coverage for: - The .resolvedColors branch, which was never entered. It emits RGBA component arrays rather than hex strings, so its output does not decode back through init(from:); a test documents that asymmetry rather than endorsing it. - Encoded structure: the exact key set, row-major point order on a non-square grid, point/colour counts on a 4x3, the 1x1 grid, explicit non-uniform points built through the designated init, and the dropping of background/smoothsColors/colorSpace. - Encoder independence: nesting inside another Codable type (the keyed subtree path) and a PropertyListEncoder round-trip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes ThemeKit render on Android through Skip in native (Skip Fuse) mode, with the same call sites you write on Apple. Android output is opt-in — add
"androidSupport": truetotheme.jsonand the generator emits the Android surface; leave it off (the default) and generated output is pure Apple SwiftUI, byte-identical to before.This is the integration branch for the whole Android roadmap; it accumulated five phases of work and is now the changelog for the next 0.x minor. The long-form version lives in
docs/android-rendering.md.Added
Android/View+AndroidThemeStyles.swift: anAndroidShapeStyleAdapterprotocol plus overloads offoregroundStyle,background(_:ignoresSafeAreaEdges:),background(_:in:),border(_:width:),Shape.fillandShape.stroke(_:lineWidth:), each wrapping its content in a view that reads@Environmentitself. Skip's SwiftUI facade has noShapeStyle.resolve(in:)customization point and no environment access outside a view body, soAndroidShapeStyleAdapterplays the roleShapeStyleplays on Apple — the namespace token accessors hang off, and the constraint that lets.surface.cardre-bind from a style to a shadowed style mid-chain.skip+skip-fuse-uidependencies,skipstoneplugin on theThemeKittarget,.dynamiclibrary product,Sources/ThemeKit/Skip/skip.yml.SKIP_ZERO=1strips all Skip machinery, restoring a plain SwiftPM package for Apple-only consumers.resolved(colorScheme:sizeClass:)onThemeAdaptiveStyle— the explicit, environment-free resolution path Android needs.Color(hex:)records a color's canonical#RRGGBBat construction, which is the only route toJSONEncoder().encode(theme)there — Android exposes no color components to read back. On Apple it additionally makes encoding byte-exact instead of round-tripping through a color space.Color(hex:)is public API, along withColor(hex: String),hexString, andColor.HexCodingError. Authoring defaults with hex is what makes them encodable on Android, so an internal initializer made that impossible from outside the package.MeshGradienton Android. ThemeKit ships its own, sharing a wire format with the Apple conformance through an internalMeshGradientCodinglayer so the two cannot drift. AmeshGradientsconfig previously could not compile on Android at all; it now compiles and renders a degraded two-stop diagonal.android-build(cross-compile via the skip CLI) andskip-zero-buildjobs alongside the macOS test job.Changed
ThemeShapeStyle<Style>'s constraint relaxes fromStyle: ShapeStyle & Sendable & Codable & EquatabletoStyle: Sendable & Codable & Equatable, with theShapeStyleconformance becoming a conditional, Apple-only extension. The old constraint was unsatisfiable on Android for two of four categories —Shadow'sShapeStyleconformance is Apple-gated, and Skip'sGradientis not aShapeStyleat all. Apple call sites are unaffected.ThemeShadowedStyle<Base>likewise drops itsBase: ShapeStylerequirement.ShapeStyle+*.swiftemits two mutually exclusive blocks — the Appleextension ShapeStyle where Self == …form and the Androidextension AndroidShapeStyleAdapter where Self == …form.Environment+Theme.swiftuses a classicEnvironmentKeyinstead of@Entry(SkipFuseUI ships no macros).Color.hexStringon macOS converts to sRGB before reading components. Previously, calling it on a catalog color (Color.red) raised an Objective-C exception that aborted the process instead of throwing. Pre-existing bug, surfaced by the new tests.1.0.0→1.18.1— the version the Android path is actually verified against, and the one README's version table quotes.Known limitations
Resolvertokensniland render unstyled — their closures need anEnvironmentValues, which cannot be constructed there.Color(red:green:blue:)colorscopyWith→ encode, the loop remote themes use, is fully covered..red.card(shadow chained onto a SwiftUI style)Colorconform toAndroidShapeStyleAdapterwould make.foregroundStyle(.red)ambiguous with Skip's own modifier.some View, soText(…).foregroundStyle(…).bold()degrades to aViewchain. Put theText-returning modifiers first.#Preview#RRGGBBhas no alpha channel, so avoid.opacity(_:)in token values..tint(.primaryColor)is not supported on either platform, and no overload is emitted for it: SwiftUI'stint(_:)takes anS?, and Swift cannot infer an implicit member's base through an optional generic. Emitting it on Android alone would build on one platform and not the other — precisely the failure mode this design exists to prevent.README also carries a rendering-fidelity table for Skip/Compose bridge behaviours that are not ThemeKit's doing but surface in ThemeKit-shaped screenshots (perceptual color space is a no-op, angular-gradient angles ignored, elliptical ≈ radial, gradients on
Image/Buttontint fall back toColor.primary, no materials, unmapped SF Symbols draw a placeholder and drop the tint), and a version-requirements table for what each capability needs from Skip.Not in this merge
The real AGSL-shader mesh renderer is built and verified on local skip-ui/skip-fuse-ui forks, but stays behind the
THEMEKIT_MESH_UPSTREAMbuild gate (off by default) until it ships in a Skip release. Nothing here requires a fork — the whole branch was verified withSKIP_DEPENDENCY_ROOTunset. Follow-up work is tracked as issues on this repo.Two planned items were closed as won't-do: an Android
ThemeGallerypreview substitute (a runtime feature, and cross-platform if ever built) and Android-compatibility badges in the configurator.Design decision worth recording
The alternative Android design — conforming
ThemeShapeStyletoShapeStylethere, backed by new upstream adaptive-style types and aThemeRegistry— was built on forks, measured, and reverted: it rendered identically, because a view reading@Environment(\.colorScheme)already honours a subtree.colorScheme()override. The generated adapter surface is therefore the permanent mechanism, not a placeholder, and the corresponding upstream RFC was never opened.Test plan
swift test— 270 tests, 16 suites pass on macOSSKIP_ZERO=1 swift build— Skip fully stripped, automatic library type restoredskip android build --plain --target ThemeKit— cross-compiles for Android, withSKIP_DEPENDENCY_ROOTunsetcopyWithswitches themes at runtime,colorSchemetracks the system toggle, subtree overrides are correctly scoped (API 36, arm64-v8a)theme-kit-demobuilds and runs on both platforms, with 20 committed parity screenshots🤖 Generated with Claude Code