Skip to content

Refactor: support style overrides and multiple Adaptation axes#12

Merged
rozd merged 4 commits into
mainfrom
refactor/style-resolver
Feb 22, 2026
Merged

Refactor: support style overrides and multiple Adaptation axes#12
rozd merged 4 commits into
mainfrom
refactor/style-resolver

Conversation

@rozd
Copy link
Copy Markdown
Owner

@rozd rozd commented Feb 22, 2026

This pull request refactors the ThemeAdaptiveStyle type to support more flexible and serializable theme adaptation by introducing a new Defaults enum and Resolver struct, replacing the previous hardcoded light/dark variant system. It adds support for adapting by color scheme, size class, or constant value, and updates all related APIs, Codable conformances, and tests accordingly.

Core API Redesign:

  • Refactored ThemeAdaptiveStyle to use a Defaults enum (with .colorScheme, .sizeClass, .value cases) and a Resolver closure, allowing adaptation by color scheme, size class, or a constant value. The previous light/dark properties are now convenience accessors, and the main value is resolved via the environment. (Sources/ThemeKit/ThemeAdaptiveStyle.swift [1] Sources/ThemeKit/ThemeAdaptiveStyle+Defaults.swift [2] Sources/ThemeKit/ThemeAdaptiveStyle+Resolver.swift [3]
  • Updated the resolved(for:) method to resolved(in:), which now resolves the style using the full EnvironmentValues rather than just ColorScheme. (Sources/ThemeKit/ThemeAdaptiveStyle.swift Sources/ThemeKit/ThemeAdaptiveStyle.swiftL3-R93)

Codable & Serialization Improvements:

  • Implemented custom Codable conformance for both ThemeAdaptiveStyle and its Defaults enum, enabling automatic detection of the adaptation axis (color scheme, size class, or value) based on JSON keys. Serialization now fails if a custom (non-default) resolver is used. (Sources/ThemeKit/ThemeAdaptiveStyle.swift [1] Sources/ThemeKit/ThemeAdaptiveStyle+Defaults.swift [2]
  • Updated documentation to describe the new serialization format and adaptation axes. (CLAUDE.md [1] [2]

Integration & API Usage Updates:

  • Updated all usages of resolved(for:) to resolved(in:) throughout the codebase, including ShapeStyle conformances, generator code, and tests, to use the new environment-based resolution. (Sources/ThemeKit/ThemeAdaptiveStyle+ShapeStyle.swift [1] Sources/ThemeKitGenerator/ThemeShapeStyleGenerator.swift [2] Sources/ThemeKitGenerator/ThemeShadowedStyleGenerator.swift [3] README.md [4] IMPLEMENTATION_DETAILS.md [5]

Testing & Validation:

Documentation:

  • Updated documentation files to reflect the new adaptation model, serializable axes, and API changes. (CLAUDE.md [1] [2]

This refactor provides a more extensible and robust foundation for theme adaptation, supporting both built-in and custom adaptation strategies with full Codable support where possible.

rozd added 3 commits February 22, 2026 09:05
ThemeAdaptiveStyle now supports pluggable resolution via a Resolver
struct, enabling per-token resolution based on full EnvironmentValues
(not just colorScheme). This allows accessibility-aware tokens,
size-class-dependent styles, and other environment-driven adaptations.

Key changes:
- Add ThemeAdaptiveStyle.Resolver with id-based Equatable
- light/dark become optional (nil for resolver-only tokens)
- Data tokens auto-generate deterministic resolver IDs via JSON+Hasher
- Custom resolvers get unique UUID-based IDs by default
- Codable encode throws for resolver-only tokens (by design)
- Generated code uses resolved(in:) for full environment access
- Update tests to use EnvironmentValues-based resolution
Refactor ThemeAdaptiveStyle to support multiple adaptation axes beyond
color scheme. The new Defaults enum (.colorScheme, .sizeClass, .value)
replaces the hardcoded light/dark properties, with each case owning its
resolver creation logic. Backward compatibility preserved via computed
.light/.dark accessors and unchanged init(light:dark:) signature.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request significantly refactors the ThemeAdaptiveStyle type to support flexible, serializable theme adaptation beyond the previous hardcoded light/dark system. The refactoring introduces a Defaults enum with three adaptation axes (colorScheme, sizeClass, and constant value) and a Resolver struct that enables custom adaptation logic while maintaining Codable support for standard cases.

Changes:

  • Refactored ThemeAdaptiveStyle to use a Defaults enum and Resolver struct, replacing direct light/dark properties with computed accessors
  • Added support for size class adaptation (compact/regular) and constant value adaptation alongside the existing color scheme adaptation
  • Updated all API usage from resolved(for:) to resolved(in:) to accept full EnvironmentValues

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
Sources/ThemeKit/ThemeAdaptiveStyle.swift Core refactoring to support Defaults and Resolver with custom Codable implementation
Sources/ThemeKit/ThemeAdaptiveStyle+Defaults.swift New Defaults enum with colorScheme, sizeClass, and value cases plus Codable logic
Sources/ThemeKit/ThemeAdaptiveStyle+Resolver.swift New Resolver struct with ID-based equality for adaptation closures
Sources/ThemeKit/ThemeAdaptiveStyle+ShapeStyle.swift Updated to use resolver instead of direct colorScheme access
Sources/ThemeKitGenerator/ThemeShapeStyleGenerator.swift Updated generated code to use resolved(in:)
Sources/ThemeKitGenerator/ThemeShadowedStyleGenerator.swift Updated generated code to use resolved(in:)
Tests/ThemeKitTests/ThemeAdaptiveStyleTests.swift Comprehensive test updates for new API, added tests for all adaptation axes
Tests/ThemeKitGeneratorTests/ThemeFileGeneratorTests.swift Updated test to verify new API in generated code
README.md Documentation update for new resolution API
IMPLEMENTATION_DETAILS.md Documentation update for new resolution API
CLAUDE.md Updated architecture documentation to reflect new adaptation model
.gitignore Added Vite cache directory to gitignore

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

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.
Comment on lines +44 to +80
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)
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.
Comment on lines +54 to +65
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)
)
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.
Comment on lines +27 to +36
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())
}
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.
Comment on lines +370 to 399
}

