Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ default.profraw
.github/pages/node_modules/

.github/pages/dist/
.github/pages/.vite/
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Tests/

### Core Types (ThemeKit target)

- **`ThemeAdaptiveStyle<Style>`** — Generic wrapper holding `light`/`dark` variants, resolved via `ColorScheme`.
- **`ThemeAdaptiveStyle<Style>`** — Generic wrapper that pairs optional `Defaults` (the serializable data) with a `Resolver` (a closure that reads `EnvironmentValues`). `Defaults` is a nested enum with three cases: `.colorScheme(light:dark:)`, `.sizeClass(compact:regular:)`, and `.value(_:)`. Each case knows how to create its own resolver. Styles created with a custom `Resolver` have `defaults = nil` and cannot be encoded. Convenience computed properties `.light`, `.dark`, `.compact`, `.regular` extract values from the matching `Defaults` case (returning `nil` for mismatched cases).
- **Codable conformances** — `Color`, `Gradient`, etc. get `Codable` conformance (via `@retroactive`) for remote/serialized theming.

### Generated Types (NOT in the package — produced per app)
Expand Down Expand Up @@ -84,7 +84,8 @@ A Svelte SPA at `.github/pages/` that lets users build `theme.json` visually ins

- All types are `nonisolated`, `Sendable`, and `Codable`
- Style resolution goes through SwiftUI's `resolve(in: EnvironmentValues)` — never requires `@Environment` in views
- Every token must have both light and dark variants (no optionals/fallbacks)
- Tokens adapt by axis: colorScheme (light/dark), sizeClass (compact/regular), or constant value — the `Defaults` enum case determines which
- Codable auto-detects format by JSON keys: `{"light","dark"}` → colorScheme, `{"compact","regular"}` → sizeClass, plain value → value
- `copyWith` pattern enables immutable theme updates at runtime
- 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
Expand Down
2 changes: 1 addition & 1 deletion IMPLEMENTATION_DETAILS.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ struct ThemeShapeStyle<Style: ShapeStyle>: ShapeStyle, Sendable {
let keyPath: KeyPath<Theme, ThemeAdaptiveStyle<Style>>

func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
environment.theme[keyPath: keyPath].resolved(for: environment.colorScheme)
environment.theme[keyPath: keyPath].resolved(in: environment)
}
}
```
Expand Down
37 changes: 33 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@

**Native-feeling theming for SwiftUI, powered by the environment.**

ThemeKit gives your app a design token system that works exactly like SwiftUI's built-in styles. Every token is adaptive — light and dark variants resolve automatically based on the system color scheme. You declare your tokens in a simple JSON file (just names, no colors), run one command, and you're ready to use `.foregroundStyle(.surface)` anywhere.
ThemeKit gives your app a design token system that works exactly like SwiftUI's built-in styles. Every token is adaptive — variants resolve automatically based on the environment (color scheme, size class, or any custom axis). You declare your tokens in a simple JSON file (just names, no colors), run one command, and you're ready to use `.foregroundStyle(.surface)` anywhere.

---

## ✨ Features

- 🍎 **SwiftUI Native** — tokens resolve through `ShapeStyle.resolve(in:)`, the same mechanism as `.primary` and `.tint`. No `@Environment` wrappers needed in views.
- 🌗 **Adaptive by Default** — every token carries light and dark variants. The correct one resolves automatically at render time — no manual `colorScheme` checks.
- 🌗 **Adaptive by Default** — tokens adapt by color scheme (light/dark), size class (compact/regular), or any custom axis via resolvers. The correct variant resolves automatically at render time.
- 📦 **Minimal Core** — the library is just `ThemeAdaptiveStyle` and a few `Codable` extensions. Everything else is generated.
- 🔧 **Easy Setup** — declare tokens in JSON, run the plugin, fill in your colors. Four steps total.
- 🔓 **Full Control** — generated files live in your project, fully readable and yours to extend.
Expand Down Expand Up @@ -117,6 +117,35 @@ nonisolated extension ThemeGradients {

That's it — your theme is ready to use.

<details>
<summary>Advanced</summary>

Beyond light/dark, tokens can adapt by **size class** or hold a **constant value**:

```swift
// Adapt by horizontal size class (compact vs regular)
surface: .init(compact: Color(hex: 0xFFFFFF), regular: Color(hex: 0xF7F5EC))

