Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d5dc153
feat: add Skip framework compatibility for Android
rozd Aug 11, 2026
b6782a5
fix: propagate hexString errors during Color encoding
rozd Aug 12, 2026
7b7f978
ci: verify Android cross-compile and SKIP_ZERO manifest
rozd Aug 12, 2026
35ec81b
feat: make theme data encodable and mesh-portable on Android (P1-1)
rozd Aug 12, 2026
f46f126
feat: generate the Android render path (P1-2)
rozd Aug 12, 2026
dcb8580
docs: correct CLAUDE.md's Skip section for the A′ render path
rozd Aug 12, 2026
a1d4ba3
Merge feat/android-render-path: Phase 1 render path (SP-2, P1-1, P1-2)
rozd Aug 12, 2026
16063c8
feat: make Color(hex:) public so themes can encode on Android
rozd Aug 12, 2026
a913048
docs: document the Android render path and draft the 0.x release notes
rozd Aug 12, 2026
aecdb3e
Merge feat/android-phase1-close: Phase 1 close-out (P1-3, P1-4)
rozd Aug 12, 2026
0cd4375
build: redirect Skip dependencies to local forks via SKIP_DEPENDENCY_…
rozd Aug 13, 2026
5cf791a
feat: adopt the upstream MeshGradient behind a build gate (P3-2)
rozd Aug 13, 2026
dca37c2
Merge feat/mesh-upstream: Phase 3 mesh gradients (P3-1, U-5, P3-2)
rozd Aug 13, 2026
5b92a70
fix: apply shadows in background(_:in:) on Android
rozd Aug 14, 2026
51ef3dd
refactor: make Android generation opt-in and Android-explicit
rozd Aug 14, 2026
cad258f
fix: add the renamed Android sources missed by the refactor commit
rozd Aug 14, 2026
1ae0bbc
build: raise the skip-fuse-ui floor to the verified version
rozd Aug 14, 2026
a3abb33
docs: land the fidelity register, version policy, and the A' close-out
rozd Aug 14, 2026
5dd3eb8
Merge branch 'main' into feat/skip-android-compatibility
rozd Aug 14, 2026
bf10c56
test: cover MeshGradient.encode's untested paths
rozd Aug 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
21 changes: 21 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConcreteType>`
- 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<Theme, …>`), 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=<dir>` (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).
114 changes: 114 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

96 changes: 95 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,27 @@ let package = Package(
products: [
.library(
name: "ThemeKit",
type: .dynamic,
targets: ["ThemeKit"]
),
.plugin(
name: "Generate Theme Files",
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"
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
Expand All @@ -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")
Expand Down
Loading
Loading