From 530d05154543e70d9cdd1626e54ff437c8508e2c Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:37:06 +0000 Subject: [PATCH 1/5] Initial plan From ef5f1e988eb8845993f7eafabaa8df72f2c1fcdb Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:41:34 +0000 Subject: [PATCH 2/5] Add ThemePreview generation with shouldGeneratePreview config flag Co-authored-by: rozd <158493+rozd@users.noreply.github.com> --- Sources/ThemeKitGenerator/ThemeConfig.swift | 11 +- .../ThemeFileGenerator.swift | 8 +- .../ThemePreviewGenerator.swift | 200 ++++++++++++++++++ .../ThemeFileGeneratorTests.swift | 70 ++++++ .../ThemePreviewGeneratorTests.swift | 154 ++++++++++++++ theme.schema.json | 4 + 6 files changed, 445 insertions(+), 2 deletions(-) create mode 100644 Sources/ThemeKitGenerator/ThemePreviewGenerator.swift create mode 100644 Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift 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..f0f3dab --- /dev/null +++ b/Sources/ThemeKitGenerator/ThemePreviewGenerator.swift @@ -0,0 +1,200 @@ +nonisolated public struct ThemePreviewGenerator: Sendable { + + nonisolated public init() {} + + nonisolated public func generate(from config: ThemeConfig) -> GeneratedFile { + var sections: [String] = [] + + // Generate color swatches if colors present + if config.categories.contains(.colors) { + let tokens = ThemeCategory.colors.tokens(from: config) + sections.append(colorSwatchesSection(tokens: tokens)) + } + + // Generate gradient strips if gradients present + if config.categories.contains(.gradients) { + let tokens = ThemeCategory.gradients.tokens(from: config) + sections.append(gradientStripsSection(tokens: tokens)) + } + + // Generate shadow showcase if shadows present + if config.categories.contains(.shadows) { + let tokens = ThemeCategory.shadows.tokens(from: config) + sections.append(shadowShowcaseSection(tokens: tokens)) + } + + let bodySections = sections.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 { + @Environment(\\.theme) var theme + @Environment(\\.self) var environment + + public init() {} + + public var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + \(bodySections.split(separator: "\n").map { " \($0)" }.joined(separator: "\n")) + } + .padding() + } + } + } + + #Preview { + ThemePreview() + } + + """ + return GeneratedFile(name: "Theme+Preview.swift", content: content) + } + + nonisolated func colorSwatchesSection(tokens: [ThemeToken]) -> String { + let swatches = tokens.map { token in + """ + ColorSwatch(name: "\(token.name)", color: theme.colors.\(token.name).resolved(in: environment)) + """ + }.joined(separator: "\n") + + return """ + // MARK: - Colors + + VStack(alignment: .leading, spacing: 12) { + Text("Colors") + .font(.headline) + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))], spacing: 12) { + \(swatches) + } + } + """ + } + + nonisolated func gradientStripsSection(tokens: [ThemeToken]) -> String { + let strips = tokens.map { token in + """ + GradientStrip(name: "\(token.name)", gradient: theme.gradients.\(token.name).resolved(in: environment)) + """ + }.joined(separator: "\n") + + return """ + // MARK: - Gradients + + VStack(alignment: .leading, spacing: 12) { + Text("Gradients") + .font(.headline) + + VStack(spacing: 12) { + \(strips) + } + } + """ + } + + nonisolated func shadowShowcaseSection(tokens: [ThemeToken]) -> String { + let cards = tokens.map { token in + """ + ShadowCard(name: "\(token.name)", shadow: theme.shadows.\(token.name).resolved(in: environment)) + """ + }.joined(separator: "\n") + + return """ + // MARK: - Shadows + + VStack(alignment: .leading, spacing: 12) { + Text("Shadows") + .font(.headline) + + VStack(spacing: 12) { + \(cards) + } + } + """ + } +} + +// MARK: - Preview Components + +private struct ColorSwatch: View { + let name: String + let color: Color + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + RoundedRectangle(cornerRadius: 8) + .fill(color) + .frame(height: 60) + + Text(name) + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + +private struct GradientStrip: View { + let name: String + let gradient: Gradient + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + RoundedRectangle(cornerRadius: 8) + .fill(LinearGradient(gradient: gradient, startPoint: .leading, endPoint: .trailing)) + .frame(height: 60) + + Text(name) + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + +private struct ShadowCard: View { + let name: String + let shadow: Shadow + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + RoundedRectangle(cornerRadius: 12) + .fill(.white) + .frame(height: 80) + .modifier(ShadowModifier(shadow: shadow)) + + Text(name) + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + +private struct ShadowModifier: ViewModifier { + let shadow: Shadow + + func body(content: Content) -> some View { + switch shadow { + case .none: + content + case .drop(let color, let radius, let x, let y): + if let color { + content.shadow(color: color, radius: radius, x: x, y: y) + } else { + content.shadow(radius: radius, x: x, y: y) + } + case .inner(let color, let radius, let x, let y): + // Inner shadows are rendered via the ShadowStyle, but for preview we show as drop + if let color { + content.shadow(color: color, radius: radius, x: x, y: y) + } else { + content.shadow(radius: radius, x: x, y: y) + } + } + } +} + 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..a0ef9c9 --- /dev/null +++ b/Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift @@ -0,0 +1,154 @@ +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_containsEnvironmentTheme() { + let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) + #expect(file.content.contains("@Environment(\\.theme) var theme")) + #expect(file.content.contains("@Environment(\\.self) var environment")) + } + + @Test func generate_containsPublicInit() { + let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) + #expect(file.content.contains("public init() {}")) + } + + @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\", color: theme.colors.surface.resolved(in: environment))")) + #expect(file.content.contains("ColorSwatch(name: \"primary\", color: theme.colors.primary.resolved(in: environment))")) + } + + @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\", gradient: theme.gradients.primary.resolved(in: environment))")) + } + + @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\", shadow: theme.shadows.card.resolved(in: environment))")) + } + + @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:")) + } +} 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"], From 421b3ea14bf1895cfe374ef1b72d587331c7527b Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:42:17 +0000 Subject: [PATCH 3/5] Update README to document shouldGeneratePreview feature Co-authored-by: rozd <158493+rozd@users.noreply.github.com> --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) 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) From fcf9e8d78b2b90ed9c3565b83fb87a9364b566f9 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:42:59 +0000 Subject: [PATCH 4/5] Add test for empty config preview generation Co-authored-by: rozd <158493+rozd@users.noreply.github.com> --- .../ThemePreviewGeneratorTests.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift b/Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift index a0ef9c9..49d36be 100644 --- a/Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift +++ b/Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift @@ -151,4 +151,16 @@ struct ThemePreviewGeneratorTests { 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")) + } } From 0663c99d18481e290e15743db62a0e370d278a48 Mon Sep 17 00:00:00 2001 From: Max Rozdobudko Date: Tue, 24 Feb 2026 10:59:17 +0200 Subject: [PATCH 5/5] fix: moves preview views to template, simplifies preview layout Refactor ThemePreviewGenerator to use ShapeStyle generics instead of resolved environment values, making preview components simpler and removing @Environment dependencies. Update configurator to render boolean config fields as polished toggle switches with descriptions, and filter out default values (false, empty string) from JSON output. Updates Theme Configurator with bool field to toggle Theme Preview generation. --- .../pages/src/components/ConfigSection.svelte | 29 +++- .github/pages/src/lib/state.svelte.js | 9 +- .github/pages/src/styles/app.css | 12 ++ .../ThemePreviewGenerator.swift | 161 +++++++++--------- .../ThemePreviewGeneratorTests.swift | 24 +-- 5 files changed, 124 insertions(+), 111 deletions(-) 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/Sources/ThemeKitGenerator/ThemePreviewGenerator.swift b/Sources/ThemeKitGenerator/ThemePreviewGenerator.swift index f0f3dab..18c1944 100644 --- a/Sources/ThemeKitGenerator/ThemePreviewGenerator.swift +++ b/Sources/ThemeKitGenerator/ThemePreviewGenerator.swift @@ -4,27 +4,36 @@ nonisolated public struct ThemePreviewGenerator: Sendable { 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 @@ -33,10 +42,6 @@ nonisolated public struct ThemePreviewGenerator: Sendable { /// A SwiftUI view that renders all theme tokens as a visual palette. public struct ThemePreview: View { - @Environment(\\.theme) var theme - @Environment(\\.self) var environment - - public init() {} public var body: some View { ScrollView { @@ -50,27 +55,27 @@ nonisolated public struct ThemePreviewGenerator: Sendable { #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)", color: theme.colors.\(token.name).resolved(in: environment)) + 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: 120))], spacing: 12) { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 96))], spacing: 12) { \(swatches) } } @@ -80,18 +85,16 @@ nonisolated public struct ThemePreviewGenerator: Sendable { nonisolated func gradientStripsSection(tokens: [ThemeToken]) -> String { let strips = tokens.map { token in """ - GradientStrip(name: "\(token.name)", gradient: theme.gradients.\(token.name).resolved(in: environment)) + GradientStrip(name: "\(token.name)", style: .\(token.style)) """ }.joined(separator: "\n") return """ // MARK: - Gradients - VStack(alignment: .leading, spacing: 12) { Text("Gradients") .font(.headline) - - VStack(spacing: 12) { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 96))], spacing: 12) { \(strips) } } @@ -101,100 +104,88 @@ nonisolated public struct ThemePreviewGenerator: Sendable { nonisolated func shadowShowcaseSection(tokens: [ThemeToken]) -> String { let cards = tokens.map { token in """ - ShadowCard(name: "\(token.name)", shadow: theme.shadows.\(token.name).resolved(in: environment)) + ShadowCard(name: "\(token.name)", style: .background.\(token.style)) """ }.joined(separator: "\n") return """ // MARK: - Shadows - VStack(alignment: .leading, spacing: 12) { Text("Shadows") .font(.headline) - - VStack(spacing: 12) { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))], spacing: 12) { \(cards) } } """ } -} - -// MARK: - Preview Components - -private struct ColorSwatch: View { - let name: String - let color: Color - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - RoundedRectangle(cornerRadius: 8) - .fill(color) - .frame(height: 60) - - Text(name) - .font(.caption) - .foregroundStyle(.secondary) - } - } -} - -private struct GradientStrip: View { - let name: String - let gradient: Gradient - var body: some View { - VStack(alignment: .leading, spacing: 4) { - RoundedRectangle(cornerRadius: 8) - .fill(LinearGradient(gradient: gradient, startPoint: .leading, endPoint: .trailing)) - .frame(height: 60) + // MARK: - Components - Text(name) - .font(.caption) - .foregroundStyle(.secondary) + 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) + } + } } + """ } -} - -private struct ShadowCard: View { - let name: String - let shadow: Shadow - var body: some View { - VStack(alignment: .leading, spacing: 4) { - RoundedRectangle(cornerRadius: 12) - .fill(.white) - .frame(height: 80) - .modifier(ShadowModifier(shadow: shadow)) - - Text(name) - .font(.caption) - .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) + } + } } + """ } -} -private struct ShadowModifier: ViewModifier { - let shadow: Shadow - - func body(content: Content) -> some View { - switch shadow { - case .none: - content - case .drop(let color, let radius, let x, let y): - if let color { - content.shadow(color: color, radius: radius, x: x, y: y) - } else { - content.shadow(radius: radius, x: x, y: y) - } - case .inner(let color, let radius, let x, let y): - // Inner shadows are rendered via the ShadowStyle, but for preview we show as drop - if let color { - content.shadow(color: color, radius: radius, x: x, y: y) - } else { - content.shadow(radius: radius, x: x, y: y) + 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/ThemePreviewGeneratorTests.swift b/Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift index 49d36be..a2dc360 100644 --- a/Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift +++ b/Tests/ThemeKitGeneratorTests/ThemePreviewGeneratorTests.swift @@ -42,15 +42,9 @@ struct ThemePreviewGeneratorTests { #expect(file.content.contains("public var body: some View")) } - @Test func generate_containsEnvironmentTheme() { + @Test func generate_doesNotContainEnvironmentProperties() { let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) - #expect(file.content.contains("@Environment(\\.theme) var theme")) - #expect(file.content.contains("@Environment(\\.self) var environment")) - } - - @Test func generate_containsPublicInit() { - let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) - #expect(file.content.contains("public init() {}")) + #expect(!file.content.contains("@Environment")) } @Test func generate_containsPreviewMacro() { @@ -70,8 +64,8 @@ struct ThemePreviewGeneratorTests { @Test func colorsPresent_includesAllColorTokens() { let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) - #expect(file.content.contains("ColorSwatch(name: \"surface\", color: theme.colors.surface.resolved(in: environment))")) - #expect(file.content.contains("ColorSwatch(name: \"primary\", color: theme.colors.primary.resolved(in: environment))")) + #expect(file.content.contains("ColorSwatch(name: \"surface\", style: .surface)")) + #expect(file.content.contains("ColorSwatch(name: \"primary\", style: .primaryColor)")) } @Test func colorsAbsent_excludesColorsSection() { @@ -92,7 +86,7 @@ struct ThemePreviewGeneratorTests { @Test func gradientsPresent_includesAllGradientTokens() { let file = ThemePreviewGenerator().generate(from: fullConfig) - #expect(file.content.contains("GradientStrip(name: \"primary\", gradient: theme.gradients.primary.resolved(in: environment))")) + #expect(file.content.contains("GradientStrip(name: \"primary\", style: .primary)")) } @Test func gradientsAbsent_excludesGradientsSection() { @@ -112,7 +106,7 @@ struct ThemePreviewGeneratorTests { @Test func shadowsPresent_includesAllShadowTokens() { let file = ThemePreviewGenerator().generate(from: fullConfig) - #expect(file.content.contains("ShadowCard(name: \"card\", shadow: theme.shadows.card.resolved(in: environment))")) + #expect(file.content.contains("ShadowCard(name: \"card\", style: .background.cardShadow)")) } @Test func shadowsAbsent_excludesShadowsSection() { @@ -125,19 +119,19 @@ struct ThemePreviewGeneratorTests { @Test func generate_includesColorSwatchComponent() { let file = ThemePreviewGenerator().generate(from: colorsOnlyConfig) - #expect(file.content.contains("private struct ColorSwatch: View")) + #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")) + #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")) + #expect(file.content.contains("private struct ShadowCard: View")) } // MARK: - Layout structure