// Same value in all environments
surface: .init(value: Color(hex: 0xF7F5EC))
```

For full control, use a **custom resolver** — a closure that reads `EnvironmentValues` directly:

```swift
surface: .init(resolver: .init(id: "high-contrast") { env in
env.colorSchemeContrast == .increased
? Color(hex: 0xFFFFFF)
: Color(hex: 0xF7F5EC)
})
```

The `id` parameter drives `Equatable` — two resolvers with the same `id` are considered equal, which lets SwiftUI skip unnecessary redraws. Omit it to get a unique auto-generated id.

> **Note:** Tokens created with a custom resolver cannot be encoded to JSON, since they have no serializable defaults.

</details>

## 🎨 Usage

### Use tokens in views
Expand Down Expand Up @@ -196,14 +225,14 @@ let theme = try JSONDecoder().decode(Theme.self, from: data)

## ⚙️ How It Works

The generated `ThemeShapeStyle<Style>` bridges your tokens into SwiftUI's style resolution system. It holds a key path into `Theme` and resolves the correct light/dark variant at render time:
The generated `ThemeShapeStyle<Style>` bridges your tokens into SwiftUI's style resolution system. It holds a key path into `Theme` and resolves the correct variant at render time:

```swift
struct ThemeShapeStyle<Style: ShapeStyle>: ShapeStyle {
let keyPath: KeyPath<Theme, ThemeAdaptiveStyle<Style>>

func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
environment.theme[keyPath: keyPath].resolved(for: environment.colorScheme)
environment.theme[keyPath: keyPath].resolved(in: environment)
}
}
```
Expand Down
88 changes: 88 additions & 0 deletions Sources/ThemeKit/ThemeAdaptiveStyle+Defaults.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import SwiftUI

nonisolated public extension ThemeAdaptiveStyle {

enum Defaults: Sendable, Equatable {
case colorScheme(light: Style, dark: Style)
case sizeClass(compact: Style, regular: Style)
case value(Style)

func makeResolver() -> Resolver {
switch self {
case .colorScheme(let light, let dark):
Resolver(id: Self.resolverID(self)) { env in
env.colorScheme == .dark ? dark : light
}
case .sizeClass(let compact, let regular):
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

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

The sizeClass resolver only checks horizontalSizeClass but completely ignores verticalSizeClass. This design choice should be documented. On some devices (like iPad in landscape), both horizontal and vertical size classes can affect layout decisions.

Consider either:

  1. Adding a comment explaining why only horizontal size class is used
  2. Providing a way to adapt based on vertical size class (perhaps through a custom resolver)
  3. Or at minimum, documenting this limitation in the API documentation
Suggested change
case .sizeClass(let compact, let regular):
case .sizeClass(let compact, let regular):
// Note: This resolver intentionally considers only the horizontal size class.
// If your layout depends on verticalSizeClass as well, define a custom Resolver.

Copilot uses AI. Check for mistakes.
Resolver(id: Self.resolverID(self)) { env in
env.horizontalSizeClass == .compact ? compact : regular
}
case .value(let style):
Resolver(id: Self.resolverID(self)) { _ in
style
}
}
}

private static func resolverID(_ defaults: Defaults) -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys
guard let data = try? encoder.encode(defaults) else {
return UUID().uuidString
}
var hasher = Hasher()
hasher.combine(data)
return String(hasher.finalize())
}
Comment on lines +27 to +36
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

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

The Hasher type in Swift does not guarantee stable hashes across different executions of a program. Hash values can change between app launches, OS versions, or even different runs. Using Hasher.finalize() to generate a stable ID for the resolver means that the same Defaults could produce different resolver IDs in different contexts, which could break equality comparisons or cause issues with serialization round-trips if the ID is persisted.

Consider using a stable hashing algorithm (such as SHA-256 from CryptoKit) or a deterministic string representation for the resolver ID to ensure consistency across app launches and platforms.

Copilot uses AI. Check for mistakes.
}
}

// MARK: - Codable

extension ThemeAdaptiveStyle.Defaults: Codable {

private enum ColorSchemeCodingKeys: String, CodingKey {
case light, dark
}

private enum SizeClassCodingKeys: String, CodingKey {
case compact, regular
}

public init(from decoder: Decoder) throws {
// Try keyed container first for colorScheme / sizeClass
if let container = try? decoder.container(keyedBy: ColorSchemeCodingKeys.self),
container.contains(.light) {
self = .colorScheme(
light: try container.decode(Style.self, forKey: .light),
dark: try container.decode(Style.self, forKey: .dark)
)
} else if let container = try? decoder.container(keyedBy: SizeClassCodingKeys.self),
container.contains(.compact) {
self = .sizeClass(
compact: try container.decode(Style.self, forKey: .compact),
regular: try container.decode(Style.self, forKey: .regular)
)
Comment on lines +54 to +65
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

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

The decoding logic only checks if the "light" key exists to determine if this is a colorScheme case, but doesn't verify that the "dark" key also exists before attempting to decode it. If JSON contains only {"light": "#FF0000"} without a "dark" key, the decode will fail with a potentially confusing KeyNotFound error rather than a clear validation error.

The same issue exists for sizeClass - only "compact" is checked for existence, but "regular" is assumed to exist. Consider checking for both keys in each case before attempting to decode, and provide a clear error message if one is missing.

Copilot uses AI. Check for mistakes.
} else {
// Plain value
let container = try decoder.singleValueContainer()
self = .value(try container.decode(Style.self))
}
}

public func encode(to encoder: Encoder) throws {
switch self {
case .colorScheme(let light, let dark):
var container = encoder.container(keyedBy: ColorSchemeCodingKeys.self)
try container.encode(light, forKey: .light)
try container.encode(dark, forKey: .dark)
case .sizeClass(let compact, let regular):
var container = encoder.container(keyedBy: SizeClassCodingKeys.self)
Comment on lines +44 to +80
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

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

The decoding logic has a potential ambiguity if JSON contains both "light" and "compact" keys (or other overlapping combinations). The current implementation checks for "light" first, then "compact", then falls back to a plain value. If a malicious or malformed JSON contains keys from multiple adaptation axes (e.g., {"light": "#FF0000", "dark": "#0000FF", "compact": "#00FF00"}), only the colorScheme values would be decoded while the compact key would be silently ignored.

Consider adding validation to ensure that the JSON contains keys for exactly one adaptation axis, and throw a descriptive error if multiple axes are detected or if keys are incomplete (e.g., "light" without "dark").

Suggested change
private enum ColorSchemeCodingKeys: String, CodingKey {
case light, dark
}
private enum SizeClassCodingKeys: String, CodingKey {
case compact, regular
}
public init(from decoder: Decoder) throws {
// Try keyed container first for colorScheme / sizeClass
if let container = try? decoder.container(keyedBy: ColorSchemeCodingKeys.self),
container.contains(.light) {
self = .colorScheme(
light: try container.decode(Style.self, forKey: .light),
dark: try container.decode(Style.self, forKey: .dark)
)
} else if let container = try? decoder.container(keyedBy: SizeClassCodingKeys.self),
container.contains(.compact) {
self = .sizeClass(
compact: try container.decode(Style.self, forKey: .compact),
regular: try container.decode(Style.self, forKey: .regular)
)
} else {
// Plain value
let container = try decoder.singleValueContainer()
self = .value(try container.decode(Style.self))
}
}
public func encode(to encoder: Encoder) throws {
switch self {
case .colorScheme(let light, let dark):
var container = encoder.container(keyedBy: ColorSchemeCodingKeys.self)
try container.encode(light, forKey: .light)
try container.encode(dark, forKey: .dark)
case .sizeClass(let compact, let regular):
var container = encoder.container(keyedBy: SizeClassCodingKeys.self)
private enum AdaptiveCodingKeys: String, CodingKey {
case light
case dark
case compact
case regular
}
public init(from decoder: Decoder) throws {
// Try a keyed container first for colorScheme / sizeClass axes.
if let container = try? decoder.container(keyedBy: AdaptiveCodingKeys.self) {
let hasLight = container.contains(.light)
let hasDark = container.contains(.dark)
let hasCompact = container.contains(.compact)
let hasRegular = container.contains(.regular)
let hasColorSchemeKeys = hasLight || hasDark
let hasSizeClassKeys = hasCompact || hasRegular
// If any axis keys are present, we must validate them.
if hasColorSchemeKeys || hasSizeClassKeys {
// Mixed axes are not allowed.
if hasColorSchemeKeys && hasSizeClassKeys {
throw DecodingError.dataCorruptedError(
forKey: hasLight ? .light : .compact,
in: container,
debugDescription: "ThemeAdaptiveStyle.Defaults must use exactly one adaptation axis. Found both colorScheme (light/dark) and sizeClass (compact/regular) keys."
)
}
if hasColorSchemeKeys {
// Require both light and dark for a valid colorScheme axis.
guard hasLight, hasDark else {
let missingKey: AdaptiveCodingKeys = hasLight ? .dark : .light
throw DecodingError.dataCorruptedError(
forKey: missingKey,
in: container,
debugDescription: "Incomplete colorScheme axis for ThemeAdaptiveStyle.Defaults. Both 'light' and 'dark' keys are required."
)
}
self = .colorScheme(
light: try container.decode(Style.self, forKey: .light),
dark: try container.decode(Style.self, forKey: .dark)
)
return
}
// Size class axis
guard hasCompact, hasRegular else {
let missingKey: AdaptiveCodingKeys = hasCompact ? .regular : .compact
throw DecodingError.dataCorruptedError(
forKey: missingKey,
in: container,
debugDescription: "Incomplete sizeClass axis for ThemeAdaptiveStyle.Defaults. Both 'compact' and 'regular' keys are required."
)
}
self = .sizeClass(
compact: try container.decode(Style.self, forKey: .compact),
regular: try container.decode(Style.self, forKey: .regular)
)
return
}
}
// If we reach here, no recognized axis keys are present: decode as a plain value.
let singleValueContainer = try decoder.singleValueContainer()
self = .value(try singleValueContainer.decode(Style.self))
}
public func encode(to encoder: Encoder) throws {
switch self {
case .colorScheme(let light, let dark):
var container = encoder.container(keyedBy: AdaptiveCodingKeys.self)
try container.encode(light, forKey: .light)
try container.encode(dark, forKey: .dark)
case .sizeClass(let compact, let regular):
var container = encoder.container(keyedBy: AdaptiveCodingKeys.self)

Copilot uses AI. Check for mistakes.
try container.encode(compact, forKey: .compact)
try container.encode(regular, forKey: .regular)
case .value(let style):
var container = encoder.singleValueContainer()
try container.encode(style)
}
}
}
26 changes: 26 additions & 0 deletions Sources/ThemeKit/ThemeAdaptiveStyle+Resolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import SwiftUI

nonisolated public extension ThemeAdaptiveStyle {

struct Resolver: Sendable {
public let id: String
public let resolve: @Sendable (EnvironmentValues) -> Style

public init(id: String, resolve: @escaping @Sendable (EnvironmentValues) -> Style) {
self.id = id
self.resolve = resolve
}

public init(resolve: @escaping @Sendable (EnvironmentValues) -> Style) {
self.init(id: UUID().uuidString, resolve: resolve)
}
}
Comment on lines +5 to +17
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

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

The test verifies that two Resolvers with the same explicit ID are considered equal even though they have different resolve closures (one returns white, one returns black). While this is the intended behavior based on ID-based equality, it creates a potential footgun where developers might accidentally create resolvers with the same ID but different behavior, leading to subtle bugs.

Consider adding documentation to the Resolver struct warning that equality is based solely on ID, not on the actual resolve behavior, and that developers should ensure unique IDs for semantically different resolvers.

Copilot uses AI. Check for mistakes.
}

// MARK: - Equatable conformance for Resolver

nonisolated extension ThemeAdaptiveStyle.Resolver: Equatable {
public static func == (lhs: ThemeAdaptiveStyle<Style>.Resolver, rhs: ThemeAdaptiveStyle<Style>.Resolver) -> Bool {
lhs.id == rhs.id
}
}
2 changes: 1 addition & 1 deletion Sources/ThemeKit/ThemeAdaptiveStyle+ShapeStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import SwiftUI

extension ThemeAdaptiveStyle: ShapeStyle where Style: ShapeStyle {
nonisolated public func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
resolved(for: environment.colorScheme)
resolver.resolve(environment)
}
}
94 changes: 86 additions & 8 deletions Sources/ThemeKit/ThemeAdaptiveStyle.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,95 @@
import SwiftUI

nonisolated public struct ThemeAdaptiveStyle<Style: Sendable & Codable & Equatable>: Sendable, Codable, Equatable {
public let light: Style
public let dark: Style
nonisolated public struct ThemeAdaptiveStyle<Style: Sendable & Codable & Equatable>: Sendable, Equatable {

nonisolated public init(light: Style, dark: Style) {
self.light = light
self.dark = dark
public let defaults: Defaults?

public let resolver: Resolver

nonisolated public init(
resolver: Resolver,
) {
self.defaults = nil
self.resolver = resolver
}

nonisolated public init(
light: Style,
dark: Style,
) {
let defaults = Defaults.colorScheme(light: light, dark: dark)
self.defaults = defaults
self.resolver = defaults.makeResolver()
}

nonisolated public init(
compact: Style,
regular: Style,
) {
let defaults = Defaults.sizeClass(compact: compact, regular: regular)
self.defaults = defaults
self.resolver = defaults.makeResolver()
}

nonisolated public init(
value: Style,
) {
let defaults = Defaults.value(value)
self.defaults = defaults
self.resolver = defaults.makeResolver()
}
}

// MARK: - Convenience accessors

public extension ThemeAdaptiveStyle {

var light: Style? {
guard case .colorScheme(let light, _) = defaults else { return nil }
return light
}

var dark: Style? {
guard case .colorScheme(_, let dark) = defaults else { return nil }
return dark
}

var compact: Style? {
guard case .sizeClass(let compact, _) = defaults else { return nil }
return compact
}

var regular: Style? {
guard case .sizeClass(_, let regular) = defaults else { return nil }
return regular
}
}

// MARK: - resolved(in:)

public extension ThemeAdaptiveStyle {
nonisolated func resolved(for colorScheme: ColorScheme) -> Style {
colorScheme == .dark ? dark : light
nonisolated func resolved(in environment: EnvironmentValues) -> Style {
resolver.resolve(environment)
}
}

// MARK: - Codable

nonisolated extension ThemeAdaptiveStyle: Codable {

public init(from decoder: Decoder) throws {
let defaults = try Defaults(from: decoder)
self.defaults = defaults
self.resolver = defaults.makeResolver()
}

public func encode(to encoder: Encoder) throws {
guard let defaults else {
throw EncodingError.invalidValue(self, .init(
codingPath: encoder.codingPath,
debugDescription: "Cannot encode ThemeAdaptiveStyle with a custom resolver — defaults are required for serialization."
))
}
try defaults.encode(to: encoder)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ nonisolated public struct ThemeShadowedStyleGenerator: Sendable {

nonisolated public func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
let shadow = environment.theme[keyPath: shadowKeyPath]
.resolved(for: environment.colorScheme)
.resolved(in: environment)
guard let shadowStyle = shadow.shadowStyle else {
return AnyShapeStyle(base)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/ThemeKitGenerator/ThemeShapeStyleGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ nonisolated public struct ThemeShapeStyleGenerator: Sendable {
}

nonisolated public func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
environment.theme[keyPath: keyPath].resolved(for: environment.colorScheme)
environment.theme[keyPath: keyPath].resolved(in: environment)
}
}

Expand Down
2 changes: 1 addition & 1 deletion Tests/ThemeKitGeneratorTests/ThemeFileGeneratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ struct ThemeFileGeneratorTests {

#expect(shapeStyle.content.contains("func resolve(in environment: EnvironmentValues)"))
#expect(shapeStyle.content.contains("environment.theme[keyPath: keyPath]"))
#expect(shapeStyle.content.contains("environment.colorScheme"))
#expect(shapeStyle.content.contains(".resolved(in: environment)"))
}

@Test func environmentExtension_containsThemeEntry() throws {
Expand Down
Loading