diff --git a/.github/pages/src/components/ConfigSection.svelte b/.github/pages/src/components/ConfigSection.svelte
index 2adf292..bc27c42 100644
--- a/.github/pages/src/components/ConfigSection.svelte
+++ b/.github/pages/src/components/ConfigSection.svelte
@@ -9,19 +9,31 @@
Configuration
{#each properties as prop}
+ {#if prop.type === 'boolean'}
+
+
+
+ {#if prop.description}
+
{prop.description}
+ {/if}
+
+
+
+ {:else}
- {#if prop.type === 'boolean'}
- setConfig(prop.key, e.target.checked)}
- />
- {:else if prop.type === 'number'}
+ {#if prop.type === 'number'}
{/if}
+ {/if}
{/each}
diff --git a/.github/pages/src/lib/state.svelte.js b/.github/pages/src/lib/state.svelte.js
index 137e88d..e5faf67 100644
--- a/.github/pages/src/lib/state.svelte.js
+++ b/.github/pages/src/lib/state.svelte.js
@@ -25,9 +25,12 @@ const jsonOutput = $derived.by(() => {
obj.styles = stylesObj;
}
- // Build config — only include if it has properties
- if (Object.keys(config).length > 0) {
- obj.config = { ...config };
+ // Build config — omit default/empty values (false booleans, empty strings)
+ const configCopy = Object.fromEntries(
+ Object.entries(config).filter(([_, v]) => v !== false && v !== '')
+ );
+ if (Object.keys(configCopy).length > 0) {
+ obj.config = configCopy;
}
return JSON.stringify(obj, null, 2);
diff --git a/.github/pages/src/styles/app.css b/.github/pages/src/styles/app.css
index fab7105..06f9f63 100644
--- a/.github/pages/src/styles/app.css
+++ b/.github/pages/src/styles/app.css
@@ -193,6 +193,18 @@ section h2 {
color: var(--color-danger);
}
+.config-field-bool {
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.config-field-desc {
+ font-size: 0.75rem;
+ color: var(--color-text-muted);
+ margin-top: 0.15rem;
+}
+
.config-field input[type='text'],
.config-field input[type='number'] {
padding: 0.5rem 0.65rem;
diff --git a/README.md b/README.md
index fa53154..fb40586 100644
--- a/README.md
+++ b/README.md
@@ -55,11 +55,17 @@ Only include the categories you need — the generated `Theme` struct will match
"gradients": [
{ "name": "primary", "style": "primaryGradient" }
]
+ },
+ "config": {
+ "outputPath": ".",
+ "shouldGeneratePreview": true
}
}
```
> Use the object form `{ "name": ..., "style": ... }` when a token name conflicts with a SwiftUI built-in (e.g. `primary` → `primaryColor`).
+>
+> Set `"shouldGeneratePreview": true` in the config section to generate a `Theme+Preview.swift` file with a SwiftUI view that displays all your tokens as a visual palette.
### 3. Generate theme files
@@ -75,6 +81,7 @@ Right-click your project in the Xcode navigator → **Generate Theme Files**.
- `Environment+Theme.swift` for environment plumbing
- `copyWith` helpers for immutable updates
- A `Theme+Defaults.swift` scaffold for you to fill in
+- A `Theme+Preview.swift` file with a visual preview of all tokens (only when `shouldGeneratePreview` is enabled)
diff --git a/Sources/ThemeKitGenerator/ThemeConfig.swift b/Sources/ThemeKitGenerator/ThemeConfig.swift
index c8d9a9f..454dd03 100644
--- a/Sources/ThemeKitGenerator/ThemeConfig.swift
+++ b/Sources/ThemeKitGenerator/ThemeConfig.swift
@@ -13,8 +13,12 @@ nonisolated public struct ThemeFile: Sendable, Codable, Equatable {
/// Relative path from the generation root where generated files should be written.
public let outputPath: String
- nonisolated public init(outputPath: String) {
+ /// When true, generates a Theme+Preview.swift file containing a SwiftUI preview view.
+ public let shouldGeneratePreview: Bool?
+
+ nonisolated public init(outputPath: String, shouldGeneratePreview: Bool? = nil) {
self.outputPath = outputPath
+ self.shouldGeneratePreview = shouldGeneratePreview
}
}
@@ -27,6 +31,11 @@ nonisolated public struct ThemeFile: Sendable, Codable, Equatable {
nonisolated public var resolvedOutputPath: String {
config?.outputPath ?? "."
}
+
+ /// Whether to generate a preview file, defaulting to false.
+ nonisolated public var shouldGeneratePreview: Bool {
+ config?.shouldGeneratePreview ?? false
+ }
}
nonisolated public struct ThemeConfig: Sendable, Codable, Equatable {
diff --git a/Sources/ThemeKitGenerator/ThemeFileGenerator.swift b/Sources/ThemeKitGenerator/ThemeFileGenerator.swift
index 8dbbc90..2be8f69 100644
--- a/Sources/ThemeKitGenerator/ThemeFileGenerator.swift
+++ b/Sources/ThemeKitGenerator/ThemeFileGenerator.swift
@@ -39,7 +39,13 @@ nonisolated public struct ThemeFileGenerator: Sendable {
nonisolated public func generate(fromJSON data: Data) throws -> (files: [GeneratedFile], outputPath: String) {
let themeFile = try JSONDecoder().decode(ThemeFile.self, from: data)
- let files = generate(from: themeFile.styles)
+ var files = generate(from: themeFile.styles)
+
+ // Conditionally add preview file
+ if themeFile.shouldGeneratePreview {
+ files.append(ThemePreviewGenerator().generate(from: themeFile.styles))
+ }
+
return (files, themeFile.resolvedOutputPath)
}
}
diff --git a/Sources/ThemeKitGenerator/ThemePreviewGenerator.swift b/Sources/ThemeKitGenerator/ThemePreviewGenerator.swift
new file mode 100644
index 0000000..18c1944
--- /dev/null
+++ b/Sources/ThemeKitGenerator/ThemePreviewGenerator.swift
@@ -0,0 +1,191 @@
+nonisolated public struct ThemePreviewGenerator: Sendable {
+
+ nonisolated public init() {}
+
+ nonisolated public func generate(from config: ThemeConfig) -> GeneratedFile {
+ var sections: [String] = []
+ var components: [String] = []
+
+ // Generate color swatches if colors present
+ if config.categories.contains(.colors) {
+ let tokens = ThemeCategory.colors.tokens(from: config)
+ sections.append(colorSwatchesSection(tokens: tokens))
+ components.append(colorSwatchComponent())
+ }
+
+ // Generate gradient strips if gradients present
+ if config.categories.contains(.gradients) {
+ let tokens = ThemeCategory.gradients.tokens(from: config)
+ sections.append(gradientStripsSection(tokens: tokens))
+ components.append(gradientStripComponent())
+ }
+
+ // Generate shadow showcase if shadows present
+ if config.categories.contains(.shadows) {
+ let tokens = ThemeCategory.shadows.tokens(from: config)
+ sections.append(shadowShowcaseSection(tokens: tokens))
+ components.append(shadowCardComponent())
+ }
+
+ let bodySections = sections.joined(separator: "\n\n")
+
+ var componentsSuffix = ""
+ if !components.isEmpty {
+ componentsSuffix = "\n\n// MARK: - Preview Components\n\n" + components.joined(separator: "\n\n")
+ }
+
+ let content = """
+ // Generated by ThemeKit — do not edit
+
+ import SwiftUI
+ import ThemeKit
+
+ /// A SwiftUI view that renders all theme tokens as a visual palette.
+ public struct ThemePreview: View {
+
+ public var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 24) {
+ \(bodySections.split(separator: "\n").map { " \($0)" }.joined(separator: "\n"))
+ }
+ .padding()
+ }
+ }
+ }
+
+ #Preview {
+ ThemePreview()
+ }\(componentsSuffix)
+
+ """
+ return GeneratedFile(name: "Theme+Preview.swift", content: content)
+ }
+
+ // MARK: - Sections
+
+ nonisolated func colorSwatchesSection(tokens: [ThemeToken]) -> String {
+ let swatches = tokens.map { token in
+ """
+ ColorSwatch(name: "\(token.name)", style: .\(token.style))
+ """
+ }.joined(separator: "\n")
+
+ return """
+ // MARK: - Colors
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Colors")
+ .font(.headline)
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 96))], spacing: 12) {
+ \(swatches)
+ }
+ }
+ """
+ }
+
+ nonisolated func gradientStripsSection(tokens: [ThemeToken]) -> String {
+ let strips = tokens.map { token in
+ """
+ GradientStrip(name: "\(token.name)", style: .\(token.style))
+ """
+ }.joined(separator: "\n")
+
+ return """
+ // MARK: - Gradients
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Gradients")
+ .font(.headline)
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 96))], spacing: 12) {
+ \(strips)
+ }
+ }
+ """
+ }
+
+ nonisolated func shadowShowcaseSection(tokens: [ThemeToken]) -> String {
+ let cards = tokens.map { token in
+ """
+ ShadowCard(name: "\(token.name)", style: .background.\(token.style))
+ """
+ }.joined(separator: "\n")
+
+ return """
+ // MARK: - Shadows
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Shadows")
+ .font(.headline)
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))], spacing: 12) {
+ \(cards)
+ }
+ }
+ """
+ }
+
+ // MARK: - Components
+
+ nonisolated func colorSwatchComponent() -> String {
+ """
+ private struct ColorSwatch: View {
+ let name: String
+ let style: Style
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ RoundedRectangle(cornerRadius: 8)
+ .fill(style)
+ .frame(height: 32)
+
+ Text(name)
+ .font(.caption)
+ .lineLimit(1)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ """
+ }
+
+ nonisolated func gradientStripComponent() -> String {
+ """
+ private struct GradientStrip: View {
+ let name: String
+ let style: Style
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ RoundedRectangle(cornerRadius: 8)
+ .fill(style)
+ .frame(height: 32)
+
+ Text(name)
+ .font(.caption)
+ .lineLimit(1)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ """
+ }
+
+ nonisolated func shadowCardComponent() -> String {
+ """
+ private struct ShadowCard: View {
+ let name: String
+ let style: Style
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ RoundedRectangle(cornerRadius: 12)
+ .fill(style)
+ .frame(height: 48)
+
+ Text(name)
+ .font(.caption)
+ .lineLimit(1)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ """
+ }
+
+}
diff --git a/Tests/ThemeKitGeneratorTests/ThemeFileGeneratorTests.swift b/Tests/ThemeKitGeneratorTests/ThemeFileGeneratorTests.swift
index 24e1a79..7796828 100644
--- a/Tests/ThemeKitGeneratorTests/ThemeFileGeneratorTests.swift
+++ b/Tests/ThemeKitGeneratorTests/ThemeFileGeneratorTests.swift
@@ -332,4 +332,74 @@ struct ThemeFileGeneratorTests {
let gradientsExt = try #require(files.first { $0.name == "ShapeStyle+ThemeGradients.swift" })
#expect(!gradientsExt.content.contains("ThemeShadowedStyle"))
}
+
+ // MARK: - Preview generation
+
+ @Test func shouldGeneratePreview_false_doesNotGeneratePreviewFile() throws {
+ let jsonWithoutPreview = Data("""
+ {
+ "styles": {
+ "colors": ["surface"]
+ },
+ "config": {
+ "outputPath": ".",
+ "shouldGeneratePreview": false
+ }
+ }
+ """.utf8)
+
+ let files = try ThemeFileGenerator().generate(fromJSON: jsonWithoutPreview).files
+ let names = Set(files.map(\.name))
+
+ #expect(!names.contains("Theme+Preview.swift"))
+ }
+
+ @Test func shouldGeneratePreview_true_generatesPreviewFile() throws {
+ let jsonWithPreview = Data("""
+ {
+ "styles": {
+ "colors": ["surface", "primary"]
+ },
+ "config": {
+ "outputPath": ".",
+ "shouldGeneratePreview": true
+ }
+ }
+ """.utf8)
+
+ let files = try ThemeFileGenerator().generate(fromJSON: jsonWithPreview).files
+ let names = Set(files.map(\.name))
+
+ #expect(names.contains("Theme+Preview.swift"))
+ }
+
+ @Test func shouldGeneratePreview_omitted_doesNotGeneratePreviewFile() throws {
+ let files = try ThemeFileGenerator().generate(fromJSON: colorsOnlyJSON).files
+ let names = Set(files.map(\.name))
+
+ #expect(!names.contains("Theme+Preview.swift"))
+ }
+
+ @Test func previewFile_containsCorrectStructure() throws {
+ let jsonWithPreview = Data("""
+ {
+ "styles": {
+ "colors": ["surface"],
+ "gradients": ["primary"]
+ },
+ "config": {
+ "outputPath": ".",
+ "shouldGeneratePreview": true
+ }
+ }
+ """.utf8)
+
+ let files = try ThemeFileGenerator().generate(fromJSON: jsonWithPreview).files
+ let previewFile = try #require(files.first { $0.name == "Theme+Preview.swift" })
+
+ #expect(previewFile.content.contains("public struct ThemePreview: View"))
+ #expect(previewFile.content.contains("#Preview"))
+ #expect(previewFile.content.contains("// MARK: - Colors"))
+ #expect(previewFile.content.contains("// MARK: - Gradients"))
+ }
}
diff --git a/Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift b/Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift
new file mode 100644
index 0000000..a2dc360
--- /dev/null
+++ b/Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift
@@ -0,0 +1,160 @@
+import Testing
+import Foundation
+@testable import ThemeKitGenerator
+
+@Suite("ThemePreviewGenerator")
+struct ThemePreviewGeneratorTests {
+
+ let colorsOnlyConfig = ThemeConfig(
+ colors: [
+ ThemeToken(name: "surface", style: "surface"),
+ ThemeToken(name: "primary", style: "primaryColor")
+ ]
+ )
+
+ let fullConfig = ThemeConfig(
+ colors: [ThemeToken(name: "surface", style: "surface")],
+ gradients: [ThemeToken(name: "primary", style: "primary")],
+ shadows: [ThemeToken(name: "card", style: "cardShadow")]
+ )
+
+ // MARK: - File generation
+
+ @Test func generate_returnsCorrectFileName() {
+ let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig)
+ #expect(file.name == "Theme+Preview.swift")
+ }
+
+ @Test func generate_includesGeneratedHeader() {
+ let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig)
+ #expect(file.content.hasPrefix("// Generated by ThemeKit — do not edit"))
+ }
+
+ @Test func generate_importsSwiftUIAndThemeKit() {
+ let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig)
+ #expect(file.content.contains("import SwiftUI"))
+ #expect(file.content.contains("import ThemeKit"))
+ }
+
+ @Test func generate_containsThemePreviewStruct() {
+ let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig)
+ #expect(file.content.contains("public struct ThemePreview: View"))
+ #expect(file.content.contains("public var body: some View"))
+ }
+
+ @Test func generate_doesNotContainEnvironmentProperties() {
+ let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig)
+ #expect(!file.content.contains("@Environment"))
+ }
+
+ @Test func generate_containsPreviewMacro() {
+ let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig)
+ #expect(file.content.contains("#Preview"))
+ #expect(file.content.contains("ThemePreview()"))
+ }
+
+ // MARK: - Colors section
+
+ @Test func colorsPresent_includesColorsSection() {
+ let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig)
+ #expect(file.content.contains("// MARK: - Colors"))
+ #expect(file.content.contains("Text(\"Colors\")"))
+ #expect(file.content.contains("ColorSwatch"))
+ }
+
+ @Test func colorsPresent_includesAllColorTokens() {
+ let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig)
+ #expect(file.content.contains("ColorSwatch(name: \"surface\", style: .surface)"))
+ #expect(file.content.contains("ColorSwatch(name: \"primary\", style: .primaryColor)"))
+ }
+
+ @Test func colorsAbsent_excludesColorsSection() {
+ let config = ThemeConfig(gradients: [ThemeToken(name: "primary", style: "primary")])
+ let file = ThemePreviewGenerator().generate(from: config)
+ #expect(!file.content.contains("// MARK: - Colors"))
+ #expect(!file.content.contains("ColorSwatch"))
+ }
+
+ // MARK: - Gradients section
+
+ @Test func gradientsPresent_includesGradientsSection() {
+ let file = ThemePreviewGenerator().generate(from: fullConfig)
+ #expect(file.content.contains("// MARK: - Gradients"))
+ #expect(file.content.contains("Text(\"Gradients\")"))
+ #expect(file.content.contains("GradientStrip"))
+ }
+
+ @Test func gradientsPresent_includesAllGradientTokens() {
+ let file = ThemePreviewGenerator().generate(from: fullConfig)
+ #expect(file.content.contains("GradientStrip(name: \"primary\", style: .primary)"))
+ }
+
+ @Test func gradientsAbsent_excludesGradientsSection() {
+ let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig)
+ #expect(!file.content.contains("// MARK: - Gradients"))
+ #expect(!file.content.contains("GradientStrip"))
+ }
+
+ // MARK: - Shadows section
+
+ @Test func shadowsPresent_includesShadowsSection() {
+ let file = ThemePreviewGenerator().generate(from: fullConfig)
+ #expect(file.content.contains("// MARK: - Shadows"))
+ #expect(file.content.contains("Text(\"Shadows\")"))
+ #expect(file.content.contains("ShadowCard"))
+ }
+
+ @Test func shadowsPresent_includesAllShadowTokens() {
+ let file = ThemePreviewGenerator().generate(from: fullConfig)
+ #expect(file.content.contains("ShadowCard(name: \"card\", style: .background.cardShadow)"))
+ }
+
+ @Test func shadowsAbsent_excludesShadowsSection() {
+ let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig)
+ #expect(!file.content.contains("// MARK: - Shadows"))
+ #expect(!file.content.contains("ShadowCard"))
+ }
+
+ // MARK: - Preview components
+
+ @Test func generate_includesColorSwatchComponent() {
+ let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig)
+ #expect(file.content.contains("private struct ColorSwatch: View"))
+ }
+
+ @Test func generate_includesGradientStripComponent() {
+ let config = ThemeConfig(gradients: [ThemeToken(name: "primary", style: "primary")])
+ let file = ThemePreviewGenerator().generate(from: config)
+ #expect(file.content.contains("private struct GradientStrip: View"))
+ }
+
+ @Test func generate_includesShadowCardComponent() {
+ let config = ThemeConfig(shadows: [ThemeToken(name: "card", style: "card")])
+ let file = ThemePreviewGenerator().generate(from: config)
+ #expect(file.content.contains("private struct ShadowCard: View"))
+ }
+
+ // MARK: - Layout structure
+
+ @Test func generate_usesScrollView() {
+ let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig)
+ #expect(file.content.contains("ScrollView"))
+ }
+
+ @Test func generate_usesVStackForSections() {
+ let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig)
+ #expect(file.content.contains("VStack(alignment: .leading, spacing:"))
+ }
+
+ @Test func emptyConfig_generatesValidPreviewWithNoSections() {
+ let config = ThemeConfig()
+ let file = ThemePreviewGenerator().generate(from: config)
+
+ #expect(file.name == "Theme+Preview.swift")
+ #expect(file.content.contains("public struct ThemePreview: View"))
+ #expect(file.content.contains("ScrollView"))
+ #expect(!file.content.contains("// MARK: - Colors"))
+ #expect(!file.content.contains("// MARK: - Gradients"))
+ #expect(!file.content.contains("// MARK: - Shadows"))
+ }
+}
diff --git a/theme.schema.json b/theme.schema.json
index c191035..03285a4 100644
--- a/theme.schema.json
+++ b/theme.schema.json
@@ -34,6 +34,10 @@
"outputPath": {
"type": "string",
"description": "Relative path from the generation root where generated files should be written (e.g., 'UI/Theme'). Defaults to '.' if not specified."
+ },
+ "shouldGeneratePreview": {
+ "type": "boolean",
+ "description": "When true, generates a Theme+Preview.swift file containing a SwiftUI view that renders all tokens as a visual palette. Defaults to false if not specified."
}
},
"required": ["outputPath"],