From bf0d823f58fa276ffe7f177d201d19a7dffa3e8b Mon Sep 17 00:00:00 2001 From: Max Rozdobudko Date: Tue, 24 Feb 2026 13:07:05 +0200 Subject: [PATCH 1/4] feat: adds support for MeshGradient theming Adds MeshGradient as a new themeable category, enabling the definition and application of mesh gradients within the app's theming system. This includes codable support and default value generation, along with UI preview capabilities, mirroring existing support for colors, gradients and shadows. --- Sources/ThemeKit/Gradient+Codable.swift | 8 +- Sources/ThemeKit/MeshGradient+Codable.swift | 76 ++++ .../ThemeKitGenerator/DefaultsGenerator.swift | 10 + Sources/ThemeKitGenerator/ThemeCategory.swift | 5 + Sources/ThemeKitGenerator/ThemeConfig.swift | 3 + .../ThemePreviewGenerator.swift | 52 ++- .../ThemeConfigTests.swift | 20 +- .../ThemeFileGeneratorTests.swift | 51 +++ .../ThemePreviewGeneratorTests.swift | 28 ++ .../MeshGradientCodableTests.swift | 390 ++++++++++++++++++ theme.schema.json | 7 +- 11 files changed, 640 insertions(+), 10 deletions(-) create mode 100644 Sources/ThemeKit/MeshGradient+Codable.swift create mode 100644 Tests/ThemeKitTests/MeshGradientCodableTests.swift diff --git a/Sources/ThemeKit/Gradient+Codable.swift b/Sources/ThemeKit/Gradient+Codable.swift index 84f6ba8..59dbf50 100644 --- a/Sources/ThemeKit/Gradient+Codable.swift +++ b/Sources/ThemeKit/Gradient+Codable.swift @@ -1,8 +1,8 @@ import SwiftUI -extension Gradient: @retroactive Codable { +nonisolated extension Gradient: @retroactive Codable { - nonisolated public init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let colors = try? container.decode([Color].self) { self = .init(colors: colors) @@ -21,7 +21,7 @@ extension Gradient: @retroactive Codable { } } - nonisolated public func encode(to encoder: Encoder) throws { + public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() if hasUniformStops { try container.encode(stops.map { $0.color }) @@ -33,7 +33,7 @@ extension Gradient: @retroactive Codable { } } - nonisolated private var hasUniformStops: Bool { + private var hasUniformStops: Bool { let count = stops.count guard count > 1 else { return true } for (i, stop) in stops.enumerated() { diff --git a/Sources/ThemeKit/MeshGradient+Codable.swift b/Sources/ThemeKit/MeshGradient+Codable.swift new file mode 100644 index 0000000..1c8dc33 --- /dev/null +++ b/Sources/ThemeKit/MeshGradient+Codable.swift @@ -0,0 +1,76 @@ +import SwiftUI + +nonisolated extension MeshGradient { + + public init( + width: Int, + height: Int, + colors: [Color] + ) { + self.init( + width: width, + height: height, + points: MeshGradient.pointsFrom(width: width, height: height), + colors: colors, + ) + } +} + +// MARK: - Codable + +nonisolated extension MeshGradient: @retroactive Codable { + + enum CodingKeys: String, CodingKey { + case width, height, colors, points + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let width = try container.decode(Int.self, forKey: .width) + let height = try container.decode(Int.self, forKey: .height) + let colors = try container.decode([Color].self, forKey: .colors) + let points = try container.decodeIfPresent([SIMD2].self, forKey: .points) + self.init( + width: width, + height: height, + points: points ?? MeshGradient.pointsFrom(width: width, height: height), + colors: colors, + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(width, forKey: .width) + try container.encode(height, forKey: .height) + + switch colors { + case .colors(let colors): + try container.encode(colors, forKey: .colors) + case .resolvedColors(let colors): + try container.encode(colors, forKey: .colors) + @unknown default: + break + } + + switch locations { + case .points(let points): + try container.encode(points, forKey: .points) + case .bezierPoints(let points): + try container.encode(points.map(\.position), forKey: .points) + @unknown default: + break + } + + } + + static func pointsFrom(width: Int, height: Int) -> [SIMD2] { + (0..( + Float(col) / Float(width - 1), + Float(row) / Float(height - 1) + ) + } + } + } +} diff --git a/Sources/ThemeKitGenerator/DefaultsGenerator.swift b/Sources/ThemeKitGenerator/DefaultsGenerator.swift index 1a5fad1..b67046c 100644 --- a/Sources/ThemeKitGenerator/DefaultsGenerator.swift +++ b/Sources/ThemeKitGenerator/DefaultsGenerator.swift @@ -50,6 +50,16 @@ nonisolated public struct DefaultsGenerator: Sendable { ) """ }.joined(separator: ",\n") + case .meshGradients: + tokenLines = tokens.map { token in + let padding = String(repeating: " ", count: maxNameLength - token.name.count) + return """ + \(token.name):\(padding) .init( + light: .init(width: 2, height: 2, colors: [<#color#>, <#color#>, <#color#>, <#color#>]), + dark: .init(width: 2, height: 2, colors: [<#color#>, <#color#>, <#color#>, <#color#>]) + ) + """ + }.joined(separator: ",\n") default: tokenLines = tokens.map { token in let padding = String(repeating: " ", count: maxNameLength - token.name.count) diff --git a/Sources/ThemeKitGenerator/ThemeCategory.swift b/Sources/ThemeKitGenerator/ThemeCategory.swift index 7db6468..cb324fb 100644 --- a/Sources/ThemeKitGenerator/ThemeCategory.swift +++ b/Sources/ThemeKitGenerator/ThemeCategory.swift @@ -1,12 +1,14 @@ nonisolated public enum ThemeCategory: CaseIterable, Sendable { case colors case gradients + case meshGradients case shadows nonisolated public var structName: String { switch self { case .colors: "ThemeColors" case .gradients: "ThemeGradients" + case .meshGradients: "ThemeMeshGradients" case .shadows: "ThemeShadows" } } @@ -15,6 +17,7 @@ nonisolated public enum ThemeCategory: CaseIterable, Sendable { switch self { case .colors: "Color" case .gradients: "Gradient" + case .meshGradients: "MeshGradient" case .shadows: "Shadow" } } @@ -23,6 +26,7 @@ nonisolated public enum ThemeCategory: CaseIterable, Sendable { switch self { case .colors: "colors" case .gradients: "gradients" + case .meshGradients: "meshGradients" case .shadows: "shadows" } } @@ -31,6 +35,7 @@ nonisolated public enum ThemeCategory: CaseIterable, Sendable { switch self { case .colors: config.colors ?? [] case .gradients: config.gradients ?? [] + case .meshGradients: config.meshGradients ?? [] case .shadows: config.shadows ?? [] } } diff --git a/Sources/ThemeKitGenerator/ThemeConfig.swift b/Sources/ThemeKitGenerator/ThemeConfig.swift index 454dd03..fa64824 100644 --- a/Sources/ThemeKitGenerator/ThemeConfig.swift +++ b/Sources/ThemeKitGenerator/ThemeConfig.swift @@ -41,15 +41,18 @@ nonisolated public struct ThemeFile: Sendable, Codable, Equatable { nonisolated public struct ThemeConfig: Sendable, Codable, Equatable { public let colors: [ThemeToken]? public let gradients: [ThemeToken]? + public let meshGradients: [ThemeToken]? public let shadows: [ThemeToken]? nonisolated public init( colors: [ThemeToken]? = nil, gradients: [ThemeToken]? = nil, + meshGradients: [ThemeToken]? = nil, shadows: [ThemeToken]? = nil ) { self.colors = colors self.gradients = gradients + self.meshGradients = meshGradients self.shadows = shadows } diff --git a/Sources/ThemeKitGenerator/ThemePreviewGenerator.swift b/Sources/ThemeKitGenerator/ThemePreviewGenerator.swift index 18c1944..15d9712 100644 --- a/Sources/ThemeKitGenerator/ThemePreviewGenerator.swift +++ b/Sources/ThemeKitGenerator/ThemePreviewGenerator.swift @@ -20,6 +20,13 @@ nonisolated public struct ThemePreviewGenerator: Sendable { components.append(gradientStripComponent()) } + // Generate mesh gradient cards if mesh gradients present + if config.categories.contains(.meshGradients) { + let tokens = ThemeCategory.meshGradients.tokens(from: config) + sections.append(meshGradientCardsSection(tokens: tokens)) + components.append(meshGradientCardComponent()) + } + // Generate shadow showcase if shadows present if config.categories.contains(.shadows) { let tokens = ThemeCategory.shadows.tokens(from: config) @@ -113,6 +120,25 @@ nonisolated public struct ThemePreviewGenerator: Sendable { VStack(alignment: .leading, spacing: 12) { Text("Shadows") .font(.headline) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 60))], spacing: 12) { + \(cards) + } + } + """ + } + + nonisolated func meshGradientCardsSection(tokens: [ThemeToken]) -> String { + let cards = tokens.map { token in + """ + MeshGradientCard(name: "\(token.name)", style: .\(token.style)) + """ + }.joined(separator: "\n") + + return """ + // MARK: - Mesh Gradients + VStack(alignment: .leading, spacing: 12) { + Text("Mesh Gradients") + .font(.headline) LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))], spacing: 12) { \(cards) } @@ -154,7 +180,7 @@ nonisolated public struct ThemePreviewGenerator: Sendable { VStack(alignment: .leading, spacing: 4) { RoundedRectangle(cornerRadius: 8) .fill(style) - .frame(height: 32) + .frame(height: 48) Text(name) .font(.caption) @@ -176,7 +202,29 @@ nonisolated public struct ThemePreviewGenerator: Sendable { VStack(alignment: .leading, spacing: 4) { RoundedRectangle(cornerRadius: 12) .fill(style) - .frame(height: 48) + .frame(height: 60) + + Text(name) + .font(.caption) + .lineLimit(1) + .foregroundStyle(.secondary) + } + } + } + """ + } + + nonisolated func meshGradientCardComponent() -> String { + """ + private struct MeshGradientCard: View { + let name: String + let style: Style + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + RoundedRectangle(cornerRadius: 12) + .fill(style) + .frame(height: 96) Text(name) .font(.caption) diff --git a/Tests/ThemeKitGeneratorTests/ThemeConfigTests.swift b/Tests/ThemeKitGeneratorTests/ThemeConfigTests.swift index 35656d5..a16a31e 100644 --- a/Tests/ThemeKitGeneratorTests/ThemeConfigTests.swift +++ b/Tests/ThemeKitGeneratorTests/ThemeConfigTests.swift @@ -12,12 +12,14 @@ struct ThemeConfigTests { { "colors": ["surface", {"name": "primary", "style": "primaryColor"}], "gradients": ["primary"], + "meshGradients": ["aurora"], "shadows": ["card"] } """.utf8) let config = try JSONDecoder().decode(ThemeConfig.self, from: json) #expect(config.colors?.count == 2) #expect(config.gradients?.count == 1) + #expect(config.meshGradients?.count == 1) #expect(config.shadows?.count == 1) } @@ -38,22 +40,25 @@ struct ThemeConfigTests { let config = try JSONDecoder().decode(ThemeConfig.self, from: json) #expect(config.colors == nil) #expect(config.gradients == nil) + #expect(config.meshGradients == nil) #expect(config.shadows == nil) } // MARK: - Categories computed property - @Test func categories_fullConfig_returnsAllThree() throws { + @Test func categories_fullConfig_returnsAll() throws { let config = ThemeConfig( colors: [ThemeToken(name: "surface", style: "surface")], gradients: [ThemeToken(name: "primary", style: "primary")], + meshGradients: [ThemeToken(name: "aurora", style: "aurora")], shadows: [ThemeToken(name: "card", style: "card")] ) let categories = config.categories - #expect(categories.count == 3) + #expect(categories.count == 4) #expect(categories[0] == .colors) #expect(categories[1] == .gradients) - #expect(categories[2] == .shadows) + #expect(categories[2] == .meshGradients) + #expect(categories[3] == .shadows) } @Test func categories_colorsOnly() { @@ -85,4 +90,13 @@ struct ThemeConfigTests { #expect(categories.count == 1) #expect(categories[0] == .gradients) } + + @Test func categories_meshGradientsOnly() { + let config = ThemeConfig( + meshGradients: [ThemeToken(name: "aurora", style: "aurora")] + ) + let categories = config.categories + #expect(categories.count == 1) + #expect(categories[0] == .meshGradients) + } } diff --git a/Tests/ThemeKitGeneratorTests/ThemeFileGeneratorTests.swift b/Tests/ThemeKitGeneratorTests/ThemeFileGeneratorTests.swift index 7796828..a3a6730 100644 --- a/Tests/ThemeKitGeneratorTests/ThemeFileGeneratorTests.swift +++ b/Tests/ThemeKitGeneratorTests/ThemeFileGeneratorTests.swift @@ -30,6 +30,14 @@ struct ThemeFileGeneratorTests { } """.utf8) + let meshGradientsOnlyJSON = Data(""" + { + "styles": { + "meshGradients": ["aurora", {"name": "sunset", "style": "sunsetMesh"}] + } + } + """.utf8) + let fullWithShadowsJSON = Data(""" { "styles": { @@ -333,6 +341,49 @@ struct ThemeFileGeneratorTests { #expect(!gradientsExt.content.contains("ThemeShadowedStyle")) } + // MARK: - Mesh gradients + + @Test func meshGradientsOnly_generatesExpectedFiles() throws { + let files = try ThemeFileGenerator().generate(fromJSON: meshGradientsOnlyJSON).files + let names = Set(files.map(\.name)) + + #expect(names.contains("ThemeShapeStyle.swift")) + #expect(names.contains("Environment+Theme.swift")) + #expect(names.contains("Theme.swift")) + #expect(names.contains("Theme+CopyWith.swift")) + #expect(names.contains("ThemeMeshGradients.swift")) + #expect(names.contains("ThemeMeshGradients+CopyWith.swift")) + #expect(names.contains("ShapeStyle+ThemeMeshGradients.swift")) + #expect(names.contains("Theme+Defaults.swift")) + #expect(files.count == 8) + } + + @Test func meshGradientsCategoryStruct_containsTokenProperties() throws { + let files = try ThemeFileGenerator().generate(fromJSON: meshGradientsOnlyJSON).files + let file = try #require(files.first { $0.name == "ThemeMeshGradients.swift" }) + + #expect(file.content.contains("let aurora: ThemeAdaptiveStyle")) + #expect(file.content.contains("let sunset: ThemeAdaptiveStyle")) + #expect(file.content.contains("struct ThemeMeshGradients")) + } + + @Test func meshGradientsShapeStyleExtension_constrainsToCorrectType() throws { + let files = try ThemeFileGenerator().generate(fromJSON: meshGradientsOnlyJSON).files + let ext = try #require(files.first { $0.name == "ShapeStyle+ThemeMeshGradients.swift" }) + + #expect(ext.content.contains("ThemeShapeStyle")) + #expect(ext.content.contains("static var aurora: Self")) + #expect(ext.content.contains("static var sunsetMesh: Self")) + } + + @Test func meshGradientsDefaults_usesMultilineFormat() throws { + let files = try ThemeFileGenerator().generate(fromJSON: meshGradientsOnlyJSON).files + let defaults = try #require(files.first { $0.name == "Theme+Defaults.swift" }) + + #expect(defaults.content.contains("light: .init(width: 2, height: 2, colors: [<#color#>, <#color#>, <#color#>, <#color#>])")) + #expect(defaults.content.contains("dark: .init(width: 2, height: 2, colors: [<#color#>, <#color#>, <#color#>, <#color#>])")) + } + // MARK: - Preview generation @Test func shouldGeneratePreview_false_doesNotGeneratePreviewFile() throws { diff --git a/Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift b/Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift index a2dc360..ee1d876 100644 --- a/Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift +++ b/Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift @@ -15,6 +15,7 @@ struct ThemePreviewGeneratorTests { let fullConfig = ThemeConfig( colors: [ThemeToken(name: "surface", style: "surface")], gradients: [ThemeToken(name: "primary", style: "primary")], + meshGradients: [ThemeToken(name: "aurora", style: "aurora")], shadows: [ThemeToken(name: "card", style: "cardShadow")] ) @@ -115,6 +116,26 @@ struct ThemePreviewGeneratorTests { #expect(!file.content.contains("ShadowCard")) } + // MARK: - Mesh Gradients section + + @Test func meshGradientsPresent_includesMeshGradientsSection() { + let file = ThemePreviewGenerator().generate(from: fullConfig) + #expect(file.content.contains("// MARK: - Mesh Gradients")) + #expect(file.content.contains("Text(\"Mesh Gradients\")")) + #expect(file.content.contains("MeshGradientCard")) + } + + @Test func meshGradientsPresent_includesAllMeshGradientTokens() { + let file = ThemePreviewGenerator().generate(from: fullConfig) + #expect(file.content.contains("MeshGradientCard(name: \"aurora\", style: .aurora)")) + } + + @Test func meshGradientsAbsent_excludesMeshGradientsSection() { + let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) + #expect(!file.content.contains("// MARK: - Mesh Gradients")) + #expect(!file.content.contains("MeshGradientCard")) + } + // MARK: - Preview components @Test func generate_includesColorSwatchComponent() { @@ -134,6 +155,12 @@ struct ThemePreviewGeneratorTests { #expect(file.content.contains("private struct ShadowCard: View")) } + @Test func generate_includesMeshGradientCardComponent() { + let config = ThemeConfig(meshGradients: [ThemeToken(name: "aurora", style: "aurora")]) + let file = ThemePreviewGenerator().generate(from: config) + #expect(file.content.contains("private struct MeshGradientCard: View")) + } + // MARK: - Layout structure @Test func generate_usesScrollView() { @@ -155,6 +182,7 @@ struct ThemePreviewGeneratorTests { #expect(file.content.contains("ScrollView")) #expect(!file.content.contains("// MARK: - Colors")) #expect(!file.content.contains("// MARK: - Gradients")) + #expect(!file.content.contains("// MARK: - Mesh Gradients")) #expect(!file.content.contains("// MARK: - Shadows")) } } diff --git a/Tests/ThemeKitTests/MeshGradientCodableTests.swift b/Tests/ThemeKitTests/MeshGradientCodableTests.swift new file mode 100644 index 0000000..c414e50 --- /dev/null +++ b/Tests/ThemeKitTests/MeshGradientCodableTests.swift @@ -0,0 +1,390 @@ +import Testing +import SwiftUI +import Foundation +@testable import ThemeKit + +@Suite("MeshGradient+Codable") +struct MeshGradientCodableTests { + + // MARK: - Helpers + + private func jsonRoundTrip(_ value: MeshGradient) throws -> MeshGradient { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(MeshGradient.self, from: data) + } + + private func jsonEncode(_ value: MeshGradient) throws -> [String: Any] { + let data = try JSONEncoder().encode(value) + return try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + // MARK: - Decoding + + @Test func decode_withAutoGeneratedPoints() throws { + let json = Data(""" + { + "width": 2, + "height": 2, + "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFFFF"] + } + """.utf8) + let mesh = try JSONDecoder().decode(MeshGradient.self, from: json) + #expect(mesh.width == 2) + #expect(mesh.height == 2) + } + + @Test func decode_withExplicitPoints() throws { + let json = Data(""" + { + "width": 2, + "height": 2, + "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFFFF"], + "points": [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]] + } + """.utf8) + let mesh = try JSONDecoder().decode(MeshGradient.self, from: json) + #expect(mesh.width == 2) + #expect(mesh.height == 2) + } + + @Test func decode_3x2_setsCorrectDimensions() throws { + let json = Data(""" + { + "width": 3, + "height": 2, + "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#00FFFF", "#FFFFFF"] + } + """.utf8) + let mesh = try JSONDecoder().decode(MeshGradient.self, from: json) + #expect(mesh.width == 3) + #expect(mesh.height == 2) + } + + @Test func decode_missingColors_throws() { + let json = Data(""" + { "width": 2, "height": 2 } + """.utf8) + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(MeshGradient.self, from: json) + } + } + + @Test func decode_missingWidth_throws() { + let json = Data(""" + { "height": 2, "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFFFF"] } + """.utf8) + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(MeshGradient.self, from: json) + } + } + + @Test func decode_missingHeight_throws() { + let json = Data(""" + { "width": 2, "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFFFF"] } + """.utf8) + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(MeshGradient.self, from: json) + } + } + + // MARK: - Encoding + + @Test func encode_producesAllExpectedKeys() throws { + let json = Data(""" + { + "width": 2, + "height": 2, + "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFFFF"] + } + """.utf8) + let mesh = try JSONDecoder().decode(MeshGradient.self, from: json) + let encoded = try jsonEncode(mesh) + + #expect(encoded["width"] as? Int == 2) + #expect(encoded["height"] as? Int == 2) + #expect(encoded["colors"] != nil) + #expect(encoded["points"] != nil) + } + + @Test func encode_colorsAreHexStrings() throws { + let json = Data(""" + { + "width": 2, + "height": 2, + "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFFFF"] + } + """.utf8) + let mesh = try JSONDecoder().decode(MeshGradient.self, from: json) + let encoded = try jsonEncode(mesh) + let colors = try #require(encoded["colors"] as? [String]) + + #expect(colors.count == 4) + #expect(colors[0] == "#FF0000") + #expect(colors[1] == "#00FF00") + #expect(colors[2] == "#0000FF") + #expect(colors[3] == "#FFFFFF") + } + + @Test func encode_pointsAreFloat2Arrays() throws { + let json = Data(""" + { + "width": 2, + "height": 2, + "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFFFF"] + } + """.utf8) + let mesh = try JSONDecoder().decode(MeshGradient.self, from: json) + let encoded = try jsonEncode(mesh) + let points = try #require(encoded["points"] as? [[NSNumber]]) + + #expect(points.count == 4) + // top-left + #expect(points[0][0].floatValue == 0.0) + #expect(points[0][1].floatValue == 0.0) + // top-right + #expect(points[1][0].floatValue == 1.0) + #expect(points[1][1].floatValue == 0.0) + } + + // MARK: - Round-trips + + @Test func roundTrip_preservesDimensions() throws { + let json = Data(""" + { + "width": 3, + "height": 3, + "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#00FFFF", "#800080", "#FFFFFF", "#808080", "#000000"] + } + """.utf8) + let original = try JSONDecoder().decode(MeshGradient.self, from: json) + let decoded = try jsonRoundTrip(original) + #expect(decoded.width == 3) + #expect(decoded.height == 3) + } + + @Test func roundTrip_preservesColorCount() throws { + let json = Data(""" + { + "width": 2, + "height": 2, + "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFFFF"] + } + """.utf8) + let original = try JSONDecoder().decode(MeshGradient.self, from: json) + let encoded = try jsonEncode(original) + let colors = try #require(encoded["colors"] as? [String]) + #expect(colors.count == 4) + } + + @Test func roundTrip_preservesColorValues() throws { + let json = Data(""" + { + "width": 2, + "height": 2, + "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFFFF"] + } + """.utf8) + let original = try JSONDecoder().decode(MeshGradient.self, from: json) + let data = try JSONEncoder().encode(original) + let reDecoded = try JSONSerialization.jsonObject(with: data) as! [String: Any] + let colors = reDecoded["colors"] as! [String] + + #expect(colors[0] == "#FF0000") + #expect(colors[1] == "#00FF00") + #expect(colors[2] == "#0000FF") + #expect(colors[3] == "#FFFFFF") + } + + @Test func roundTrip_preservesPointCount() throws { + let json = Data(""" + { + "width": 2, + "height": 2, + "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFFFF"] + } + """.utf8) + let original = try JSONDecoder().decode(MeshGradient.self, from: json) + let encoded = try jsonEncode(original) + let points = try #require(encoded["points"] as? [[NSNumber]]) + #expect(points.count == 4) + } + + @Test func roundTrip_withExplicitPoints_preservesPoints() throws { + let json = Data(""" + { + "width": 2, + "height": 2, + "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFFFF"], + "points": [[0.0, 0.0], [0.8, 0.0], [0.2, 1.0], [1.0, 1.0]] + } + """.utf8) + let original = try JSONDecoder().decode(MeshGradient.self, from: json) + let encoded = try jsonEncode(original) + let points = try #require(encoded["points"] as? [[NSNumber]]) + + // Non-uniform points should survive the round-trip + #expect(points[1][0].floatValue == Float(0.8)) + #expect(points[2][0].floatValue == Float(0.2)) + } + + @Test func roundTrip_encodeDecodeEncode_stableJSON() throws { + let json = Data(""" + { + "width": 2, + "height": 2, + "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFFFF"] + } + """.utf8) + let first = try JSONDecoder().decode(MeshGradient.self, from: json) + let firstData = try JSONEncoder().encode(first) + let second = try JSONDecoder().decode(MeshGradient.self, from: firstData) + let secondData = try JSONEncoder().encode(second) + + let firstJSON = try JSONSerialization.jsonObject(with: firstData) as! [String: Any] + let secondJSON = try JSONSerialization.jsonObject(with: secondData) as! [String: Any] + + #expect(firstJSON["width"] as? Int == secondJSON["width"] as? Int) + #expect(firstJSON["height"] as? Int == secondJSON["height"] as? Int) + #expect((firstJSON["colors"] as? [String]) == (secondJSON["colors"] as? [String])) + } + + // MARK: - Convenience init encoding + + @Test func convenienceInit_encodesCorrectStructure() throws { + let mesh = MeshGradient( + width: 2, + height: 2, + colors: [Color(hex: 0xFF0000), Color(hex: 0x00FF00), Color(hex: 0x0000FF), Color(hex: 0xFFFFFF)] + ) + let encoded = try jsonEncode(mesh) + + #expect(encoded["width"] as? Int == 2) + #expect(encoded["height"] as? Int == 2) + + let colors = try #require(encoded["colors"] as? [String]) + #expect(colors == ["#FF0000", "#00FF00", "#0000FF", "#FFFFFF"]) + + let points = try #require(encoded["points"] as? [[NSNumber]]) + #expect(points.count == 4) + } + + @Test func convenienceInit_roundTrips() throws { + let original = MeshGradient( + width: 2, + height: 2, + colors: [Color(hex: 0xFF0000), Color(hex: 0x00FF00), Color(hex: 0x0000FF), Color(hex: 0xFFFFFF)] + ) + let decoded = try jsonRoundTrip(original) + #expect(decoded.width == original.width) + #expect(decoded.height == original.height) + } + + @Test func convenienceInit_3x2_roundTripsWithCorrectPointCount() throws { + let mesh = MeshGradient( + width: 3, + height: 2, + colors: [ + Color(hex: 0xFF0000), Color(hex: 0x00FF00), Color(hex: 0x0000FF), + Color(hex: 0xFFFF00), Color(hex: 0x00FFFF), Color(hex: 0xFFFFFF), + ] + ) + let encoded = try jsonEncode(mesh) + let points = try #require(encoded["points"] as? [[NSNumber]]) + #expect(points.count == 6) + let colors = try #require(encoded["colors"] as? [String]) + #expect(colors.count == 6) + } + + // MARK: - Bezier points encoding + + @Test func encode_bezierPoints_encodesPositions() throws { + let p = SIMD2(0, 0) // placeholder control point + let bezierPts: [MeshGradient.BezierPoint] = [ + .init(position: .init(0.0, 0.0), leadingControlPoint: p, topControlPoint: p, trailingControlPoint: p, bottomControlPoint: p), + .init(position: .init(1.0, 0.0), leadingControlPoint: p, topControlPoint: p, trailingControlPoint: p, bottomControlPoint: p), + .init(position: .init(0.0, 1.0), leadingControlPoint: p, topControlPoint: p, trailingControlPoint: p, bottomControlPoint: p), + .init(position: .init(1.0, 1.0), leadingControlPoint: p, topControlPoint: p, trailingControlPoint: p, bottomControlPoint: p), + ] + let mesh = MeshGradient( + width: 2, + height: 2, + bezierPoints: bezierPts, + colors: [Color(hex: 0xFF0000), Color(hex: 0x00FF00), Color(hex: 0x0000FF), Color(hex: 0xFFFFFF)] + ) + let encoded = try jsonEncode(mesh) + + let points = try #require(encoded["points"] as? [[NSNumber]]) + #expect(points.count == 4) + #expect(points[0][0].floatValue == 0.0) + #expect(points[0][1].floatValue == 0.0) + #expect(points[3][0].floatValue == 1.0) + #expect(points[3][1].floatValue == 1.0) + } + + @Test func encode_bezierPoints_discardsControlPoints() throws { + let bezierPts: [MeshGradient.BezierPoint] = [ + .init(position: .init(0.0, 0.0), leadingControlPoint: .init(-0.1, -0.1), topControlPoint: .init(0.0, -0.1), trailingControlPoint: .init(0.1, 0.0), bottomControlPoint: .init(0.0, 0.1)), + .init(position: .init(1.0, 0.0), leadingControlPoint: .init(0.9, 0.0), topControlPoint: .init(1.0, -0.1), trailingControlPoint: .init(1.1, 0.0), bottomControlPoint: .init(1.0, 0.1)), + .init(position: .init(0.0, 1.0), leadingControlPoint: .init(-0.1, 1.0), topControlPoint: .init(0.0, 0.9), trailingControlPoint: .init(0.1, 1.0), bottomControlPoint: .init(0.0, 1.1)), + .init(position: .init(1.0, 1.0), leadingControlPoint: .init(0.9, 1.0), topControlPoint: .init(1.0, 0.9), trailingControlPoint: .init(1.1, 1.0), bottomControlPoint: .init(1.0, 1.1)), + ] + let mesh = MeshGradient( + width: 2, + height: 2, + bezierPoints: bezierPts, + colors: [Color(hex: 0xFF0000), Color(hex: 0x00FF00), Color(hex: 0x0000FF), Color(hex: 0xFFFFFF)] + ) + let encoded = try jsonEncode(mesh) + + // Only positions survive; control points are discarded + let points = try #require(encoded["points"] as? [[NSNumber]]) + #expect(points.count == 4) + // Each point should have exactly 2 components (x, y), not control point data + for point in points { + #expect(point.count == 2) + } + #expect(points[0][0].floatValue == 0.0) + #expect(points[1][0].floatValue == 1.0) + } + + // MARK: - Point grid generation + + @Test func convenienceInit_producesCorrectPointGrid() { + let points = MeshGradient.pointsFrom(width: 3, height: 2) + // 3×2 grid = 6 points + #expect(points.count == 6) + // First point: top-left (0,0) + #expect(points[0] == SIMD2(0.0, 0.0)) + // Last point: bottom-right (1,1) + #expect(points[5] == SIMD2(1.0, 1.0)) + // Middle of first row: (0.5, 0) + #expect(points[1] == SIMD2(0.5, 0.0)) + } + + @Test func convenienceInit_2x2_producesFourCornerPoints() { + let points = MeshGradient.pointsFrom(width: 2, height: 2) + #expect(points.count == 4) + #expect(points[0] == SIMD2(0.0, 0.0)) + #expect(points[1] == SIMD2(1.0, 0.0)) + #expect(points[2] == SIMD2(0.0, 1.0)) + #expect(points[3] == SIMD2(1.0, 1.0)) + } + + @Test func convenienceInit_3x3_producesInteriorPoints() { + let points = MeshGradient.pointsFrom(width: 3, height: 3) + #expect(points.count == 9) + // Center point at (0.5, 0.5) + #expect(points[4] == SIMD2(0.5, 0.5)) + // Mid-top at (0.5, 0) + #expect(points[1] == SIMD2(0.5, 0.0)) + // Mid-left at (0, 0.5) + #expect(points[3] == SIMD2(0.0, 0.5)) + } + + @Test func convenienceInit_1x1_producesSingleOriginPoint() { + // Edge case: 1×1 grid has a single point, but (0/0, 0/0) is NaN; + // the implementation divides by (width-1) which is 0. Verify behavior. + let points = MeshGradient.pointsFrom(width: 1, height: 1) + #expect(points.count == 1) + } +} diff --git a/theme.schema.json b/theme.schema.json index 188f956..c91caa3 100644 --- a/theme.schema.json +++ b/theme.schema.json @@ -6,7 +6,7 @@ "type": "object", "properties": { "styles": { - "description": "Style token definitions for colors, gradients, and shadows.", + "description": "Style token definitions for colors, gradients, mesh gradients, and shadows.", "type": "object", "properties": { "colors": { @@ -19,6 +19,11 @@ "type": "array", "items": { "$ref": "#/$defs/tokenEntry" } }, + "meshGradients": { + "description": "Mesh gradient style tokens (resolved as ThemeAdaptiveStyle).", + "type": "array", + "items": { "$ref": "#/$defs/tokenEntry" } + }, "shadows": { "description": "Shadow style tokens (resolved as ThemeAdaptiveStyle).", "type": "array", From 2b413a08ae4bf3fa73ae6f3b6b769a159afe7590 Mon Sep 17 00:00:00 2001 From: Max Rozdobudko Date: Tue, 24 Feb 2026 20:48:46 +0200 Subject: [PATCH 2/4] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../MeshGradientCodableTests.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Tests/ThemeKitTests/MeshGradientCodableTests.swift b/Tests/ThemeKitTests/MeshGradientCodableTests.swift index c414e50..162dad6 100644 --- a/Tests/ThemeKitTests/MeshGradientCodableTests.swift +++ b/Tests/ThemeKitTests/MeshGradientCodableTests.swift @@ -386,5 +386,31 @@ struct MeshGradientCodableTests { // the implementation divides by (width-1) which is 0. Verify behavior. let points = MeshGradient.pointsFrom(width: 1, height: 1) #expect(points.count == 1) + let point = points[0] + // The single point should be at the origin and have finite coordinates. + #expect(point == SIMD2(0.0, 0.0)) + #expect(point.x.isFinite && point.y.isFinite) + } + + // MARK: - Invalid dimension handling + + @Test func convenienceInit_zeroWidth_returnsEmptyPointGrid() { + let points = MeshGradient.pointsFrom(width: 0, height: 3) + #expect(points.isEmpty) + } + + @Test func convenienceInit_zeroHeight_returnsEmptyPointGrid() { + let points = MeshGradient.pointsFrom(width: 3, height: 0) + #expect(points.isEmpty) + } + + @Test func convenienceInit_negativeWidth_returnsEmptyPointGrid() { + let points = MeshGradient.pointsFrom(width: -1, height: 3) + #expect(points.isEmpty) + } + + @Test func convenienceInit_negativeHeight_returnsEmptyPointGrid() { + let points = MeshGradient.pointsFrom(width: 3, height: -1) + #expect(points.isEmpty) } } From 28e1c6ee23dd09280d4dca464d2678705350639d Mon Sep 17 00:00:00 2001 From: Max Rozdobudko Date: Tue, 24 Feb 2026 20:49:29 +0200 Subject: [PATCH 3/4] fix: improves MeshGradient Codable implementation Enhances the MeshGradient Codable implementation to handle unsupported color and location types during encoding by throwing appropriate errors. Also, avoids potential division by zero when calculating gradient points for 1x1 images. --- Sources/ThemeKit/MeshGradient+Codable.swift | 28 +++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/Sources/ThemeKit/MeshGradient+Codable.swift b/Sources/ThemeKit/MeshGradient+Codable.swift index 1c8dc33..b5495b6 100644 --- a/Sources/ThemeKit/MeshGradient+Codable.swift +++ b/Sources/ThemeKit/MeshGradient+Codable.swift @@ -49,7 +49,13 @@ nonisolated extension MeshGradient: @retroactive Codable { case .resolvedColors(let colors): try container.encode(colors, forKey: .colors) @unknown default: - break + throw EncodingError.invalidValue( + colors, + EncodingError.Context( + codingPath: [CodingKeys.colors], + debugDescription: "Unsupported color type", + ) + ) } switch locations { @@ -58,17 +64,29 @@ nonisolated extension MeshGradient: @retroactive Codable { case .bezierPoints(let points): try container.encode(points.map(\.position), forKey: .points) @unknown default: - break + throw EncodingError.invalidValue( + locations, + EncodingError.Context( + codingPath: [CodingKeys.points], + debugDescription: "Unsupported location type", + ) + ) } } static func pointsFrom(width: Int, height: Int) -> [SIMD2] { - (0.. 0, height > 0 else { + return [] + } + if width == 1 && height == 1 { + return [SIMD2(0.5, 0.5)] + } + return (0..( - Float(col) / Float(width - 1), - Float(row) / Float(height - 1) + width > 1 ? Float(col) / Float(width - 1) : 0.5, + height > 1 ? Float(row) / Float(height - 1) : 0.5, ) } } From 6968f24a2f2d1d00fc9fc3ac83459cc8499705ae Mon Sep 17 00:00:00 2001 From: Max Rozdobudko Date: Wed, 25 Feb 2026 08:29:00 +0200 Subject: [PATCH 4/4] fix: refines mesh gradient control point generation Ensures that initial mesh gradient control points are consistently generated from the (0,0) origin for single-dimension grids (e.g., 1x1, 1xN, or Nx1). This provides a more predictable and standard basis for mesh interpolation. Updates documentation to explicitly list mesh gradients as a supported style token. --- Sources/ThemeKit/MeshGradient+Codable.swift | 6 +++--- Sources/ThemeKitGenerator/ThemeConfig.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/ThemeKit/MeshGradient+Codable.swift b/Sources/ThemeKit/MeshGradient+Codable.swift index b5495b6..bc1cfbd 100644 --- a/Sources/ThemeKit/MeshGradient+Codable.swift +++ b/Sources/ThemeKit/MeshGradient+Codable.swift @@ -80,13 +80,13 @@ nonisolated extension MeshGradient: @retroactive Codable { return [] } if width == 1 && height == 1 { - return [SIMD2(0.5, 0.5)] + return [SIMD2(0.0, 0.0)] } return (0..( - width > 1 ? Float(col) / Float(width - 1) : 0.5, - height > 1 ? Float(row) / Float(height - 1) : 0.5, + width > 1 ? Float(col) / Float(width - 1) : 0.0, + height > 1 ? Float(row) / Float(height - 1) : 0.0, ) } } diff --git a/Sources/ThemeKitGenerator/ThemeConfig.swift b/Sources/ThemeKitGenerator/ThemeConfig.swift index fa64824..dacfbad 100644 --- a/Sources/ThemeKitGenerator/ThemeConfig.swift +++ b/Sources/ThemeKitGenerator/ThemeConfig.swift @@ -2,7 +2,7 @@ import Foundation /// Root structure of a theme.json file. nonisolated public struct ThemeFile: Sendable, Codable, Equatable { - /// Style token definitions (colors, gradients, shadows). + /// Style token definitions (colors, gradients, mesh gradients, shadows). public let styles: ThemeConfig /// Generation configuration (output path, etc.).