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..bc1cfbd --- /dev/null +++ b/Sources/ThemeKit/MeshGradient+Codable.swift @@ -0,0 +1,94 @@ +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: + throw EncodingError.invalidValue( + colors, + EncodingError.Context( + codingPath: [CodingKeys.colors], + debugDescription: "Unsupported color type", + ) + ) + } + + 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: + throw EncodingError.invalidValue( + locations, + EncodingError.Context( + codingPath: [CodingKeys.points], + debugDescription: "Unsupported location type", + ) + ) + } + + } + + static func pointsFrom(width: Int, height: Int) -> [SIMD2] { + guard width > 0, height > 0 else { + return [] + } + if width == 1 && height == 1 { + return [SIMD2(0.0, 0.0)] + } + return (0..( + width > 1 ? Float(col) / Float(width - 1) : 0.0, + height > 1 ? Float(row) / Float(height - 1) : 0.0, + ) + } + } + } +} 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..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.). @@ -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..162dad6 --- /dev/null +++ b/Tests/ThemeKitTests/MeshGradientCodableTests.swift @@ -0,0 +1,416 @@ +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) + 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) + } +} 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",