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
8 changes: 4 additions & 4 deletions Sources/ThemeKit/Gradient+Codable.swift
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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 })
Expand All @@ -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() {
Expand Down
94 changes: 94 additions & 0 deletions Sources/ThemeKit/MeshGradient+Codable.swift
Original file line number Diff line number Diff line change
@@ -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)
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

No validation exists for negative or zero width/height values. The decoding logic should validate that both width and height are positive integers (at least 2 for proper mesh gradient generation given the current pointsFrom implementation). Consider adding validation that throws a DecodingError for invalid dimensions.

Suggested change
let height = try container.decode(Int.self, forKey: .height)
let height = try container.decode(Int.self, forKey: .height)
// Validate decoded dimensions to avoid invalid mesh configurations and division by zero
guard width >= 2, height >= 2 else {
let invalidKey: CodingKeys = width < 2 ? .width : .height
throw DecodingError.dataCorruptedError(
forKey: invalidKey,
in: container,
debugDescription: "MeshGradient width and height must be at least 2. Received width=\(width), height=\(height)."
)
}

Copilot uses AI. Check for mistakes.
let colors = try container.decode([Color].self, forKey: .colors)
let points = try container.decodeIfPresent([SIMD2<Float>].self, forKey: .points)
self.init(
width: width,
height: height,
points: points ?? MeshGradient.pointsFrom(width: width, height: height),
Comment on lines +32 to +36
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

No validation exists to ensure the points array (if provided) has the correct count matching width × height. When points are explicitly provided, they must match the mesh dimensions. If the count doesn't match, the SwiftUI MeshGradient initializer may fail. Consider validating that points.count == width × height when decoding explicit points.

Suggested change
let points = try container.decodeIfPresent([SIMD2<Float>].self, forKey: .points)
self.init(
width: width,
height: height,
points: points ?? MeshGradient.pointsFrom(width: width, height: height),
let decodedPoints = try container.decodeIfPresent([SIMD2<Float>].self, forKey: .points)
let resolvedPoints: [SIMD2<Float>]
if let decodedPoints {
let expectedCount = width * height
guard decodedPoints.count == expectedCount else {
throw DecodingError.dataCorruptedError(
forKey: .points,
in: container,
debugDescription: "Expected \(expectedCount) points for mesh of size \(width)x\(height), but found \(decodedPoints.count)."
)
}
resolvedPoints = decodedPoints
} else {
resolvedPoints = MeshGradient.pointsFrom(width: width, height: height)
}
self.init(
width: width,
height: height,
points: resolvedPoints,

Copilot uses AI. Check for mistakes.
colors: colors,
)
}
Comment on lines +27 to +39
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

There is no validation to ensure the number of colors matches the expected count (width × height). The MeshGradient API requires exactly width × height colors for a valid mesh, but the decoding logic doesn't verify this constraint. If a mismatch occurs, the SwiftUI MeshGradient initializer may fail or produce unexpected behavior. Consider adding validation to throw a DecodingError if colors.count != width × height.

Copilot uses AI. Check for mistakes.

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<Float>] {
guard width > 0, height > 0 else {
return []
}
if width == 1 && height == 1 {
return [SIMD2<Float>(0.0, 0.0)]
}
return (0..<height).flatMap { row in
(0..<width).map { col in
SIMD2<Float>(
width > 1 ? Float(col) / Float(width - 1) : 0.0,
height > 1 ? Float(row) / Float(height - 1) : 0.0,
)
}
}
}
}
10 changes: 10 additions & 0 deletions Sources/ThemeKitGenerator/DefaultsGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions Sources/ThemeKitGenerator/ThemeCategory.swift
Original file line number Diff line number Diff line change
@@ -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"
}
}
Expand All @@ -15,6 +17,7 @@ nonisolated public enum ThemeCategory: CaseIterable, Sendable {
switch self {
case .colors: "Color"
case .gradients: "Gradient"
case .meshGradients: "MeshGradient"
case .shadows: "Shadow"
}
}
Expand All @@ -23,6 +26,7 @@ nonisolated public enum ThemeCategory: CaseIterable, Sendable {
switch self {
case .colors: "colors"
case .gradients: "gradients"
case .meshGradients: "meshGradients"
case .shadows: "shadows"
}
}
Expand All @@ -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 ?? []
}
}
Expand Down
5 changes: 4 additions & 1 deletion Sources/ThemeKitGenerator/ThemeConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.).
Expand Down Expand Up @@ -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
}

Expand Down
52 changes: 50 additions & 2 deletions Sources/ThemeKitGenerator/ThemePreviewGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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<Style: ShapeStyle>: 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)
Expand Down
20 changes: 17 additions & 3 deletions Tests/ThemeKitGeneratorTests/ThemeConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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() {
Expand Down Expand Up @@ -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)
}
}
Loading