// MARK: - Equality across forms

@Test func equality_sameColorScheme_areEqual() {
let a = ThemeAdaptiveStyle(light: Color(hex: 0xFF0000), dark: Color(hex: 0x0000FF))
let b = ThemeAdaptiveStyle(light: Color(hex: 0xFF0000), dark: Color(hex: 0x0000FF))
#expect(a == b)
}

@Test func equality_sameSizeClass_areEqual() {
let a = ThemeAdaptiveStyle(compact: Color(hex: 0xFF0000), regular: Color(hex: 0x0000FF))
let b = ThemeAdaptiveStyle(compact: Color(hex: 0xFF0000), regular: Color(hex: 0x0000FF))
#expect(a == b)
}

@Test func equality_sameValue_areEqual() {
let a = ThemeAdaptiveStyle(value: Color(hex: 0xFF0000))
let b = ThemeAdaptiveStyle(value: Color(hex: 0xFF0000))
#expect(a == b)
}

@Test func equality_differentForms_areNotEqual() {
let colorScheme = ThemeAdaptiveStyle(light: Color(hex: 0xFF0000), dark: Color(hex: 0x0000FF))
let sizeClass = ThemeAdaptiveStyle(compact: Color(hex: 0xFF0000), regular: Color(hex: 0x0000FF))
let value = ThemeAdaptiveStyle(value: Color(hex: 0xFF0000))
#expect(colorScheme != sizeClass)
#expect(colorScheme != value)
#expect(sizeClass != value)
}
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.

There are no tests that verify the behavior when decoding malformed or incomplete JSON. Consider adding tests for edge cases such as:

  • JSON with only "light" key but missing "dark"
  • JSON with only "compact" key but missing "regular"
  • JSON with mixed keys from different adaptation axes (e.g., both "light" and "compact")
  • Empty JSON object {}

These tests would help ensure that the decoder provides clear error messages for invalid input rather than failing with generic KeyNotFound or other confusing errors.

Copilot uses AI. Check for mistakes.
Comment on lines +5 to +17
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)
}
}
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.
@codecov
Copy link
Copy Markdown

codecov Bot commented Feb 22, 2026

Codecov Report

❌ Patch coverage is 99.64158% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 99.35%. Comparing base (e7cb664) to head (b0a4f1f).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
Sources/ThemeKit/ThemeAdaptiveStyle+Defaults.swift 98.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #12      +/-   ##
==========================================
+ Coverage   99.30%   99.35%   +0.05%     
==========================================
  Files          28       30       +2     
  Lines        1154     1405     +251     
==========================================
+ Hits         1146     1396     +250     
- Misses          8        9       +1     

☔ View full report in Codecov by Sentry.
📢 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.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@rozd rozd merged commit f168dc0 into main Feb 22, 2026
3 checks passed
@rozd rozd deleted the refactor/style-resolver branch February 22, 2026 21:34
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