Skip to content

Android support - #18

Open
rozd wants to merge 20 commits into
mainfrom
feat/skip-android-compatibility
Open

Android support#18
rozd wants to merge 20 commits into
mainfrom
feat/skip-android-compatibility

Conversation

@rozd

@rozd rozd commented Aug 11, 2026

Copy link
Copy Markdown
Owner

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": true to theme.json and the generator emits the Android surface; leave it off (the default) and generated output is pure Apple SwiftUI, byte-identical to before.

// Before — Android only.
@Environment(\.theme) var theme
@Environment(\.colorScheme) var colorScheme

Text("Hello")
    .foregroundStyle(theme.colors.primary.resolved(colorScheme: colorScheme) ?? .primary)

// After — iOS and Android, same file, no #if.
Text("Hello")
    .foregroundStyle(.primaryColor)

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 render path (opt-in). The generator emits Android/View+AndroidThemeStyles.swift: an AndroidShapeStyleAdapter protocol plus overloads of foregroundStyle, background(_:ignoresSafeAreaEdges:), background(_:in:), border(_:width:), Shape.fill and Shape.stroke(_:lineWidth:), each wrapping its content in a view that reads @Environment itself. Skip's SwiftUI facade has no ShapeStyle.resolve(in:) customization point and no environment access outside a view body, so AndroidShapeStyleAdapter plays the role ShapeStyle plays on Apple — the namespace token accessors hang off, and the constraint that lets .surface.card re-bind from a style to a shadowed style mid-chain.
  • Packaging: skip + skip-fuse-ui dependencies, skipstone plugin on the ThemeKit target, .dynamic library product, Sources/ThemeKit/Skip/skip.yml. SKIP_ZERO=1 strips all Skip machinery, restoring a plain SwiftPM package for Apple-only consumers.
  • resolved(colorScheme:sizeClass:) on ThemeAdaptiveStyle — the explicit, environment-free resolution path Android needs.
  • Theme encoding on Android. Color(hex:) records a color's canonical #RRGGBB at construction, which is the only route to JSONEncoder().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 with Color(hex: String), hexString, and Color.HexCodingError. Authoring defaults with hex is what makes them encodable on Android, so an internal initializer made that impossible from outside the package.
  • MeshGradient on Android. ThemeKit ships its own, sharing a wire format with the Apple conformance through an internal MeshGradientCoding layer so the two cannot drift. A meshGradients config previously could not compile on Android at all; it now compiles and renders a degraded two-stop diagonal.
  • CI: android-build (cross-compile via the skip CLI) and skip-zero-build jobs alongside the macOS test job.
  • rozd/theme-kit-demo — a dual-platform Skip app exercising every token category from one shared source tree, with iOS/Android screenshots as the parity baseline.

Changed

  • ThemeShapeStyle<Style>'s constraint relaxes from Style: ShapeStyle & Sendable & Codable & Equatable to Style: Sendable & Codable & Equatable, with the ShapeStyle conformance becoming a conditional, Apple-only extension. The old constraint was unsatisfiable on Android for two of four categories — Shadow's ShapeStyle conformance is Apple-gated, and Skip's Gradient is not a ShapeStyle at all. Apple call sites are unaffected.
  • ThemeShadowedStyle<Base> likewise drops its Base: ShapeStyle requirement.
  • Generated ShapeStyle+*.swift emits two mutually exclusive blocks — the Apple extension ShapeStyle where Self == … form and the Android extension AndroidShapeStyleAdapter where Self == … form.
  • Generated Environment+Theme.swift uses a classic EnvironmentKey instead of @Entry (SkipFuseUI ships no macros).
  • Color.hexString on 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.
  • skip-fuse-ui floor raised 1.0.01.18.1 — the version the Android path is actually verified against, and the one README's version table quotes.

Known limitations

Area On Android
Custom Resolver tokens Resolve to nil and render unstyled — their closures need an EnvironmentValues, which cannot be constructed there.
Inner shadows Data only; drop shadows render normally.
Mesh gradients Degraded two-stop diagonal. Real mesh rendering needs upstream Compose work.
Encoding Color(red:green:blue:) colors Fails. Only hex-constructed colors carry a recorded spelling. Decode → copyWith → encode, the loop remote themes use, is fully covered.
.red.card (shadow chained onto a SwiftUI style) Unavailable — letting Color conform to AndroidShapeStyleAdapter would make .foregroundStyle(.red) ambiguous with Skip's own modifier.
Modifier return types Overloads return some View, so Text(…).foregroundStyle(…).bold() degrades to a View chain. Put the Text-returning modifiers first.
#Preview Unavailable — no macros in Skip's SwiftUI facade.
Alpha in hex values #RRGGBB has 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's tint(_:) takes an S?, 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/Button tint fall back to Color.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_UPSTREAM build gate (off by default) until it ships in a Skip release. Nothing here requires a fork — the whole branch was verified with SKIP_DEPENDENCY_ROOT unset. Follow-up work is tracked as issues on this repo.

Two planned items were closed as won't-do: an Android ThemeGallery preview 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 ThemeShapeStyle to ShapeStyle there, backed by new upstream adaptive-style types and a ThemeRegistry — 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 macOS
  • SKIP_ZERO=1 swift build — Skip fully stripped, automatic library type restored
  • skip android build --plain --target ThemeKit — cross-compiles for Android, with SKIP_DEPENDENCY_ROOT unset
  • Emulator: custom environment key round-trips, copyWith switches themes at runtime, colorScheme tracks the system toggle, subtree overrides are correctly scoped (API 36, arm64-v8a)
  • Emulator: generated render path renders on device; mesh verified on API 36 and API 32
  • theme-kit-demo builds and runs on both platforms, with 20 committed parity screenshots

🤖 Generated with Claude Code

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

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.66548% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 99.00%. Comparing base (2759540) to head (bf10c56).

Files with missing lines Patch % Lines
Sources/ThemeKit/MeshGradient+Codable.swift 78.57% 6 Missing ⚠️
...atorTests/AndroidViewModifiersGeneratorTests.swift 96.95% 6 Missing ⚠️
Sources/ThemeKit/Color+Hex.swift 91.66% 3 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 without EnvironmentValues.
  • Update generator templates and tests to emit Skip-safe code (e.g., EnvironmentKey instead 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)
rozd and others added 2 commits August 12, 2026 13:02
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 rozd changed the title Add Skip framework compatibility for Android Android support Aug 12, 2026
@rozd
rozd marked this pull request as draft August 12, 2026 13:48
rozd and others added 15 commits August 12, 2026 18:26
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
rozd marked this pull request as ready for review August 14, 2026 18:20
rozd and others added 2 commits August 14, 2026 21:30
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants