diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb55bc0..004e329 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, feat/skip-android-compatibility] pull_request: - branches: [main] + branches: [main, feat/skip-android-compatibility] jobs: test: @@ -42,3 +42,39 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.lcov + + # Proves the ThemeKit target still cross-compiles for Android via Skip. + # Compile-only: rendering claims need an emulator and land with P1-3. + android-build: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + # Installs the skip CLI, the Swift Android SDK, and a matching host + # toolchain (via swiftly), and runs `skip doctor`. The host Swift version + # is deliberately unpinned here so it stays consistent with the Android + # SDK artifactbundle the action installs. + - name: Setup Skip + uses: skiptools/actions/setup-skip@v1 + with: + install-swift-android-sdk: true + + # --target scoping avoids the Darwin-only GeneratedCodeSwift* verification + # targets, which cannot cross-compile. + - name: Build for Android + run: skip android build --plain --target ThemeKit + + # Proves the SKIP_ZERO escape hatch still yields a plain SwiftPM package for + # Apple-only consumers, with every Skip dependency and plugin stripped. + skip-zero-build: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: Setup Swift 6.2 + uses: swift-actions/setup-swift@v2 + with: + swift-version: 6.2 + + - name: Build without Skip + run: SKIP_ZERO=1 swift build diff --git a/CLAUDE.md b/CLAUDE.md index e98259e..c25540c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,3 +90,24 @@ A Svelte SPA at `.github/pages/` that lets users build `theme.json` visually ins - Generated `ShapeStyle` extensions constrain `Self` to `ThemeShapeStyle` - Shadow tokens also generate unconstrained instance properties on `ShapeStyle` for composition — static and instance properties coexist without conflict - Test targets use stub implementations of generated types for testing core types + +## Skip / Android Compatibility + +Android support is **opt-in** via `"androidSupport": true` in `theme.json`. By default (`androidSupport: false`), generated output is pure Apple SwiftUI with no Android code. When enabled, the generator emits additional Android-specific types and modifier overloads. + +The `ThemeKit` target cross-compiles for Android via [Skip](https://skip.dev) in native (Fuse) mode: `Sources/ThemeKit/Skip/skip.yml`, skipstone plugin + SkipFuseUI dependency in Package.swift, `.dynamic` library product. `SKIP_ZERO=1` strips all Skip machinery (block at the bottom of Package.swift — keep it in sync when editing targets/products). + +- Library Android types live in `Sources/ThemeKit/Android/`: `AndroidResolvedStyle` (how a token value renders on Android), `AndroidResolvableStyle` (protocol for token types), `AndroidShadow` (drop shadow primitives), `View.androidThemeShadow(_:)` (shadow application), and shims `MeshGradient+Android.swift` and `MeshGradient+AndroidUpstream.swift`. These are Android-only workarounds for SkipFuseUI's missing customization points. +- Generator naming: `AndroidShapeStyleAdapter` (protocol that replaces `ShapeStyle` as the resolver namespace on Android), `androidThemeRendering(...)` method (replaces `resolve(in:)`), `androidResolvedStyle` property (returns `AndroidResolvedStyle`). +- Apple-only APIs are gated with `#if !os(Android)`: `ShapeStyle` conformances (`resolve(in:)` doesn't exist in SkipFuseUI) and `ShadowStyle`. When `androidSupport: true`, generator templates emit both Apple and Android variants; when false, only Apple. +- Cross-platform resolution API: `resolved(colorScheme:sizeClass:)` (environment values can't be read outside `@Environment` on Android). +- **The Android render path is generated, not gated off** (when `androidSupport: true`). `ThemeShapeStyle`/`ThemeShadowedStyle` are emitted on both platforms (their `ShapeStyle` conformances are Apple-only conditional extensions); `ShapeStyle+*.swift` emits two mutually exclusive blocks, with `AndroidShapeStyleAdapter` standing in for `ShapeStyle` as the accessor namespace on Android; and `Android/View+AndroidThemeStyles.swift` carries the modifier overloads plus the `@Environment`-reading wrapper views. **This file stays generated** (not in ThemeKit): the protocol requirement `androidThemeRendering(theme: Theme, ...)`, the wrapper views' `@Environment(\.theme) var theme: Theme`, and conformances for `ThemeShapeStyle`/`ThemeShadowedStyle` all name the generated `Theme` type (and styles hold `KeyPath`), which the library cannot know. See `tmp/02-phase1-render-path.md` for why the protocol (rather than concrete types) is load-bearing. +- **The generated Android surface is the permanent mechanism, not a placeholder.** The alternative — conforming `ThemeShapeStyle` to `ShapeStyle` on Android, backed by new upstream `ColorSchemeShapeStyle`/`SizeClassShapeStyle` types and a `ThemeRegistry` — was built, measured, and reverted: it rendered identically, because a view reading `@Environment(\.colorScheme)` already honours a subtree `.colorScheme()` override. No registry exists anywhere in `Sources/`. An upstream RFC for custom dynamic shape styles was never opened for the same reason. Treat `AndroidShapeStyleAdapter` + the wrapper views as stable and don't plan around replacing them. +- `Color` encoding works on Android for colours built via `Color(hex:)`, which record their hex at construction (`Color+Hex.swift`); colours built from components still can't encode there. `Color(hex:)`, `Color(hex: String)`, `hexString` and `Color.HexCodingError` are **public API** for that reason — app devs need them to author encodable defaults. The `Tests/GeneratedCodeSwift*` fixtures author their colours with `Color(hex:)` and import ThemeKit non-`@testable`, so `swift build` is what pins that public surface across all four language modes. +- `MeshGradient` has a ThemeKit-owned shim on Android (`Sources/ThemeKit/Android/MeshGradient+Android.swift`) sharing its wire format with the Apple conformance via `MeshGradientCoding`, so the `meshGradients` config category is portable and renders degraded rather than not compiling. +- Verify Android compilation with: `skip android build --plain --target ThemeKit` (requires the skip CLI and a Swift Android SDK; `--target` scoping avoids the Darwin-only GeneratedCode* verification targets). Rendering claims need an emulator — `#if os(Android)` is false under Robolectric. +- **`Package.resolved` needs restoring after two kinds of build.** `SKIP_DEPENDENCY_ROOT=` (see below) turns the Skip dependencies into path deps, which have no pins, so resolving prunes them from the lockfile. `SKIP_ZERO=1` empties the dependency list entirely, so SwiftPM deletes `Package.resolved` outright — normally masked by manifest caching, but it surfaces after *any* manifest edit (a bare comment is enough). Neither leaks fork URLs: path dependencies produce no lockfile entry at all. Run `git checkout -- Package.resolved` after either. +- **Developing against unreleased Skip changes:** set `SKIP_DEPENDENCY_ROOT` to a directory of local Skip checkouts (`~/dev/skiptools`) and the manifest block at the bottom of `Package.swift` redirects every `skip*` dependency there. The rewrite is all-or-nothing on purpose — skip-fuse-ui's own manifest reads the same variable, so a partial rewrite leaves two declarations of one identity and fails resolution. `skip-model` is pinned explicitly because root path deps override transitive identities. The manifest never names a fork URL. +- **`~/dev/rozd/theme-kit-demo` is the render path's regression harness**, not just a showcase: `Android/View+AndroidThemeStyles.swift` is entirely `#if os(Android)`, so the Darwin-only `GeneratedCodeSwift*` targets compile it to nothing. Its only real compile coverage is `skip android build` in that sibling repo, which depends on ThemeKit by path (`.package(path: "../theme-kit")`) until the integration branch merges. Changing a generator template means rebuilding it there too. +- **Dependency floors are documentation.** `Package.swift`'s `from:` versions are the ones the Android path is verified against, and README's "Version requirements" table quotes them — bump both together, never one alone. +- User-facing docs: `README.md`'s Skip / Android section (opt-in flag, support matrix, rendering-fidelity table, version requirements) and `docs/android-rendering.md` (drafted release notes, not tagged). diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..d37222e --- /dev/null +++ b/Package.resolved @@ -0,0 +1,114 @@ +{ + "originHash" : "d0b8383f31559f70e816864f8f25f727f35317ef5fc1f07a73ef4c571df653e3", + "pins" : [ + { + "identity" : "skip", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip.git", + "state" : { + "revision" : "885f0c520e1ebdbec1f0e296d713293dadc5a2f4", + "version" : "1.9.5" + } + }, + { + "identity" : "skip-android-bridge", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-android-bridge.git", + "state" : { + "revision" : "545ea1b2d7ba4abc82daa2be00f81f4ea8e64e5b", + "version" : "0.6.4" + } + }, + { + "identity" : "skip-bridge", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-bridge.git", + "state" : { + "revision" : "72b7b1d4734332cfdc4b519539b5beec0fb3ac00", + "version" : "0.17.2" + } + }, + { + "identity" : "skip-foundation", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-foundation.git", + "state" : { + "revision" : "94d47aeed3bb8027ef3ad8e07a8771b52529c238", + "version" : "1.4.2" + } + }, + { + "identity" : "skip-fuse", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-fuse.git", + "state" : { + "revision" : "8f3295094ad29075730284c5197c7f1d94c0f2d9", + "version" : "1.0.2" + } + }, + { + "identity" : "skip-fuse-ui", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-fuse-ui.git", + "state" : { + "revision" : "d27fc109268b21feb98a48bc1a3f4558e162d9ca", + "version" : "1.18.1" + } + }, + { + "identity" : "skip-lib", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-lib.git", + "state" : { + "revision" : "76e7da8a870b5b66ea0c3264f648b58b73bcdc0d", + "version" : "1.4.0" + } + }, + { + "identity" : "skip-model", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-model.git", + "state" : { + "revision" : "54c7914e985e5ae07b1a8fe29e7aac7156b88874", + "version" : "1.7.6" + } + }, + { + "identity" : "skip-ui", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-ui.git", + "state" : { + "revision" : "ef7bbdd541cdf2efd3ce6ecde72337e1beb92366", + "version" : "1.59.1" + } + }, + { + "identity" : "skip-unit", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-unit.git", + "state" : { + "revision" : "c89af47fd645e04db863e938ade39f91e1bb62b8", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-android-native", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/swift-android-native.git", + "state" : { + "revision" : "7e6e833e6f163a2b75340f75c70b1d96ea6b8135", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-jni", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/swift-jni.git", + "state" : { + "revision" : "fe76ac21aca639976833b5ea3e875dc072519ac4", + "version" : "0.5.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 097a67b..aed7f39 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,7 @@ let package = Package( products: [ .library( name: "ThemeKit", + type: .dynamic, targets: ["ThemeKit"] ), .plugin( @@ -22,9 +23,19 @@ let package = Package( targets: ["Generate Theme Files"] ), ], + // These floors are the versions the Android path is actually verified against, and they are + // the ones README's "Version requirements" table quotes — keep the two in sync. + dependencies: [ + .package(url: "https://source.skip.tools/skip.git", from: "1.9.5"), + .package(url: "https://source.skip.tools/skip-fuse-ui.git", from: "1.18.1"), + ], targets: [ .target( - name: "ThemeKit" + name: "ThemeKit", + dependencies: [ + .product(name: "SkipFuseUI", package: "skip-fuse-ui") + ], + plugins: [.plugin(name: "skipstone", package: "skip")] ), .target( name: "ThemeKitGenerator" @@ -96,3 +107,86 @@ let package = Package( ), ] ) + +// Setting the SKIP_ZERO=1 environment strips the Skip plugin and all Skip dependencies, +// restoring a plain SwiftPM package for Apple-only consumers. +if Context.environment["SKIP_ZERO"] ?? "0" != "0" { + package.targets.forEach { target in + target.plugins?.removeAll(where: { + if case .plugin(let name, _) = $0 { + return name == "skipstone" + } else { + return false + } + }) + + target.dependencies.removeAll(where: { dependency in + if case .productItem(_, let package, _, _) = dependency { + return package == "skip" || package?.hasPrefix("skip-") == true + } else { + return false + } + }) + } + + package.dependencies.removeAll(where: { dependency in + if case .sourceControl(_, let url, _) = dependency.kind { + return url.hasPrefix("https://source.skip.dev/") || url.hasPrefix("https://source.skip.tools/") + } else { + return false + } + }) + + // Restore the default (automatic) library type — dynamic is only needed for Android/JNI loading. + package.products = [ + .library(name: "ThemeKit", targets: ["ThemeKit"]), + .plugin(name: "Generate Theme Files", targets: ["Generate Theme Files"]), + ] +} + +// Setting SKIP_DEPENDENCY_ROOT to a directory of local Skip checkouts points every Skip +// dependency at those working copies, for developing against unreleased Skip changes. +// +// The rewrite is deliberately all-or-nothing: skip-fuse-ui's own manifest reads the same +// variable and redirects every dependency whose name begins with "skip", so redirecting only +// some of them here would leave two different declarations of the same package identity and +// fail resolution outright. +// +// This runs last on purpose. The SKIP_ZERO block above matches on `.sourceControl`, and would +// no longer recognise these dependencies once they had become `.fileSystem`. +// +// No fork URL appears anywhere in this manifest — only local paths, and only when the variable +// is set — so nothing can leak into a consumer's Package.resolved. +if Context.environment["SKIP_ZERO"] ?? "0" == "0", + let dependencyRoot = Context.environment["SKIP_DEPENDENCY_ROOT"] { + package.dependencies = package.dependencies.map { dependency in + guard case .sourceControl(_, let url, _) = dependency.kind, + let name = url.split(separator: "/").last?.split(separator: ".").first, + name.hasPrefix("skip") else { + return dependency + } + return .package(path: "\(dependencyRoot)/\(name)") + } + + // A root package's path dependencies override transitive declarations of the same identity, + // so the Skip packages this manifest never names directly have to be pinned here too. + package.dependencies.append(.package(path: "\(dependencyRoot)/skip-model")) +} + +// Setting THEMEKIT_MESH_UPSTREAM=1 compiles ThemeKit's Android side against the real +// MeshGradient in skip-fuse-ui (which exists only on the local forks until upstream releases, +// so this is only meaningful together with SKIP_DEPENDENCY_ROOT) instead of the bundled +// degraded shim in Sources/ThemeKit/Android/MeshGradient+Android.swift. Unset, consumers get +// the shim — flipping this default is a post-release follow-up, not part of this plan. +// +// Note: the env-var cannot be set from Xcode — the Android build reaches this manifest through +// Xcode's Run Script phase → `skip gradle` → a Gradle task that execs `swift build`, and the +// variable does not survive that chain. When working against the forks from Xcode, temporarily +// replace the condition with `if true` (do not commit that). +if Context.environment["THEMEKIT_MESH_UPSTREAM"] ?? "0" != "0" { + if let themeKit = package.targets.first(where: { $0.name == "ThemeKit" }) { + var settings = themeKit.swiftSettings ?? [] + settings.append(.define("THEMEKIT_MESH_UPSTREAM")) + themeKit.swiftSettings = settings + } +} diff --git a/Plugins/GenerateTestFixturesPlugin/GenerateTestFixturesPlugin.swift b/Plugins/GenerateTestFixturesPlugin/GenerateTestFixturesPlugin.swift index f099069..ed13e4f 100644 --- a/Plugins/GenerateTestFixturesPlugin/GenerateTestFixturesPlugin.swift +++ b/Plugins/GenerateTestFixturesPlugin/GenerateTestFixturesPlugin.swift @@ -40,6 +40,7 @@ struct GenerateTestFixturesPlugin: BuildToolPlugin { let categoryKeys = styles.keys let shouldGeneratePreview = config["shouldGeneratePreview"] as? Bool ?? false + let androidSupport = config["androidSupport"] as? Bool ?? false // Map JSON category keys to struct names let categoryStructNames: [String: String] = [ @@ -57,6 +58,11 @@ struct GenerateTestFixturesPlugin: BuildToolPlugin { files.append("Theme.swift") files.append("Theme+CopyWith.swift") + // Android render path (only when androidSupport is true) + if androidSupport { + files.append("Android/View+AndroidThemeStyles.swift") + } + // Conditional: ThemeShadowedStyle only when shadows present if categoryKeys.contains("shadows") { files.append("ThemeShadowedStyle.swift") diff --git a/README.md b/README.md index ee1191d..4763cee 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,14 @@ ThemeKit gives your app a design token system that works exactly like SwiftUI's - 🪄 **Easy Setup** — declare tokens in JSON, run the plugin once, fill in your colors, done. **Zero imports** required in your app code. - 📖 **Transparent Logic** — the thin core and generated files are easy to read. Each file has a clear, specific role that is obvious at a glance. - 🎛️ **Full Control** — generated files live in your project, fully readable and yours to extend. +- 🤖 **Skip / Android Ready** — opt in with `"androidSupport": true` and the same call sites render on Android with [Skip](https://skip.dev) (native/Fuse mode). `.foregroundStyle(.primaryColor)` is spelled identically on both platforms — no `#if os(Android)`, no manual resolution. Off by default: generated output stays pure Apple SwiftUI. ## 🍿 Demo https://github.com/user-attachments/assets/f4563c6a-57e2-4356-bd87-72276ec9bf96 +[**rozd/theme-kit-demo**](https://github.com/rozd/theme-kit-demo) is a dual-platform [Skip](https://skip.dev) app covering every token category from one shared source tree, with side-by-side iOS and Android screenshots. + ## 🛠️ Configurator You don't need to write JSON by hand, use the [**ThemeKit Configurator**](https://rozd.github.io/theme-kit/) — a visual editor that lets you toggle categories, add tokens, configure style overrides, and copy the finished `theme.json` straight into your project. @@ -180,12 +183,15 @@ RoundedRectangle(cornerRadius: 12) .fill(.surface.card) // theme color + theme shadow RoundedRectangle(cornerRadius: 12) - .fill(.red.card) // SwiftUI color + theme shadow + .fill(.red.card) // SwiftUI color + theme shadow (Apple-only) RoundedRectangle(cornerRadius: 12) .fill(.surface.card.innerGlow) // multiple shadows chained ``` +> Chaining onto a **theme** style (`.surface.card`) works everywhere. Chaining onto a **SwiftUI** +> style (`.red.card`) is Apple-only — see [Skip / Android](#-skip--android). + ### Switch themes at runtime The generated `Environment+Theme.swift` provides implicit theme injection — every token resolves against `Theme.default` automatically, so things just work with no setup. When you need to switch themes at runtime, override the environment value: @@ -231,18 +237,132 @@ Every type conforms to `Codable`, so themes can come from a remote API, a bundle let theme = try JSONDecoder().decode(Theme.self, from: data) ``` +## 🤖 Skip / Android + +ThemeKit works with [Skip](https://skip.dev) in **native (Skip Fuse) mode**, and Android generation is **opt-in** — by default, generated output is pure Apple SwiftUI. Enable it by adding `"androidSupport": true` to your `theme.json` config: + +```json +{ + "styles": { /* tokens */ }, + "config": { + "outputPath": ".", + "androidSupport": true + } +} +``` + +With it enabled, the call sites are the same ones you write on Apple: + +```swift +// This file compiles and renders on iOS and Android. No #if, no manual resolution. +Text("Hello") + .foregroundStyle(.primaryColor) + +RoundedRectangle(cornerRadius: 12) + .fill(.surface.card) +``` + +Add ThemeKit to your Skip app the way you'd add any Skip module — the package already ships the `skipstone` plugin and its `Skip/skip.yml` — then generate your theme files as usual. Nothing about the integration steps changes. + +
+How the same spelling works on both platforms + +On Apple, tokens resolve through `ShapeStyle.resolve(in:)`. Skip's SwiftUI facade has no such customization point, and environment values can't be read outside a view body there — so on Android the generator emits a small parallel surface instead: overloads of the style-taking modifiers (`foregroundStyle`, `background`, `border`, `fill`, `stroke`) that wrap your content in a view which reads `@Environment` itself. + +Those overloads are constrained to a generated `AndroidShapeStyleAdapter` protocol, which plays exactly the role `ShapeStyle` plays on Apple — the namespace your token accessors hang off, and the constraint that lets `.surface.card` re-bind from a style to a shadowed style mid-chain. Only ThemeKit's own types conform to it, so the overloads can never be ambiguous with Skip's. + +
+ +### Support matrix + +| Feature | Apple | Android | +|---|:--:|:--:| +| Token data, `Codable` decode, `copyWith` | ✅ | ✅ | +| `.foregroundStyle(.primaryColor)` and friends — identical spelling | ✅ | ✅ | +| Colors, gradients | ✅ | ✅ | +| Shadows | ✅ | drop only — `.inner` is data | +| Mesh gradients | ✅ | degraded — two-stop diagonal¹ | +| Encode theme → JSON | ✅ | `Color(hex:)` colors only | +| `#Preview` | ✅ | ❌ | +| Custom `Resolver` tokens | ✅ | ❌ — resolve to `nil` | +| `.red.card` (shadow on a *SwiftUI* style) | ✅ | ❌ | +| `.tint(.primaryColor)` | ❌ | ❌ | + +¹ A real AGSL-shader mesh renderer exists for skip-ui/skip-fuse-ui, built and verified on forks +ahead of upstream PRs — see [Version requirements](#version-requirements). + +### Rendering fidelity + +These are Skip/Compose bridge behaviors, not ThemeKit ones — but they surface in ThemeKit-shaped +screenshots, so they're worth knowing before you file a bug against a token. + +| SwiftUI behavior | On Android | +|---|---| +| `Gradient.colorSpace(.perceptual)` | silent no-op — sRGB interpolation | +| `AngularGradient` start/end angles | ignored | +| `EllipticalGradient` | approximated by a radial gradient | +| Gradient on `Image` / `Button` tint | color-only consumers — falls back to `Color.primary` (`Text` does render gradients) | +| Materials, glass effects | not present in the bridge | +| SF Symbols outside Skip's ~324 Material mappings | placeholder glyph, and the tint is ignored | +| `.colorScheme(.dark)` on a subtree | ThemeKit tokens honour it; SwiftUI's built-in palette (`Color.red`…) reads the system theme and ignores it | + +### Things worth knowing + +**Author your defaults with `Color(hex:)`.** A color records its hex spelling at construction, and that recording is the only way `JSONEncoder().encode(theme)` can work on Android — the platform exposes no color components to read back. Colors built with `Color(red:green:blue:)` decode fine but cannot re-encode there. + +```swift +// Encodes everywhere. +surface: .init(light: Color(hex: 0xF7F5F2), dark: Color(hex: 0x1A1A1F)) +``` + +The hex format is `#RRGGBB` with **no alpha channel**, so avoid `.opacity(_:)` in token values — the derived color isn't the one that was recorded. + +**Custom `Resolver` styles resolve to `nil` on Android** and render unstyled; their closures need an `EnvironmentValues`, which cannot be constructed there. Tokens built from `.colorScheme(light:dark:)`, `.sizeClass(compact:regular:)` or `.value(_:)` are unaffected. + +**`.tint(.primaryColor)` isn't supported on either platform.** SwiftUI's `tint(_:)` takes an `S?`, and Swift can't infer an implicit member's base through an optional generic. Resolve explicitly instead: + +```swift +.tint(theme.colors.primary.resolved(colorScheme: colorScheme) ?? .accentColor) +``` + +**Modifier return types.** The Android overloads return `some View`, so a chain that relies on staying a `Text` (`Text(…).foregroundStyle(…).bold()`) degrades to a `View` chain there. Reorder so the `Text`-returning modifiers come first. + +**Apple-only projects:** if you don't use Skip, set `SKIP_ZERO=1` when resolving packages to strip every Skip dependency and plugin — ThemeKit then behaves as a plain SwiftPM package. Without it the Skip packages *resolve* but never build for Apple targets, which is the Skip-ecosystem norm. + +### Version requirements + +| ThemeKit capability | Requires | +|---|---| +| Everything in the support matrix above | `skip` ≥ 1.9.5, `skip-fuse-ui` ≥ 1.18.1 — the current floor | +| Gradient decode without ThemeKit's workaround | a skip-fuse-ui release with a public `Gradient.Stop` init — not released yet | +| Real mesh gradients (AGSL shader, API 33+) | a skip-ui/skip-fuse-ui release with `MeshGradient` — not released yet | +| Inner shadows on fills | no upstream design yet | + +The unreleased rows are already covered by shipped workarounds, so nothing fails to build on +today's Skip — they bound fidelity, not compilation. When each lands, ThemeKit drops the +workaround and raises the floor in the same release. + +See [`docs/android-rendering.md`](docs/android-rendering.md) for the full release notes, and [rozd/theme-kit-demo](https://github.com/rozd/theme-kit-demo) for a dual-platform app with side-by-side screenshots. + ## ⚙️ How It Works The generated `ThemeShapeStyle