diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a6d9803..e6c7ebcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **`ThemeDesign` domain model** — composes from Gallery-native types (`GalleryPalette` + `[Decoration]`) for structured theme output. Generated by AI once, applied deterministically to all screenshots +- **`ThemeDesignApplier`** — re-renders through `GalleryHTMLRenderer.renderScreen()` pipeline with overridden palette and merged decorations (no HTML patching) +- **`GalleryPalette.textColor`** — optional explicit text color, overrides the auto-detect heuristic +- **`Decoration.label()` shape** — text/emoji decorative elements (e.g. `Decoration(shape: .label("✨"), ...)`) +- **`DecorationAnimation`** — float, drift, pulse, spin, twinkle animations for decorations +- **`ScreenLayout.withDecorations()`** — creates a copy with additional decorations +- **`GalleryHTMLRenderer.renderDecorations()`** — renders `ScreenLayout.decorations` (previously dead code) using `cqi` units +- **`buildDesignContext()` on `ScreenTheme`** — prompt method that instructs AI to return `ThemeDesign` JSON +- **`design()` on `ThemeProvider`/`ThemeRepository`** — generate a ThemeDesign from AI in one call +- **`--design-only` / `--apply-design` CLI flags** — batch theme workflow +- **REST endpoints** — `POST /app-shots/themes/design` and `POST /app-shots/themes/apply-design` + +### Changed +- **`GalleryHTMLRenderer` refactored to Mustache templates** — all HTML extracted from Swift into 7 `.mustache` template files using [swift-mustache](https://github.com/hummingbird-project/swift-mustache). The renderer only builds context dictionaries; all HTML, CSS colors (via CSS custom properties in `theme-vars.mustache`), and keyframe animations live in templates. Templates are pre-compiled at startup via `MustacheLibrary` for performance. Preview rendering is cached per template ID. +- **`DecorationShape.displayCharacter`** — computed property on the model instead of renderer logic +- **`GalleryPalette.isLight` + `headlineColor`** — theme detection and text color derivation moved from renderer to palette +- **`Decoration` extended** — new optional fields: `color`, `background`, `borderRadius`, `animation` +- **Theme selection no longer requires auto-compose** — clicking a theme applies immediately to slides with existing preview HTML via `ThemeDesign` (1 AI call for design, then deterministic apply to all slides) +- **Blitz plugin: `design()` implemented** — generates `ThemeDesign` via compose bridge `mode: "design"`, enabling the fast design→apply-design flow + --- ## [0.1.65] - 2026-04-06 diff --git a/Package.resolved b/Package.resolved index 65f0a719..44efdbb8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "9410889530f3d9cb13bff0ffb6ff384b29c47001be8a16101ae70738b1f5dbc9", + "originHash" : "106db5577c8d4adb70ee9ed16d6c4cc0523fb8420002633a912e6fb58318d621", "pins" : [ { "identity" : "appstoreconnect-swift-sdk", @@ -217,6 +217,15 @@ "version" : "2.8.0" } }, + { + "identity" : "swift-mustache", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hummingbird-project/swift-mustache.git", + "state" : { + "revision" : "2e2a84698dd8a5fff2fe28857f0f95bb03d21d64", + "version" : "2.0.2" + } + }, { "identity" : "swift-nio", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index c23d47f1..8be9c95f 100644 --- a/Package.swift +++ b/Package.swift @@ -17,6 +17,7 @@ let package = Package( .package(url: "https://github.com/steipete/SweetCookieKit.git", from: "0.3.0"), .package(url: "https://github.com/hummingbird-project/hummingbird.git", exact: "2.21.1"), .package(url: "https://github.com/hummingbird-project/hummingbird-websocket.git", from: "2.6.0"), + .package(url: "https://github.com/hummingbird-project/swift-mustache.git", from: "2.0.2"), ], targets: [ .target( @@ -31,7 +32,9 @@ let package = Package( name: "Domain", dependencies: [ .product(name: "Mockable", package: "Mockable"), + .product(name: "Mustache", package: "swift-mustache"), ], + resources: [.copy("Screenshots/Gallery/Resources")], swiftSettings: [.define("MOCKING")] ), .target( diff --git a/Sources/ASCCommand/ClientProvider.swift b/Sources/ASCCommand/ClientProvider.swift index d365985f..afb6682c 100644 --- a/Sources/ASCCommand/ClientProvider.swift +++ b/Sources/ASCCommand/ClientProvider.swift @@ -240,6 +240,10 @@ struct ClientProvider { AggregateTemplateRepository.shared } + static func makeGalleryTemplateRepository() -> AggregateGalleryTemplateRepository { + AggregateGalleryTemplateRepository.shared + } + static func makeThemeRepository() -> AggregateThemeRepository { AggregateThemeRepository.shared } diff --git a/Sources/ASCCommand/Commands/AppShots/AppShotsCommand.swift b/Sources/ASCCommand/Commands/AppShots/AppShotsCommand.swift index 9066fc12..a54cb38e 100644 --- a/Sources/ASCCommand/Commands/AppShots/AppShotsCommand.swift +++ b/Sources/ASCCommand/Commands/AppShots/AppShotsCommand.swift @@ -5,6 +5,6 @@ struct AppShotsCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "app-shots", abstract: "AI-powered App Store screenshot planning and generation", - subcommands: [AppShotsTemplatesCommand.self, AppShotsThemesCommand.self, AppShotsGenerate.self, AppShotsExport.self, AppShotsConfig.self] + subcommands: [AppShotsTemplatesCommand.self, AppShotsGalleryTemplatesCommand.self, AppShotsThemesCommand.self, AppShotsGenerate.self, AppShotsExport.self, AppShotsConfig.self] ) } diff --git a/Sources/ASCCommand/Commands/AppShots/AppShotsExport.swift b/Sources/ASCCommand/Commands/AppShots/AppShotsExport.swift index 19658950..eb414916 100644 --- a/Sources/ASCCommand/Commands/AppShots/AppShotsExport.swift +++ b/Sources/ASCCommand/Commands/AppShots/AppShotsExport.swift @@ -43,15 +43,13 @@ struct AppShotsExport: AsyncParsableCommand { static func renderToPNG(html: String, width: Int = 1320, height: Int = 2868, renderer: any HTMLRenderer) async throws -> Data { var htmlContent = html - // Ensure preview fills the full viewport for image export - // Themed HTML from `themes apply` uses width:320px — replace with 100% + // Convert 320px preview container to fill viewport for export if htmlContent.contains("width:320px") { htmlContent = htmlContent - .replacingOccurrences(of: "width:320px", with: "width:100%;height:100%") - .replacingOccurrences(of: "min-height:100vh;background:#111", with: "margin:0;overflow:hidden") + .replacingOccurrences(of: "width:320px;aspect-ratio:1320/2868;container-type:inline-size", with: "width:100%;height:100%;container-type:inline-size") .replacingOccurrences(of: "display:flex;justify-content:center;align-items:center;min-height:100vh;background:#111", with: "margin:0;overflow:hidden") if !htmlContent.contains("html,body{") { - htmlContent = htmlContent.replacingOccurrences(of: "box-sizing:border-box}", with: "box-sizing:border-box}html,body{width:100%;height:100%}") + htmlContent = htmlContent.replacingOccurrences(of: "box-sizing:border-box}", with: "box-sizing:border-box}html,body{width:100%;height:100%;margin:0;overflow:hidden}") } } diff --git a/Sources/ASCCommand/Commands/AppShots/AppShotsGalleryTemplates.swift b/Sources/ASCCommand/Commands/AppShots/AppShotsGalleryTemplates.swift new file mode 100644 index 00000000..3c7012ff --- /dev/null +++ b/Sources/ASCCommand/Commands/AppShots/AppShotsGalleryTemplates.swift @@ -0,0 +1,77 @@ +import ArgumentParser +import Domain +import Foundation + +struct AppShotsGalleryTemplatesCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "gallery-templates", + abstract: "Browse gallery templates (multi-screen sets)", + subcommands: [AppShotsGalleryTemplatesList.self, AppShotsGalleryTemplatesGet.self] + ) +} + +// MARK: - List + +struct AppShotsGalleryTemplatesList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List available gallery templates" + ) + + @OptionGroup var globals: GlobalOptions + + func run() async throws { + let repo = ClientProvider.makeGalleryTemplateRepository() + print(try await execute(repo: repo)) + } + + func execute(repo: any GalleryTemplateRepository, affordanceMode: AffordanceMode = .cli) async throws -> String { + let galleries = try await repo.listGalleries() + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems( + galleries, + headers: ["App Name", "Shots", "Template", "Ready"], + rowMapper: { [$0.appName, "\($0.shotCount)", $0.template?.name ?? "-", $0.isReady ? "✓" : "✗"] }, + affordanceMode: affordanceMode + ) + } +} + +// MARK: - Get + +struct AppShotsGalleryTemplatesGet: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "get", + abstract: "Get details of a specific gallery template" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Gallery template ID") + var id: String + + @Flag(name: .long, help: "Output self-contained HTML gallery preview page") + var preview: Bool = false + + func run() async throws { + let repo = ClientProvider.makeGalleryTemplateRepository() + print(try await execute(repo: repo)) + } + + func execute(repo: any GalleryTemplateRepository) async throws -> String { + guard let gallery = try await repo.getGallery(templateId: id) else { + throw ValidationError("Gallery template '\(id)' not found. Run `asc app-shots gallery-templates list` to see available templates.") + } + + if preview { + return gallery.previewHTML + } + + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems( + [gallery], + headers: ["App Name", "Shots", "Template", "Ready"], + rowMapper: { [$0.appName, "\($0.shotCount)", $0.template?.name ?? "-", $0.isReady ? "✓" : "✗"] } + ) + } +} diff --git a/Sources/ASCCommand/Commands/AppShots/AppShotsTemplates.swift b/Sources/ASCCommand/Commands/AppShots/AppShotsTemplates.swift index 85ede701..85ab8ae6 100644 --- a/Sources/ASCCommand/Commands/AppShots/AppShotsTemplates.swift +++ b/Sources/ASCCommand/Commands/AppShots/AppShotsTemplates.swift @@ -160,23 +160,30 @@ struct AppShotsTemplatesApply: AsyncParsableCommand { throw ValidationError("Template '\(id)' not found. Run `asc app-shots templates list` to see available templates.") } + let shot = AppShot(screenshot: screenshot, type: .feature) + shot.headline = headline + shot.body = subtitle + shot.tagline = tagline + if preview == .image, let renderer { - let content = TemplateContent(headline: headline, subtitle: subtitle, tagline: tagline, screenshotFile: screenshot) - let html = template.apply(content: content, fillViewport: true) + let html = template.apply(shot: shot, fillViewport: true) return try await renderToImage(html: html, renderer: renderer) } if preview == .html { - let content = TemplateContent(headline: headline, subtitle: subtitle, tagline: tagline, screenshotFile: URL(fileURLWithPath: screenshot).lastPathComponent) - return template.apply(content: content) + let shotForHTML = AppShot(screenshot: URL(fileURLWithPath: screenshot).lastPathComponent, type: .feature) + shotForHTML.headline = headline + shotForHTML.body = subtitle + shotForHTML.tagline = tagline + return template.apply(shot: shotForHTML) } - let screen = ScreenDesign(index: 0, template: template, screenshotFile: screenshot, heading: headline, subheading: subtitle ?? "") + // Return the shot as JSON output let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) return try formatter.formatAgentItems( - [screen], - headers: ["Heading", "Screenshot", "Template", "Complete"], - rowMapper: { [$0.heading, $0.screenshotFile, $0.template?.name ?? "-", $0.isComplete ? "✓" : "✗"] } + [shot], + headers: ["Headline", "Screenshot", "Template", "Configured"], + rowMapper: { [$0.headline ?? "-", $0.screenshot, template.name, $0.isConfigured ? "✓" : "✗"] } ) } diff --git a/Sources/ASCCommand/Commands/AppShots/AppShotsThemes.swift b/Sources/ASCCommand/Commands/AppShots/AppShotsThemes.swift index 087e21fe..2783f83c 100644 --- a/Sources/ASCCommand/Commands/AppShots/AppShotsThemes.swift +++ b/Sources/ASCCommand/Commands/AppShots/AppShotsThemes.swift @@ -6,7 +6,13 @@ struct AppShotsThemesCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "themes", abstract: "Browse visual themes for screenshot composition", - subcommands: [AppShotsThemesList.self, AppShotsThemesGet.self, AppShotsThemesApply.self] + subcommands: [ + AppShotsThemesList.self, + AppShotsThemesGet.self, + AppShotsThemesDesign.self, + AppShotsThemesApplyDesign.self, + AppShotsThemesApply.self, + ] ) } @@ -76,12 +82,125 @@ struct AppShotsThemesGet: AsyncParsableCommand { } } -// MARK: - Apply +// MARK: - Design (generate ThemeDesign JSON — 1 AI call) + +struct AppShotsThemesDesign: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "design", + abstract: "Generate a ThemeDesign (palette + decorations) from AI — one call, reusable" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Theme ID") + var id: String + + func run() async throws { + let repo = ClientProvider.makeThemeRepository() + print(try await execute(themeRepo: repo)) + } + + func execute(themeRepo: any ThemeRepository) async throws -> String { + let design = try await themeRepo.design(themeId: id) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(design) + return String(data: data, encoding: .utf8) ?? "{}" + } +} + +// MARK: - Apply Design (deterministic — no AI) + +struct AppShotsThemesApplyDesign: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "apply-design", + abstract: "Apply a cached ThemeDesign to a template — deterministic, no AI" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Path to ThemeDesign JSON file") + var design: String + + @Option(name: .long, help: "Template ID") + var template: String + + @Option(name: .long, help: "Path to screenshot file") + var screenshot: String + + @Option(name: .long, help: "Headline text") + var headline: String = "Your Headline" + + @Option(name: .long, help: "Subtitle text") + var subtitle: String? + + @Option(name: .long, help: "Tagline text") + var tagline: String? + + @Option(name: .long, help: "Preview format: html (default) or image") + var preview: PreviewFormat? + + @Option(name: .long, help: "Output PNG path (for --preview image)") + var imageOutput: String? + + @Option(name: .long, help: "Canvas width in pixels") + var canvasWidth: Int = 1320 + + @Option(name: .long, help: "Canvas height in pixels") + var canvasHeight: Int = 2868 + + func run() async throws { + let templateRepo = ClientProvider.makeTemplateRepository() + if preview == .image { + let renderer = ClientProvider.makeHTMLRenderer() + print(try await execute(templateRepo: templateRepo, renderer: renderer)) + } else { + print(try await execute(templateRepo: templateRepo)) + } + } + + func execute(templateRepo: any TemplateRepository, renderer: (any HTMLRenderer)? = nil) async throws -> String { + let designData = try Data(contentsOf: URL(fileURLWithPath: design)) + let themeDesign = try JSONDecoder().decode(ThemeDesign.self, from: designData) + + guard let tmpl = try await templateRepo.getTemplate(id: template) else { + throw ValidationError("Template '\(template)' not found.") + } + + let screenshotFile = (preview == .image) ? screenshot : URL(fileURLWithPath: screenshot).lastPathComponent + let shot = AppShot(screenshot: screenshotFile, type: .feature) + shot.headline = headline + shot.body = subtitle + shot.tagline = tagline + + let themedHTML = ThemeDesignApplier.apply(themeDesign, shot: shot, screenLayout: tmpl.screenLayout) + let page = ThemedPage(body: themedHTML, width: canvasWidth, height: canvasHeight, fillViewport: preview == .image) + + if preview == .image, let renderer { + return try await renderToImage(html: page.html, renderer: renderer) + } + + return page.html + } + + private func renderToImage(html: String, renderer: any HTMLRenderer) async throws -> String { + let pngData = try await renderer.render(html: html, width: canvasWidth, height: canvasHeight) + let outputPath = imageOutput ?? ".asc/app-shots/output/screen-0.png" + let fileURL = URL(fileURLWithPath: outputPath) + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try pngData.write(to: fileURL) + let result: [String: Any] = ["exported": outputPath, "width": canvasWidth, "height": canvasHeight, "bytes": pngData.count] + let data = try JSONSerialization.data(withJSONObject: result, options: [.prettyPrinted, .sortedKeys]) + return String(data: data, encoding: .utf8) ?? "{}" + } +} + +// MARK: - Apply (full AI restyle — fallback) struct AppShotsThemesApply: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "apply", - abstract: "Apply a theme to a template — renders deterministic layout, then AI restyles" + abstract: "Apply a theme via full AI restyle" ) @OptionGroup var globals: GlobalOptions @@ -133,10 +252,12 @@ struct AppShotsThemesApply: AsyncParsableCommand { } let screenshotFile = (preview == .image) ? screenshot : URL(fileURLWithPath: screenshot).lastPathComponent - let content = TemplateContent(headline: headline, subtitle: subtitle, tagline: tagline, screenshotFile: screenshotFile) + let shot = AppShot(screenshot: screenshotFile, type: .feature) + shot.headline = headline + shot.body = subtitle + shot.tagline = tagline - // Domain: template renders fragment, theme repo composes, ThemedPage wraps - let fragment = tmpl.renderFragment(content: content) + let fragment = tmpl.renderFragment(shot: shot) let themedHTML = try await themeRepo.compose(themeId: theme, html: fragment, canvasWidth: canvasWidth, canvasHeight: canvasHeight) let page = ThemedPage(body: themedHTML, width: canvasWidth, height: canvasHeight, fillViewport: preview == .image) @@ -157,6 +278,4 @@ struct AppShotsThemesApply: AsyncParsableCommand { let data = try JSONSerialization.data(withJSONObject: result, options: [.prettyPrinted, .sortedKeys]) return String(data: data, encoding: .utf8) ?? "{}" } - - // wrapInPage logic moved to Domain/ScreenshotPlans/ThemedPage.swift } diff --git a/Sources/ASCCommand/Commands/Web/Controllers/AppShotsController.swift b/Sources/ASCCommand/Commands/Web/Controllers/AppShotsController.swift index 76915a07..c639f623 100644 --- a/Sources/ASCCommand/Commands/Web/Controllers/AppShotsController.swift +++ b/Sources/ASCCommand/Commands/Web/Controllers/AppShotsController.swift @@ -12,6 +12,7 @@ struct AppShotsController: Sendable { let themeRepo: any ThemeRepository let htmlRenderer: any HTMLRenderer let configStorage: any AppShotsConfigStorage + let galleryTemplateRepo: any GalleryTemplateRepository func addRoutes(to group: RouterGroup) { @@ -27,6 +28,71 @@ struct AppShotsController: Sendable { return try restFormat(themes) } + group.get("/app-shots/gallery-templates") { _, _ -> Response in + let galleries = try await self.galleryTemplateRepo.listGalleries() + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(["data": galleries]) + return restResponse(String(data: data, encoding: .utf8) ?? "[]") + } + + // MARK: - Gallery Compose + + group.post("/app-shots/gallery/compose") { request, _ -> Response in + let body = try await request.body.collect(upTo: 50 * 1024 * 1024) + guard let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let templateId = json["templateId"] as? String, + let screenshotsB64 = json["screenshots"] as? [String] else { + return jsonError("Missing templateId or screenshots array") + } + + do { + guard let sampleGallery = try await self.galleryTemplateRepo.getGallery(templateId: templateId) else { + return jsonError("Gallery template not found", status: .notFound) + } + + // Write screenshots to temp and build data URL map + var paths: [String] = [] + var dataURLs: [String: String] = [:] + for (i, b64) in screenshotsB64.enumerated() { + guard let data = Data(base64Encoded: b64) else { continue } + let path = FileManager.default.temporaryDirectory + .appendingPathComponent("gallery-\(UUID().uuidString)-\(i).png") + try data.write(to: path) + paths.append(path.path) + dataURLs[path.path] = "data:image/png;base64,\(b64)" + } + + // Domain does the work + let gallery = sampleGallery.applyScreenshots(paths) + + // Override with AI-generated headlines if provided + if let headlines = json["headlines"] as? [[String: Any]] { + for (i, h) in headlines.enumerated() where i < gallery.appShots.count { + if let headline = h["headline"] as? String, !headline.isEmpty { + gallery.appShots[i].headline = headline + } + if let tagline = h["tagline"] as? String, !tagline.isEmpty { + gallery.appShots[i].tagline = tagline + } + if let body = h["body"] as? String, !body.isEmpty { + gallery.appShots[i].body = body + } + } + } + + let pages = gallery.renderAll().map { html in + var inlined = html + for (path, url) in dataURLs { inlined = inlined.replacingOccurrences(of: path, with: url) } + return GalleryHTMLRenderer.wrapPage(inlined) + } + + return restResponse(jsonEncode(["screens": pages])) + } catch { + return jsonError("Gallery compose failed: \(error.localizedDescription)", status: .internalServerError) + } + } + // MARK: - Templates Apply group.post("/app-shots/templates/apply") { request, _ -> Response in @@ -36,25 +102,63 @@ struct AppShotsController: Sendable { let headline = json["headline"] as? String else { return jsonError("Missing templateId or headline") } - let screenshotBase64 = json["screenshot"] as? String let previewFormat = json["preview"] as? String ?? "html" + // Support both single "screenshot" and multi "screenshots" + let screenshotsB64: [String] + if let arr = json["screenshots"] as? [String] { + screenshotsB64 = arr + } else if let single = json["screenshot"] as? String { + screenshotsB64 = [single] + } else { + screenshotsB64 = [] + } do { - let screenshotPath = try writeTempScreenshot(screenshotBase64) - guard let tmpl = try await self.templateRepo.getTemplate(id: templateId) else { + // Write each screenshot to temp and build data URL map + var paths: [String] = [] + var dataURLs: [String: String] = [:] + for (i, b64) in screenshotsB64.enumerated() { + guard let data = Data(base64Encoded: b64) else { continue } + let path = FileManager.default.temporaryDirectory + .appendingPathComponent("tmpl-\(UUID().uuidString)-\(i).png") + try data.write(to: path) + paths.append(path.path) + dataURLs[path.path] = "data:image/png;base64,\(b64)" + } + + let shot = AppShot(screenshots: paths, type: .feature) + shot.headline = headline + shot.body = json["subtitle"] as? String + shot.tagline = json["tagline"] as? String + + // Resolve screenLayout + palette from single or gallery template + let screenLayout: ScreenLayout + let palette: GalleryPalette + if let tmpl = try await self.templateRepo.getTemplate(id: templateId) { + screenLayout = tmpl.screenLayout + palette = tmpl.palette + } else if let gallery = try await self.galleryTemplateRepo.getGallery(templateId: templateId), + let tmpl = gallery.template, + let p = gallery.palette { + screenLayout = tmpl.screens[.feature] ?? tmpl.screens[.hero] ?? ScreenLayout(headline: TextSlot(y: 0.04, size: 0.10)) + palette = p + } else { return jsonError("Template not found", status: .notFound) } - let background = Self.parseBackground(json["background"]) - let content = TemplateContent(headline: headline, subtitle: json["subtitle"] as? String, tagline: json["tagline"] as? String, screenshotFile: screenshotPath, background: background, textColor: json["textColor"] as? String) + + let renderHTML = { (fillViewport: Bool) -> String in + let html = GalleryHTMLRenderer.renderScreen(shot, screenLayout: screenLayout, palette: palette) + return GalleryHTMLRenderer.wrapPage(html, fillViewport: fillViewport) + } if previewFormat == "image" { - let html = tmpl.apply(content: content, fillViewport: true) + let html = renderHTML(true) let pngData = try await AppShotsExport.renderToPNG(html: html, renderer: self.htmlRenderer) return restResponse(jsonEncode(["png": pngData.base64EncodedString(), "width": 1320, "height": 2868])) } - var html = tmpl.apply(content: content) - html = Self.inlineBase64(html, screenshotPath: screenshotPath, base64: screenshotBase64) + var html = renderHTML(false) + for (path, url) in dataURLs { html = html.replacingOccurrences(of: path, with: url) } return restResponse(jsonEncode(["html": html])) } catch { return jsonError("Apply failed: \(error.localizedDescription)", status: .internalServerError) @@ -75,12 +179,25 @@ struct AppShotsController: Sendable { do { let screenshotPath = try writeTempScreenshot(screenshotBase64) - guard let tmpl = try await self.templateRepo.getTemplate(id: templateId) else { + + let shot = AppShot(screenshot: screenshotPath, type: .feature) + shot.headline = headline + shot.body = json["subtitle"] as? String + shot.tagline = json["tagline"] as? String + + // Resolve template from single or gallery + let fragment: String + if let tmpl = try await self.templateRepo.getTemplate(id: templateId) { + fragment = tmpl.renderFragment(shot: shot) + } else if let gallery = try await self.galleryTemplateRepo.getGallery(templateId: templateId), + let tmpl = gallery.template, + let p = gallery.palette { + let layout = tmpl.screens[.feature] ?? tmpl.screens[.hero] ?? ScreenLayout(headline: TextSlot(y: 0.04, size: 0.10)) + fragment = GalleryHTMLRenderer.renderScreen(shot, screenLayout: layout, palette: p) + } else { return jsonError("Template not found", status: .notFound) } - let background = Self.parseBackground(json["background"]) - let content = TemplateContent(headline: headline, subtitle: json["subtitle"] as? String, tagline: json["tagline"] as? String, screenshotFile: screenshotPath, background: background, textColor: json["textColor"] as? String) - let fragment = tmpl.renderFragment(content: content) + let themedHTML = try await self.themeRepo.compose(themeId: themeId, html: fragment, canvasWidth: 1320, canvasHeight: 2868) var html = ThemedPage(body: themedHTML, width: 1320, height: 2868).html html = Self.inlineBase64(html, screenshotPath: screenshotPath, base64: screenshotBase64) @@ -90,6 +207,66 @@ struct AppShotsController: Sendable { } } + // MARK: - Themes Design (generate once, apply many) + + group.post("/app-shots/themes/design") { request, _ -> Response in + let body = try await request.body.collect(upTo: 1024 * 1024) + guard let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let themeId = json["themeId"] as? String else { + return jsonError("Missing themeId") + } + do { + let design = try await self.themeRepo.design(themeId: themeId) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(design) + return restResponse(String(data: data, encoding: .utf8) ?? "{}") + } catch { + return jsonError("Theme design failed: \(error.localizedDescription)", status: .internalServerError) + } + } + + group.post("/app-shots/themes/apply-design") { request, _ -> Response in + let body = try await request.body.collect(upTo: 10 * 1024 * 1024) + guard let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let templateId = json["templateId"] as? String, + let headline = json["headline"] as? String, + let designJSON = json["design"] as? [String: Any] else { + return jsonError("Missing templateId, headline, or design") + } + let screenshotBase64 = json["screenshot"] as? String + + do { + let designData = try JSONSerialization.data(withJSONObject: designJSON) + let design = try JSONDecoder().decode(ThemeDesign.self, from: designData) + + let screenshotPath = try writeTempScreenshot(screenshotBase64) + let shot = AppShot(screenshot: screenshotPath, type: .feature) + shot.headline = headline + shot.body = json["subtitle"] as? String + shot.tagline = json["tagline"] as? String + + // Resolve screen layout from single or gallery template + let screenLayout: ScreenLayout + if let tmpl = try await self.templateRepo.getTemplate(id: templateId) { + screenLayout = tmpl.screenLayout + } else if let gallery = try await self.galleryTemplateRepo.getGallery(templateId: templateId), + let tmpl = gallery.template { + screenLayout = tmpl.screens[.feature] ?? tmpl.screens[.hero] ?? ScreenLayout(headline: TextSlot(y: 0.04, size: 0.10)) + } else { + return jsonError("Template not found", status: .notFound) + } + + // Re-render through the standard pipeline with design palette + decorations + let themedHTML = ThemeDesignApplier.apply(design, shot: shot, screenLayout: screenLayout) + var html = GalleryHTMLRenderer.wrapPage(themedHTML, fillViewport: false) + html = Self.inlineBase64(html, screenshotPath: screenshotPath, base64: screenshotBase64) + return restResponse(jsonEncode(["html": html])) + } catch { + return jsonError("Apply design failed: \(error.localizedDescription)", status: .internalServerError) + } + } + // MARK: - Export group.post("/app-shots/export") { request, _ -> Response in @@ -153,22 +330,6 @@ struct AppShotsController: Sendable { // MARK: - Helpers - /// Parse a background JSON object into a SlideBackground. - private static func parseBackground(_ value: Any?) -> SlideBackground? { - guard let dict = value as? [String: Any], - let type = dict["type"] as? String else { return nil } - if type == "gradient", - let from = dict["from"] as? String, - let to = dict["to"] as? String { - let angle = dict["angle"] as? Int ?? 180 - return .gradient(from: from, to: to, angle: angle) - } - if let color = dict["color"] as? String { - return .solid(color) - } - return nil - } - /// Replace temp file paths with data URLs for inline browser display. private static func inlineBase64(_ html: String, screenshotPath: String, base64: String?) -> String { guard let b64 = base64 else { return html } diff --git a/Sources/ASCCommand/Commands/Web/RESTRoutes.swift b/Sources/ASCCommand/Commands/Web/RESTRoutes.swift index fac3e3d8..40ffece3 100644 --- a/Sources/ASCCommand/Commands/Web/RESTRoutes.swift +++ b/Sources/ASCCommand/Commands/Web/RESTRoutes.swift @@ -53,7 +53,8 @@ enum RESTRoutes { templateRepo: AggregateTemplateRepository.shared, themeRepo: AggregateThemeRepository.shared, htmlRenderer: WebKitHTMLRenderer(), - configStorage: FileAppShotsConfigStorage() + configStorage: FileAppShotsConfigStorage(), + galleryTemplateRepo: AggregateGalleryTemplateRepository.shared ).addRoutes(to: v1) } } diff --git a/Sources/Domain/ScreenshotPlans/LayoutMode.swift b/Sources/Domain/ScreenshotPlans/LayoutMode.swift deleted file mode 100644 index 27c9f6cf..00000000 --- a/Sources/Domain/ScreenshotPlans/LayoutMode.swift +++ /dev/null @@ -1,5 +0,0 @@ -public enum LayoutMode: String, Sendable, Codable, Equatable { - case center = "center" - case left = "left" - case tilted = "tilted" -} diff --git a/Sources/Domain/ScreenshotPlans/ScreenDesign.swift b/Sources/Domain/ScreenshotPlans/ScreenDesign.swift deleted file mode 100644 index d872fb1a..00000000 --- a/Sources/Domain/ScreenshotPlans/ScreenDesign.swift +++ /dev/null @@ -1,141 +0,0 @@ -import Foundation - -/// A single screen in a screenshot design — rich domain object. -/// -/// Knows its template, content, and screenshot. Can render a preview -/// and tell you what actions are available. -/// -/// ``` -/// let screen = ScreenDesign(index: 0, template: topHero, screenshotFile: "screen.png", -/// heading: "Ship Faster", subheading: "One command away") -/// screen.previewHTML // → HTML with real screenshot in template layout -/// screen.isComplete // → true (has template + heading + screenshot) -/// screen.affordances // → { generate, changeTemplate, preview } -/// ``` -public struct ScreenDesign: Sendable, Equatable, Identifiable { - public let id: String - public let index: Int - /// The template applied to this screen. `nil` = no template yet. - public let template: ScreenshotTemplate? - public let screenshotFile: String - public let heading: String - public let subheading: String - // Legacy fields kept for backward compatibility with existing ScreenshotDesign - public let layoutMode: LayoutMode - public let visualDirection: String - public let imagePrompt: String - - /// Rich initializer — with template. - public init( - index: Int, - template: ScreenshotTemplate?, - screenshotFile: String, - heading: String, - subheading: String - ) { - self.id = "\(index)" - self.index = index - self.template = template - self.screenshotFile = screenshotFile - self.heading = heading - self.subheading = subheading - self.layoutMode = .center - self.visualDirection = "" - self.imagePrompt = "" - } - - /// Legacy initializer — without template (backward compat). - public init( - index: Int, - screenshotFile: String, - heading: String, - subheading: String, - layoutMode: LayoutMode, - visualDirection: String, - imagePrompt: String - ) { - self.id = "\(index)" - self.index = index - self.template = nil - self.screenshotFile = screenshotFile - self.heading = heading - self.subheading = subheading - self.layoutMode = layoutMode - self.visualDirection = visualDirection - self.imagePrompt = imagePrompt - } -} - -// MARK: - Rich Domain Behavior - -extension ScreenDesign { - /// Whether this screen has everything needed for generation. - public var isComplete: Bool { - template != nil && !heading.isEmpty && !screenshotFile.isEmpty - } - - /// Self-contained HTML preview showing the template with real content. - /// Returns empty string if no template is set. - public var previewHTML: String { - guard let template else { return "" } - return TemplateHTMLRenderer.renderPage( - template, - content: TemplateContent( - headline: heading, - subtitle: subheading.isEmpty ? nil : subheading, - screenshotFile: screenshotFile - ) - ) - } -} - -// MARK: - Affordances - -extension ScreenDesign: AffordanceProviding { - public var affordances: [String: String] { - var cmds: [String: String] = [ - "changeTemplate": "asc app-shots templates list", - ] - if isComplete, let template { - cmds["generate"] = "asc app-shots generate --design design.json" - cmds["preview"] = "asc app-shots templates apply --id \(template.id) --screenshot \(screenshotFile) --headline \"\(heading)\"" - } - if let template { - cmds["templateDetail"] = "asc app-shots templates get --id \(template.id)" - } - return cmds - } -} - -// MARK: - Codable (template excluded — resolved at runtime) - -extension ScreenDesign: Codable { - private enum CodingKeys: String, CodingKey { - case index, screenshotFile, heading, subheading, layoutMode, visualDirection, imagePrompt - } - - public init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - let index = try c.decode(Int.self, forKey: .index) - self.init( - index: index, - screenshotFile: try c.decode(String.self, forKey: .screenshotFile), - heading: try c.decode(String.self, forKey: .heading), - subheading: try c.decode(String.self, forKey: .subheading), - layoutMode: try c.decode(LayoutMode.self, forKey: .layoutMode), - visualDirection: try c.decode(String.self, forKey: .visualDirection), - imagePrompt: try c.decode(String.self, forKey: .imagePrompt) - ) - } - - public func encode(to encoder: any Encoder) throws { - var c = encoder.container(keyedBy: CodingKeys.self) - try c.encode(index, forKey: .index) - try c.encode(screenshotFile, forKey: .screenshotFile) - try c.encode(heading, forKey: .heading) - try c.encode(subheading, forKey: .subheading) - try c.encode(layoutMode, forKey: .layoutMode) - try c.encode(visualDirection, forKey: .visualDirection) - try c.encode(imagePrompt, forKey: .imagePrompt) - } -} diff --git a/Sources/Domain/ScreenshotPlans/ScreenshotTemplate.swift b/Sources/Domain/ScreenshotPlans/ScreenshotTemplate.swift deleted file mode 100644 index 62be236a..00000000 --- a/Sources/Domain/ScreenshotPlans/ScreenshotTemplate.swift +++ /dev/null @@ -1,285 +0,0 @@ -import Foundation - -/// A reusable template for composing App Store screenshots. -/// -/// Templates define the visual layout: background, text positions, and device placement. -/// Users pick a template, fill in their content (headline, subtitle, screenshot), -/// and produce a `ScreenshotDesign` ready for generation. -/// -/// Plugins (like Blitz) register their own templates via `TemplateRepository`. -public struct ScreenshotTemplate: Sendable, Equatable, Identifiable { - public let id: String - public let name: String - public let category: TemplateCategory - public let supportedSizes: [ScreenSize] - public let description: String - public let background: SlideBackground - public let textSlots: [TemplateTextSlot] - public let deviceSlots: [TemplateDeviceSlot] - - public init( - id: String, - name: String, - category: TemplateCategory, - supportedSizes: [ScreenSize], - description: String, - background: SlideBackground, - textSlots: [TemplateTextSlot], - deviceSlots: [TemplateDeviceSlot] - ) { - self.id = id - self.name = name - self.category = category - self.supportedSizes = supportedSizes - self.description = description - self.background = background - self.textSlots = textSlots - self.deviceSlots = deviceSlots - } - - /// Self-contained HTML page previewing this template with default sample text. - public var previewHTML: String { - TemplateHTMLRenderer.renderPage(self) - } - - /// Apply this template with the given content — returns a full HTML page. - /// - /// This is the core domain operation. Both CLI and REST call this. - public func apply(content: TemplateContent? = nil, fillViewport: Bool = false) -> String { - TemplateHTMLRenderer.renderPage(self, content: content, fillViewport: fillViewport) - } - - /// Render the inner HTML fragment (no page wrapper) for composition pipelines. - /// - /// Used when the output will be further processed (e.g. theme compose). - public func renderFragment(content: TemplateContent? = nil) -> String { - TemplateHTMLRenderer.render(self, content: content) - } -} - -// MARK: - Semantic Booleans - -extension ScreenshotTemplate { - /// Whether this template supports portrait orientation. - public var isPortrait: Bool { supportedSizes.contains(.portrait) } - - /// Whether this template supports landscape orientation. - public var isLandscape: Bool { supportedSizes.contains(.landscape) } - - /// Number of device slots in this template. - public var deviceCount: Int { deviceSlots.count } -} - -// MARK: - Codable (includes computed properties for REST/JSON consumers) - -extension ScreenshotTemplate: Codable { - private enum CodingKeys: String, CodingKey { - case id, name, category, supportedSizes, description, background, textSlots, deviceSlots - case previewHTML, deviceCount - } - - public init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(String.self, forKey: .id) - name = try c.decode(String.self, forKey: .name) - category = try c.decode(TemplateCategory.self, forKey: .category) - supportedSizes = try c.decode([ScreenSize].self, forKey: .supportedSizes) - description = try c.decode(String.self, forKey: .description) - background = try c.decode(SlideBackground.self, forKey: .background) - textSlots = try c.decode([TemplateTextSlot].self, forKey: .textSlots) - deviceSlots = try c.decode([TemplateDeviceSlot].self, forKey: .deviceSlots) - } - - public func encode(to encoder: any Encoder) throws { - var c = encoder.container(keyedBy: CodingKeys.self) - try c.encode(id, forKey: .id) - try c.encode(name, forKey: .name) - try c.encode(category, forKey: .category) - try c.encode(supportedSizes, forKey: .supportedSizes) - try c.encode(description, forKey: .description) - try c.encode(background, forKey: .background) - try c.encode(textSlots, forKey: .textSlots) - try c.encode(deviceSlots, forKey: .deviceSlots) - try c.encode(previewHTML, forKey: .previewHTML) - try c.encode(deviceCount, forKey: .deviceCount) - } -} - -// MARK: - Presentable - -extension ScreenshotTemplate: Presentable { - public static var tableHeaders: [String] { - ["ID", "Name", "Category", "Devices"] - } - public var tableRow: [String] { - [id, name, category.rawValue, "\(deviceCount)"] - } -} - -// MARK: - Affordances - -extension ScreenshotTemplate: AffordanceProviding { - public var affordances: [String: String] { - [ - "preview": "asc app-shots templates get --id \(id) --preview", - "apply": "asc app-shots templates apply --id \(id) --screenshot screen.png", - "detail": "asc app-shots templates get --id \(id)", - "listAll": "asc app-shots templates list", - ] - } -} - -// MARK: - Supporting Types - -/// Template category for filtering and organization. -public enum TemplateCategory: String, Sendable, Equatable, Codable, CaseIterable { - case bold - case minimal - case elegant - case professional - case playful - case showcase - case custom -} - -/// Screen size/orientation category for template compatibility. -public enum ScreenSize: String, Sendable, Equatable, Codable, CaseIterable { - case portrait // Tall phone (9:19+) - case portrait43 // iPad-like (3:4) - case landscape // Wide (16:9, Mac) - case square // 1:1 -} - -/// A text slot in a template — defines where text appears and its default content. -public struct TemplateTextSlot: Sendable, Equatable, Codable { - /// The role this text plays (heading, subheading, tagline). - public let role: TextRole - /// Default preview text shown in template browser. - public let preview: String - /// Horizontal position (0–1, normalized to canvas width). - public let x: Double - /// Vertical position (0–1, normalized to canvas height). - public let y: Double - /// Font size relative to canvas width (0.1 = 10%). - public let fontSize: Double - /// Font weight (100–900). - public let fontWeight: Int - /// Text color (hex or rgba). - public let color: String - /// Text alignment. - public let textAlign: String - /// Optional font family override. - public let font: String? - /// Optional letter spacing. - public let letterSpacing: String? - /// Optional line height. - public let lineHeight: Double? - /// Optional text transform (uppercase, etc.). - public let textTransform: String? - /// Optional font style (italic, etc.). - public let fontStyle: String? - - public init( - role: TextRole, - preview: String, - x: Double, y: Double, - fontSize: Double, - fontWeight: Int = 700, - color: String, - textAlign: String = "center", - font: String? = nil, - letterSpacing: String? = nil, - lineHeight: Double? = nil, - textTransform: String? = nil, - fontStyle: String? = nil - ) { - self.role = role - self.preview = preview - self.x = x - self.y = y - self.fontSize = fontSize - self.fontWeight = fontWeight - self.color = color - self.textAlign = textAlign - self.font = font - self.letterSpacing = letterSpacing - self.lineHeight = lineHeight - self.textTransform = textTransform - self.fontStyle = fontStyle - } -} - -/// The role a text slot plays in a template. -public enum TextRole: String, Sendable, Equatable, Codable { - case heading - case subheading - case tagline -} - -/// Background style for a slide or template. -public enum SlideBackground: Sendable, Equatable { - case solid(String) - case gradient(from: String, to: String, angle: Int) -} - -extension SlideBackground: Codable { - private enum CodingKeys: String, CodingKey { - case type, color, from, to, angle - } - - public init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - let type = try c.decode(String.self, forKey: .type) - switch type { - case "gradient": - let from = try c.decode(String.self, forKey: .from) - let to = try c.decode(String.self, forKey: .to) - let angle = try c.decodeIfPresent(Int.self, forKey: .angle) ?? 180 - self = .gradient(from: from, to: to, angle: angle) - default: - let color = try c.decode(String.self, forKey: .color) - self = .solid(color) - } - } - - public func encode(to encoder: any Encoder) throws { - var c = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .solid(let color): - try c.encode("solid", forKey: .type) - try c.encode(color, forKey: .color) - case .gradient(let from, let to, let angle): - try c.encode("gradient", forKey: .type) - try c.encode(from, forKey: .from) - try c.encode(to, forKey: .to) - try c.encode(angle, forKey: .angle) - } - } -} - -/// A device slot in a template — defines where a screenshot device appears. -public struct TemplateDeviceSlot: Sendable, Equatable, Codable { - /// Center X position (0–1). - public let x: Double - /// Top Y position (0–1). - public let y: Double - /// Width relative to canvas (0–1). - public let scale: Double - /// Rotation in degrees. - public let rotation: Double? - /// Z-index for overlapping devices. - public let zIndex: Int? - - public init( - x: Double, y: Double, - scale: Double, - rotation: Double? = nil, - zIndex: Int? = nil - ) { - self.x = x - self.y = y - self.scale = scale - self.rotation = rotation - self.zIndex = zIndex - } -} diff --git a/Sources/Domain/ScreenshotPlans/TemplateContent.swift b/Sources/Domain/ScreenshotPlans/TemplateContent.swift deleted file mode 100644 index c9f84cae..00000000 --- a/Sources/Domain/ScreenshotPlans/TemplateContent.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation - -/// User content to render inside a template. -public struct TemplateContent: Sendable, Equatable { - public let headline: String - public let subtitle: String? - /// Optional tagline override. `nil` uses the template's default tagline. - public let tagline: String? - public let screenshotFile: String - - // Style overrides (from AI refine). `nil` uses template defaults. - public let background: SlideBackground? - public let textColor: String? - - public init( - headline: String, - subtitle: String? = nil, - tagline: String? = nil, - screenshotFile: String, - background: SlideBackground? = nil, - textColor: String? = nil - ) { - self.headline = headline - self.subtitle = subtitle - self.tagline = tagline - self.screenshotFile = screenshotFile - self.background = background - self.textColor = textColor - } -} diff --git a/Sources/Domain/ScreenshotPlans/TemplateHTMLRenderer.swift b/Sources/Domain/ScreenshotPlans/TemplateHTMLRenderer.swift deleted file mode 100644 index b6699545..00000000 --- a/Sources/Domain/ScreenshotPlans/TemplateHTMLRenderer.swift +++ /dev/null @@ -1,195 +0,0 @@ -import Foundation - -/// Renders self-contained HTML previews of `ScreenshotTemplate`. -/// -/// Output uses `cqi` units + `container-type:inline-size` so it scales -/// to any container width. Includes a realistic wireframe iPhone with -/// status bar, UI cards, and the real iPhone frame PNG overlay. -public enum TemplateHTMLRenderer { - - /// Base64 data URL of the iPhone frame PNG. - /// Set by infrastructure at startup (e.g. from plugin's iphone-frame.png). - /// If nil, a CSS wireframe is used instead. - nonisolated(unsafe) public static var phoneFrameDataURL: String? - - /// Render a complete HTML page (for saving as .html file). - /// When `fillViewport` is true, the preview fills the entire viewport (for image export). - public static func renderPage(_ template: ScreenshotTemplate, content: TemplateContent? = nil, fillViewport: Bool = false) -> String { - let inner = render(template, content: content) - let previewStyle = fillViewport - ? "width:100%;height:100%;container-type:inline-size" - : "width:320px;aspect-ratio:1320/2868;container-type:inline-size" - let bodyStyle = fillViewport - ? "margin:0;overflow:hidden" - : "display:flex;justify-content:center;align-items:center;min-height:100vh;background:#111" - let htmlHeight = fillViewport ? "html,body{width:100%;height:100%}" : "" - return "" + - "" + - "\(template.name)" + - "" + - "
\(inner)
" - } - - /// Render an inline HTML div (for embedding in any container). - /// The container MUST have `container-type:inline-size` for `cqi` units to work. - public static func render(_ template: ScreenshotTemplate, content: TemplateContent? = nil) -> String { - let bg = content?.background ?? template.background - let bgCSS = backgroundCSS(bg) - let lit = isLightColor(bg) - let textHTML = template.textSlots.map { renderText($0, content: content) }.joined() - let deviceHTML = template.deviceSlots.map { renderDevice($0, lit: lit, screenshotFile: content?.screenshotFile) }.joined() - - return "
" + - "\(textHTML)\(deviceHTML)
" - } - - // MARK: - Background - - private static func backgroundCSS(_ bg: SlideBackground) -> String { - switch bg { - case .solid(let color): return color - case .gradient(let from, let to, let angle): return "linear-gradient(\(angle)deg,\(from),\(to))" - } - } - - private static func isLightColor(_ bg: SlideBackground) -> Bool { - let hex: String - switch bg { - case .solid(let color): hex = color - case .gradient(let from, _, _): hex = from - } - let h = hex.replacingOccurrences(of: "#", with: "") - guard h.count == 6, - let r = UInt8(h.prefix(2), radix: 16), - let g = UInt8(h.dropFirst(2).prefix(2), radix: 16), - let b = UInt8(h.dropFirst(4).prefix(2), radix: 16) else { return false } - return (Int(r) * 299 + Int(g) * 587 + Int(b) * 114) / 1000 > 160 - } - - // MARK: - Text - - private static func renderText(_ slot: TemplateTextSlot, content: TemplateContent?) -> String { - let text: String - if let content { - switch slot.role { - case .heading: text = content.headline - case .subheading: text = content.subtitle ?? "" - case .tagline: text = content.tagline ?? slot.preview - } - } else { - text = slot.preview - } - guard !text.isEmpty else { return "" } - - let display = text.replacingOccurrences(of: "\n", with: "
") - let top = fmt(slot.y * 100) - let size = fmt(slot.fontSize * 100) - let align = slot.textAlign - let left = align == "left" - ? "left:\(fmt(slot.x * 100))%;right:5%" - : "left:5%;right:5%" - let font = slot.font.map { "'\($0)'," } ?? "" - let fontFamily = "\(font)system-ui,-apple-system,sans-serif" - - var s = "position:absolute;top:\(top)%;\(left);text-align:\(align);z-index:2;" - s += "color:\(content?.textColor ?? slot.color);" - s += "font-size:max(8px,\(size)cqi);" - s += "font-weight:\(slot.fontWeight);" - s += "line-height:\(slot.lineHeight ?? 1.1);" - s += "font-family:\(fontFamily);" - s += "font-style:\(slot.fontStyle ?? "normal");" - s += "letter-spacing:\(slot.letterSpacing ?? "-0.02em");" - if let tt = slot.textTransform { s += "text-transform:\(tt);" } - s += "white-space:pre-line;" - - return "
\(display)
" - } - - // MARK: - Device - - private static func renderDevice(_ slot: TemplateDeviceSlot, lit: Bool, screenshotFile: String?) -> String { - let w = fmt(slot.scale * 100) - let cx = fmt(slot.x * 100) - let cy = fmt(slot.y * 100) - let rot = slot.rotation.map { "rotate(\($0)deg)" } ?? "" - let transform = "translateX(-50%) \(rot)" - let z = slot.zIndex.map { "z-index:\($0);" } ?? "" - - var html = "
" - - if let file = screenshotFile { - html += "\"\"" - } else { - html += renderWireframePhone(lit: lit) - } - - html += "
" - return html - } - - // MARK: - Wireframe Phone - - private static func renderWireframePhone(lit: Bool) -> String { - let scr = lit ? "rgba(255,255,255,0.65)" : "rgba(255,255,255,0.06)" - let ui = lit ? "rgba(0,0,0,0.06)" : "rgba(255,255,255,0.06)" - let ui2 = lit ? "rgba(0,0,0,0.10)" : "rgba(255,255,255,0.09)" - let uitx = lit ? "rgba(0,0,0,0.30)" : "rgba(255,255,255,0.25)" - let shadow = lit ? "0.12" : "0.35" - let frameBg = lit ? "rgba(0,0,0,0.06)" : "rgba(255,255,255,0.08)" - let frameBorder = lit ? "rgba(0,0,0,0.12)" : "rgba(255,255,255,0.15)" - - // Real iPhone frame overlay or CSS fallback - let frameOverlay: String - let outerStyle: String - if let dataURL = phoneFrameDataURL { - frameOverlay = "\"\"" - outerStyle = "aspect-ratio:1470/3000;position:relative;filter:drop-shadow(0 4px 20px rgba(0,0,0,\(shadow)))" - } else { - frameOverlay = "" - outerStyle = "aspect-ratio:1470/3000;position:relative;filter:drop-shadow(0 4px 20px rgba(0,0,0,\(shadow)));background:\(frameBg);border-radius:12%/5.5%;border:1.5px solid \(frameBorder);overflow:hidden" - } - - return """ -
\ -
\ -
\ -
9:41
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\ -
\(frameOverlay)
- """ - } - - private static func fmt(_ value: Double) -> String { - String(format: "%.1f", value) - } -} diff --git a/Sources/Domain/ScreenshotPlans/ThemedPage.swift b/Sources/Domain/ScreenshotPlans/ThemedPage.swift deleted file mode 100644 index 1b1514a1..00000000 --- a/Sources/Domain/ScreenshotPlans/ThemedPage.swift +++ /dev/null @@ -1,38 +0,0 @@ -/// A themed screenshot page — wraps composed HTML body in a full HTML document. -/// -/// Domain value type that owns the page-wrapping logic. Used by both CLI and REST -/// after theme composition to produce the final renderable HTML. -public struct ThemedPage: Sendable, Equatable { - public let body: String - public let width: Int - public let height: Int - public let fillViewport: Bool - - public init(body: String, width: Int, height: Int, fillViewport: Bool = false) { - self.body = body - self.width = width - self.height = height - self.fillViewport = fillViewport - } - - /// The full HTML page ready for rendering or display. - public var html: String { - let previewStyle = fillViewport - ? "width:100%;height:100%;container-type:inline-size" - : "width:320px;aspect-ratio:\(width)/\(height);container-type:inline-size" - let bodyStyle = fillViewport - ? "margin:0;overflow:hidden" - : "display:flex;justify-content:center;align-items:center;min-height:100vh;background:#111" - let htmlHeight = fillViewport ? "html,body{width:100%;height:100%}" : "" - return """ - \ - \ - Themed Screenshot\ - \ -
\(body)
- """ - } -} diff --git a/Sources/Domain/Screenshots/AppShotTemplate.swift b/Sources/Domain/Screenshots/AppShotTemplate.swift new file mode 100644 index 00000000..8a29988a --- /dev/null +++ b/Sources/Domain/Screenshots/AppShotTemplate.swift @@ -0,0 +1,147 @@ +import Foundation + +/// A single-screenshot template for App Store screenshots. +/// +/// Wraps the unified `ScreenLayout` + `GalleryPalette` types +/// with metadata for filtering (category, supportedSizes). +/// +/// ``` +/// let tmpl = AppShotTemplate(id: "top-hero", name: "Top Hero", +/// category: .bold, supportedSizes: [.portrait], +/// screenLayout: ScreenLayout(headline: TextSlot(y: 0.04, size: 0.10), +/// device: DeviceSlot(y: 0.18, width: 0.85)), +/// palette: GalleryPalette(id: "top-hero", name: "Top Hero", +/// background: "linear-gradient(150deg,#4338CA,#6D28D9)")) +/// ``` +public struct AppShotTemplate: Sendable, Identifiable { + public let id: String + public let name: String + public let category: TemplateCategory + public let supportedSizes: [ScreenSize] + public let description: String + public let screenLayout: ScreenLayout + public let palette: GalleryPalette + public init( + id: String, + name: String, + category: TemplateCategory = .custom, + supportedSizes: [ScreenSize] = [.portrait], + description: String = "", + screenLayout: ScreenLayout, + palette: GalleryPalette + ) { + self.id = id + self.name = name + self.category = category + self.supportedSizes = supportedSizes + self.description = description + self.screenLayout = screenLayout + self.palette = palette + } + + /// Self-contained HTML preview — renders with TextSlot.preview placeholders. + /// Cached after first computation — templates are immutable. + public var previewHTML: String { + GalleryHTMLRenderer.cachedPreview(id: "tmpl-\(id)") { + let shot = AppShot(screenshot: "", type: .feature) + let html = GalleryHTMLRenderer.renderScreen(shot, screenLayout: screenLayout, palette: palette) + return GalleryHTMLRenderer.wrapPage(html) + } + } + + /// Apply with user content — returns a full HTML page. + public func apply(shot: AppShot, fillViewport: Bool = false) -> String { + let html = GalleryHTMLRenderer.renderScreen(shot, screenLayout: screenLayout, palette: palette) + return GalleryHTMLRenderer.wrapPage(html, fillViewport: fillViewport) + } + + /// Render inner HTML fragment for composition pipelines. + public func renderFragment(shot: AppShot) -> String { + GalleryHTMLRenderer.renderScreen(shot, screenLayout: screenLayout, palette: palette) + } +} + +// MARK: - Semantic Booleans + +extension AppShotTemplate { + public var isPortrait: Bool { supportedSizes.contains(.portrait) } + public var isLandscape: Bool { supportedSizes.contains(.landscape) } + public var deviceCount: Int { screenLayout.deviceCount } +} + +// MARK: - Equatable + +extension AppShotTemplate: Equatable { + public static func == (lhs: AppShotTemplate, rhs: AppShotTemplate) -> Bool { + lhs.id == rhs.id && lhs.name == rhs.name && lhs.category == rhs.category + && lhs.screenLayout == rhs.screenLayout && lhs.palette == rhs.palette + } +} + +// MARK: - Codable + +extension AppShotTemplate: Codable { + private enum CodingKeys: String, CodingKey { + case id, name, category, supportedSizes, description + case screenLayout, palette + case previewHTML, deviceCount + } + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + category = try c.decodeIfPresent(TemplateCategory.self, forKey: .category) ?? .custom + supportedSizes = try c.decodeIfPresent([ScreenSize].self, forKey: .supportedSizes) ?? [.portrait] + description = try c.decodeIfPresent(String.self, forKey: .description) ?? "" + screenLayout = try c.decode(ScreenLayout.self, forKey: .screenLayout) + palette = try c.decode(GalleryPalette.self, forKey: .palette) + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(name, forKey: .name) + try c.encode(category, forKey: .category) + try c.encode(supportedSizes, forKey: .supportedSizes) + try c.encode(description, forKey: .description) + try c.encode(screenLayout, forKey: .screenLayout) + try c.encode(palette, forKey: .palette) + try c.encode(previewHTML, forKey: .previewHTML) + try c.encode(deviceCount, forKey: .deviceCount) + } +} + +// MARK: - Presentable + +extension AppShotTemplate: Presentable { + public static var tableHeaders: [String] { + ["ID", "Name", "Category", "Devices"] + } + public var tableRow: [String] { + [id, name, category.rawValue, "\(deviceCount)"] + } +} + +// MARK: - Affordances + +extension AppShotTemplate: AffordanceProviding { + public var affordances: [String: String] { + [ + "preview": "asc app-shots templates get --id \(id) --preview", + "apply": "asc app-shots templates apply --id \(id) --screenshot screen.png", + "detail": "asc app-shots templates get --id \(id)", + "listAll": "asc app-shots templates list", + ] + } +} + +// MARK: - Supporting Types (kept for metadata) + +public enum TemplateCategory: String, Sendable, Equatable, Codable, CaseIterable { + case bold, minimal, elegant, professional, playful, showcase, custom +} + +public enum ScreenSize: String, Sendable, Equatable, Codable, CaseIterable { + case portrait, portrait43, landscape, square +} diff --git a/Sources/Domain/ScreenshotPlans/AppShotsConfig.swift b/Sources/Domain/Screenshots/AppShotsConfig.swift similarity index 100% rename from Sources/Domain/ScreenshotPlans/AppShotsConfig.swift rename to Sources/Domain/Screenshots/AppShotsConfig.swift diff --git a/Sources/Domain/ScreenshotPlans/AppShotsConfigStorage.swift b/Sources/Domain/Screenshots/AppShotsConfigStorage.swift similarity index 100% rename from Sources/Domain/ScreenshotPlans/AppShotsConfigStorage.swift rename to Sources/Domain/Screenshots/AppShotsConfigStorage.swift diff --git a/Sources/Domain/Screenshots/Gallery/AppShot.swift b/Sources/Domain/Screenshots/Gallery/AppShot.swift new file mode 100644 index 00000000..9c84542b --- /dev/null +++ b/Sources/Domain/Screenshots/Gallery/AppShot.swift @@ -0,0 +1,134 @@ +import Foundation + +/// A single designed App Store screenshot. +/// +/// Named after the CLI command `asc app-shots`. +/// Can be used standalone or as part of a `Gallery`. +/// +/// ``` +/// let shot = AppShot(screenshot: "screen-0.png", type: .hero) +/// shot.headline = "Make your map yours" +/// shot.badges = ["iPhone 17", "Ultra 3"] +/// shot.isConfigured // → true +/// ``` +public final class AppShot: @unchecked Sendable, Identifiable { + public let id: String + public let screenshots: [String] + public private(set) var type: ScreenType + + public var tagline: String? + public var headline: String? + public var body: String? + public var badges: [String] + public var trustMarks: [String]? + + /// First screenshot (convenience for single-device templates). + public var screenshot: String { screenshots.first ?? "" } + + public init( + id: String = UUID().uuidString, + screenshots: [String], + type: ScreenType = .feature + ) { + self.id = id + self.screenshots = screenshots + self.type = type + self.badges = [] + } + + /// Convenience: single screenshot. + public convenience init( + id: String = UUID().uuidString, + screenshot: String, + type: ScreenType = .feature + ) { + self.init(id: id, screenshots: [screenshot], type: type) + } + + /// Whether this app shot has enough content to render. + public var isConfigured: Bool { + guard let headline else { return false } + return !headline.isEmpty + } + + /// Compose this shot into HTML using a screen template and palette. + /// Caller decides which template — enables per-shot override. + public func compose(screenLayout: ScreenLayout, palette: GalleryPalette) -> String { + GalleryHTMLRenderer.renderScreen(self, screenLayout: screenLayout, palette: palette) + } +} + +// MARK: - Codable + +extension AppShot: Codable { + private enum CodingKeys: String, CodingKey { + case screenshot, screenshots, type, tagline, headline, body, badges, trustMarks + } + + public convenience init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + // Support both "screenshot" (single) and "screenshots" (array) + let shots: [String] + if let arr = try? c.decode([String].self, forKey: .screenshots) { + shots = arr + } else if let single = try? c.decode(String.self, forKey: .screenshot) { + shots = [single] + } else { + shots = [] + } + let type = try c.decodeIfPresent(ScreenType.self, forKey: .type) ?? .feature + self.init(screenshots: shots, type: type) + self.tagline = try c.decodeIfPresent(String.self, forKey: .tagline) + self.headline = try c.decodeIfPresent(String.self, forKey: .headline) + self.body = try c.decodeIfPresent(String.self, forKey: .body) + self.badges = try c.decodeIfPresent([String].self, forKey: .badges) ?? [] + self.trustMarks = try c.decodeIfPresent([String].self, forKey: .trustMarks) + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + if screenshots.count > 1 { + try c.encode(screenshots, forKey: .screenshots) + } else { + try c.encode(screenshot, forKey: .screenshot) + } + try c.encode(type, forKey: .type) + try c.encodeIfPresent(tagline, forKey: .tagline) + try c.encodeIfPresent(headline, forKey: .headline) + try c.encodeIfPresent(body, forKey: .body) + if !badges.isEmpty { try c.encode(badges, forKey: .badges) } + try c.encodeIfPresent(trustMarks, forKey: .trustMarks) + } +} + +// MARK: - Presentable + +extension AppShot: Presentable { + public static var tableHeaders: [String] { + ["Headline", "Screenshot", "Type", "Configured"] + } + public var tableRow: [String] { + [headline ?? "-", screenshot, type.rawValue, isConfigured ? "✓" : "✗"] + } +} + +// MARK: - Affordances + +extension AppShot: AffordanceProviding { + public var affordances: [String: String] { + var cmds: [String: String] = [ + "listTemplates": "asc app-shots templates list", + ] + if isConfigured { + cmds["preview"] = "asc app-shots templates apply --screenshot \(screenshot) --headline \"\(headline ?? "")\"" + } + return cmds + } +} + +/// The type of screen in an App Store screenshot gallery. +public enum ScreenType: String, Sendable, Equatable, Codable { + case hero // first impression — branding, trust marks, big headline + case feature // feature showcase — headline + device frame + case social // social proof — ratings, testimonials, press +} diff --git a/Sources/Domain/Screenshots/Gallery/Gallery.swift b/Sources/Domain/Screenshots/Gallery/Gallery.swift new file mode 100644 index 00000000..f6ee69af --- /dev/null +++ b/Sources/Domain/Screenshots/Gallery/Gallery.swift @@ -0,0 +1,206 @@ +import Foundation + +/// A coordinated set of App Store screenshots for an app. +/// +/// Created from screenshot files. First becomes hero, rest are features. +/// Apply a template (where things go) and palette (colors) to render. +/// +/// ``` +/// let gallery = Gallery(appName: "BezelBlend", +/// screenshots: ["screen-0.png", "screen-1.png"]) +/// gallery.appShots[0].headline = "PREMIUM DEVICE MOCKUPS." +/// gallery.template = featureWalkthrough +/// gallery.palette = greenMint +/// gallery.isReady // → true when all shots configured + template + palette +/// ``` +public final class Gallery: @unchecked Sendable, Identifiable { + public let id: String + public let appName: String + + public private(set) var appShots: [AppShot] + public var template: GalleryTemplate? + public var palette: GalleryPalette? + + /// Create a gallery from screenshot files. + /// First screenshot becomes `.hero`, rest become `.feature`. + public init( + id: String = UUID().uuidString, + appName: String, + screenshots: [String] + ) { + self.id = id + self.appName = appName + self.appShots = screenshots.enumerated().map { index, file in + AppShot( + screenshot: file, + type: index == 0 ? .hero : .feature + ) + } + } + + // MARK: - Apply Screenshots + + /// Create a new Gallery from a sample gallery template, replacing screenshots with user's files. + /// Copies template, palette, and content (headline, badges, etc.) from the sample. + public func applyScreenshots(_ screenshots: [String]) -> Gallery { + let gallery = Gallery(appName: appName, screenshots: screenshots) + gallery.template = template + gallery.palette = palette + for (i, shot) in gallery.appShots.enumerated() { + if i < appShots.count { + let sample = appShots[i] + shot.headline = sample.headline + shot.tagline = sample.tagline + shot.body = sample.body + shot.badges = sample.badges + shot.trustMarks = sample.trustMarks + } + } + return gallery + } + + // MARK: - Screenshot Distribution + + /// Distribute screenshots across screens based on device count per screen. + /// Multi-device templates consume multiple screenshots per screen. + /// + /// - Returns: Array of screenshot groups. Each group fills one screen's devices. + public static func distributeScreenshots( + _ screenshots: [String], + screenLayout: ScreenLayout + ) -> [[String]] { + let devicesPerScreen = max(1, screenLayout.deviceCount) + var result: [[String]] = [] + var i = 0 + while i < screenshots.count { + let end = min(i + devicesPerScreen, screenshots.count) + result.append(Array(screenshots[i.. [String] { + guard let template, let palette else { return [] } + return appShots.compactMap { shot in + guard shot.isConfigured, + let screenLayout = template.screens[shot.type] else { return nil } + return shot.compose(screenLayout: screenLayout, palette: palette) + } + } + + /// Render a single shot at index. + /// Optionally override the screen template (for per-shot customization in the UI). + public func renderShot( + at index: Int, + with overrideTemplate: ScreenLayout? = nil + ) -> String? { + guard let template, let palette, + appShots.indices.contains(index), + appShots[index].isConfigured else { return nil } + let shot = appShots[index] + let screenLayout = overrideTemplate ?? template.screens[shot.type] + guard let screenLayout else { return nil } + return shot.compose(screenLayout: screenLayout, palette: palette) + } + + /// Self-contained HTML preview showing all panels as a horizontal gallery strip. + /// Cached after first computation — gallery template data is immutable. + public var previewHTML: String { + GalleryHTMLRenderer.cachedPreview(id: "gallery-\(id)") { + GalleryHTMLRenderer.renderPreviewPage(self) + } + } + + public var readiness: GalleryReadiness { + GalleryReadiness( + hasPalette: palette != nil, + hasTemplate: template != nil, + configuredCount: appShots.filter(\.isConfigured).count, + totalCount: appShots.count + ) + } +} + +// MARK: - Codable + +extension Gallery: Codable { + private enum CodingKeys: String, CodingKey { + case appName, appShots, template, palette, previewHTML + } + + public convenience init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let appName = try c.decode(String.self, forKey: .appName) + // Decode appShots directly — they carry their own screenshot/type/content + let shots = try c.decode([AppShot].self, forKey: .appShots) + self.init(appName: appName, screenshots: []) + self.appShots = shots + self.template = try c.decodeIfPresent(GalleryTemplate.self, forKey: .template) + self.palette = try c.decodeIfPresent(GalleryPalette.self, forKey: .palette) + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(appName, forKey: .appName) + try c.encode(appShots, forKey: .appShots) + try c.encodeIfPresent(template, forKey: .template) + try c.encodeIfPresent(palette, forKey: .palette) + try c.encode(previewHTML, forKey: .previewHTML) + } +} + +// MARK: - Presentable + +extension Gallery: Presentable { + public static var tableHeaders: [String] { + ["App Name", "Shots", "Template", "Ready"] + } + public var tableRow: [String] { + [appName, "\(shotCount)", template?.name ?? "-", isReady ? "✓" : "✗"] + } +} + +// MARK: - Affordances + +extension Gallery: AffordanceProviding { + public var affordances: [String: String] { + var cmds: [String: String] = [ + "listAll": "asc app-shots gallery-templates list", + ] + if let t = template { + cmds["detail"] = "asc app-shots gallery-templates get --id \(t.id)" + cmds["preview"] = "asc app-shots gallery-templates get --id \(t.id) --preview" + } + return cmds + } +} + +/// Progress check for a gallery. +public struct GalleryReadiness: Sendable, Equatable { + public let hasPalette: Bool + public let hasTemplate: Bool + public let configuredCount: Int + public let totalCount: Int + + public var isReady: Bool { hasPalette && hasTemplate && configuredCount == totalCount } + public var progress: String { "\(configuredCount)/\(totalCount) app shots configured" } +} diff --git a/Sources/Domain/Screenshots/Gallery/GalleryHTMLRenderer.swift b/Sources/Domain/Screenshots/Gallery/GalleryHTMLRenderer.swift new file mode 100644 index 00000000..de2228f1 --- /dev/null +++ b/Sources/Domain/Screenshots/Gallery/GalleryHTMLRenderer.swift @@ -0,0 +1,234 @@ +import Foundation + +/// Renders App Store screenshot screens as HTML. +/// +/// **SRP:** Builds context dictionaries from domain models. Nothing else. +/// **OCP:** All HTML, CSS colors, keyframes, and theme logic live in `.mustache` templates. +/// +/// Templates are pre-compiled via `MustacheLibrary` at startup for performance. +public enum GalleryHTMLRenderer { + + nonisolated(unsafe) public static var phoneFrameDataURL: String? + + // MARK: - Preview Cache + + /// Caches rendered `previewHTML` keyed by template/gallery ID. + /// Templates are immutable data — preview only changes when `phoneFrameDataURL` changes. + private static let previewCache = PreviewCache() + + /// Get or compute a cached preview. + public static func cachedPreview(id: String, compute: () -> String) -> String { + previewCache.get(id: id, compute: compute) + } + + /// Clear all cached previews (e.g. when phone frame loads after templates). + public static func clearPreviewCache() { + previewCache.clear() + } + + private final class PreviewCache: @unchecked Sendable { + private var store: [String: String] = [:] + private let lock = NSLock() + + func get(id: String, compute: () -> String) -> String { + lock.lock() + if let cached = store[id] { + lock.unlock() + return cached + } + lock.unlock() + let value = compute() + lock.lock() + store[id] = value + lock.unlock() + return value + } + + func clear() { + lock.lock() + store.removeAll() + lock.unlock() + } + } + + // MARK: - Screen Rendering + + /// Render a single AppShot as an HTML fragment for one screen. + public static func renderScreen( + _ shot: AppShot, + screenLayout: ScreenLayout, + palette: GalleryPalette + ) -> String { + let context = buildScreenContext(shot, screenLayout: screenLayout, palette: palette) + return HTMLComposer.render(template: "screen", with: context) + } + + /// Build the full context dictionary for a screen template. + public static func buildScreenContext( + _ shot: AppShot, + screenLayout: ScreenLayout, + palette: GalleryPalette + ) -> [String: Any] { + let hl = screenLayout.headline + let pad = 5.0 + + var context: [String: Any] = [ + "background": palette.background, + "theme": palette.isLight ? "light" : "dark", + "themeVars": HTMLComposer.render(template: "theme-vars", with: [:]), + ] + + // Tagline + if let tgSlot = screenLayout.tagline { + let tgText = shot.tagline ?? tgSlot.preview ?? "" + if !tgText.isEmpty { + context["tagline"] = textSlotContext(tgSlot, content: tgText, color: palette.headlineColor, pad: pad) + } + } + + // Headline + let hlContent = shot.headline ?? hl.preview ?? "" + if !hlContent.isEmpty { + context["headline"] = textSlotContext(hl, content: hlContent.replacingOccurrences(of: "\n", with: "
"), color: palette.headlineColor, pad: pad) + } + + // Subheading + if let subSlot = screenLayout.subheading { + let subText = shot.body ?? subSlot.preview ?? "" + if !subText.isEmpty { + var sub = textSlotContext(subSlot, content: subText.replacingOccurrences(of: "\n", with: "
"), color: "", pad: pad) + sub["padRight"] = fmt(pad + 3) + context["subheading"] = sub + } + } + + // Trust marks + if let marks = shot.trustMarks, !marks.isEmpty { + let hlLines = Double(hlContent.components(separatedBy: "\n").count) + let afterHeading = hl.y * 100 + hlLines * hl.size * 100 * 1.0 + 1 + context["trustMarks"] = [ + "top": fmt(afterHeading), + "pad": fmt(pad), + "items": marks.map { ["text": $0, "fontSize": fmt(hl.size * 100 * 0.28)] }, + ] as [String: Any] + } + + // Badges + if !shot.badges.isEmpty { + context["badges"] = badgeContexts(shot.badges, headlineSlot: hl) + } + + // Devices + let devSlots = screenLayout.devices.isEmpty && shot.type == .hero + ? [DeviceSlot(x: 0.5, y: 0.42, width: 0.65)] + : screenLayout.devices + context["devices"] = devSlots.enumerated().map { (devIndex, dev) in + let screenshotFile = devIndex < shot.screenshots.count ? shot.screenshots[devIndex] : "" + return deviceContext(dev, screenshot: screenshotFile) + } + + // Decorations + if !screenLayout.decorations.isEmpty { + context["decorations"] = decorationContexts(screenLayout.decorations) + if screenLayout.decorations.contains(where: { $0.animation != nil }) { + context["hasAnimations"] = true + context["keyframesHTML"] = HTMLComposer.render(template: "keyframes", with: [:]) + } + } + + return context + } + + // MARK: - Page Wrapper + + public static func wrapPage(_ inner: String, fillViewport: Bool = false) -> String { + HTMLComposer.render(template: "page-wrapper", with: pageContext(inner: inner, fillViewport: fillViewport)) + } + + /// Build page wrapper context. Shared with `ThemedPage`. + public static func pageContext( + inner: String, + fillViewport: Bool = false, + width: Int = 1320, + height: Int = 2868 + ) -> [String: Any] { + var ctx: [String: Any] = [ + "inner": inner, + "aspectRatio": "\(width)/\(height)", + ] + if fillViewport { ctx["fillViewport"] = true } + return ctx + } + + // MARK: - Preview + + public static func renderPreviewPage(_ gallery: Gallery) -> String { + let screens = gallery.renderAll() + guard !screens.isEmpty else { return "" } + let screenDivs = screens.map { HTMLComposer.render(template: "preview-screen", with: ["screen": $0]) }.joined() + return HTMLComposer.render(template: "preview-page", with: [ + "screenDivs": screenDivs, + "themeVars": HTMLComposer.render(template: "theme-vars", with: [:]), + ]) + } + + // MARK: - Context Builders + + private static func textSlotContext(_ slot: TextSlot, content: String, color: String, pad: Double) -> [String: Any] { + [ + "top": fmt(slot.y * 100), "pad": fmt(pad), + "weight": "\(slot.weight)", "fontSize": fmt(slot.size * 100), + "color": color, "align": slot.align, "content": content, + ] + } + + private static func badgeContexts(_ badges: [String], headlineSlot hl: TextSlot) -> [[String: Any]] { + let badgeTop = hl.y * 100 + 1.0 + return badges.enumerated().map { (i, badge) in + let bx = hl.align == "left" ? 65.0 + Double(i % 2) * 12.0 : 60.0 + Double(i % 2) * 15.0 + return [ + "left": fmt(bx), "top": fmt(badgeTop + Double(i) * 7.0), + "fontSize": fmt(hl.size * 100 * 0.28), "text": badge, + ] + } + } + + private static func decorationContexts(_ decorations: [Decoration]) -> [[String: Any]] { + decorations.enumerated().map { (i, deco) in + [ + "left": fmt(deco.x * 100), "top": fmt(deco.y * 100), + "fontSize": fmt(deco.size * 100), "opacity": fmt(deco.opacity), + "background": deco.background ?? "transparent", + "color": deco.color ?? "", + "useDefaultColor": deco.color == nil, + "borderRadius": deco.borderRadius ?? "50%", + "animStyle": deco.animation.map { "animation:td-\($0.rawValue) \(3 + i % 4)s ease-in-out infinite;" } ?? "", + "content": deco.shape.displayCharacter, + ] + } + } + + private static func deviceContext(_ slot: DeviceSlot, screenshot: String) -> [String: Any] { + let dl = fmt((slot.x - slot.width / 2) * 100) + let dt = fmt(slot.y * 100) + let dw = fmt(slot.width * 100) + + if !screenshot.isEmpty { + return ["left": dl, "top": dt, "width": dw, "hasScreenshot": true, "screenshot": screenshot] + } else { + var ctx: [String: Any] = ["left": dl, "top": dt, "width": dw, "hasWireframe": true] + ctx["wireframeHTML"] = HTMLComposer.render(template: "wireframe", with: [:]) + if let dataURL = phoneFrameDataURL { + ctx["hasPhoneFrame"] = true + ctx["phoneFrameURL"] = dataURL + } else { + ctx["noPhoneFrame"] = true + } + return ctx + } + } + + static func fmt(_ value: Double) -> String { + String(format: "%.1f", value) + } +} diff --git a/Sources/Domain/Screenshots/Gallery/GalleryPalette.swift b/Sources/Domain/Screenshots/Gallery/GalleryPalette.swift new file mode 100644 index 00000000..a68aa96f --- /dev/null +++ b/Sources/Domain/Screenshots/Gallery/GalleryPalette.swift @@ -0,0 +1,48 @@ +import Foundation + +/// The color scheme for a gallery — HOW things look. +/// +/// Controls background, text colors, badge styling, decoration tints. +/// Same palette works with any template layout. +public struct GalleryPalette: Sendable, Equatable, Identifiable, Codable { + public let id: String + public let name: String + public let background: String + /// Explicit primary text color. When set, overrides the auto-detect heuristic. + public let textColor: String? + + public init( + id: String, + name: String, + background: String, + textColor: String? = nil + ) { + self.id = id + self.name = name + self.background = background + self.textColor = textColor + } + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + background = try c.decode(String.self, forKey: .background) + textColor = try c.decodeIfPresent(String.self, forKey: .textColor) + } +} + +// MARK: - Theme Detection + +extension GalleryPalette { + + /// Whether this palette has a light background (heuristic based on hex values). + public var isLight: Bool { + guard textColor == nil else { return false } + let lightHex = ["#f", "#F", "#e", "#E", "#d", "#D", "#c", "#C", "#b", "#B", "#a8", "#A8", "#9"] + return lightHex.contains(where: { background.contains($0) }) + } + + /// Primary text color — explicit or auto-detected from background. + public var headlineColor: String { textColor ?? (isLight ? "#000" : "#fff") } +} diff --git a/Sources/Domain/Screenshots/Gallery/GalleryTemplate.swift b/Sources/Domain/Screenshots/Gallery/GalleryTemplate.swift new file mode 100644 index 00000000..29f5e31a --- /dev/null +++ b/Sources/Domain/Screenshots/Gallery/GalleryTemplate.swift @@ -0,0 +1,84 @@ +import Foundation + +/// Defines WHERE things go in each screen type — pure layout, no colors. +/// +/// A gallery template contains a `ScreenLayout` for each screen type +/// (hero, feature, social). Same template works with any palette. +public struct GalleryTemplate: Sendable, Equatable, Identifiable { + public let id: String + public let name: String + public let description: String + public let background: String + public let screens: [ScreenType: ScreenLayout] + + public init( + id: String, + name: String, + description: String = "", + background: String = "", + screens: [ScreenType: ScreenLayout] = [:] + ) { + self.id = id + self.name = name + self.description = description + self.background = background + self.screens = screens + } +} + +// MARK: - Codable +// Custom coding so `screens` serializes as {"hero": {...}, "feature": {...}} +// instead of Swift's default [key, value, key, value] array encoding. + +extension GalleryTemplate: Codable { + private enum CodingKeys: String, CodingKey { + case id, name, description, background, screens + } + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + description = try c.decodeIfPresent(String.self, forKey: .description) ?? "" + background = try c.decodeIfPresent(String.self, forKey: .background) ?? "" + let raw = try c.decode([String: ScreenLayout].self, forKey: .screens) + var mapped: [ScreenType: ScreenLayout] = [:] + for (key, value) in raw { + guard let screenType = ScreenType(rawValue: key) else { continue } + mapped[screenType] = value + } + screens = mapped + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(name, forKey: .name) + try c.encode(description, forKey: .description) + try c.encode(background, forKey: .background) + let raw = Dictionary(uniqueKeysWithValues: screens.map { ($0.key.rawValue, $0.value) }) + try c.encode(raw, forKey: .screens) + } +} + +// MARK: - Presentable + +extension GalleryTemplate: Presentable { + public static var tableHeaders: [String] { + ["ID", "Name", "Screen Types"] + } + public var tableRow: [String] { + [id, name, screens.keys.map(\.rawValue).sorted().joined(separator: ", ")] + } +} + +// MARK: - Affordances + +extension GalleryTemplate: AffordanceProviding { + public var affordances: [String: String] { + [ + "detail": "asc app-shots gallery-templates get --id \(id)", + "listAll": "asc app-shots gallery-templates list", + ] + } +} diff --git a/Sources/Domain/Screenshots/Gallery/GalleryTemplateRepository.swift b/Sources/Domain/Screenshots/Gallery/GalleryTemplateRepository.swift new file mode 100644 index 00000000..ac0d84b5 --- /dev/null +++ b/Sources/Domain/Screenshots/Gallery/GalleryTemplateRepository.swift @@ -0,0 +1,16 @@ +import Foundation +import Mockable + +/// A provider that supplies sample galleries (gallery templates with sample panel content). +@Mockable +public protocol GalleryTemplateProvider: Sendable { + var providerId: String { get } + func galleries() async throws -> [Gallery] +} + +/// Repository for querying gallery templates. +@Mockable +public protocol GalleryTemplateRepository: Sendable { + func listGalleries() async throws -> [Gallery] + func getGallery(templateId: String) async throws -> Gallery? +} diff --git a/Sources/Domain/Screenshots/Gallery/HTMLComposer.swift b/Sources/Domain/Screenshots/Gallery/HTMLComposer.swift new file mode 100644 index 00000000..adb2cb89 --- /dev/null +++ b/Sources/Domain/Screenshots/Gallery/HTMLComposer.swift @@ -0,0 +1,62 @@ +import Foundation +import Mustache + +/// Composes HTML from Mustache templates and content. +/// +/// Uses `MustacheLibrary` to load and compile all `.mustache` templates once. +/// Templates support standard Mustache syntax: +/// - `{{varName}}` — HTML-escaped variable +/// - `{{{varName}}}` — raw/unescaped variable +/// - `{{#section}}...{{/section}}` — truthy section (if/each) +/// - `{{^section}}...{{/section}}` — inverted (else) +/// - `{{> partial}}` — partial inclusion +public enum HTMLComposer { + + /// Pre-compiled template library loaded from bundle resources. + nonisolated(unsafe) private static var _library: MustacheLibrary? + + /// The template library. Lazily loads from bundle Resources on first access. + public static var library: MustacheLibrary { + if let lib = _library { return lib } + let lib = loadLibrary() + _library = lib + return lib + } + + /// Replace the library (e.g. for plugins providing custom templates). + public static func setLibrary(_ lib: MustacheLibrary) { + _library = lib + } + + /// Render a named template with the given context. + public static func render(template name: String, with context: Any) -> String { + library.render(context, withTemplate: name) ?? "" + } + + /// Render an inline template string with the given context. + public static func render(_ template: String, with context: Any) -> String { + guard let compiled = try? MustacheTemplate(string: template) else { return template } + return compiled.render(context, library: library) + } + + // MARK: - Library Loading + + private static func loadLibrary() -> MustacheLibrary { + guard let resourceURL = Bundle.module.url(forResource: "Resources", withExtension: nil) else { + return MustacheLibrary() + } + // Load .mustache files manually and compile + var templates: [String: MustacheTemplate] = [:] + guard let files = try? FileManager.default.contentsOfDirectory(atPath: resourceURL.path) else { + return MustacheLibrary() + } + for file in files where file.hasSuffix(".mustache") { + let name = String(file.dropLast(".mustache".count)) + let path = resourceURL.appendingPathComponent(file).path + guard let content = try? String(contentsOfFile: path, encoding: .utf8), + let compiled = try? MustacheTemplate(string: content) else { continue } + templates[name] = compiled + } + return MustacheLibrary(templates: templates) + } +} diff --git a/Sources/Domain/Screenshots/Gallery/Resources/keyframes.mustache b/Sources/Domain/Screenshots/Gallery/Resources/keyframes.mustache new file mode 100644 index 00000000..fcaa475c --- /dev/null +++ b/Sources/Domain/Screenshots/Gallery/Resources/keyframes.mustache @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/Sources/Domain/Screenshots/Gallery/Resources/page-wrapper.mustache b/Sources/Domain/Screenshots/Gallery/Resources/page-wrapper.mustache new file mode 100644 index 00000000..fcf45402 --- /dev/null +++ b/Sources/Domain/Screenshots/Gallery/Resources/page-wrapper.mustache @@ -0,0 +1,20 @@ + + + + + + + + +
{{{inner}}}
+ + \ No newline at end of file diff --git a/Sources/Domain/Screenshots/Gallery/Resources/preview-page.mustache b/Sources/Domain/Screenshots/Gallery/Resources/preview-page.mustache new file mode 100644 index 00000000..6c57cab7 --- /dev/null +++ b/Sources/Domain/Screenshots/Gallery/Resources/preview-page.mustache @@ -0,0 +1,8 @@ + + +{{{themeVars}}} +
{{{screenDivs}}}
+ \ No newline at end of file diff --git a/Sources/Domain/Screenshots/Gallery/Resources/preview-screen.mustache b/Sources/Domain/Screenshots/Gallery/Resources/preview-screen.mustache new file mode 100644 index 00000000..eca3f2c6 --- /dev/null +++ b/Sources/Domain/Screenshots/Gallery/Resources/preview-screen.mustache @@ -0,0 +1 @@ +
{{{screen}}}
\ No newline at end of file diff --git a/Sources/Domain/Screenshots/Gallery/Resources/screen.mustache b/Sources/Domain/Screenshots/Gallery/Resources/screen.mustache new file mode 100644 index 00000000..8ec5c8d7 --- /dev/null +++ b/Sources/Domain/Screenshots/Gallery/Resources/screen.mustache @@ -0,0 +1 @@ +{{{themeVars}}}
{{#tagline}}
{{{content}}}
{{/tagline}}{{#headline}}
{{{content}}}
{{/headline}}{{#subheading}}
{{{content}}}
{{/subheading}}{{#trustMarks}}
{{#items}}{{text}}{{/items}}
{{/trustMarks}}{{#badges}}
{{text}}
{{/badges}}{{#devices}}{{#hasScreenshot}}
{{/hasScreenshot}}{{#hasWireframe}}
{{#hasPhoneFrame}}
{{{wireframeHTML}}}
{{/hasPhoneFrame}}{{#noPhoneFrame}}
{{{wireframeHTML}}}
{{/noPhoneFrame}}
{{/hasWireframe}}{{/devices}}{{#decorations}}
{{{content}}}
{{/decorations}}{{#hasAnimations}}{{{keyframesHTML}}}{{/hasAnimations}}
\ No newline at end of file diff --git a/Sources/Domain/Screenshots/Gallery/Resources/theme-vars.mustache b/Sources/Domain/Screenshots/Gallery/Resources/theme-vars.mustache new file mode 100644 index 00000000..3d1c1e5d --- /dev/null +++ b/Sources/Domain/Screenshots/Gallery/Resources/theme-vars.mustache @@ -0,0 +1,34 @@ + \ No newline at end of file diff --git a/Sources/Domain/Screenshots/Gallery/Resources/wireframe.mustache b/Sources/Domain/Screenshots/Gallery/Resources/wireframe.mustache new file mode 100644 index 00000000..fa6ef2ff --- /dev/null +++ b/Sources/Domain/Screenshots/Gallery/Resources/wireframe.mustache @@ -0,0 +1,31 @@ +
+
+
9:41
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/Sources/Domain/Screenshots/Gallery/ScreenLayout.swift b/Sources/Domain/Screenshots/Gallery/ScreenLayout.swift new file mode 100644 index 00000000..24d1c771 --- /dev/null +++ b/Sources/Domain/Screenshots/Gallery/ScreenLayout.swift @@ -0,0 +1,246 @@ +import Foundation + +/// Layout for a single screen type — where text and devices go. +/// +/// Supports tagline (above headline), headline, subheading (below headline), +/// single device, side-by-side (2 devices), or triple fan (3 devices). +public struct ScreenLayout: Sendable, Equatable, Codable { + public let tagline: TextSlot? + public let headline: TextSlot + public let subheading: TextSlot? + public let devices: [DeviceSlot] + public let decorations: [Decoration] + + public init( + tagline: TextSlot? = nil, + headline: TextSlot, + subheading: TextSlot? = nil, + devices: [DeviceSlot] = [], + decorations: [Decoration] = [] + ) { + self.tagline = tagline + self.headline = headline + self.subheading = subheading + self.devices = devices + self.decorations = decorations + } + + /// Convenience: single device. + public init( + tagline: TextSlot? = nil, + headline: TextSlot, + subheading: TextSlot? = nil, + device: DeviceSlot, + decorations: [Decoration] = [] + ) { + self.tagline = tagline + self.headline = headline + self.subheading = subheading + self.devices = [device] + self.decorations = decorations + } + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + tagline = try c.decodeIfPresent(TextSlot.self, forKey: .tagline) + headline = try c.decode(TextSlot.self, forKey: .headline) + subheading = try c.decodeIfPresent(TextSlot.self, forKey: .subheading) + if let arr = try? c.decode([DeviceSlot].self, forKey: .devices) { + devices = arr + } else if let single = try? c.decode(DeviceSlot.self, forKey: .device) { + devices = [single] + } else { + devices = [] + } + decorations = try c.decodeIfPresent([Decoration].self, forKey: .decorations) ?? [] + } + + private enum CodingKeys: String, CodingKey { + case tagline, headline, subheading, device, devices, decorations + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encodeIfPresent(tagline, forKey: .tagline) + try c.encode(headline, forKey: .headline) + try c.encodeIfPresent(subheading, forKey: .subheading) + if !devices.isEmpty { try c.encode(devices, forKey: .devices) } + if !decorations.isEmpty { try c.encode(decorations, forKey: .decorations) } + } + + public var deviceCount: Int { devices.count } + + /// Return a copy with the given decorations (replaces existing). + public func withDecorations(_ decorations: [Decoration]) -> ScreenLayout { + ScreenLayout( + tagline: tagline, headline: headline, subheading: subheading, + devices: devices, decorations: decorations + ) + } +} + +/// A text position in a screen layout. +/// +/// `preview` is the placeholder text shown in the template browser. +/// When the user applies the template, their AppShot content replaces it. +public struct TextSlot: Sendable, Equatable, Codable { + public let y: Double + public let size: Double + public let weight: Int + public let align: String + public let preview: String? + + public init(y: Double, size: Double, weight: Int = 900, align: String = "center", preview: String? = nil) { + self.y = y + self.size = size + self.weight = weight + self.align = align + self.preview = preview + } + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + y = try c.decode(Double.self, forKey: .y) + size = try c.decode(Double.self, forKey: .size) + weight = try c.decodeIfPresent(Int.self, forKey: .weight) ?? 900 + align = try c.decodeIfPresent(String.self, forKey: .align) ?? "center" + preview = try c.decodeIfPresent(String.self, forKey: .preview) + } +} + +/// Where the device frame appears in a screen. +public struct DeviceSlot: Sendable, Equatable, Codable { + public let x: Double + public let y: Double + public let width: Double + + public init(x: Double = 0.5, y: Double, width: Double) { + self.x = x + self.y = y + self.width = width + } +} + +/// A decorative element — ambient shapes or text/emoji labels. +/// +/// Positions (`x`, `y`) are normalized 0-1. `size` is relative to container width, +/// rendered as `cqi` units by `GalleryHTMLRenderer` for consistent scaling. +public struct Decoration: Sendable, Equatable, Codable { + public let shape: DecorationShape + public let x: Double + public let y: Double + public let size: Double + public let opacity: Double + /// Text/shape color (optional — renderer picks a default based on background). + public let color: String? + /// Pill background for label decorations (e.g. "rgba(255,255,255,0.1)"). + public let background: String? + /// CSS border-radius for pill shape (e.g. "50%", "12px"). + public let borderRadius: String? + /// Animation for movement (float, drift, pulse, spin, twinkle). + public let animation: DecorationAnimation? + + public init( + shape: DecorationShape, x: Double, y: Double, size: Double, opacity: Double = 1.0, + color: String? = nil, background: String? = nil, + borderRadius: String? = nil, animation: DecorationAnimation? = nil + ) { + self.shape = shape + self.x = x + self.y = y + self.size = size + self.opacity = opacity + self.color = color + self.background = background + self.borderRadius = borderRadius + self.animation = animation + } + + private enum CodingKeys: String, CodingKey { + case shape, x, y, size, opacity, color, background, borderRadius, animation + } + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + shape = try c.decode(DecorationShape.self, forKey: .shape) + x = try c.decode(Double.self, forKey: .x) + y = try c.decode(Double.self, forKey: .y) + size = try c.decode(Double.self, forKey: .size) + opacity = try c.decodeIfPresent(Double.self, forKey: .opacity) ?? 1.0 + color = try c.decodeIfPresent(String.self, forKey: .color) + background = try c.decodeIfPresent(String.self, forKey: .background) + borderRadius = try c.decodeIfPresent(String.self, forKey: .borderRadius) + animation = try c.decodeIfPresent(DecorationAnimation.self, forKey: .animation) + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(shape, forKey: .shape) + try c.encode(x, forKey: .x) + try c.encode(y, forKey: .y) + try c.encode(size, forKey: .size) + if opacity != 1.0 { try c.encode(opacity, forKey: .opacity) } + try c.encodeIfPresent(color, forKey: .color) + try c.encodeIfPresent(background, forKey: .background) + try c.encodeIfPresent(borderRadius, forKey: .borderRadius) + try c.encodeIfPresent(animation, forKey: .animation) + } +} + +/// Shape type for decorations. +/// +/// Simple shapes (gem, orb, sparkle, arrow) render as CSS shapes. +/// Labels render as text/emoji with optional pill background. +public enum DecorationShape: Sendable, Equatable, Codable { + case gem, orb, sparkle, arrow + case label(String) + + /// The display character for this shape. + public var displayCharacter: String { + switch self { + case .label(let text): text + case .gem: "◆" + case .orb: "●" + case .sparkle: "✦" + case .arrow: "›" + } + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + // Try string first (simple shapes: "gem", "orb", etc.) + if let raw = try? container.decode(String.self) { + switch raw { + case "gem": self = .gem + case "orb": self = .orb + case "sparkle": self = .sparkle + case "arrow": self = .arrow + default: self = .label(raw) + } + return + } + // Try {"label": "text"} format + let dict = try container.decode([String: String].self) + if let text = dict["label"] { + self = .label(text) + } else { + self = .gem + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .gem: try container.encode("gem") + case .orb: try container.encode("orb") + case .sparkle: try container.encode("sparkle") + case .arrow: try container.encode("arrow") + case .label(let text): try container.encode(["label": text]) + } + } +} + +/// Animation type for decorative elements. +public enum DecorationAnimation: String, Sendable, Equatable, Codable { + case float, drift, pulse, spin, twinkle +} diff --git a/Sources/Domain/ScreenshotPlans/HTMLRenderer.swift b/Sources/Domain/Screenshots/HTMLRenderer.swift similarity index 100% rename from Sources/Domain/ScreenshotPlans/HTMLRenderer.swift rename to Sources/Domain/Screenshots/HTMLRenderer.swift diff --git a/Sources/Domain/ScreenshotPlans/ScreenTheme.swift b/Sources/Domain/Screenshots/ScreenTheme.swift similarity index 65% rename from Sources/Domain/ScreenshotPlans/ScreenTheme.swift rename to Sources/Domain/Screenshots/ScreenTheme.swift index d223999b..16388fca 100644 --- a/Sources/Domain/ScreenshotPlans/ScreenTheme.swift +++ b/Sources/Domain/Screenshots/ScreenTheme.swift @@ -95,6 +95,42 @@ extension ScreenTheme { "IMPORTANT: Integrate the floating elements naturally — they should enhance the design without covering the device screenshot or text. Use CSS animations (float, drift, pulse, spin) for movement. Vary sizes (small to medium) and opacity (0.15–0.7) for depth.", ].joined(separator: "\n") } + + + /// Produces a prompt that instructs AI to return a `ThemeDesign` JSON object. + /// + /// Unlike `buildContext()` which expects full HTML back, this asks for structured + /// JSON with normalized 0-1 positions and container-relative sizes rendered as `cqi`. + /// The resulting `ThemeDesign` can be applied deterministically to any template fragment. + public func buildDesignContext() -> String { + [ + "Visual theme: \"\(name)\" — \(description)", + "Overall style: \(aiHints.style)", + "Background: \(aiHints.background)", + "Floating decorative elements to include: \(aiHints.floatingElements.joined(separator: ", "))", + "Color palette: \(aiHints.colorPalette)", + "Text styling: \(aiHints.textStyle)", + "", + "Output ONLY a JSON object matching this schema (no markdown, no explanation):", + "{", + " \"palette\": {\"id\": \"theme-id\", \"name\": \"Theme Name\", \"background\": \"CSS background value\", \"textColor\": \"#hex\"},", + " \"decorations\": [", + " {\"shape\": {\"label\": \"emoji or text\"}, \"x\": 0-1, \"y\": 0-1, \"size\": 0.02-0.06, \"opacity\": 0.15-0.7,", + " \"color\": \"#hex\", \"background\": \"css color\", \"borderRadius\": \"css value\",", + " \"animation\": \"float\"|\"drift\"|\"pulse\"|\"spin\"|\"twinkle\"|null}", + " ]", + "}", + "", + "Rules:", + "- palette.background: CSS value (e.g. \"linear-gradient(135deg, #0f172a, #7c3aed)\" or \"#1a1a2e\")", + "- palette.textColor: hex color for text (must contrast with background)", + "- Positions (x, y) are normalized 0-1 (0=left/top, 1=right/bottom)", + "- Size is relative to container width (0.04 = 4% of width), rendered as cqi units", + "- Include 4-8 decorations that match the theme", + "- Place decorations away from center to avoid covering device screenshots or text", + "- Use varied sizes (0.02-0.06) and opacity (0.15-0.7) for depth", + ].joined(separator: "\n") + } } // MARK: - ThemeAIHints @@ -150,6 +186,31 @@ public protocol ThemeProvider: Sendable { /// - Blitz: spawns `node compose.mjs` with `mode: "restyle"` → Claude /// - Others: could use Gemini, local LLM, or deterministic CSS transforms func compose(html: String, theme: ScreenTheme, canvasWidth: Int, canvasHeight: Int) async throws -> String + + /// Generate a `ThemeDesign` for a theme — called once, applied to all screenshots. + /// + /// Returns a structured design (colors, floats) that can be applied deterministically + /// via `ThemeDesignApplier` without additional AI calls. + func design(theme: ScreenTheme) async throws -> ThemeDesign +} + +/// Default implementation: providers that don't implement `design()` yet. +extension ThemeProvider { + public func design(theme: ScreenTheme) async throws -> ThemeDesign { + // Fallback: compose a minimal fragment and parse the result + // Plugins should override this with a proper `buildDesignContext()` call + throw ThemeDesignError.notSupported + } +} + +public enum ThemeDesignError: Error, CustomStringConvertible { + case notSupported + + public var description: String { + switch self { + case .notSupported: return "This theme provider does not support design generation yet" + } + } } /// Repository for querying visual themes across all providers. @@ -163,4 +224,7 @@ public protocol ThemeRepository: Sendable { /// Compose themed HTML — finds the provider that owns the theme and delegates to it. func compose(themeId: String, html: String, canvasWidth: Int, canvasHeight: Int) async throws -> String + + /// Generate a `ThemeDesign` — called once, applied to all screenshots via `ThemeDesignApplier`. + func design(themeId: String) async throws -> ThemeDesign } diff --git a/Sources/Domain/ScreenshotPlans/ScreenshotPlans+RESTRoutes.swift b/Sources/Domain/Screenshots/ScreenshotPlans+RESTRoutes.swift similarity index 100% rename from Sources/Domain/ScreenshotPlans/ScreenshotPlans+RESTRoutes.swift rename to Sources/Domain/Screenshots/ScreenshotPlans+RESTRoutes.swift diff --git a/Sources/Domain/ScreenshotPlans/TemplateRepository.swift b/Sources/Domain/Screenshots/TemplateRepository.swift similarity index 80% rename from Sources/Domain/ScreenshotPlans/TemplateRepository.swift rename to Sources/Domain/Screenshots/TemplateRepository.swift index efd1b56f..13ce3930 100644 --- a/Sources/Domain/ScreenshotPlans/TemplateRepository.swift +++ b/Sources/Domain/Screenshots/TemplateRepository.swift @@ -11,7 +11,7 @@ public protocol TemplateProvider: Sendable { var providerId: String { get } /// Return all templates this provider offers. - func templates() async throws -> [ScreenshotTemplate] + func templates() async throws -> [AppShotTemplate] } /// Repository for querying screenshot templates. @@ -21,8 +21,8 @@ public protocol TemplateProvider: Sendable { @Mockable public protocol TemplateRepository: Sendable { /// List all templates from all providers, optionally filtered by screen size. - func listTemplates(size: ScreenSize?) async throws -> [ScreenshotTemplate] + func listTemplates(size: ScreenSize?) async throws -> [AppShotTemplate] /// Get a specific template by ID (searches all providers). - func getTemplate(id: String) async throws -> ScreenshotTemplate? + func getTemplate(id: String) async throws -> AppShotTemplate? } diff --git a/Sources/Domain/Screenshots/ThemeDesign.swift b/Sources/Domain/Screenshots/ThemeDesign.swift new file mode 100644 index 00000000..c05293fc --- /dev/null +++ b/Sources/Domain/Screenshots/ThemeDesign.swift @@ -0,0 +1,22 @@ +import Foundation + +/// A structured theme design that can be applied deterministically to any template. +/// +/// Composes from Gallery-native types: +/// - `GalleryPalette` for background + textColor +/// - `[Decoration]` for floating elements (label shapes with cqi sizing) +/// +/// Returned by AI once, then reused across all screenshots without additional AI calls. +/// Applied via `ThemeDesignApplier`, which re-renders through `GalleryHTMLRenderer.renderScreen()` +/// — the same pipeline used by templates and galleries. +public struct ThemeDesign: Sendable, Equatable, Codable { + /// Palette with background and optional textColor override. + public let palette: GalleryPalette + /// Decorative floating elements (label shapes with normalized 0-1 positions). + public let decorations: [Decoration] + + public init(palette: GalleryPalette, decorations: [Decoration]) { + self.palette = palette + self.decorations = decorations + } +} diff --git a/Sources/Domain/Screenshots/ThemeDesignApplier.swift b/Sources/Domain/Screenshots/ThemeDesignApplier.swift new file mode 100644 index 00000000..3aa6ff24 --- /dev/null +++ b/Sources/Domain/Screenshots/ThemeDesignApplier.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Deterministically applies a `ThemeDesign` to a shot + layout. +/// +/// Instead of patching HTML post-hoc, re-renders through the standard +/// `GalleryHTMLRenderer.renderScreen()` pipeline with an overridden palette +/// and merged decorations. This ensures consistent `cqi` sizing and +/// identical rendering behavior as templates and galleries. +public enum ThemeDesignApplier { + + /// Apply a ThemeDesign by re-rendering with overridden palette and merged decorations. + /// + /// - Parameters: + /// - design: The theme design (palette + decorations) + /// - shot: The app shot with content (headline, screenshot, etc.) + /// - screenLayout: The template's screen layout + /// - Returns: Themed HTML fragment + public static func apply( + _ design: ThemeDesign, + shot: AppShot, + screenLayout: ScreenLayout + ) -> String { + let themedLayout = screenLayout.withDecorations( + screenLayout.decorations + design.decorations + ) + return GalleryHTMLRenderer.renderScreen(shot, screenLayout: themedLayout, palette: design.palette) + } +} diff --git a/Sources/Domain/Screenshots/ThemedPage.swift b/Sources/Domain/Screenshots/ThemedPage.swift new file mode 100644 index 00000000..344a48f6 --- /dev/null +++ b/Sources/Domain/Screenshots/ThemedPage.swift @@ -0,0 +1,28 @@ +/// A themed screenshot page — wraps composed HTML body in a full HTML document. +/// +/// Domain value type that owns the page-wrapping logic. Used by both CLI and REST +/// after theme composition to produce the final renderable HTML. +public struct ThemedPage: Sendable, Equatable { + public let body: String + public let width: Int + public let height: Int + public let fillViewport: Bool + + public init(body: String, width: Int, height: Int, fillViewport: Bool = false) { + self.body = body + self.width = width + self.height = height + self.fillViewport = fillViewport + } + + /// The full HTML page ready for rendering or display. + public var html: String { + let ctx = GalleryHTMLRenderer.pageContext( + inner: body, + fillViewport: fillViewport, + width: width, + height: height + ) + return HTMLComposer.render(template: "page-wrapper", with: ctx) + } +} diff --git a/Sources/Infrastructure/ScreenshotPlans/AggregateGalleryTemplateRepository.swift b/Sources/Infrastructure/ScreenshotPlans/AggregateGalleryTemplateRepository.swift new file mode 100644 index 00000000..736d1ff2 --- /dev/null +++ b/Sources/Infrastructure/ScreenshotPlans/AggregateGalleryTemplateRepository.swift @@ -0,0 +1,29 @@ +import Domain +import Foundation + +/// Aggregates gallery templates from all registered providers. +public final actor AggregateGalleryTemplateRepository: GalleryTemplateRepository { + public static let shared = AggregateGalleryTemplateRepository() + + private var providers: [any GalleryTemplateProvider] = [] + + public init() {} + + public func register(provider: any GalleryTemplateProvider) { + providers.append(provider) + } + + public func listGalleries() async throws -> [Gallery] { + var all: [Gallery] = [] + for provider in providers { + let galleries = try await provider.galleries() + all.append(contentsOf: galleries) + } + return all + } + + public func getGallery(templateId: String) async throws -> Gallery? { + let all = try await listGalleries() + return all.first { $0.template?.id == templateId } + } +} diff --git a/Sources/Infrastructure/ScreenshotPlans/AggregateTemplateRepository.swift b/Sources/Infrastructure/ScreenshotPlans/AggregateTemplateRepository.swift index 1eb1bb1d..eeff8c0a 100644 --- a/Sources/Infrastructure/ScreenshotPlans/AggregateTemplateRepository.swift +++ b/Sources/Infrastructure/ScreenshotPlans/AggregateTemplateRepository.swift @@ -19,8 +19,8 @@ public final actor AggregateTemplateRepository: TemplateRepository { providers.append(provider) } - public func listTemplates(size: ScreenSize?) async throws -> [ScreenshotTemplate] { - var all: [ScreenshotTemplate] = [] + public func listTemplates(size: ScreenSize?) async throws -> [AppShotTemplate] { + var all: [AppShotTemplate] = [] for provider in providers { let templates = try await provider.templates() all.append(contentsOf: templates) @@ -32,7 +32,7 @@ public final actor AggregateTemplateRepository: TemplateRepository { return all } - public func getTemplate(id: String) async throws -> ScreenshotTemplate? { + public func getTemplate(id: String) async throws -> AppShotTemplate? { let all = try await listTemplates(size: nil) return all.first { $0.id == id } } diff --git a/Sources/Infrastructure/ScreenshotPlans/AggregateThemeRepository.swift b/Sources/Infrastructure/ScreenshotPlans/AggregateThemeRepository.swift index 234fbf40..c3465252 100644 --- a/Sources/Infrastructure/ScreenshotPlans/AggregateThemeRepository.swift +++ b/Sources/Infrastructure/ScreenshotPlans/AggregateThemeRepository.swift @@ -43,6 +43,16 @@ public final actor AggregateThemeRepository: ThemeRepository { } throw ThemeComposeError.themeNotFound(themeId) } + + public func design(themeId: String) async throws -> ThemeDesign { + for provider in providers { + let themes = try await provider.themes() + if let theme = themes.first(where: { $0.id == themeId }) { + return try await provider.design(theme: theme) + } + } + throw ThemeComposeError.themeNotFound(themeId) + } } /// Errors from theme composition. diff --git a/Tests/ASCCommandTests/Commands/AppShots/AppShotsGalleryTemplatesTests.swift b/Tests/ASCCommandTests/Commands/AppShots/AppShotsGalleryTemplatesTests.swift new file mode 100644 index 00000000..0bd44578 --- /dev/null +++ b/Tests/ASCCommandTests/Commands/AppShots/AppShotsGalleryTemplatesTests.swift @@ -0,0 +1,85 @@ +import Foundation +import Mockable +import Testing +@testable import ASCCommand +@testable import Domain + +@Suite("AppShotsGalleryTemplates") +struct AppShotsGalleryTemplatesTests { + + // MARK: - List + + @Test func `list gallery templates returns galleries with affordances`() async throws { + let mockRepo = MockGalleryTemplateRepository() + let gallery = Gallery(appName: "TestApp", screenshots: ["s0.png", "s1.png"]) + gallery.template = GalleryTemplate(id: "neon-pop", name: "Neon Pop", screens: [ + .hero: ScreenLayout(headline: TextSlot(y: 0.07, size: 0.08)), + .feature: ScreenLayout(headline: TextSlot(y: 0.05, size: 0.08)), + ]) + gallery.palette = GalleryPalette(id: "p", name: "P", background: "#000") + gallery.appShots[0].headline = "Hero" + gallery.appShots[1].headline = "Feature" + given(mockRepo).listGalleries().willReturn([gallery]) + + let cmd = try AppShotsGalleryTemplatesList.parse(["--pretty"]) + let output = try await cmd.execute(repo: mockRepo) + #expect(output.contains("\"appName\" : \"TestApp\"")) + #expect(output.contains("asc app-shots gallery-templates list")) + } + + @Test func `list gallery templates table format`() async throws { + let mockRepo = MockGalleryTemplateRepository() + let gallery = Gallery(appName: "TestApp", screenshots: ["s0.png"]) + gallery.template = GalleryTemplate(id: "neon", name: "Neon", screens: [:]) + gallery.palette = GalleryPalette(id: "p", name: "P", background: "#000") + gallery.appShots[0].headline = "Test" + given(mockRepo).listGalleries().willReturn([gallery]) + + let cmd = try AppShotsGalleryTemplatesList.parse(["--output", "table"]) + let output = try await cmd.execute(repo: mockRepo) + #expect(output.contains("TestApp")) + } + + // MARK: - Get + + @Test func `get gallery template returns specific gallery`() async throws { + let mockRepo = MockGalleryTemplateRepository() + let gallery = Gallery(appName: "BezelBlend", screenshots: ["s0.png"]) + gallery.template = GalleryTemplate(id: "neon-pop", name: "Neon Pop", screens: [:]) + gallery.palette = GalleryPalette(id: "p", name: "P", background: "#000") + gallery.appShots[0].headline = "Premium" + given(mockRepo).getGallery(templateId: .value("neon-pop")).willReturn(gallery) + + let cmd = try AppShotsGalleryTemplatesGet.parse(["--id", "neon-pop", "--pretty"]) + let output = try await cmd.execute(repo: mockRepo) + #expect(output.contains("\"appName\" : \"BezelBlend\"")) + } + + @Test func `get gallery template returns error when not found`() async throws { + let mockRepo = MockGalleryTemplateRepository() + given(mockRepo).getGallery(templateId: .value("nope")).willReturn(nil) + + let cmd = try AppShotsGalleryTemplatesGet.parse(["--id", "nope"]) + do { + _ = try await cmd.execute(repo: mockRepo) + Issue.record("Expected error") + } catch { + #expect("\(error)".contains("not found")) + } + } + + @Test func `get gallery template with preview flag returns HTML`() async throws { + let mockRepo = MockGalleryTemplateRepository() + let gallery = Gallery(appName: "TestApp", screenshots: ["s0.png"]) + gallery.template = GalleryTemplate(id: "neon-pop", name: "Neon Pop", screens: [ + .hero: ScreenLayout(headline: TextSlot(y: 0.07, size: 0.08)), + ]) + gallery.palette = GalleryPalette(id: "p", name: "P", background: "#000") + gallery.appShots[0].headline = "Test" + given(mockRepo).getGallery(templateId: .value("neon-pop")).willReturn(gallery) + + let cmd = try AppShotsGalleryTemplatesGet.parse(["--id", "neon-pop", "--preview"]) + let output = try await cmd.execute(repo: mockRepo) + #expect(output.contains("")) + } +} diff --git a/Tests/ASCCommandTests/Commands/AppShots/AppShotsTemplatesTests.swift b/Tests/ASCCommandTests/Commands/AppShots/AppShotsTemplatesTests.swift index 25769896..4ffcf068 100644 --- a/Tests/ASCCommandTests/Commands/AppShots/AppShotsTemplatesTests.swift +++ b/Tests/ASCCommandTests/Commands/AppShots/AppShotsTemplatesTests.swift @@ -80,7 +80,7 @@ struct AppShotsTemplatesTests { let output = try await cmd.execute(repo: mockRepo) #expect(output.contains("Ship Faster")) #expect(output.contains("screen-1.png")) - #expect(output.contains("asc app-shots generate")) + #expect(output.contains("asc app-shots templates")) } @Test func `apply with preview image renders HTML to PNG and writes file`() async throws { @@ -148,15 +148,17 @@ struct AppShotsTemplatesTests { // MARK: - Helpers -private func makeTemplate(id: String, name: String) -> ScreenshotTemplate { - ScreenshotTemplate( +private func makeTemplate(id: String, name: String) -> AppShotTemplate { + AppShotTemplate( id: id, name: name, category: .bold, supportedSizes: [.portrait], description: "Test", - background: .gradient(from: "#000", to: "#111", angle: 180), - textSlots: [TemplateTextSlot(role: .heading, preview: "Test", x: 0.5, y: 0.04, fontSize: 0.1, color: "#fff")], - deviceSlots: [TemplateDeviceSlot(x: 0.5, y: 0.18, scale: 0.85)] + screenLayout: ScreenLayout( + headline: TextSlot(y: 0.04, size: 0.1, weight: 700, align: "center"), + device: DeviceSlot(x: 0.5, y: 0.18, width: 0.85) + ), + palette: GalleryPalette(id: id, name: name, background: "linear-gradient(180deg,#000,#111)") ) } diff --git a/Tests/ASCCommandTests/Commands/AppShots/AppShotsThemesTests.swift b/Tests/ASCCommandTests/Commands/AppShots/AppShotsThemesTests.swift index d6a391e0..cc73d551 100644 --- a/Tests/ASCCommandTests/Commands/AppShots/AppShotsThemesTests.swift +++ b/Tests/ASCCommandTests/Commands/AppShots/AppShotsThemesTests.swift @@ -165,6 +165,58 @@ struct AppShotsThemesTests { #expect(output.contains("")) } + // MARK: - Design (separate subcommand) + + @Test func `design outputs ThemeDesign JSON from AI`() async throws { + let mockThemeRepo = MockThemeRepository() + let design = ThemeDesign( + palette: GalleryPalette(id: "space", name: "Space", + background: "linear-gradient(135deg, #0f172a, #7c3aed)", + textColor: "#ffffff"), + decorations: [ + Decoration(shape: .label("✨"), x: 0.85, y: 0.12, size: 0.04, opacity: 0.6, + color: "#fff", background: "rgba(255,255,255,0.1)", + borderRadius: "50%", animation: .twinkle), + ] + ) + given(mockThemeRepo).design(themeId: .value("space")).willReturn(design) + + let cmd = try AppShotsThemesDesign.parse(["--id", "space"]) + let output = try await cmd.execute(themeRepo: mockThemeRepo) + #expect(output.contains("\"palette\"")) + #expect(output.contains("#ffffff")) + #expect(output.contains("\"decorations\"")) + } + + // MARK: - Apply Design (separate subcommand) + + @Test func `apply-design renders themed HTML from design JSON`() async throws { + let design = ThemeDesign( + palette: GalleryPalette(id: "dark", name: "Dark", background: "#1a1a2e", textColor: "#e0e7ff"), + decorations: [] + ) + let designPath = NSTemporaryDirectory() + "test-design.json" + let data = try JSONEncoder().encode(design) + try data.write(to: URL(fileURLWithPath: designPath)) + + let mockTemplateRepo = MockTemplateRepository() + given(mockTemplateRepo).getTemplate(id: .value("top-hero")).willReturn( + makeTemplate(id: "top-hero", name: "Top Hero") + ) + + let cmd = try AppShotsThemesApplyDesign.parse([ + "--design", designPath, + "--template", "top-hero", + "--screenshot", "screen.png", + "--headline", "Test", + ]) + let output = try await cmd.execute(templateRepo: mockTemplateRepo) + #expect(output.contains("#1a1a2e")) + #expect(output.contains("")) + + try? FileManager.default.removeItem(atPath: designPath) + } + @Test func `apply fails when template not found`() async throws { let mockThemeRepo = MockThemeRepository() let mockTemplateRepo = MockTemplateRepository() @@ -187,15 +239,17 @@ struct AppShotsThemesTests { // MARK: - Helpers -private func makeTemplate(id: String, name: String) -> ScreenshotTemplate { - ScreenshotTemplate( +private func makeTemplate(id: String, name: String) -> AppShotTemplate { + AppShotTemplate( id: id, name: name, category: .bold, supportedSizes: [.portrait], description: "Test", - background: .gradient(from: "#000", to: "#111", angle: 180), - textSlots: [TemplateTextSlot(role: .heading, preview: "Test", x: 0.5, y: 0.04, fontSize: 0.1, color: "#fff")], - deviceSlots: [TemplateDeviceSlot(x: 0.5, y: 0.18, scale: 0.85)] + screenLayout: ScreenLayout( + headline: TextSlot(y: 0.04, size: 0.1, weight: 700, align: "center"), + device: DeviceSlot(x: 0.5, y: 0.18, width: 0.85) + ), + palette: GalleryPalette(id: id, name: name, background: "linear-gradient(180deg,#000,#111)") ) } diff --git a/Tests/ASCCommandTests/Commands/Web/AppShotsControllerTests.swift b/Tests/ASCCommandTests/Commands/Web/AppShotsControllerTests.swift index 8785f432..c4b31a4c 100644 --- a/Tests/ASCCommandTests/Commands/Web/AppShotsControllerTests.swift +++ b/Tests/ASCCommandTests/Commands/Web/AppShotsControllerTests.swift @@ -40,12 +40,11 @@ struct AppShotsControllerTests { // MARK: - Helpers -private func makeTemplate(id: String, name: String) -> ScreenshotTemplate { - ScreenshotTemplate( - id: id, name: name, category: .bold, supportedSizes: [.portrait], - description: "Test", background: .gradient(from: "#000", to: "#111", angle: 180), - textSlots: [TemplateTextSlot(role: .heading, preview: "Test", x: 0.5, y: 0.04, fontSize: 0.1, color: "#fff")], - deviceSlots: [TemplateDeviceSlot(x: 0.5, y: 0.18, scale: 0.85)] +private func makeTemplate(id: String, name: String) -> AppShotTemplate { + AppShotTemplate( + id: id, name: name, category: .bold, supportedSizes: [.portrait], description: "Test", + screenLayout: ScreenLayout(headline: TextSlot(y: 0.04, size: 0.1, weight: 700, align: "center"), device: DeviceSlot(x: 0.5, y: 0.18, width: 0.85)), + palette: GalleryPalette(id: id, name: name, background: "linear-gradient(180deg,#000,#111)") ) } diff --git a/Tests/DomainTests/ScreenshotPlans/ScreenDesignTests.swift b/Tests/DomainTests/ScreenshotPlans/ScreenDesignTests.swift deleted file mode 100644 index 1ba54dd9..00000000 --- a/Tests/DomainTests/ScreenshotPlans/ScreenDesignTests.swift +++ /dev/null @@ -1,96 +0,0 @@ -import Foundation -import Testing -@testable import Domain - -@Suite("ScreenDesign — Rich Domain") -struct ScreenDesignRichTests { - - @Test func `screen design carries template and screenshot`() { - let template = MockRepositoryFactory.makeScreenshotTemplate(id: "top-hero") - let screen = ScreenDesign( - index: 0, - template: template, - screenshotFile: "screen-1.png", - heading: "Ship Faster", - subheading: "One command away" - ) - #expect(screen.template?.id == "top-hero") - #expect(screen.screenshotFile == "screen-1.png") - #expect(screen.heading == "Ship Faster") - #expect(screen.subheading == "One command away") - } - - @Test func `screen design without template is incomplete`() { - let screen = ScreenDesign( - index: 0, - template: nil, - screenshotFile: "screen-1.png", - heading: "", - subheading: "" - ) - #expect(!screen.isComplete) - } - - @Test func `screen design with template and heading is complete`() { - let template = MockRepositoryFactory.makeScreenshotTemplate() - let screen = ScreenDesign( - index: 0, - template: template, - screenshotFile: "screen-1.png", - heading: "Ship Faster", - subheading: "" - ) - #expect(screen.isComplete) - } - - @Test func `previewHTML renders template with real content`() { - let template = MockRepositoryFactory.makeScreenshotTemplate() - let screen = ScreenDesign( - index: 0, - template: template, - screenshotFile: "screen-1.png", - heading: "Ship Faster", - subheading: "" - ) - let html = screen.previewHTML - #expect(html.contains("Ship Faster")) - #expect(html.contains("screen-1.png")) - #expect(html.contains("linear-gradient")) - } - - @Test func `previewHTML without template returns empty`() { - let screen = ScreenDesign( - index: 0, - template: nil, - screenshotFile: "screen-1.png", - heading: "Ship Faster", - subheading: "" - ) - #expect(screen.previewHTML.isEmpty) - } - - @Test func `affordances include generate when complete`() { - let template = MockRepositoryFactory.makeScreenshotTemplate() - let screen = ScreenDesign( - index: 0, - template: template, - screenshotFile: "screen-1.png", - heading: "Ship Faster", - subheading: "" - ) - #expect(screen.affordances["generate"] != nil) - #expect(screen.affordances["changeTemplate"] == "asc app-shots templates list") - } - - @Test func `affordances exclude generate when incomplete`() { - let screen = ScreenDesign( - index: 0, - template: nil, - screenshotFile: "screen-1.png", - heading: "", - subheading: "" - ) - #expect(screen.affordances["generate"] == nil) - #expect(screen.affordances["changeTemplate"] == "asc app-shots templates list") - } -} diff --git a/Tests/DomainTests/ScreenshotPlans/TemplateApplyTests.swift b/Tests/DomainTests/ScreenshotPlans/TemplateApplyTests.swift deleted file mode 100644 index 8cfa8caf..00000000 --- a/Tests/DomainTests/ScreenshotPlans/TemplateApplyTests.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation -import Testing -@testable import Domain - -@Suite -struct TemplateApplyTests { - - @Test func `template apply returns HTML with headline`() { - let template = MockRepositoryFactory.makeScreenshotTemplate(id: "hero", name: "Hero") - let content = TemplateContent(headline: "Hello World", screenshotFile: "screen.png") - let html = template.apply(content: content) - #expect(html.contains("Hello World")) - #expect(html.contains("")) - } - - @Test func `template apply for viewport returns full-page HTML`() { - let template = MockRepositoryFactory.makeScreenshotTemplate(id: "hero", name: "Hero") - let content = TemplateContent(headline: "Test", screenshotFile: "screen.png") - let html = template.apply(content: content, fillViewport: true) - #expect(html.contains("width:100%")) - } - - @Test func `template apply with nil content uses default preview`() { - let template = MockRepositoryFactory.makeScreenshotTemplate(id: "hero", name: "Hero") - let html = template.apply() - #expect(html.contains("")) - } -} diff --git a/Tests/DomainTests/ScreenshotPlans/ScreenshotTemplateTests.swift b/Tests/DomainTests/Screenshots/AppShotTemplateTests.swift similarity index 58% rename from Tests/DomainTests/ScreenshotPlans/ScreenshotTemplateTests.swift rename to Tests/DomainTests/Screenshots/AppShotTemplateTests.swift index f718962d..e09c5157 100644 --- a/Tests/DomainTests/ScreenshotPlans/ScreenshotTemplateTests.swift +++ b/Tests/DomainTests/Screenshots/AppShotTemplateTests.swift @@ -2,18 +2,18 @@ import Foundation import Testing @testable import Domain -@Suite("ScreenshotTemplate") -struct ScreenshotTemplateTests { +@Suite("AppShotTemplate") +struct AppShotTemplateTests { @Test func `template has id, name, and category`() { - let template = MockRepositoryFactory.makeScreenshotTemplate() + let template = MockRepositoryFactory.makeAppShotTemplate() #expect(template.id == "top-hero") #expect(template.name == "Top Hero") #expect(template.category == .bold) } @Test func `template reports supported sizes`() { - let template = MockRepositoryFactory.makeScreenshotTemplate( + let template = MockRepositoryFactory.makeAppShotTemplate( supportedSizes: [.portrait, .landscape] ) #expect(template.supportedSizes.contains(.portrait)) @@ -21,7 +21,7 @@ struct ScreenshotTemplateTests { } @Test func `portrait template is portrait`() { - let template = MockRepositoryFactory.makeScreenshotTemplate( + let template = MockRepositoryFactory.makeAppShotTemplate( supportedSizes: [.portrait] ) #expect(template.isPortrait) @@ -29,44 +29,42 @@ struct ScreenshotTemplateTests { } @Test func `landscape template is landscape`() { - let template = MockRepositoryFactory.makeScreenshotTemplate( + let template = MockRepositoryFactory.makeAppShotTemplate( supportedSizes: [.landscape] ) #expect(!template.isPortrait) #expect(template.isLandscape) } - @Test func `template reports device count`() { - let single = MockRepositoryFactory.makeScreenshotTemplate(deviceCount: 1) - #expect(single.deviceCount == 1) + @Test func `template with device has deviceCount 1`() { + let template = MockRepositoryFactory.makeAppShotTemplate() + #expect(template.deviceCount == 1) + } - let duo = MockRepositoryFactory.makeScreenshotTemplate( - id: "duo", - deviceCount: 2 - ) - #expect(duo.deviceCount == 2) + @Test func `template without device has deviceCount 0`() { + let template = MockRepositoryFactory.makeAppShotTemplate(hasDevice: false) + #expect(template.deviceCount == 0) } @Test func `template affordances include preview, apply, and list`() { - let template = MockRepositoryFactory.makeScreenshotTemplate(id: "top-hero") + let template = MockRepositoryFactory.makeAppShotTemplate(id: "top-hero") #expect(template.affordances["apply"] == "asc app-shots templates apply --id top-hero --screenshot screen.png") #expect(template.affordances["detail"] == "asc app-shots templates get --id top-hero") #expect(template.affordances["listAll"] == "asc app-shots templates list") #expect(template.affordances["preview"] == "asc app-shots templates get --id top-hero --preview") } - @Test func `previewHTML contains background and text`() { - let template = MockRepositoryFactory.makeScreenshotTemplate() + @Test func `previewHTML contains background gradient`() { + let template = MockRepositoryFactory.makeAppShotTemplate() let html = template.previewHTML #expect(html.contains("linear-gradient")) - #expect(html.contains("Your")) - #expect(html.contains("Headline")) + #expect(html.contains("")) } @Test func `template is codable`() throws { - let template = MockRepositoryFactory.makeScreenshotTemplate() + let template = MockRepositoryFactory.makeAppShotTemplate() let data = try JSONEncoder().encode(template) - let decoded = try JSONDecoder().decode(ScreenshotTemplate.self, from: data) + let decoded = try JSONDecoder().decode(AppShotTemplate.self, from: data) #expect(decoded == template) } } diff --git a/Tests/DomainTests/ScreenshotPlans/AppShotsConfigTests.swift b/Tests/DomainTests/Screenshots/AppShotsConfigTests.swift similarity index 100% rename from Tests/DomainTests/ScreenshotPlans/AppShotsConfigTests.swift rename to Tests/DomainTests/Screenshots/AppShotsConfigTests.swift diff --git a/Tests/DomainTests/Screenshots/Gallery/AppShotTests.swift b/Tests/DomainTests/Screenshots/Gallery/AppShotTests.swift new file mode 100644 index 00000000..7b46dd1d --- /dev/null +++ b/Tests/DomainTests/Screenshots/Gallery/AppShotTests.swift @@ -0,0 +1,75 @@ +import Foundation +import Testing +@testable import Domain + +@Suite("AppShot") +struct AppShotTests { + + // ── User: "I create an app shot from my screenshot" ── + + @Test func `app shot is created from a screenshot file`() { + let shot = AppShot(screenshot: "screen-0.png") + #expect(shot.screenshot == "screen-0.png") + } + + @Test func `app shot defaults to feature type`() { + let shot = AppShot(screenshot: "screen-0.png") + #expect(shot.type == .feature) + } + + @Test func `app shot can be hero type`() { + let shot = AppShot(screenshot: "screen-0.png", type: .hero) + #expect(shot.type == .hero) + } + + @Test func `app shot can be social type`() { + let shot = AppShot(screenshot: "screen-0.png", type: .social) + #expect(shot.type == .social) + } + + // ── User: "I give it a headline" ── + + @Test func `app shot without headline is not configured`() { + let shot = AppShot(screenshot: "screen-0.png") + #expect(!shot.isConfigured) + } + + @Test func `app shot with headline is configured`() { + let shot = AppShot(screenshot: "screen-0.png") + shot.headline = "PREMIUM DEVICE MOCKUPS." + #expect(shot.isConfigured) + } + + @Test func `app shot with empty headline is not configured`() { + let shot = AppShot(screenshot: "screen-0.png") + shot.headline = "" + #expect(!shot.isConfigured) + } + + // ── User: "I add badges to highlight features" ── + + @Test func `app shot starts with no badges`() { + let shot = AppShot(screenshot: "screen-0.png") + #expect(shot.badges.isEmpty) + } + + @Test func `app shot can have feature badges`() { + let shot = AppShot(screenshot: "screen-0.png") + shot.badges = ["Mesh", "Gradient"] + #expect(shot.badges == ["Mesh", "Gradient"]) + } + + // ── User: "Hero gets trust marks like ratings" ── + + @Test func `hero shot can have trust marks`() { + let shot = AppShot(screenshot: "screen-0.png", type: .hero) + shot.headline = "Make your map yours" + shot.trustMarks = ["4.8 ⭐", "10K ratings"] + #expect(shot.trustMarks == ["4.8 ⭐", "10K ratings"]) + } + + @Test func `app shot starts with no trust marks`() { + let shot = AppShot(screenshot: "screen-0.png") + #expect(shot.trustMarks == nil) + } +} diff --git a/Tests/DomainTests/Screenshots/Gallery/GalleryApplyTests.swift b/Tests/DomainTests/Screenshots/Gallery/GalleryApplyTests.swift new file mode 100644 index 00000000..5847154c --- /dev/null +++ b/Tests/DomainTests/Screenshots/Gallery/GalleryApplyTests.swift @@ -0,0 +1,135 @@ +import Foundation +import Testing +@testable import Domain + +@Suite("Gallery Apply Screenshots") +struct GalleryApplyTests { + + // ── User: "I upload 5 screenshots with a gallery template" ── + + @Test func `gallery distributes screenshots to app shots by order`() { + let gallery = Gallery( + appName: "TestApp", + screenshots: ["s0.png", "s1.png", "s2.png", "s3.png", "s4.png"] + ) + #expect(gallery.appShots.count == 5) + #expect(gallery.appShots[0].screenshot == "s0.png") + #expect(gallery.appShots[0].type == .hero) + #expect(gallery.appShots[1].screenshot == "s1.png") + #expect(gallery.appShots[4].screenshot == "s4.png") + } + + // ── User: "Single multi-device template consumes multiple screenshots" ── + + @Test func `side by side template creates fewer screens from same screenshots`() { + let screenshots = ["s0.png", "s1.png", "s2.png", "s3.png", "s4.png"] + + // Single device template: 5 screenshots → 5 screens + let singleDevice = ScreenLayout( + headline: TextSlot(y: 0.04, size: 0.08), + device: DeviceSlot(y: 0.18, width: 0.85) + ) + let singleScreens = Gallery.distributeScreenshots(screenshots, screenLayout: singleDevice) + #expect(singleScreens.count == 5) + #expect(singleScreens[0] == ["s0.png"]) + #expect(singleScreens[4] == ["s4.png"]) + + // Dual device template: 5 screenshots → 3 screens (2+2+1) + let dualDevice = ScreenLayout( + headline: TextSlot(y: 0.04, size: 0.08), + devices: [ + DeviceSlot(x: 0.35, y: 0.20, width: 0.62), + DeviceSlot(x: 0.65, y: 0.24, width: 0.62), + ] + ) + let dualScreens = Gallery.distributeScreenshots(screenshots, screenLayout: dualDevice) + #expect(dualScreens.count == 3) + #expect(dualScreens[0] == ["s0.png", "s1.png"]) + #expect(dualScreens[1] == ["s2.png", "s3.png"]) + #expect(dualScreens[2] == ["s4.png"]) + + // Triple device template: 5 screenshots → 2 screens (3+2) + let tripleDevice = ScreenLayout( + headline: TextSlot(y: 0.04, size: 0.08), + devices: [ + DeviceSlot(x: 0.22, y: 0.15, width: 0.45), + DeviceSlot(x: 0.50, y: 0.12, width: 0.52), + DeviceSlot(x: 0.78, y: 0.15, width: 0.45), + ] + ) + let tripleScreens = Gallery.distributeScreenshots(screenshots, screenLayout: tripleDevice) + #expect(tripleScreens.count == 2) + #expect(tripleScreens[0] == ["s0.png", "s1.png", "s2.png"]) + #expect(tripleScreens[1] == ["s3.png", "s4.png"]) + } + + // ── User: "AppShot can hold multiple screenshots for multi-device" ── + + @Test func `app shot can carry multiple screenshots`() { + let shot = AppShot(screenshots: ["s0.png", "s1.png"], type: .feature) + shot.headline = "Compare Plans" + #expect(shot.screenshots == ["s0.png", "s1.png"]) + #expect(shot.screenshot == "s0.png") // first one for backward compat + } + + // ── User: "I apply my screenshots to a gallery template" ── + + @Test func `applyScreenshots creates new gallery with user screenshots and sample content`() { + // Sample gallery (from gallery-templates.json) + let sample = Gallery(appName: "BezelBlend", screenshots: ["", ""]) + sample.appShots[0].headline = "PREMIUM MOCKUPS" + sample.appShots[0].tagline = "BEZELBLEND" + sample.appShots[0].badges = ["iPhone 17"] + sample.appShots[1].headline = "CUSTOMIZE" + sample.appShots[1].tagline = "BACKGROUNDS" + sample.template = GalleryTemplate( + id: "neon-pop", name: "Neon Pop", + background: "linear-gradient(165deg, #a8ff78, #78ffd6)", + screens: [ + .hero: ScreenLayout(headline: TextSlot(y: 0.07, size: 0.08)), + .feature: ScreenLayout(headline: TextSlot(y: 0.05, size: 0.08), device: DeviceSlot(y: 0.36, width: 0.68)), + ] + ) + sample.palette = GalleryPalette(id: "g", name: "G", background: "#a8ff78") + + // User applies their screenshots + let gallery = sample.applyScreenshots(["my-hero.png", "my-feature.png"]) + + // New gallery has user's screenshots + #expect(gallery.appShots.count == 2) + #expect(gallery.appShots[0].screenshot == "my-hero.png") + #expect(gallery.appShots[1].screenshot == "my-feature.png") + + // But keeps sample content + #expect(gallery.appShots[0].headline == "PREMIUM MOCKUPS") + #expect(gallery.appShots[0].tagline == "BEZELBLEND") + #expect(gallery.appShots[0].badges == ["iPhone 17"]) + #expect(gallery.appShots[1].headline == "CUSTOMIZE") + + // Keeps template and palette + #expect(gallery.template?.id == "neon-pop") + #expect(gallery.palette != nil) + + // Is ready to render + #expect(gallery.isReady) + } + + @Test func `applyScreenshots with more screenshots than sample creates extra feature shots`() { + let sample = Gallery(appName: "App", screenshots: [""]) + sample.appShots[0].headline = "HERO" + sample.template = GalleryTemplate(id: "t", name: "T", screens: [ + .hero: ScreenLayout(headline: TextSlot(y: 0.05, size: 0.08)), + .feature: ScreenLayout(headline: TextSlot(y: 0.05, size: 0.08)), + ]) + sample.palette = GalleryPalette(id: "p", name: "P", background: "#fff") + + let gallery = sample.applyScreenshots(["s0.png", "s1.png", "s2.png"]) + #expect(gallery.appShots.count == 3) + #expect(gallery.appShots[0].headline == "HERO") + #expect(gallery.appShots[0].type == .hero) + #expect(gallery.appShots[1].type == .feature) + #expect(gallery.appShots[2].type == .feature) + // Extra shots beyond sample don't have headlines — not configured + #expect(gallery.appShots[1].headline == nil) + } +} diff --git a/Tests/DomainTests/Screenshots/Gallery/GalleryCodableTests.swift b/Tests/DomainTests/Screenshots/Gallery/GalleryCodableTests.swift new file mode 100644 index 00000000..3e0a89da --- /dev/null +++ b/Tests/DomainTests/Screenshots/Gallery/GalleryCodableTests.swift @@ -0,0 +1,300 @@ +import Foundation +import Testing +@testable import Domain + +@Suite("Gallery Codable") +struct GalleryCodableTests { + + // ── User: "I can save and load a gallery template as JSON" ── + + @Test func `screen template round-trips through JSON`() throws { + let template = ScreenLayout( + headline: TextSlot(y: 0.02, size: 0.10, weight: 900, align: "center"), + device: DeviceSlot(y: 0.15, width: 0.85), + decorations: [ + Decoration(shape: .gem, x: 0.9, y: 0.06, size: 0.06) + ] + ) + let data = try JSONEncoder().encode(template) + let decoded = try JSONDecoder().decode(ScreenLayout.self, from: data) + #expect(decoded == template) + } + + @Test func `screen template without device round-trips`() throws { + let hero = ScreenLayout( + headline: TextSlot(y: 0.25, size: 0.12) + ) + let data = try JSONEncoder().encode(hero) + let decoded = try JSONDecoder().decode(ScreenLayout.self, from: data) + #expect(decoded.devices.isEmpty) + #expect(decoded.headline.size == 0.12) + } + + @Test func `gallery template round-trips through JSON`() throws { + let template = GalleryTemplate( + id: "neon-pop", + name: "Neon Pop", + screens: [ + .hero: ScreenLayout(headline: TextSlot(y: 0.25, size: 0.08)), + .feature: ScreenLayout( + headline: TextSlot(y: 0.02, size: 0.10), + device: DeviceSlot(y: 0.36, width: 0.68) + ), + ] + ) + let data = try JSONEncoder().encode(template) + let decoded = try JSONDecoder().decode(GalleryTemplate.self, from: data) + #expect(decoded.id == "neon-pop") + #expect(decoded.screens[.hero]?.devices.isEmpty == true) + #expect(decoded.screens[.feature]?.devices.first?.width == 0.68) + } + + @Test func `gallery palette round-trips through JSON`() throws { + let palette = GalleryPalette( + id: "green-mint", + name: "Green Mint", + background: "linear-gradient(135deg, #c4f7a0, #a0f7e0)" + ) + let data = try JSONEncoder().encode(palette) + let decoded = try JSONDecoder().decode(GalleryPalette.self, from: data) + #expect(decoded == palette) + } + + @Test func `decoration round-trips through JSON`() throws { + let deco = Decoration(shape: .gem, x: 0.9, y: 0.06, size: 0.06, opacity: 0.8) + let data = try JSONEncoder().encode(deco) + let decoded = try JSONDecoder().decode(Decoration.self, from: data) + #expect(decoded == deco) + } + + // ── User: "I can decode a gallery template from a JSON file" ── + + @Test func `gallery template decodes from JSON string`() throws { + let json = """ + { + "id": "neon-pop", + "name": "Neon Pop", + "screens": { + "hero": { + "headline": { "y": 0.25, "size": 0.08, "weight": 900, "align": "left" } + }, + "feature": { + "headline": { "y": 0.02, "size": 0.10, "weight": 900, "align": "center" }, + "device": { "x": 0.5, "y": 0.36, "width": 0.68 } + } + } + } + """ + let template = try JSONDecoder().decode(GalleryTemplate.self, from: Data(json.utf8)) + #expect(template.id == "neon-pop") + #expect(template.screens.count == 2) + #expect(template.screens[.hero]?.headline.align == "left") + #expect(template.screens[.feature]?.devices.first?.width == 0.68) + } + + // ── User: "I can save and load a Gallery as JSON" ── + + @Test func `gallery round-trips through JSON`() throws { + let gallery = Gallery(appName: "BezelBlend", screenshots: ["s0.png", "s1.png"]) + gallery.appShots[0].tagline = "BEZELBLEND" + gallery.appShots[0].headline = "PREMIUM\nDEVICE\nMOCKUPS." + gallery.appShots[0].badges = ["iPhone 17"] + gallery.appShots[0].trustMarks = ["4.9 STARS"] + gallery.appShots[1].tagline = "BACKGROUNDS" + gallery.appShots[1].headline = "CUSTOMIZE\nEVERY DETAIL" + gallery.appShots[1].body = "Pick from solid colors." + gallery.appShots[1].badges = ["Mesh"] + gallery.template = GalleryTemplate( + id: "neon-pop", name: "Neon Pop", + background: "linear-gradient(165deg, #a8ff78, #78ffd6)", + screens: [ + .hero: ScreenLayout(headline: TextSlot(y: 0.07, size: 0.08, weight: 900, align: "left")), + .feature: ScreenLayout(headline: TextSlot(y: 0.05, size: 0.08), device: DeviceSlot(y: 0.36, width: 0.68)), + ] + ) + gallery.palette = GalleryPalette(id: "green", name: "Green", background: "linear-gradient(165deg, #a8ff78, #78ffd6)") + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(gallery) + let decoded = try JSONDecoder().decode(Gallery.self, from: data) + + #expect(decoded.appName == "BezelBlend") + #expect(decoded.appShots.count == 2) + #expect(decoded.appShots[0].tagline == "BEZELBLEND") + #expect(decoded.appShots[0].headline == "PREMIUM\nDEVICE\nMOCKUPS.") + #expect(decoded.appShots[0].badges == ["iPhone 17"]) + #expect(decoded.appShots[0].trustMarks == ["4.9 STARS"]) + #expect(decoded.appShots[0].type == .hero) + #expect(decoded.appShots[1].tagline == "BACKGROUNDS") + #expect(decoded.appShots[1].body == "Pick from solid colors.") + #expect(decoded.template?.id == "neon-pop") + #expect(decoded.palette?.background.contains("#a8ff78") == true) + } + + @Test func `gallery decodes from JSON file format`() throws { + let json = """ + { + "appName": "BezelBlend", + "template": { + "id": "neon-pop", + "name": "Neon Pop", + "background": "linear-gradient(165deg, #a8ff78, #78ffd6)", + "screens": { + "hero": { "headline": { "y": 0.07, "size": 0.08, "weight": 900, "align": "left" } }, + "feature": { "headline": { "y": 0.05, "size": 0.08, "weight": 900, "align": "left" }, "device": { "x": 0.5, "y": 0.36, "width": 0.68 } } + } + }, + "palette": { "id": "green", "name": "Green", "background": "linear-gradient(165deg, #a8ff78, #78ffd6)" }, + "appShots": [ + { "screenshot": "s0.png", "type": "hero", "tagline": "BEZELBLEND", "headline": "PREMIUM\\nDEVICE\\nMOCKUPS.", "badges": ["iPhone 17", "Ultra 3"], "trustMarks": ["4.9 STARS", "PRO QUALITY"] }, + { "screenshot": "s1.png", "type": "feature", "tagline": "BACKGROUNDS", "headline": "CUSTOMIZE\\nEVERY DETAIL", "body": "Pick from solid colors.", "badges": ["Mesh", "Gradient"] } + ] + } + """ + let gallery = try JSONDecoder().decode(Gallery.self, from: Data(json.utf8)) + #expect(gallery.appName == "BezelBlend") + #expect(gallery.appShots.count == 2) + #expect(gallery.appShots[0].type == .hero) + #expect(gallery.appShots[0].headline == "PREMIUM\nDEVICE\nMOCKUPS.") + #expect(gallery.appShots[1].type == .feature) + #expect(gallery.appShots[1].body == "Pick from solid colors.") + #expect(gallery.template?.id == "neon-pop") + #expect(gallery.template?.background.contains("#a8ff78") == true) + } + + @Test func `array of galleries decodes — gallery-templates.json format`() throws { + let json = """ + [ + { + "appName": "BezelBlend", + "template": { "id": "neon-pop", "name": "Neon Pop", "background": "#a8ff78", "screens": { "hero": { "headline": { "y": 0.07, "size": 0.08, "weight": 900, "align": "left" } } } }, + "palette": { "id": "g", "name": "G", "background": "#a8ff78" }, + "appShots": [ + { "screenshot": "s0.png", "type": "hero", "headline": "HERO" } + ] + }, + { + "appName": "BezelBlend", + "template": { "id": "blue-depth", "name": "Blue Depth", "background": "#edf1f8", "screens": { "hero": { "headline": { "y": 0.04, "size": 0.11, "weight": 900, "align": "center" } } } }, + "palette": { "id": "b", "name": "B", "background": "#edf1f8" }, + "appShots": [ + { "screenshot": "s0.png", "type": "hero", "headline": "BezelBlend" } + ] + } + ] + """ + let galleries = try JSONDecoder().decode([Gallery].self, from: Data(json.utf8)) + #expect(galleries.count == 2) + #expect(galleries[0].template?.id == "neon-pop") + #expect(galleries[0].appShots[0].headline == "HERO") + #expect(galleries[1].template?.id == "blue-depth") + #expect(galleries[1].appShots[0].headline == "BezelBlend") + } + + // ── Palette with textColor ── + + @Test func `palette with textColor round-trips`() throws { + let palette = GalleryPalette( + id: "space", name: "Space", + background: "linear-gradient(135deg, #0f172a, #7c3aed)", + textColor: "#e0e7ff" + ) + let data = try JSONEncoder().encode(palette) + let decoded = try JSONDecoder().decode(GalleryPalette.self, from: data) + #expect(decoded == palette) + #expect(decoded.textColor == "#e0e7ff") + } + + @Test func `palette without textColor decodes as nil`() throws { + let json = """ + {"id":"g","name":"G","background":"#000"} + """ + let palette = try JSONDecoder().decode(GalleryPalette.self, from: Data(json.utf8)) + #expect(palette.textColor == nil) + } + + // ── Decoration with label shape and styling ── + + @Test func `label decoration round-trips`() throws { + let deco = Decoration( + shape: .label("✨"), x: 0.85, y: 0.12, size: 0.04, opacity: 0.6, + color: "#fff", background: "rgba(255,255,255,0.1)", + borderRadius: "50%", animation: .twinkle + ) + let data = try JSONEncoder().encode(deco) + let decoded = try JSONDecoder().decode(Decoration.self, from: data) + #expect(decoded == deco) + #expect(decoded.shape == .label("✨")) + #expect(decoded.animation == .twinkle) + } + + @Test func `decoration without new fields decodes with defaults`() throws { + let json = """ + {"shape":"gem","x":0.9,"y":0.06,"size":0.06,"opacity":0.8} + """ + let deco = try JSONDecoder().decode(Decoration.self, from: Data(json.utf8)) + #expect(deco.shape == .gem) + #expect(deco.color == nil) + #expect(deco.animation == nil) + } + + @Test func `label decoration decodes from JSON`() throws { + let json = """ + {"shape":{"label":"🎯"},"x":0.1,"y":0.2,"size":0.03,"opacity":0.5, + "color":"#fff","background":"rgba(0,0,0,0.1)","borderRadius":"8px","animation":"float"} + """ + let deco = try JSONDecoder().decode(Decoration.self, from: Data(json.utf8)) + #expect(deco.shape == .label("🎯")) + #expect(deco.color == "#fff") + #expect(deco.animation == .float) + } + + // ── ScreenLayout.withDecorations ── + + @Test func `screenLayout withDecorations returns copy with new decorations`() { + let layout = ScreenLayout( + headline: TextSlot(y: 0.04, size: 0.10), + device: DeviceSlot(y: 0.42, width: 0.85) + ) + let decos = [Decoration(shape: .label("✨"), x: 0.8, y: 0.1, size: 0.04)] + let themed = layout.withDecorations(decos) + #expect(themed.decorations.count == 1) + #expect(themed.headline == layout.headline) + #expect(themed.devices == layout.devices) + } + + @Test func `array of gallery templates decodes from JSON`() throws { + let json = """ + [ + { + "id": "neon-pop", + "name": "Neon Pop", + "screens": { + "hero": { "headline": { "y": 0.07, "size": 0.08, "weight": 900, "align": "left" } }, + "feature": { + "headline": { "y": 0.05, "size": 0.08, "weight": 900, "align": "left" }, + "device": { "x": 0.5, "y": 0.36, "width": 0.68 } + } + } + }, + { + "id": "blue-depth", + "name": "Blue Depth", + "screens": { + "hero": { "headline": { "y": 0.04, "size": 0.11, "weight": 900, "align": "center" } }, + "feature": { + "headline": { "y": 0.025, "size": 0.11, "weight": 900, "align": "center" }, + "device": { "x": 0.5, "y": 0.14, "width": 0.82 } + } + } + } + ] + """ + let templates = try JSONDecoder().decode([GalleryTemplate].self, from: Data(json.utf8)) + #expect(templates.count == 2) + #expect(templates[0].id == "neon-pop") + #expect(templates[1].id == "blue-depth") + #expect(templates[1].screens[.feature]?.devices.first?.width == 0.82) + } +} diff --git a/Tests/DomainTests/Screenshots/Gallery/GalleryComposeTests.swift b/Tests/DomainTests/Screenshots/Gallery/GalleryComposeTests.swift new file mode 100644 index 00000000..6ef50729 --- /dev/null +++ b/Tests/DomainTests/Screenshots/Gallery/GalleryComposeTests.swift @@ -0,0 +1,173 @@ +import Foundation +import Testing +@testable import Domain + +@Suite("Gallery Compose") +struct GalleryComposeTests { + + // ── User: "I pick a template, my screenshots go in, I get HTML" ── + + @Test func `app shot composes with screen template and palette`() { + let shot = AppShot(screenshot: "screen-0.png", type: .feature) + shot.headline = "CUSTOMIZE EVERY DETAIL" + + let screenLayout = ScreenLayout( + headline: TextSlot(y: 0.05, size: 0.08, weight: 900, align: "left"), + device: DeviceSlot(y: 0.36, width: 0.68) + ) + let palette = GalleryPalette( + id: "green", + name: "Green Mint", + background: "linear-gradient(165deg, #a8ff78, #78ffd6)" + ) + + let html = shot.compose(screenLayout: screenLayout, palette: palette) + #expect(html.contains("CUSTOMIZE EVERY DETAIL")) + #expect(html.contains("screen-0.png")) // real screenshot in device + #expect(html.contains("linear-gradient")) + } + + @Test func `hero shot composes without device frame`() { + let shot = AppShot(screenshot: "screen-0.png", type: .hero) + shot.headline = "PREMIUM DEVICE MOCKUPS." + + let heroTemplate = ScreenLayout( + headline: TextSlot(y: 0.07, size: 0.08, weight: 900, align: "left") + // no device — hero uses screenshot as background + ) + let palette = GalleryPalette( + id: "green", + name: "Green", + background: "linear-gradient(165deg, #a8ff78, #78ffd6)" + ) + + let html = shot.compose(screenLayout: heroTemplate, palette: palette) + #expect(html.contains("PREMIUM DEVICE MOCKUPS.")) + // hero with screenshot shows real image in device + #expect(html.contains("screen-0.png")) + } + + @Test func `hero shot without screenshot shows wireframe`() { + let shot = AppShot(screenshot: "", type: .hero) + shot.headline = "HERO" + let heroTemplate = ScreenLayout(headline: TextSlot(y: 0.07, size: 0.08)) + let palette = GalleryPalette(id: "g", name: "G", background: "#fff") + let html = shot.compose(screenLayout: heroTemplate, palette: palette) + #expect(html.contains("HERO")) + #expect(html.contains("9:41")) + } + + @Test func `feature shot composes with device frame`() { + let shot = AppShot(screenshot: "screen-1.png", type: .feature) + shot.headline = "Friends" + + let featureTemplate = ScreenLayout( + headline: TextSlot(y: 0.02, size: 0.10, weight: 900, align: "center"), + device: DeviceSlot(y: 0.15, width: 0.85) + ) + let palette = GalleryPalette( + id: "blue", + name: "Blue", + background: "#edf1f8" + ) + + let html = shot.compose(screenLayout: featureTemplate, palette: palette) + #expect(html.contains("Friends")) + #expect(html.contains("screen-1.png")) // real screenshot in device + } + + // ── User: "Gallery renders all my shots at once" ── + + @Test func `gallery renders all configured shots`() { + let gallery = Gallery( + appName: "BezelBlend", + screenshots: ["screen-0.png", "screen-1.png", "screen-2.png"] + ) + gallery.appShots[0].headline = "PREMIUM MOCKUPS" + gallery.appShots[1].headline = "BACKGROUNDS" + gallery.appShots[2].headline = "TEMPLATES" + + gallery.template = GalleryTemplate( + id: "neon-pop", + name: "Neon Pop", + screens: [ + .hero: ScreenLayout(headline: TextSlot(y: 0.07, size: 0.08)), + .feature: ScreenLayout( + headline: TextSlot(y: 0.05, size: 0.08), + device: DeviceSlot(y: 0.36, width: 0.68) + ), + ] + ) + gallery.palette = GalleryPalette( + id: "green", + name: "Green", + background: "linear-gradient(165deg, #a8ff78, #78ffd6)" + ) + + let results = gallery.renderAll() + #expect(results.count == 3) + #expect(results[0].contains("PREMIUM MOCKUPS")) + #expect(results[1].contains("BACKGROUNDS")) + #expect(results[2].contains("TEMPLATES")) + } + + @Test func `gallery skips unconfigured shots`() { + let gallery = Gallery( + appName: "TestApp", + screenshots: ["s0.png", "s1.png"] + ) + gallery.appShots[0].headline = "Hero" + // s1 has no headline — not configured + + gallery.template = GalleryTemplate(id: "t", name: "T", screens: [ + .hero: ScreenLayout(headline: TextSlot(y: 0.05, size: 0.08)), + .feature: ScreenLayout(headline: TextSlot(y: 0.05, size: 0.08)), + ]) + gallery.palette = GalleryPalette(id: "p", name: "P", background: "#fff") + + let results = gallery.renderAll() + #expect(results.count == 1) + } + + @Test func `gallery renderAll returns empty without template`() { + let gallery = Gallery(appName: "X", screenshots: ["s.png"]) + gallery.appShots[0].headline = "H" + gallery.palette = GalleryPalette(id: "p", name: "P", background: "#fff") + + #expect(gallery.renderAll().isEmpty) + } + + @Test func `gallery renderAll returns empty without palette`() { + let gallery = Gallery(appName: "X", screenshots: ["s.png"]) + gallery.appShots[0].headline = "H" + gallery.template = GalleryTemplate(id: "t", name: "T", screens: [:]) + + #expect(gallery.renderAll().isEmpty) + } + + // ── User: "I can override template for a single shot" ── + + @Test func `single shot can use different template at render time`() { + let shot = AppShot(screenshot: "screen-0.png") + shot.headline = "Custom" + + let templateA = ScreenLayout( + headline: TextSlot(y: 0.02, size: 0.10, weight: 900, align: "center"), + device: DeviceSlot(y: 0.15, width: 0.85) + ) + let templateB = ScreenLayout( + headline: TextSlot(y: 0.25, size: 0.12, weight: 700, align: "left"), + device: DeviceSlot(y: 0.30, width: 0.70) + ) + let palette = GalleryPalette(id: "p", name: "P", background: "#000") + + let htmlA = shot.compose(screenLayout: templateA, palette: palette) + let htmlB = shot.compose(screenLayout: templateB, palette: palette) + + // Same content, different layouts — both contain the headline + #expect(htmlA.contains("Custom")) + #expect(htmlB.contains("Custom")) + // But different structure + #expect(htmlA != htmlB) + } +} diff --git a/Tests/DomainTests/Screenshots/Gallery/GalleryHTMLRendererTests.swift b/Tests/DomainTests/Screenshots/Gallery/GalleryHTMLRendererTests.swift new file mode 100644 index 00000000..cee7d12c --- /dev/null +++ b/Tests/DomainTests/Screenshots/Gallery/GalleryHTMLRendererTests.swift @@ -0,0 +1,185 @@ +import Foundation +import Testing +@testable import Domain + +@Suite("GalleryHTMLRenderer") +struct GalleryHTMLRendererTests { + + // MARK: - Helpers + + private let headlineSlot = TextSlot(y: 0.04, size: 0.10, weight: 900, align: "center") + private let taglineSlot = TextSlot(y: 0.02, size: 0.03, weight: 600, align: "center", preview: "YOUR APP") + private let subheadingSlot = TextSlot(y: 0.20, size: 0.035, weight: 400, align: "left") + private let darkPalette = GalleryPalette(id: "p", name: "P", background: "#000") + + private func renderWithLayout( + _ shot: AppShot, + tagline: TextSlot? = nil, + headline: TextSlot? = nil, + subheading: TextSlot? = nil, + device: DeviceSlot = DeviceSlot(y: 0.42, width: 0.85), + decorations: [Decoration] = [], + palette: GalleryPalette? = nil + ) -> String { + let layout = ScreenLayout( + tagline: tagline, + headline: headline ?? headlineSlot, + subheading: subheading, + device: device, + decorations: decorations + ) + return GalleryHTMLRenderer.renderScreen(shot, screenLayout: layout, palette: palette ?? darkPalette) + } + + // MARK: - Headline + + @Test func `headline uses cqi font size`() { + let shot = AppShot(screenshot: "s.png", type: .feature) + shot.headline = "Ship Faster" + let html = renderWithLayout(shot) + #expect(html.contains("10.0cqi")) + #expect(html.contains("Ship Faster")) + } + + @Test func `headline converts newlines to br`() { + let shot = AppShot(screenshot: "s.png", type: .feature) + shot.headline = "Line 1\nLine 2" + let html = renderWithLayout(shot) + #expect(html.contains("Line 1
Line 2")) + } + + // MARK: - Tagline + + @Test func `tagline uses cqi font size and uppercase`() { + let shot = AppShot(screenshot: "s.png", type: .feature) + shot.headline = "Test" + shot.tagline = "BEZELBLEND" + let html = renderWithLayout(shot, tagline: taglineSlot) + #expect(html.contains("3.0cqi")) + #expect(html.contains("text-transform:uppercase")) + #expect(html.contains("BEZELBLEND")) + } + + // MARK: - Subheading + + @Test func `subheading uses cqi font size`() { + let shot = AppShot(screenshot: "s.png", type: .feature) + shot.headline = "Test" + shot.body = "Description text" + let html = renderWithLayout(shot, subheading: subheadingSlot) + #expect(html.contains("3.5cqi")) + #expect(html.contains("Description text")) + } + + // MARK: - Badges + + @Test func `badges use cqi font size`() { + let shot = AppShot(screenshot: "s.png", type: .feature) + shot.headline = "Test" + shot.badges = ["iPhone 17", "Mesh"] + let html = renderWithLayout(shot) + #expect(html.contains("2.8cqi")) + #expect(html.contains("iPhone 17")) + #expect(html.contains("Mesh")) + } + + // MARK: - Trust Marks + + @Test func `trust marks render below headline`() { + let shot = AppShot(screenshot: "", type: .hero) + shot.headline = "Ship Faster" + shot.trustMarks = ["4.9 STARS", "#1 IN CATEGORY"] + let html = renderWithLayout(shot) + #expect(html.contains("4.9 STARS")) + #expect(html.contains("#1 IN CATEGORY")) + #expect(html.contains("cqi")) + } + + // MARK: - Device + + @Test func `device with screenshot produces img tag`() { + let shot = AppShot(screenshot: "screen.png", type: .feature) + shot.headline = "Test" + let html = renderWithLayout(shot) + #expect(html.contains("")) + // All panels should be present + #expect(html.contains("PREMIUM")) + #expect(html.contains("CUSTOMIZE")) + #expect(html.contains("EXPORT")) + // Gallery structure + #expect(html.contains("#a8ff78")) + #expect(html.contains("TESTAPP")) + } + + @Test func `gallery previewHTML is empty when not ready`() { + let gallery = Gallery(appName: "Test", screenshots: ["s0.png"]) + // No template or palette set + let html = gallery.previewHTML + #expect(html.isEmpty) + } + + @Test func `gallery with dark palette uses dark theme`() { + let gallery = Gallery(appName: "Test", screenshots: ["s0.png"]) + gallery.appShots[0].headline = "Dark" + gallery.template = GalleryTemplate(id: "t", name: "T", screens: [ + .hero: ScreenLayout(headline: TextSlot(y: 0.04, size: 0.10)), + ]) + gallery.palette = GalleryPalette(id: "p", name: "P", background: "#0a0a0a") + let html = gallery.previewHTML + #expect(html.contains("data-theme=\"dark\"")) + } +} diff --git a/Tests/DomainTests/Screenshots/Gallery/GalleryPreviewTests.swift b/Tests/DomainTests/Screenshots/Gallery/GalleryPreviewTests.swift new file mode 100644 index 00000000..6e71c7df --- /dev/null +++ b/Tests/DomainTests/Screenshots/Gallery/GalleryPreviewTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +@testable import Domain + +@Suite("Gallery Preview HTML") +struct GalleryPreviewTests { + + private func makeGallery( + templateId: String = "neon-pop", + templateName: String = "Neon Pop", + background: String = "linear-gradient(165deg, #a8ff78, #78ffd6)", + heroHeadline: String = "PREMIUM\nMOCKUPS.", + featureHeadline: String = "CUSTOMIZE", + hasDevice: Bool = true + ) -> Gallery { + let gallery = Gallery(appName: "TestApp", screenshots: ["s0.png", "s1.png"]) + gallery.appShots[0].headline = heroHeadline + gallery.appShots[1].headline = featureHeadline + + let featureDevices = hasDevice ? [DeviceSlot(y: 0.36, width: 0.68)] : [] + gallery.template = GalleryTemplate( + id: templateId, name: templateName, background: background, + screens: [ + .hero: ScreenLayout(headline: TextSlot(y: 0.07, size: 0.08, weight: 900, align: "left")), + .feature: ScreenLayout(headline: TextSlot(y: 0.05, size: 0.08), devices: featureDevices), + ] + ) + gallery.palette = GalleryPalette(id: "p", name: "P", background: background) + return gallery + } + + @Test func `gallery generates previewHTML with all panels`() { + let gallery = makeGallery() + let html = gallery.previewHTML + #expect(!html.isEmpty) + #expect(html.contains("")) + #expect(html.contains("PREMIUM")) + #expect(html.contains("CUSTOMIZE")) + #expect(html.contains("#a8ff78")) + } + + @Test func `gallery with dark background uses light text`() { + let gallery = makeGallery( + templateId: "cosmic", templateName: "Cosmic", + background: "linear-gradient(170deg, #0f0c29, #302b63)" + ) + let html = gallery.previewHTML + #expect(html.contains("#fff")) + } + + @Test func `gallery with light background uses dark text`() { + let gallery = makeGallery(background: "linear-gradient(165deg, #a8ff78, #78ffd6)") + let html = gallery.previewHTML + #expect(html.contains("color:#000")) + } + + @Test func `gallery previewHTML is included in JSON encoding`() throws { + let gallery = makeGallery() + let data = try JSONEncoder().encode(gallery) + let json = String(data: data, encoding: .utf8) ?? "" + #expect(json.contains("previewHTML")) + #expect(json.contains("")) + } +} diff --git a/Tests/DomainTests/Screenshots/Gallery/GalleryTemplateRepositoryTests.swift b/Tests/DomainTests/Screenshots/Gallery/GalleryTemplateRepositoryTests.swift new file mode 100644 index 00000000..6c00a0a1 --- /dev/null +++ b/Tests/DomainTests/Screenshots/Gallery/GalleryTemplateRepositoryTests.swift @@ -0,0 +1,46 @@ +import Foundation +import Testing +import Mockable +@testable import Domain + +@Suite("GalleryTemplateRepository") +struct GalleryTemplateRepositoryTests { + + // ── User: "I browse gallery templates" ── + + @Test func `list returns all galleries`() async throws { + let repo = MockGalleryTemplateRepository() + let g1 = Gallery(appName: "App1", screenshots: ["s.png"]) + g1.template = MockRepositoryFactory.makeGalleryTemplate(id: "neon-pop") + let g2 = Gallery(appName: "App2", screenshots: ["s.png"]) + g2.template = MockRepositoryFactory.makeGalleryTemplate(id: "blue-depth") + + given(repo).listGalleries().willReturn([g1, g2]) + + let galleries = try await repo.listGalleries() + #expect(galleries.count == 2) + #expect(galleries[0].template?.id == "neon-pop") + #expect(galleries[1].template?.id == "blue-depth") + } + + // ── User: "I pick a specific gallery template" ── + + @Test func `get returns gallery by template id`() async throws { + let repo = MockGalleryTemplateRepository() + let g = Gallery(appName: "App", screenshots: ["s.png"]) + g.template = MockRepositoryFactory.makeGalleryTemplate(id: "neon-pop", name: "Neon Pop") + + given(repo).getGallery(templateId: .value("neon-pop")).willReturn(g) + + let gallery = try await repo.getGallery(templateId: "neon-pop") + #expect(gallery?.template?.id == "neon-pop") + } + + @Test func `get returns nil for unknown id`() async throws { + let repo = MockGalleryTemplateRepository() + given(repo).getGallery(templateId: .value("unknown")).willReturn(nil) + + let gallery = try await repo.getGallery(templateId: "unknown") + #expect(gallery == nil) + } +} diff --git a/Tests/DomainTests/Screenshots/Gallery/GalleryTests.swift b/Tests/DomainTests/Screenshots/Gallery/GalleryTests.swift new file mode 100644 index 00000000..01f1b18b --- /dev/null +++ b/Tests/DomainTests/Screenshots/Gallery/GalleryTests.swift @@ -0,0 +1,147 @@ +import Foundation +import Testing +@testable import Domain + +@Suite("Gallery") +struct GalleryTests { + + // ── User: "I create a gallery from my screenshots" ── + + @Test func `gallery is created from screenshot files`() { + let gallery = Gallery( + appName: "BezelBlend", + screenshots: ["screen-0.png", "screen-1.png", "screen-2.png"] + ) + #expect(gallery.appName == "BezelBlend") + #expect(gallery.shotCount == 3) + } + + @Test func `first screenshot becomes hero, rest are features`() { + let gallery = Gallery( + appName: "BezelBlend", + screenshots: ["screen-0.png", "screen-1.png", "screen-2.png"] + ) + #expect(gallery.appShots[0].type == .hero) + #expect(gallery.appShots[1].type == .feature) + #expect(gallery.appShots[2].type == .feature) + } + + @Test func `each app shot carries its screenshot`() { + let gallery = Gallery( + appName: "BezelBlend", + screenshots: ["screen-0.png", "screen-1.png"] + ) + #expect(gallery.appShots[0].screenshot == "screen-0.png") + #expect(gallery.appShots[1].screenshot == "screen-1.png") + } + + @Test func `hero shot is the first app shot`() { + let gallery = Gallery( + appName: "BezelBlend", + screenshots: ["screen-0.png", "screen-1.png"] + ) + #expect(gallery.heroShot?.screenshot == "screen-0.png") + #expect(gallery.heroShot?.type == .hero) + } + + // ── User: "I configure each shot with a headline" ── + + @Test func `new gallery has no configured shots`() { + let gallery = Gallery( + appName: "BezelBlend", + screenshots: ["screen-0.png", "screen-1.png"] + ) + #expect(gallery.unconfiguredShots.count == 2) + } + + @Test func `configuring a shot reduces unconfigured count`() { + let gallery = Gallery( + appName: "BezelBlend", + screenshots: ["screen-0.png", "screen-1.png"] + ) + gallery.appShots[0].headline = "PREMIUM DEVICE MOCKUPS." + #expect(gallery.unconfiguredShots.count == 1) + } + + // ── User: "Is my gallery ready?" ── + + @Test func `gallery without template is not ready`() { + let gallery = Gallery( + appName: "BezelBlend", + screenshots: ["screen-0.png"] + ) + gallery.appShots[0].headline = "PREMIUM DEVICE MOCKUPS." + #expect(!gallery.isReady) + } + + @Test func `gallery without palette is not ready`() { + let gallery = Gallery( + appName: "BezelBlend", + screenshots: ["screen-0.png"] + ) + gallery.appShots[0].headline = "PREMIUM DEVICE MOCKUPS." + gallery.template = GalleryTemplate( + id: "walkthrough", + name: "Feature Walkthrough", + screens: [:] + ) + #expect(!gallery.isReady) + } + + @Test func `gallery with template and palette and all shots configured is ready`() { + let gallery = Gallery( + appName: "BezelBlend", + screenshots: ["screen-0.png"] + ) + gallery.appShots[0].headline = "PREMIUM DEVICE MOCKUPS." + gallery.template = GalleryTemplate( + id: "walkthrough", + name: "Feature Walkthrough", + screens: [:] + ) + gallery.palette = GalleryPalette( + id: "green-mint", + name: "Green Mint", + background: "linear-gradient(135deg, #c4f7a0, #a0f7e0)" + ) + #expect(gallery.isReady) + } + + @Test func `gallery with unconfigured shots is not ready`() { + let gallery = Gallery( + appName: "BezelBlend", + screenshots: ["screen-0.png", "screen-1.png"] + ) + gallery.appShots[0].headline = "PREMIUM DEVICE MOCKUPS." + // screen-1 has no headline + gallery.template = GalleryTemplate( + id: "walkthrough", + name: "Feature Walkthrough", + screens: [:] + ) + gallery.palette = GalleryPalette( + id: "green-mint", + name: "Green Mint", + background: "linear-gradient(135deg, #c4f7a0, #a0f7e0)" + ) + #expect(!gallery.isReady) + } + + // ── User: "I can check my progress" ── + + @Test func `readiness shows progress`() { + let gallery = Gallery( + appName: "BezelBlend", + screenshots: ["screen-0.png", "screen-1.png", "screen-2.png"] + ) + gallery.appShots[0].headline = "Hero" + gallery.appShots[2].headline = "Feature 2" + + let readiness = gallery.readiness + #expect(readiness.configuredCount == 2) + #expect(readiness.totalCount == 3) + #expect(!readiness.hasPalette) + #expect(!readiness.hasTemplate) + #expect(readiness.progress == "2/3 app shots configured") + } +} diff --git a/Tests/DomainTests/Screenshots/Gallery/HTMLComposerTests.swift b/Tests/DomainTests/Screenshots/Gallery/HTMLComposerTests.swift new file mode 100644 index 00000000..f76d9677 --- /dev/null +++ b/Tests/DomainTests/Screenshots/Gallery/HTMLComposerTests.swift @@ -0,0 +1,131 @@ +import Foundation +import Testing +@testable import Domain + +@Suite("HTMLComposer") +struct HTMLComposerTests { + + // MARK: - Variable Substitution + + @Test func `replaces simple variable`() { + let result = HTMLComposer.render("Hello {{name}}!", with: ["name": "World"]) + #expect(result == "Hello World!") + } + + @Test func `replaces multiple variables`() { + let result = HTMLComposer.render("{{a}} and {{b}}", with: ["a": "X", "b": "Y"]) + #expect(result == "X and Y") + } + + @Test func `missing variable renders empty`() { + let result = HTMLComposer.render("Hello {{missing}}!", with: [:]) + #expect(result == "Hello !") + } + + @Test func `replaces same variable multiple times`() { + let result = HTMLComposer.render("{{x}}-{{x}}", with: ["x": "ok"]) + #expect(result == "ok-ok") + } + + // MARK: - Sections (if/each) + + @Test func `section renders when value is truthy`() { + let result = HTMLComposer.render("{{#show}}visible{{/show}}", with: ["show": true]) + #expect(result == "visible") + } + + @Test func `section skips when value is missing`() { + let result = HTMLComposer.render("{{#show}}visible{{/show}}", with: [:]) + #expect(result == "") + } + + @Test func `section renders when value is empty string`() { + // Mustache treats any non-nil value as truthy (including empty string) + let result = HTMLComposer.render("{{#show}}visible{{/show}}", with: ["show": ""]) + #expect(result == "visible") + } + + @Test func `section preserves surrounding content`() { + let result = HTMLComposer.render("before{{#x}} middle {{/x}}after", with: ["x": "1"]) + #expect(result == "before middle after") + } + + @Test func `section with variable inside`() { + let result = HTMLComposer.render("{{#color}}
text
{{/color}}", with: ["color": "#fff"]) + #expect(result == "
text
") + } + + // MARK: - Array Iteration + + @Test func `section iterates over array items`() { + let result = HTMLComposer.render( + "{{#items}}
  • {{value}}
  • {{/items}}", + with: ["items": [["value": "A"], ["value": "B"]]] + ) + #expect(result == "
  • A
  • B
  • ") + } + + @Test func `section renders empty for missing key`() { + let result = HTMLComposer.render("{{#items}}X{{/items}}", with: [:]) + #expect(result == "") + } + + @Test func `section renders empty for empty array`() { + let result = HTMLComposer.render("{{#items}}X{{/items}}", with: ["items": [Any]()]) + #expect(result == "") + } + + // MARK: - Nested Sections + + @Test func `nested sections resolve correctly`() { + let result = HTMLComposer.render("{{#outer}}A{{#inner}}B{{/inner}}C{{/outer}}", with: ["outer": true, "inner": true]) + #expect(result == "ABC") + } + + @Test func `nested section outer true inner false`() { + let result = HTMLComposer.render("{{#outer}}A{{#inner}}B{{/inner}}C{{/outer}}", with: ["outer": true]) + #expect(result == "AC") + } + + @Test func `nested section outer false hides all`() { + let result = HTMLComposer.render("{{#outer}}A{{#inner}}B{{/inner}}C{{/outer}}", with: [:]) + #expect(result == "") + } + + @Test func `multiple nested sections in sequence`() { + let result = HTMLComposer.render("{{#w}}[{{#a}}A{{/a}}{{#b}}B{{/b}}]{{/w}}", with: ["w": true, "b": true]) + #expect(result == "[B]") + } + + // MARK: - Dot Notation + + @Test func `dot notation accesses nested values`() { + let ctx: [String: Any] = ["slot": ["y": "10", "size": "5"]] + let result = HTMLComposer.render("top:{{slot.y}}%;font-size:{{slot.size}}cqi", with: ctx) + #expect(result == "top:10%;font-size:5cqi") + } + + // MARK: - Raw Output + + @Test func `triple braces output raw value`() { + let result = HTMLComposer.render("{{{content}}}", with: ["content": "bold"]) + #expect(result == "bold") + } + + // MARK: - Complex Template + + @Test func `renders screen-like template`() { + let template = """ +
    {{#tagline}}{{tagline}}{{/tagline}}

    {{headline}}

    {{#badges}}{{text}}{{/badges}}
    + """ + let ctx: [String: Any] = [ + "background": "#000", + "headline": "Ship Faster", + "badges": [["text": "New"], ["text": "Hot"]], + ] + let result = HTMLComposer.render(template, with: ctx) + #expect(result == """ +

    Ship Faster

    NewHot
    + """) + } +} diff --git a/Tests/DomainTests/Screenshots/Gallery/ScreenLayoutTests.swift b/Tests/DomainTests/Screenshots/Gallery/ScreenLayoutTests.swift new file mode 100644 index 00000000..14e0e940 --- /dev/null +++ b/Tests/DomainTests/Screenshots/Gallery/ScreenLayoutTests.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing +@testable import Domain + +@Suite("ScreenLayout") +struct ScreenLayoutGalleryTests { + + // ── User: "My gallery template has different layouts per screen type" ── + + @Test func `gallery template maps screen types to screen templates`() { + let heroLayout = ScreenLayout( + headline: TextSlot(y: 0.25, size: 0.12, weight: 900, align: "left") + ) + let featureLayout = ScreenLayout( + headline: TextSlot(y: 0.02, size: 0.10, weight: 900, align: "center"), + device: DeviceSlot(y: 0.15, width: 0.85) + ) + let template = GalleryTemplate( + id: "walkthrough", + name: "Feature Walkthrough", + screens: [.hero: heroLayout, .feature: featureLayout] + ) + #expect(template.screens[.hero] != nil) + #expect(template.screens[.feature] != nil) + #expect(template.screens[.social] == nil) + } + + // ── User: "Hero has no device frame — screenshot IS the background" ── + + @Test func `hero screen template has no device slot`() { + let hero = ScreenLayout( + headline: TextSlot(y: 0.25, size: 0.12) + ) + #expect(hero.devices.isEmpty) + } + + // ── User: "Feature screen has a device frame" ── + + @Test func `feature screen template has device slot`() { + let feature = ScreenLayout( + headline: TextSlot(y: 0.02, size: 0.10), + device: DeviceSlot(y: 0.15, width: 0.85) + ) + #expect(feature.devices.count == 1) + #expect(feature.devices.first?.width == 0.85) + } + + // ── User: "Templates can have decorative shapes" ── + + @Test func `screen template can have decorations`() { + let feature = ScreenLayout( + headline: TextSlot(y: 0.02, size: 0.10), + device: DeviceSlot(y: 0.15, width: 0.85), + decorations: [ + Decoration(shape: .gem, x: 0.85, y: 0.1, size: 0.04), + Decoration(shape: .orb, x: 0.1, y: 0.9, size: 0.03, opacity: 0.6), + ] + ) + #expect(feature.decorations.count == 2) + #expect(feature.decorations[0].shape == .gem) + #expect(feature.decorations[1].opacity == 0.6) + } + + @Test func `screen template defaults to no decorations`() { + let feature = ScreenLayout( + headline: TextSlot(y: 0.02, size: 0.10) + ) + #expect(feature.decorations.isEmpty) + } +} + +@Suite("GalleryPalette") +struct GalleryPaletteTests { + + // ── User: "I pick a color scheme for my gallery" ── + + @Test func `palette has id, name, and background`() { + let palette = GalleryPalette( + id: "green-mint", + name: "Green Mint", + background: "linear-gradient(135deg, #c4f7a0, #a0f7e0)" + ) + #expect(palette.id == "green-mint") + #expect(palette.name == "Green Mint") + #expect(palette.background.contains("gradient")) + } +} diff --git a/Tests/DomainTests/Screenshots/Gallery/TemplatePreviewPageTests.swift b/Tests/DomainTests/Screenshots/Gallery/TemplatePreviewPageTests.swift new file mode 100644 index 00000000..258a0f20 --- /dev/null +++ b/Tests/DomainTests/Screenshots/Gallery/TemplatePreviewPageTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +@testable import Domain + +@Suite("Template Preview Page") +struct TemplatePreviewPageTests { + + @Test func `preview page renders multiple templates`() { + let templates = [ + AppShotTemplate( + id: "bold-hero", name: "Bold Hero", category: .bold, + screenLayout: ScreenLayout( + headline: TextSlot(y: 0.04, size: 0.10, weight: 900, align: "center", preview: "SHIP FASTER"), + device: DeviceSlot(x: 0.5, y: 0.18, width: 0.85) + ), + palette: GalleryPalette(id: "bold", name: "Bold", background: "linear-gradient(150deg,#4338CA,#6D28D9)") + ), + AppShotTemplate( + id: "minimal", name: "Minimal", category: .minimal, + screenLayout: ScreenLayout( + headline: TextSlot(y: 0.06, size: 0.08, weight: 700, align: "center", preview: "Clean Design"), + device: DeviceSlot(x: 0.5, y: 0.25, width: 0.75) + ), + palette: GalleryPalette(id: "light", name: "Light", background: "#f5f5f7") + ), + ] + + for tmpl in templates { + let html = tmpl.previewHTML + #expect(!html.isEmpty, "Preview for \(tmpl.id) should not be empty") + #expect(html.contains("")) + #expect(html.contains("container-type")) + } + } + + @Test func `preview contains template background`() { + let tmpl = AppShotTemplate( + id: "gradient", name: "Gradient", + screenLayout: ScreenLayout(headline: TextSlot(y: 0.04, size: 0.10, preview: "Test")), + palette: GalleryPalette(id: "g", name: "G", background: "linear-gradient(135deg,#ff6b6b,#feca57)") + ) + let html = tmpl.previewHTML + #expect(html.contains("#ff6b6b")) + #expect(html.contains("#feca57")) + } + + @Test func `multi-device template preview renders all device slots`() { + let tmpl = AppShotTemplate( + id: "duo", name: "Duo", + screenLayout: ScreenLayout( + headline: TextSlot(y: 0.04, size: 0.08, preview: "Compare"), + devices: [ + DeviceSlot(x: 0.35, y: 0.2, width: 0.6), + DeviceSlot(x: 0.65, y: 0.24, width: 0.6), + ] + ), + palette: GalleryPalette(id: "d", name: "D", background: "#1a1a2e") + ) + let html = tmpl.previewHTML + #expect(html.contains("")) + // Both devices should be rendered (as wireframes since no screenshot) + #expect(html.contains("9:41")) // wireframe status bar + } +} diff --git a/Tests/DomainTests/Screenshots/Gallery/TextSlotPreviewTests.swift b/Tests/DomainTests/Screenshots/Gallery/TextSlotPreviewTests.swift new file mode 100644 index 00000000..40aaf1cc --- /dev/null +++ b/Tests/DomainTests/Screenshots/Gallery/TextSlotPreviewTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import Domain + +@Suite("TextSlot Preview") +struct TextSlotPreviewTests { + + @Test func `text slot has preview placeholder text`() { + let slot = TextSlot(y: 0.03, size: 0.04, weight: 700, align: "left", preview: "APP MANAGEMENT") + #expect(slot.preview == "APP MANAGEMENT") + } + + @Test func `text slot preview defaults to nil`() { + let slot = TextSlot(y: 0.04, size: 0.10) + #expect(slot.preview == nil) + } + + @Test func `screen template has tagline and subheading slots`() { + let template = ScreenLayout( + tagline: TextSlot(y: 0.03, size: 0.04, preview: "APP MANAGEMENT"), + headline: TextSlot(y: 0.07, size: 0.085, preview: "Submit new\nversions in\nseconds."), + subheading: TextSlot(y: 0.92, size: 0.055, preview: "Try it free →"), + device: DeviceSlot(y: 0.28, width: 0.92) + ) + #expect(template.tagline?.preview == "APP MANAGEMENT") + #expect(template.headline.preview == "Submit new\nversions in\nseconds.") + #expect(template.subheading?.preview == "Try it free →") + } + + @Test func `screen template tagline and subheading are optional`() { + let template = ScreenLayout( + headline: TextSlot(y: 0.04, size: 0.10), + device: DeviceSlot(y: 0.18, width: 0.85) + ) + #expect(template.tagline == nil) + #expect(template.subheading == nil) + } + + @Test func `preview HTML uses TextSlot preview text when AppShot has no content`() { + let template = ScreenLayout( + tagline: TextSlot(y: 0.03, size: 0.04, preview: "SCREENSHOTS"), + headline: TextSlot(y: 0.07, size: 0.085, preview: "Generate App\nStore shots"), + device: DeviceSlot(y: 0.28, width: 0.85) + ) + let palette = GalleryPalette(id: "t", name: "T", background: "#4338CA") + let shot = AppShot(screenshot: "", type: .feature) + // No headline set on shot — renderer uses TextSlot.preview + let html = GalleryHTMLRenderer.renderScreen(shot, screenLayout: template, palette: palette) + #expect(html.contains("SCREENSHOTS")) + #expect(html.contains("Generate App")) + } + + @Test func `render uses AppShot content over TextSlot preview`() { + let template = ScreenLayout( + tagline: TextSlot(y: 0.03, size: 0.04, preview: "DEFAULT TAGLINE"), + headline: TextSlot(y: 0.07, size: 0.085, preview: "Default Headline") + ) + let palette = GalleryPalette(id: "t", name: "T", background: "#000") + let shot = AppShot(screenshot: "", type: .feature) + shot.tagline = "MY TAGLINE" + shot.headline = "My Real Headline" + let html = GalleryHTMLRenderer.renderScreen(shot, screenLayout: template, palette: palette) + #expect(html.contains("MY TAGLINE")) + #expect(html.contains("My Real Headline")) + #expect(!html.contains("DEFAULT TAGLINE")) + #expect(!html.contains("Default Headline")) + } + + @Test func `text slot with preview round-trips through JSON`() throws { + let slot = TextSlot(y: 0.03, size: 0.04, weight: 700, align: "left", preview: "APP MANAGEMENT") + let data = try JSONEncoder().encode(slot) + let decoded = try JSONDecoder().decode(TextSlot.self, from: data) + #expect(decoded.preview == "APP MANAGEMENT") + #expect(decoded.y == 0.03) + } +} diff --git a/Tests/DomainTests/ScreenshotPlans/ScreenThemeTests.swift b/Tests/DomainTests/Screenshots/ScreenThemeTests.swift similarity index 81% rename from Tests/DomainTests/ScreenshotPlans/ScreenThemeTests.swift rename to Tests/DomainTests/Screenshots/ScreenThemeTests.swift index 48469ea2..a60a67ca 100644 --- a/Tests/DomainTests/ScreenshotPlans/ScreenThemeTests.swift +++ b/Tests/DomainTests/Screenshots/ScreenThemeTests.swift @@ -70,6 +70,31 @@ struct ScreenThemeTests { #expect(context.contains("twinkling stars (varying sizes), small planets, comet trails")) } + // MARK: - Build Design Context + + @Test func `buildDesignContext includes JSON schema instruction`() { + let theme = makeTheme() + let context = theme.buildDesignContext() + #expect(context.contains("\"palette\"")) + #expect(context.contains("\"textColor\"")) + #expect(context.contains("\"decorations\"")) + #expect(context.contains("JSON")) + } + + @Test func `buildDesignContext specifies normalized positions`() { + let theme = makeTheme() + let context = theme.buildDesignContext() + #expect(context.contains("0-1")) + #expect(context.contains("cqi")) + } + + @Test func `buildDesignContext includes theme name and style`() { + let theme = makeTheme() + let context = theme.buildDesignContext() + #expect(context.contains("Space")) + #expect(context.contains("cosmic")) + } + // MARK: - Codable @Test func `theme is codable`() throws { diff --git a/Tests/DomainTests/Screenshots/TemplateApplyTests.swift b/Tests/DomainTests/Screenshots/TemplateApplyTests.swift new file mode 100644 index 00000000..3fb1e6a2 --- /dev/null +++ b/Tests/DomainTests/Screenshots/TemplateApplyTests.swift @@ -0,0 +1,31 @@ +import Foundation +import Testing +@testable import Domain + +@Suite +struct TemplateApplyTests { + + @Test func `template apply returns HTML with headline`() { + let template = MockRepositoryFactory.makeAppShotTemplate(id: "hero", name: "Hero") + let shot = AppShot(screenshot: "screen.png", type: .feature) + shot.headline = "Hello World" + let html = template.apply(shot: shot) + #expect(html.contains("Hello World")) + #expect(html.contains("")) + } + + @Test func `template apply for viewport returns full-page HTML`() { + let template = MockRepositoryFactory.makeAppShotTemplate(id: "hero", name: "Hero") + let shot = AppShot(screenshot: "screen.png", type: .feature) + shot.headline = "Test" + let html = template.apply(shot: shot, fillViewport: true) + #expect(html.contains("width:100%")) + } + + @Test func `template previewHTML uses template name`() { + let template = MockRepositoryFactory.makeAppShotTemplate(id: "hero", name: "Hero") + let html = template.previewHTML + #expect(html.contains("")) + #expect(html.contains("Hero")) + } +} diff --git a/Tests/DomainTests/ScreenshotPlans/TemplateRenderTests.swift b/Tests/DomainTests/Screenshots/TemplateRenderTests.swift similarity index 66% rename from Tests/DomainTests/ScreenshotPlans/TemplateRenderTests.swift rename to Tests/DomainTests/Screenshots/TemplateRenderTests.swift index 5291fcbd..f047bb79 100644 --- a/Tests/DomainTests/ScreenshotPlans/TemplateRenderTests.swift +++ b/Tests/DomainTests/Screenshots/TemplateRenderTests.swift @@ -5,18 +5,15 @@ import Testing @Suite struct TemplateRenderTests { - // MARK: - renderFragment (inner HTML, no page wrapper) - @Test func `template renderFragment returns inner HTML with content`() { - let template = MockRepositoryFactory.makeScreenshotTemplate(id: "hero", name: "Hero") - let content = TemplateContent(headline: "Buy Now", screenshotFile: "screen.png") - let html = template.renderFragment(content: content) + let template = MockRepositoryFactory.makeAppShotTemplate(id: "hero", name: "Hero") + let shot = AppShot(screenshot: "screen.png", type: .feature) + shot.headline = "Buy Now" + let html = template.renderFragment(shot: shot) #expect(html.contains("Buy Now")) #expect(!html.contains("")) } - // MARK: - ThemedPage wraps body in full HTML page - @Test func `themed page wraps body in full HTML`() { let page = ThemedPage(body: "
    styled
    ", width: 1320, height: 2868) #expect(page.html.contains("")) @@ -28,10 +25,8 @@ struct TemplateRenderTests { #expect(page.html.contains("width:100%")) } - // MARK: - Codable includes computed properties - @Test func `template JSON encoding includes previewHTML`() throws { - let template = MockRepositoryFactory.makeScreenshotTemplate(id: "hero", name: "Hero") + let template = MockRepositoryFactory.makeAppShotTemplate(id: "hero", name: "Hero") let data = try JSONEncoder().encode(template) let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] #expect(json["previewHTML"] as? String != nil) @@ -39,9 +34,9 @@ struct TemplateRenderTests { } @Test func `template JSON encoding includes deviceCount`() throws { - let template = MockRepositoryFactory.makeScreenshotTemplate(id: "hero", name: "Hero", deviceCount: 2) + let template = MockRepositoryFactory.makeAppShotTemplate(id: "hero", name: "Hero") let data = try JSONEncoder().encode(template) let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] - #expect(json["deviceCount"] as? Int == 2) + #expect(json["deviceCount"] as? Int == 1) } } diff --git a/Tests/DomainTests/Screenshots/ThemeDesignApplierTests.swift b/Tests/DomainTests/Screenshots/ThemeDesignApplierTests.swift new file mode 100644 index 00000000..e5b4ae59 --- /dev/null +++ b/Tests/DomainTests/Screenshots/ThemeDesignApplierTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing +@testable import Domain + +@Suite("ThemeDesignApplier") +struct ThemeDesignApplierTests { + + // MARK: - Helpers + + private let layout = ScreenLayout( + headline: TextSlot(y: 0.04, size: 0.10, weight: 900, align: "center"), + device: DeviceSlot(x: 0.5, y: 0.42, width: 0.85) + ) + + private func makeShot(screenshot: String = "screen.png", headline: String = "Ship Faster") -> AppShot { + let shot = AppShot(screenshot: screenshot, type: .feature) + shot.headline = headline + return shot + } + + private func makeDesign( + background: String = "linear-gradient(135deg, #0f172a, #7c3aed)", + textColor: String? = "#e0e7ff", + decorations: [Decoration] = [ + Decoration(shape: .label("✨"), x: 0.85, y: 0.12, size: 0.04, opacity: 0.6, + color: "#fff", background: "rgba(255,255,255,0.1)", + borderRadius: "50%", animation: .twinkle), + ] + ) -> ThemeDesign { + ThemeDesign( + palette: GalleryPalette(id: "space", name: "Space", background: background, textColor: textColor), + decorations: decorations + ) + } + + // MARK: - Re-rendering through pipeline + + @Test func `applier renders through GalleryHTMLRenderer`() { + let design = makeDesign() + let html = ThemeDesignApplier.apply(design, shot: makeShot(), screenLayout: layout) + // Should be a full renderScreen output + #expect(html.contains("container-type:inline-size")) + #expect(html.contains("Ship Faster")) + } + + @Test func `applier uses design palette background`() { + let design = makeDesign(background: "linear-gradient(135deg, #0f172a, #7c3aed)") + let html = ThemeDesignApplier.apply(design, shot: makeShot(), screenLayout: layout) + #expect(html.contains("#0f172a")) + } + + @Test func `applier uses design palette textColor`() { + let design = makeDesign(textColor: "#e0e7ff") + let html = ThemeDesignApplier.apply(design, shot: makeShot(), screenLayout: layout) + #expect(html.contains("color:#e0e7ff")) + } + + @Test func `applier renders decorations with cqi units`() { + let design = makeDesign() + let html = ThemeDesignApplier.apply(design, shot: makeShot(), screenLayout: layout) + #expect(html.contains("✨")) + #expect(html.contains("4.0cqi")) + #expect(html.contains("left:85.0%")) + #expect(html.contains("z-index:3")) + } + + @Test func `applier preserves device img tag`() { + let design = makeDesign() + let html = ThemeDesignApplier.apply(design, shot: makeShot(), screenLayout: layout) + #expect(html.contains(" ScreenDesign { - ScreenDesign( - index: index, - screenshotFile: screenshotFile, - heading: heading, - subheading: subheading, - layoutMode: layoutMode, - visualDirection: visualDirection, - imagePrompt: imagePrompt - ) - } - // MARK: - Plugins static func makePlugin( @@ -1199,30 +1179,74 @@ struct MockRepositoryFactory { // MARK: - Screenshot Templates - static func makeScreenshotTemplate( + // MARK: - Gallery + + static func makeAppShot( + screenshot: String = "screen-0.png", + type: ScreenType = .feature, + headline: String? = nil, + badges: [String] = [], + trustMarks: [String]? = nil + ) -> AppShot { + let shot = AppShot(screenshot: screenshot, type: type) + shot.headline = headline + shot.badges = badges + shot.trustMarks = trustMarks + return shot + } + + static func makeGallery( + appName: String = "Test App", + screenshots: [String] = ["screen-0.png", "screen-1.png"] + ) -> Gallery { + Gallery(appName: appName, screenshots: screenshots) + } + + static func makeGalleryTemplate( + id: String = "walkthrough", + name: String = "Feature Walkthrough", + screens: [ScreenType: ScreenLayout] = [:] + ) -> GalleryTemplate { + GalleryTemplate(id: id, name: name, screens: screens) + } + + static func makeGalleryPalette( + id: String = "green-mint", + name: String = "Green Mint", + background: String = "linear-gradient(135deg, #c4f7a0, #a0f7e0)" + ) -> GalleryPalette { + GalleryPalette(id: id, name: name, background: background) + } + + static func makeScreenLayout( + headline: TextSlot = TextSlot(y: 0.02, size: 0.10), + devices: [DeviceSlot] = [DeviceSlot(y: 0.15, width: 0.85)], + decorations: [Decoration] = [] + ) -> ScreenLayout { + ScreenLayout(headline: headline, devices: devices, decorations: decorations) + } + + static func makeAppShotTemplate( id: String = "top-hero", name: String = "Top Hero", category: TemplateCategory = .bold, supportedSizes: [ScreenSize] = [.portrait], description: String = "Indigo gradient with bold headline", - background: SlideBackground = .gradient(from: "#4338CA", to: "#6D28D9", angle: 150), - deviceCount: Int = 1 - ) -> ScreenshotTemplate { - let textSlots = [ - TemplateTextSlot(role: .heading, preview: "Your\nHeadline", x: 0.5, y: 0.04, fontSize: 0.10, color: "#FFFFFF") - ] - let deviceSlots = (0.. AppShotTemplate { + let devices = hasDevice ? [DeviceSlot(x: 0.5, y: 0.18, width: 0.85)] : [] + return AppShotTemplate( id: id, name: name, category: category, supportedSizes: supportedSizes, description: description, - background: background, - textSlots: textSlots, - deviceSlots: deviceSlots + screenLayout: ScreenLayout( + headline: TextSlot(y: 0.04, size: 0.10, weight: 700, align: "center", preview: name), + devices: devices + ), + palette: GalleryPalette(id: id, name: name, background: background) ) } } diff --git a/Tests/InfrastructureTests/ScreenshotPlans/AggregateTemplateRepositoryTests.swift b/Tests/InfrastructureTests/ScreenshotPlans/AggregateTemplateRepositoryTests.swift index 0af2ffb5..ff82a167 100644 --- a/Tests/InfrastructureTests/ScreenshotPlans/AggregateTemplateRepositoryTests.swift +++ b/Tests/InfrastructureTests/ScreenshotPlans/AggregateTemplateRepositoryTests.swift @@ -82,29 +82,24 @@ private func makeTemplate( id: String = "test", name: String = "Test Template", supportedSizes: [ScreenSize] = [.portrait] -) -> ScreenshotTemplate { - ScreenshotTemplate( - id: id, - name: name, - category: .bold, - supportedSizes: supportedSizes, - description: "Test", - background: .gradient(from: "#000", to: "#111", angle: 180), - textSlots: [TemplateTextSlot(role: .heading, preview: "Test", x: 0.5, y: 0.04, fontSize: 0.1, color: "#fff")], - deviceSlots: [TemplateDeviceSlot(x: 0.5, y: 0.18, scale: 0.85)] +) -> AppShotTemplate { + AppShotTemplate( + id: id, name: name, category: .bold, supportedSizes: supportedSizes, description: "Test", + screenLayout: ScreenLayout(headline: TextSlot(y: 0.04, size: 0.1, weight: 700, align: "center"), device: DeviceSlot(x: 0.5, y: 0.18, width: 0.85)), + palette: GalleryPalette(id: id, name: name, background: "linear-gradient(180deg,#000,#111)") ) } private struct StubTemplateProvider: TemplateProvider { let providerId: String - let _templates: [ScreenshotTemplate] + let _templates: [AppShotTemplate] - init(providerId: String, templates: [ScreenshotTemplate]) { + init(providerId: String, templates: [AppShotTemplate]) { self.providerId = providerId self._templates = templates } - func templates() async throws -> [ScreenshotTemplate] { + func templates() async throws -> [AppShotTemplate] { _templates } } diff --git a/apps/asc-web/command-center/css/layout.css b/apps/asc-web/command-center/css/layout.css index cf88e6f8..94ac9718 100644 --- a/apps/asc-web/command-center/css/layout.css +++ b/apps/asc-web/command-center/css/layout.css @@ -85,7 +85,7 @@ .auth-info span { display: block; font-weight: 600; } /* ========== MAIN ========== */ -.main { margin-left: var(--sidebar-w); flex: 1; display: flex; flex-direction: column; min-height: 100vh; } +.main { margin-left: var(--sidebar-w); flex: 1; display: flex; flex-direction: column; min-height: 100vh; min-width: 0; overflow: hidden; } /* ========== HEADER ========== */ .header { diff --git a/docs/features/app-shots-themes.md b/docs/features/app-shots-themes.md index 242ab52b..22d58d45 100644 --- a/docs/features/app-shots-themes.md +++ b/docs/features/app-shots-themes.md @@ -16,8 +16,10 @@ Themes are **plugin-provided** — each plugin registers its own themes with its Separate **layout** (deterministic) from **styling** (AI-driven): -1. **Step 1 — Deterministic HTML**: Render the template using `TemplateHTMLRenderer` → exact pixel-perfect layout -2. **Step 2 — AI Restyle**: Send that HTML + theme context to the plugin's AI provider → restyles while **preserving all positions** +1. **Step 1 — Deterministic HTML**: Render the template using `GalleryHTMLRenderer.renderScreen()` with Mustache templates → exact pixel-perfect layout +2. **Step 2 — ThemeDesign**: Generate a `ThemeDesign` (palette + decorations) via 1 AI call (`ThemeProvider.design()`) +3. **Step 3 — Deterministic Apply**: Apply `ThemeDesign` to all slides via `ThemeDesignApplier` — no additional AI calls +4. **Fallback — Full AI Restyle**: If `design()` unavailable, falls back to `ThemeProvider.compose()` which does per-slide AI restyle via `/themes/apply` Themes live in Domain as a data model (`ScreenTheme` + `ThemeAIHints`), registered by plugins via `ThemeProvider` (same pattern as `TemplateProvider`). @@ -28,17 +30,17 @@ Themes live in Domain as a data model (`ScreenTheme` + `ThemeAIHints`), register ### Plugin UI ``` -Capture → Design (pick template) → Compose (pick theme → Auto-Compose) → Export +Capture → Design (pick template) → Pick Theme → Export ``` 1. User captures screenshots -2. User picks a template (defines layout: text slots, device slots) -3. User picks a theme (visual style) -4. Auto-Compose: - - `TemplateHTMLRenderer.render(template, content)` → deterministic HTML - - Send HTML + `ScreenTheme.buildContext()` to plugin's AI provider → restyles - - Result: themed HTML with exact template layout preserved -5. Export PNGs +2. User picks a template (defines layout: text slots, device slots) → preview HTML rendered immediately +3. User picks a theme → triggers: + - `POST /themes/design {themeId}` → 1 AI call → returns `ThemeDesign` JSON (palette + decorations) + - For each slide: `ThemeDesignApplier.apply(design, shot, layout)` → deterministic re-render (no AI) +4. Export PNGs + +**No auto-compose required.** Theme applies immediately to slides with existing preview HTML. ### CLI / Agent @@ -136,6 +138,8 @@ Apply a theme to a template+screenshot composition. Two-step process: | `--canvas-height` | `2868` | Canvas height in pixels | | `--preview` | — | Preview format: `html` (default) or `image` (renders to PNG) | | `--image-output` | `.asc/app-shots/output/screen-0.png` | Output PNG path (for `--preview image`) | +| `--design-only` | — | Output `ThemeDesign` JSON instead of themed HTML (generate once, apply many) | +| `--apply-design` | — | Path to cached `ThemeDesign` JSON file (skip AI, apply deterministically) | ```bash # Output themed HTML (default) @@ -178,11 +182,23 @@ Domain Infrastructure ASCComman │ themes() → [ScreenTheme] │ └─────────────────────┘ │ compose(html:theme:...) │ │ → String (themed HTML) │ +│ design(theme:) │ +│ → ThemeDesign (JSON) │ +├─────────────────────────────┤ +│ ThemeDesign │ +│ background: ThemeBackground│ +│ textColor: String │ +│ floats: [ThemeFloat] │ +├─────────────────────────────┤ +│ ThemeDesignApplier │ +│ apply(design, to: html) │ +│ → themed HTML (no AI) │ ├─────────────────────────────┤ │ ThemeRepository (protocol) │ │ listThemes() │ │ getTheme(id:) │ │ compose(themeId:html:...) │ +│ design(themeId:) │ └─────────────────────────────┘ ``` @@ -235,6 +251,73 @@ AI generates: **No theme selected**: Skip step 2, use deterministic HTML as-is. +### ThemeDesign — Efficient Batch Workflow + +Instead of calling AI per-screenshot, generate a `ThemeDesign` once and apply deterministically: + +``` +Step 1: AI generates ThemeDesign JSON (one call) + │ + ▼ +ThemeDesign { background, textColor, floats[] } + │ + ├──► Screenshot 1 → ThemeDesignApplier.apply() → themed HTML (no AI) + ├──► Screenshot 2 → ThemeDesignApplier.apply() → themed HTML (no AI) + └──► Screenshot 3 → ThemeDesignApplier.apply() → themed HTML (no AI) +``` + +**Critical: All float elements use `cqi` units** (container query inline-size), not `px`. This ensures consistent sizing between preview (320px) and export (full viewport). + +```bash +# Generate design once +asc app-shots themes apply --theme space --template top-hero \ + --screenshot screen.png --design-only > design.json + +# Apply to multiple screenshots without AI +asc app-shots themes apply --template top-hero \ + --screenshot screen-0.png --headline "Feature 1" \ + --apply-design design.json --preview html > s0.html + +asc app-shots themes apply --template top-hero \ + --screenshot screen-1.png --headline "Feature 2" \ + --apply-design design.json --preview html > s1.html +``` + +**ThemeDesign JSON format (uses Gallery-native types):** + +```json +{ + "palette": { + "id": "space", "name": "Space", + "background": "linear-gradient(135deg, #0f172a, #7c3aed)", + "textColor": "#ffffff" + }, + "decorations": [ + { + "shape": {"label": "✨"}, "x": 0.85, "y": 0.12, "size": 0.04, "opacity": 0.6, + "color": "#fff", "background": "rgba(255,255,255,0.1)", + "borderRadius": "50%", "animation": "twinkle" + } + ] +} +``` + +### Verification + +```bash +# Preview themed HTML in browser +asc app-shots themes apply --theme space --template top-hero \ + --screenshot screen.png --headline "Test" > themed.html && open themed.html + +# Design-only output for inspection +asc app-shots themes apply --theme space --template top-hero \ + --screenshot screen.png --design-only | python3 -m json.tool + +# Apply cached design and verify +asc app-shots themes apply --template top-hero --screenshot screen.png \ + --headline "Test" --apply-design design.json --preview html > test.html && open test.html +``` + --- ## Domain Models @@ -263,7 +346,8 @@ public struct ScreenTheme: Sendable, Equatable, Identifiable, Codable { | Method | Returns | Description | |--------|---------|-------------| -| `buildContext()` | `String` | Produces the theme prompt for the plugin's AI provider | +| `buildContext()` | `String` | Produces the theme prompt for the plugin's AI provider (returns full HTML) | +| `buildDesignContext()` | `String` | Produces a prompt that instructs AI to return `ThemeDesign` JSON | **Affordances:** @@ -285,6 +369,34 @@ public struct ThemeAIHints: Sendable, Equatable, Codable { } ``` +### `ThemeDesign` + +Structured theme output — composed from Gallery-native types. Generated by AI once, applied deterministically to all screenshots via `ThemeDesignApplier`. + +```swift +public struct ThemeDesign: Sendable, Equatable, Codable { + public let palette: GalleryPalette // background + textColor + public let decorations: [Decoration] // floating elements (label shapes) +} +``` + +Reuses existing Gallery types: +- `GalleryPalette` carries `background` (CSS) + `textColor` (hex, optional) +- `Decoration` with `.label("✨")` shape + normalized 0-1 positions + `cqi` sizing +- `DecorationAnimation` for float/drift/pulse/spin/twinkle + +### `ThemeDesignApplier` + +Applies a `ThemeDesign` by re-rendering through the standard `GalleryHTMLRenderer.renderScreen()` pipeline. No HTML patching — same rendering path as templates and galleries. + +```swift +public enum ThemeDesignApplier { + public static func apply(_ design: ThemeDesign, shot: AppShot, screenLayout: ScreenLayout) -> String +} +``` + +Merges design decorations into the layout, overrides palette, and re-renders. + ### `ThemeProvider` / `ThemeRepository` (protocols) ```swift @@ -292,16 +404,16 @@ public struct ThemeAIHints: Sendable, Equatable, Codable { public protocol ThemeProvider: Sendable { var providerId: String { get } func themes() async throws -> [ScreenTheme] - /// Restyle deterministic HTML with a theme using this provider's AI backend. func compose(html: String, theme: ScreenTheme, canvasWidth: Int, canvasHeight: Int) async throws -> String + func design(theme: ScreenTheme) async throws -> ThemeDesign // generate once, apply many } @Mockable public protocol ThemeRepository: Sendable { func listThemes() async throws -> [ScreenTheme] func getTheme(id: String) async throws -> ScreenTheme? - /// Compose themed HTML — delegates to the provider that owns the theme. func compose(themeId: String, html: String, canvasWidth: Int, canvasHeight: Int) async throws -> String + func design(themeId: String) async throws -> ThemeDesign } ``` @@ -327,12 +439,16 @@ IMPORTANT: Integrate the floating elements naturally — they should enhance the | File | Purpose | |------|---------| -| `Sources/Domain/ScreenshotPlans/ScreenTheme.swift` | `ScreenTheme` + `ThemeAIHints` + `ThemeProvider` + `ThemeRepository` + affordances + `buildContext()` | -| `Sources/Infrastructure/ScreenshotPlans/AggregateThemeRepository.swift` | Actor that aggregates themes from all registered providers | -| `Sources/ASCCommand/Commands/AppShots/AppShotsThemes.swift` | `themes list` / `get` commands | -| `Tests/DomainTests/ScreenshotPlans/ScreenThemeTests.swift` | Domain model tests | -| `Tests/InfrastructureTests/ScreenshotPlans/AggregateThemeRepositoryTests.swift` | Repository aggregation + compose delegation tests | -| `Tests/ASCCommandTests/Commands/AppShots/AppShotsThemesTests.swift` | Command tests | +| `Sources/Domain/Screenshots/ScreenTheme.swift` | `ScreenTheme` + `ThemeAIHints` + `ThemeProvider` + `ThemeRepository` + `buildContext()` + `buildDesignContext()` | +| `Sources/Domain/Screenshots/ThemeDesign.swift` | `ThemeDesign` + `ThemeBackground` + `ThemeFloat` + `ThemeFloatAnimation` | +| `Sources/Domain/Screenshots/ThemeDesignApplier.swift` | Deterministic applier: applies ThemeDesign to HTML using `cqi` units | +| `Sources/Infrastructure/Screenshots/AggregateThemeRepository.swift` | Actor that aggregates themes from all registered providers | +| `Sources/ASCCommand/Commands/AppShots/AppShotsThemes.swift` | `themes list` / `get` / `apply` commands with `--design-only` and `--apply-design` | +| `Tests/DomainTests/Screenshots/ScreenThemeTests.swift` | Domain model tests (11 tests) | +| `Tests/DomainTests/Screenshots/ThemeDesignTests.swift` | ThemeDesign model tests (9 tests) | +| `Tests/DomainTests/Screenshots/ThemeDesignApplierTests.swift` | Applier tests: cqi sizing, float positions, preservation (10 tests) | +| `Tests/InfrastructureTests/Screenshots/AggregateThemeRepositoryTests.swift` | Repository aggregation + compose delegation tests | +| `Tests/ASCCommandTests/Commands/AppShots/AppShotsThemesTests.swift` | Command tests (11 tests) | ### Modified Files @@ -346,9 +462,12 @@ IMPORTANT: Integrate the floating elements naturally — they should enhance the ## Testing ```bash -swift test --filter 'ScreenThemeTests' # Domain (8 tests) +swift test --filter 'ScreenThemeTests' # Domain (11 tests) +swift test --filter 'ThemeDesignTests' # ThemeDesign model (9 tests) +swift test --filter 'ThemeDesignApplierTests' # Applier (10 tests) +swift test --filter 'GalleryHTMLRendererTests' # Renderer building blocks (12 tests) swift test --filter 'AggregateThemeRepositoryTests' # Infrastructure (7 tests) -swift test --filter 'AppShotsThemesTests' # Commands (5 tests) +swift test --filter 'AppShotsThemesTests' # Commands (11 tests) swift test --filter 'AppShots' # All app-shots tests ``` diff --git a/docs/features/app-shots.md b/docs/features/app-shots.md index 023f4280..7e1334f7 100644 --- a/docs/features/app-shots.md +++ b/docs/features/app-shots.md @@ -1,398 +1,395 @@ # App Shots -Create professional App Store marketing screenshots from raw app screenshots. Three approaches: +Create professional App Store marketing screenshots. Two modes: -| | **Enhance** | **Compose + Enhance** | **Theme + Compose** | -|---|---|---|---| -| **What you do** | Feed a screenshot to Gemini AI | Pick a template, apply it, then enhance with AI | Pick a template + theme, AI restyles | -| **Command** | `asc app-shots generate` | `templates apply` → `generate` | `themes apply` → compose bridge | -| **AI required** | Yes (Gemini) | Yes (Gemini) | Yes (Claude via compose bridge) | -| **Control level** | Low — AI decides layout | Medium — you pick the template | High — exact layout + themed styling | - -See [App Shots Themes](app-shots-themes.md) for the full theme system design. +| | **Gallery** | **Single Template** | +|---|---|---| +| **What you do** | Upload screenshots → pick a gallery style → all shots styled as a coordinated set | Pick a template → apply to one screenshot | +| **Output** | Hero + feature screens matching App Store gallery | One styled screenshot | +| **AI enhance** | Optional: Stage 1 CSS polish + Stage 2 Gemini photorealistic | Optional: Gemini enhance | --- ## Quick Start ```bash -# 1. Save your Gemini API key (one-time) -asc app-shots config --gemini-api-key AIzaSy... - -# 2. Enhance a screenshot -asc app-shots generate --file .asc/app-shots/screen-0.png +# Gallery mode — all screenshots at once +asc app-shots gallery create \ + --app-name "BezelBlend" \ + --screenshots screen-0.png screen-1.png screen-2.png screen-3.png -# Output: .asc/app-shots/output/screen-0.png +# Single template mode — one screenshot +asc app-shots templates apply \ + --id top-hero \ + --screenshot screen-0.png \ + --headline "Ship Faster" \ + --preview html > preview.html && open preview.html ``` -That's it. Gemini analyzes your screenshot, wraps it in a photorealistic iPhone mockup, adds marketing text, and outputs a polished App Store image. - --- ## CLI Reference -### `asc app-shots generate` +### `asc app-shots templates list` -Enhance a single screenshot into a marketing image using Gemini AI. +List available single-shot templates. | Flag | Default | Description | |------|---------|-------------| -| `--file` | *(required)* | Screenshot file to enhance | -| `--device-type` | — | Named device type — resizes output to exact App Store dimensions | -| `--style-reference` | — | Reference image whose visual style Gemini replicates | -| `--prompt` | — | Custom prompt (overrides the built-in auto-enhance prompt) | -| `--gemini-api-key` | — | Gemini API key (falls back to `GEMINI_API_KEY` env, then saved config) | -| `--model` | `gemini-3.1-flash-image-preview` | Gemini model | -| `--output-dir` | `.asc/app-shots/output` | Directory for generated PNGs | +| `--size` | — | Filter by size: `portrait`, `landscape`, `portrait43`, `square` | +| `--output` | `json` | Output format: `json`, `table`, `markdown` | +| `--pretty` | — | Pretty-print JSON | ```bash -# Auto-enhance — AI analyzes and designs everything -asc app-shots generate --file screen.png +asc app-shots templates list +asc app-shots templates list --size portrait --output table +``` -# Resize to exact App Store dimensions -asc app-shots generate --file screen.png --device-type APP_IPHONE_67 +### `asc app-shots templates apply` -# Style transfer — match another screenshot's look -asc app-shots generate --file screen.png --style-reference competitor.png +Apply a template to a screenshot. Returns an `AppShot` with affordances. -# Custom prompt — tell Gemini exactly what you want -asc app-shots generate --file screen.png \ - --prompt "Add warm glow, deepen shadows, make text pop" +| Flag | Default | Description | +|------|---------|-------------| +| `--id` | *(required)* | Template ID | +| `--screenshot` | *(required)* | Path to screenshot file | +| `--headline` | *(required)* | Headline text | +| `--subtitle` | — | Body text | +| `--tagline` | — | Tagline text | +| `--preview` | — | Preview format: `html` or `image` | +| `--image-output` | `.asc/app-shots/output/screen-0.png` | Output PNG path | -# Generate multiple device sizes -asc app-shots generate --file screen.png --device-type APP_IPHONE_69 --output-dir output/69 -asc app-shots generate --file screen.png --device-type APP_IPHONE_67 --output-dir output/67 -asc app-shots generate --file screen.png --device-type APP_IPAD_PRO_129 --output-dir output/ipad -``` +```bash +# Preview as HTML +asc app-shots templates apply \ + --id top-hero --screenshot screen.png --headline "Ship Faster" \ + --preview html > composed.html && open composed.html -**JSON output:** -```json -{ - "generated" : ".asc/app-shots/output/screen-0.png" -} +# Export to PNG +asc app-shots templates apply \ + --id top-hero --screenshot screen.png --headline "Ship Faster" \ + --preview image --image-output marketing.png ``` -**How the built-in prompt works:** - -The default auto-enhance prompt tells Gemini to: -- Analyze the app screenshot (purpose, features, color scheme) -- Replace flat device frames with a photorealistic iPhone 15 Pro mockup -- Find the most compelling UI panel and "break it out" from the device with a drop shadow -- Add a bold 2-4 word ACTION VERB headline (e.g. "TRACK WEATHER") if none exists -- Apply a clean gradient background complementing the app's colors -- Add 1-2 subtle supporting elements (badges, stats) +### `asc app-shots gallery-templates list` -For better results, use the **`asc-app-shots-prompt` skill** in Claude Code — it reads your screenshot, identifies exact UI panels and colors, and generates a targeted `--prompt` that names specific elements instead of letting Gemini guess. - ---- - -### `asc app-shots templates list` - -List available screenshot templates. Templates are provided by plugins (e.g. Blitz Screenshots ships 23 built-in templates). +List gallery templates (multi-screen sets with sample content). | Flag | Default | Description | |------|---------|-------------| -| `--size` | — | Filter by size: `portrait`, `landscape`, `portrait43`, `square` | -| `--preview` | — | Include self-contained HTML preview for each template | | `--output` | `json` | Output format: `json`, `table`, `markdown` | | `--pretty` | — | Pretty-print JSON | ```bash -asc app-shots templates list -asc app-shots templates list --size portrait --output table +asc app-shots gallery-templates list +asc app-shots gallery-templates list --output table ``` -**JSON output:** -```json -{ - "data": [ - { - "id": "top-hero", - "name": "Top Hero", - "category": "bold", - "supportedSizes": ["portrait"], - "deviceCount": 1, - "affordances": { - "preview": "asc app-shots templates get --id top-hero --preview", - "apply": "asc app-shots templates apply --id top-hero --screenshot screen.png", - "detail": "asc app-shots templates get --id top-hero", - "listAll": "asc app-shots templates list" - } - } - ] -} +### `asc app-shots gallery-templates get` + +Get a specific gallery template. + +| Flag | Default | Description | +|------|---------|-------------| +| `--id` | *(required)* | Gallery template ID | +| `--preview` | — | Output self-contained HTML gallery preview page | + +```bash +asc app-shots gallery-templates get --id neon-pop --pretty +asc app-shots gallery-templates get --id neon-pop --preview > preview.html && open preview.html ``` -### `asc app-shots templates get` +### `asc app-shots themes design` -Get details of a specific template. +Generate a ThemeDesign (palette + decorations) from AI — one call, reusable across slides. | Flag | Default | Description | |------|---------|-------------| -| `--id` | *(required)* | Template ID | -| `--preview` | — | Output self-contained HTML preview page | +| `--id` | *(required)* | Theme ID | ```bash -asc app-shots templates get --id top-hero -asc app-shots templates get --id top-hero --preview > preview.html && open preview.html +asc app-shots themes design --id luxury > design.json ``` -### `asc app-shots templates apply` +### `asc app-shots themes apply-design` -Apply a template to a screenshot. Returns a `ScreenDesign` with affordances for next steps. +Apply a cached ThemeDesign deterministically — no AI call. | Flag | Default | Description | |------|---------|-------------| -| `--id` | *(required)* | Template ID | +| `--design` | *(required)* | Path to ThemeDesign JSON file | +| `--template` | *(required)* | Template ID | | `--screenshot` | *(required)* | Path to screenshot file | -| `--headline` | *(required)* | Headline text | -| `--subtitle` | — | Subtitle text | -| `--tagline` | — | Tagline text (overrides template default) | -| `--app-name` | `My App` | App name | +| `--headline` | `Your Headline` | Headline text | | `--preview` | — | Preview format: `html` or `image` | -| `--image-output` | `.asc/app-shots/output/screen-0.png` | Output PNG path (for `--preview image`) | +| `--image-output` | `.asc/app-shots/output/screen-0.png` | Output PNG path | ```bash -# Get design JSON with affordances -asc app-shots templates apply \ - --id top-hero \ - --screenshot screen.png \ - --headline "Ship Faster" +# Two-step workflow: generate once, apply many +asc app-shots themes design --id luxury > design.json +asc app-shots themes apply-design --design design.json \ + --template top-hero --screenshot screen.png --headline "Ship Faster" \ + --preview html > themed.html +``` -# Preview as HTML in browser -asc app-shots templates apply \ - --id top-hero \ - --screenshot screen.png \ - --headline "Ship Faster" \ - --preview html > composed.html && open composed.html +### `asc app-shots generate` -# Export directly to PNG -asc app-shots templates apply \ - --id top-hero \ - --screenshot screen.png \ - --headline "Ship Faster" \ - --preview image --image-output marketing-screen.png -``` +Enhance a screenshot with Gemini AI. -**JSON output:** -```json -{ - "data": [ - { - "heading": "Ship Faster", - "screenshotFile": "screen.png", - "isComplete": true, - "affordances": { - "generate": "asc app-shots generate --design design.json", - "preview": "asc app-shots templates apply --id top-hero --screenshot screen.png --headline \"Ship Faster\"", - "changeTemplate": "asc app-shots templates list", - "templateDetail": "asc app-shots templates get --id top-hero" - } - } - ] -} -``` +| Flag | Default | Description | +|------|---------|-------------| +| `--file` | *(required)* | Screenshot file to enhance | +| `--device-type` | — | Resize output to App Store dimensions | +| `--style-reference` | — | Reference image for style transfer | +| `--prompt` | — | Custom Gemini prompt | +| `--gemini-api-key` | — | API key (falls back to env/config) | ---- +```bash +asc app-shots generate --file screen.png +asc app-shots generate --file screen.png --device-type APP_IPHONE_67 +``` ### `asc app-shots config` -Manage the stored Gemini API key. +Manage Gemini API key. ```bash asc app-shots config --gemini-api-key AIzaSy... # Save -asc app-shots config # Show (masked) +asc app-shots config # Show asc app-shots config --remove # Delete ``` -**Key resolution order:** `--gemini-api-key` flag → `$GEMINI_API_KEY` env var → `~/.asc/app-shots-config.json` - --- -## Typical Workflows +## Architecture -### Workflow 1: Quick Enhance (simplest) +``` +ASCCommand Domain Infrastructure ++------------------------------+ +--------------------------------------+ +----------------------------------+ +| AppShotsCommand | | AppShot | | AggregateTemplateRepository | +| ├── templates | | screenshot, headline, tagline | | Aggregates TemplateProviders | +| │ ├── list | | body, badges, trustMarks | +----------------------------------+ +| │ ├── get | | type: .hero | .feature | .social | | AggregateGalleryTemplateRepo | +| │ └── apply | | isConfigured, compose() | | Aggregates GalleryProviders | +| ├── gallery-templates | | | +----------------------------------+ +| │ └── list | | Gallery | | FileAppShotsConfigStorage | +| ├── generate (Gemini) | | appName, appShots: [AppShot] | | ~/.asc/app-shots-config.json | +| └── config | | template, palette | +----------------------------------+ ++------------------------------+ | renderAll(), previewHTML | + | | + | GalleryTemplate | + | screens: [ScreenType: ScreenLayout]| + | id, name, description, background | + | | + | ScreenLayout | + | headline: TextSlot | + | devices: [DeviceSlot] | + | decorations: [Decoration] | + | | + | GalleryPalette | + | id, name, background (CSS) | + | | + | AppShotTemplate | + | screenLayout + palette | + | category, supportedSizes | + | | + | GalleryHTMLRenderer | + | renderScreen() — context builder | + | renderPreviewPage() | + | wrapPage() | + | cachedPreview() — preview cache | + | | + | HTMLComposer (Mustache) | + | render(template:, with:) | + | Pre-compiled MustacheLibrary | + | | + | .mustache templates (Resources/) | + | screen, wireframe, page-wrapper | + | theme-vars, keyframes, preview-* | + +--------------------------------------+ +``` -```bash -# One command — AI handles everything -asc app-shots generate --file .asc/app-shots/screen-0.png +**Dependency flow:** `ASCCommand → Domain ← Infrastructure` + +**Unified rendering:** Everything renders through `GalleryHTMLRenderer.renderScreen()`. Both `Gallery.renderAll()` and `AppShotTemplate.apply()` delegate to it. + +### Responsive Sizing (`cqi` Units) + +All text and element sizing uses CSS Container Query Inline-size (`cqi`) units. This ensures consistent proportions between preview (320px container) and export (full viewport). -# Resize to required App Store dimensions -asc app-shots generate --file .asc/app-shots/screen-0.png --device-type APP_IPHONE_67 +``` +1cqi = 1% of the container's inline size ``` -### Workflow 2: Template + Enhance (recommended) +`GalleryHTMLRenderer` builds context dictionaries from domain models and delegates all HTML rendering to Mustache templates: -```bash -# 1. Browse templates -asc app-shots templates list --output table +| Entry Point | Template | Purpose | +|-------------|----------|---------| +| `renderScreen()` | `screen.mustache` | Full screen: text, devices, badges, decorations | +| `wrapPage()` | `page-wrapper.mustache` | HTML document wrapper | +| `renderPreviewPage()` | `preview-page.mustache` + `preview-screen.mustache` | Gallery preview strip | +| (inline) | `wireframe.mustache` | Phone wireframe mockup | +| (inline) | `theme-vars.mustache` | CSS custom properties for light/dark themes | +| (inline) | `keyframes.mustache` | CSS animation keyframes | -# 2. Preview one -asc app-shots templates get --id top-hero --preview > preview.html -open preview.html +**SRP:** The renderer only builds data contexts — zero HTML, zero colors, zero CSS. All presentation lives in `.mustache` templates. Color scheme (light/dark) is handled by CSS custom properties in `theme-vars.mustache`. -# 3. Apply to your screenshot -asc app-shots templates apply \ - --id top-hero \ - --screenshot .asc/app-shots/screen-0.png \ - --headline "Ship Faster" \ - --preview > composed.html -open composed.html +### Mustache Template System + +Templates use [swift-mustache](https://github.com/hummingbird-project/swift-mustache) (from Hummingbird). All templates are pre-compiled into a `MustacheLibrary` at startup for fast rendering. -# 4. Enhance the composed result with AI -asc app-shots generate --file .asc/app-shots/output/screen-0.png --device-type APP_IPHONE_67 +```swift +// Named template rendering (pre-compiled) +HTMLComposer.render(template: "screen", with: context) + +// Inline template rendering +HTMLComposer.render("Hello {{name}}!", with: ["name": "World"]) +// → "Hello World!" + +// Replace the template library (e.g. from a plugin) +HTMLComposer.setLibrary(customLibrary) ``` -### Workflow 3: Skill-driven (Claude writes the prompt) +Standard Mustache syntax: `{{var}}`, `{{{raw}}}`, `{{#section}}...{{/section}}`, `{{^inverted}}...{{/inverted}}`. + +### Verification ```bash -# In Claude Code, use the asc-app-shots-prompt skill: -# "Analyze this screenshot and generate a prompt for app-shots" -# → Claude reads the image, generates a targeted --prompt - -# Then generate -asc app-shots generate --file screen.png \ - --prompt '' \ - --device-type APP_IPHONE_67 +# Preview a template as HTML — open in browser to visually verify +asc app-shots templates apply --id top-hero --screenshot screen.png --headline "Test" \ + --preview html > preview.html && open preview.html + +# Export to PNG and verify sizing consistency +asc app-shots templates apply --id top-hero --screenshot screen.png --headline "Test" \ + --preview image --image-output export.png ``` --- -## Architecture +## Domain Models -``` -ASCCommand Domain Infrastructure -+-----------------------------------+ +-----------------------------------+ +-----------------------------------+ -| AppShotsCommand | | ScreenDesign | | AggregateTemplateRepository | -| ├── templates | | index, heading, subheading | | (actor) | -| │ ├── list (TemplateRepo) | | template?, screenshotFile | | Aggregates TemplateProviders | -| │ ├── get (TemplateRepo) | | isComplete, previewHTML | +-----------------------------------+ -| │ └── apply (TemplateRepo) | | affordances: generate, preview | | FileAppShotsConfigStorage | -| ├── generate (Gemini direct) | | | | ~/.asc/app-shots-config.json | -| └── config (ConfigStorage) | | ScreenshotTemplate | +-----------------------------------+ -+-----------------------------------+ | id, name, category, background | - | textSlots[], deviceSlots[] | - | isPortrait, deviceCount | - | previewHTML, affordances | - | | - | SlideBackground | - | .solid(color) | - | .gradient(from, to, angle) | - | | - | TemplateProvider (protocol) | - | TemplateRepository (protocol) | - | AppShotsConfigStorage (protocol) | - +-----------------------------------+ -``` +### `AppShot` -**Dependency flow:** `ASCCommand → Domain ← Infrastructure` +A single designed App Store screenshot — the core content unit. -**Key design note:** `generate` calls the Gemini API directly via `URLSession` — no repository abstraction. This keeps the single-file enhancement path simple. When `--device-type` is specified, output is resized to exact App Store dimensions via CoreGraphics. +| Field | Type | Description | +|-------|------|-------------| +| `screenshot` | `String` | Source screenshot file path | +| `type` | `ScreenType` | `.hero`, `.feature`, `.social` | +| `headline` | `String?` | Main headline text | +| `tagline` | `String?` | Small caps text above headline | +| `body` | `String?` | Description paragraph below headline | +| `badges` | `[String]` | Feature badge pills (e.g. "iPhone 17", "Mesh") | +| `trustMarks` | `[String]?` | Trust badges (hero only, e.g. "4.9 STARS") | ---- +**Computed:** `isConfigured` (has headline), `isHero`, `isStandalone` -## Domain Models +**Key method:** `compose(screenLayout:, palette:) → String` — renders HTML -### `ScreenDesign` +### `Gallery` -A single screen — knows its template, content, and how to preview itself. +A coordinated set of App Store screenshots. Created from screenshot files, first becomes hero. | Field | Type | Description | |-------|------|-------------| -| `index` | `Int` | Screen order (0-based) | -| `template` | `ScreenshotTemplate?` | Applied template (runtime only, excluded from Codable) | -| `screenshotFile` | `String` | Source screenshot path | -| `heading` | `String` | Main headline | -| `subheading` | `String` | Supporting text | -| `layoutMode` | `LayoutMode` | Layout hint (legacy) | -| `visualDirection` | `String` | Visual description (legacy) | -| `imagePrompt` | `String` | Per-screen Gemini prompt (legacy) | - -**Computed properties:** -| Property | Type | Description | -|----------|------|-------------| -| `isComplete` | `Bool` | `template != nil && !heading.isEmpty && !screenshotFile.isEmpty` | -| `previewHTML` | `String` | Self-contained HTML preview (empty if no template) | - -**Affordances** (state-aware): -| Key | When | Command | -|-----|------|---------| -| `generate` | `isComplete` | `asc app-shots generate --design design.json` | -| `preview` | `isComplete` | `asc app-shots templates apply --id {id} ...` | -| `changeTemplate` | always | `asc app-shots templates list` | -| `templateDetail` | has template | `asc app-shots templates get --id {id}` | - -### `ScreenshotTemplate` - -Reusable template for composing screenshots. Registered by plugins via `TemplateProvider`. +| `appName` | `String` | App name | +| `appShots` | `[AppShot]` | Screenshots with content (first = hero) | +| `template` | `GalleryTemplate?` | Layout per screen type | +| `palette` | `GalleryPalette?` | Color scheme | + +**Computed:** `isReady`, `readiness`, `shotCount`, `heroShot`, `unconfiguredShots`, `previewHTML` + +**Key method:** `renderAll() → [String]` — renders all configured shots + +**Codable:** Gallery serializes to/from JSON. `gallery-templates.json` is `[Gallery]`. + +### `GalleryTemplate` + +Layout rules per screen type. A gallery template defines WHERE things go. | Field | Type | Description | |-------|------|-------------| | `id` | `String` | Unique identifier | | `name` | `String` | Display name | -| `category` | `TemplateCategory` | `bold`, `minimal`, `elegant`, `professional`, `playful`, `showcase`, `custom` | -| `supportedSizes` | `[ScreenSize]` | `portrait`, `landscape`, `portrait43`, `square` | | `description` | `String` | Human-readable description | -| `background` | `SlideBackground` | `.solid(color)` or `.gradient(from, to, angle)` | -| `textSlots` | `[TemplateTextSlot]` | Text positions with role, preview, style | -| `deviceSlots` | `[TemplateDeviceSlot]` | Device positions with scale, rotation | +| `background` | `String` | CSS background (shared by all screens) | +| `screens` | `[ScreenType: ScreenLayout]` | Layout per type | + +### `ScreenLayout` -**Semantic booleans:** `isPortrait`, `isLandscape`, `deviceCount` +Layout for one screen type. Supports tagline/headline/subheading text slots, single/side-by-side/triple-fan device arrangements. -**Affordances:** `preview`, `apply`, `detail`, `listAll` +| Field | Type | Description | +|-------|------|-------------| +| `tagline` | `TextSlot?` | Small caps text above headline | +| `headline` | `TextSlot` | Main headline position and style | +| `subheading` | `TextSlot?` | Supporting text below headline | +| `devices` | `[DeviceSlot]` | Device positions (empty = no device) | +| `decorations` | `[Decoration]` | Ambient shapes | -### `SlideBackground` +### `GalleryPalette` + +Color scheme — HOW things look. + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `String` | Identifier | +| `name` | `String` | Display name | +| `background` | `String` | CSS background value | + +### `AppShotTemplate` + +Convenience wrapper for single-shot templates. Wraps `ScreenLayout` + `GalleryPalette` with filter metadata. + +| Field | Type | Description | +|-------|------|-------------| +| `screenLayout` | `ScreenLayout` | Layout | +| `palette` | `GalleryPalette` | Colors | +| `category` | `TemplateCategory` | `bold`, `minimal`, `elegant`, etc. | +| `supportedSizes` | `[ScreenSize]` | `portrait`, `landscape`, etc. | + +### Supporting Types ```swift -public enum SlideBackground: Sendable, Equatable, Codable { - case solid(String) - case gradient(from: String, to: String, angle: Int) -} +public struct TextSlot { y, size, weight, align, preview? } +public struct DeviceSlot { x, y, width } +public struct Decoration { shape, x, y, size, opacity, color?, background?, borderRadius?, animation? } +public enum DecorationShape { gem, orb, sparkle, arrow, label(String) } // .displayCharacter computed property +public enum DecorationAnimation { float, drift, pulse, spin, twinkle } +public enum ScreenType { hero, feature, social } +public enum TemplateCategory { bold, minimal, elegant, professional, playful, showcase, custom } +public enum ScreenSize { portrait, portrait43, landscape, square } ``` -### Protocols +### `GalleryPalette` — Derived Colors + +`GalleryPalette` owns theme detection and derived text colors: ```swift -@Mockable -public protocol TemplateProvider: Sendable { - var providerId: String { get } - func templates() async throws -> [ScreenshotTemplate] -} - -@Mockable -public protocol TemplateRepository: Sendable { - func listTemplates(size: ScreenSize?) async throws -> [ScreenshotTemplate] - func getTemplate(id: String) async throws -> ScreenshotTemplate? -} - -@Mockable -public protocol AppShotsConfigStorage: Sendable { - func load() throws -> AppShotsConfig? - func save(_ config: AppShotsConfig) throws - func delete() throws -} +palette.isLight // heuristic from background hex values +palette.headlineColor // explicit textColor or auto-detected from isLight ``` ---- +### `HTMLComposer` (Mustache) + +Wraps `MustacheLibrary` from [swift-mustache](https://github.com/hummingbird-project/swift-mustache). Templates are pre-compiled at startup. + +```swift +// Named template (pre-compiled, fast) +HTMLComposer.render(template: "screen", with: context) + +// Inline template +HTMLComposer.render("Hello {{name}}!", with: ["name": "World"]) +``` + +### Protocols -## Device Sizes - -Use `--device-type` on `generate` to resize output to exact App Store dimensions. - -| Display Type | Device | Width | Height | -|---|---|---|---| -| `APP_IPHONE_69` | iPhone 6.9" | 1320 | 2868 | -| `APP_IPHONE_67` | iPhone 6.7" | 1290 | 2796 | -| `APP_IPHONE_65` | iPhone 6.5" | 1260 | 2736 | -| `APP_IPHONE_61` | iPhone 6.1" | 1179 | 2556 | -| `APP_IPHONE_58` | iPhone 5.8" | 1125 | 2436 | -| `APP_IPHONE_55` | iPhone 5.5" | 1242 | 2208 | -| `APP_IPHONE_47` | iPhone 4.7" | 750 | 1334 | -| `APP_IPAD_PRO_129` | iPad 13" | 2048 | 2732 | -| `APP_IPAD_PRO_3GEN_11` | iPad 11" | 1668 | 2388 | -| `APP_APPLE_TV` | Apple TV | 1920 | 1080 | -| `APP_DESKTOP` | Mac | 2560 | 1600 | -| `APP_APPLE_VISION_PRO` | Vision Pro | 3840 | 2160 | +```swift +@Mockable protocol TemplateProvider { func templates() async throws -> [AppShotTemplate] } +@Mockable protocol TemplateRepository { func listTemplates(size:) ... ; func getTemplate(id:) ... } +@Mockable protocol GalleryTemplateProvider { func galleries() async throws -> [Gallery] } +@Mockable protocol GalleryTemplateRepository { func listGalleries() ... ; func getGallery(templateId:) ... } +``` --- @@ -402,40 +399,72 @@ Use `--device-type` on `generate` to resize output to exact App Store dimensions ``` Sources/ -├── Domain/ScreenshotPlans/ -│ ├── ScreenDesign.swift # Single screen (rich domain, carries template) -│ ├── ScreenshotTemplate.swift # Template model + SlideBackground, TemplateCategory, ScreenSize, TextSlot, DeviceSlot -│ ├── TemplateRepository.swift # TemplateProvider + TemplateRepository protocols -│ ├── TemplateHTMLRenderer.swift # Renders template previews as HTML -│ ├── TemplateContent.swift # Content to fill into a template -│ ├── AppShotsConfig.swift # Gemini API key model -│ ├── AppShotsConfigStorage.swift # @Mockable config storage protocol -│ └── LayoutMode.swift # center, left, right (legacy) -├── Infrastructure/ScreenshotPlans/ -│ ├── AggregateTemplateRepository.swift # Actor aggregating TemplateProviders -│ └── FileAppShotsConfigStorage.swift # ~/.asc/app-shots-config.json +├── Domain/Screenshots/ +│ ├── Gallery/ +│ │ ├── AppShot.swift # Content unit (headline, badges, trustMarks) +│ │ ├── Gallery.swift # Aggregate (appShots + template + palette) +│ │ ├── GalleryTemplate.swift # Per-screen-type layouts +│ │ ├── GalleryPalette.swift # Color scheme +│ │ ├── ScreenLayout.swift # TextSlot, DeviceSlot, Decoration +│ │ ├── GalleryHTMLRenderer.swift # Context builder → Mustache templates (renderScreen, wrapPage) +│ │ ├── HTMLComposer.swift # Mustache wrapper (MustacheLibrary, cached compilation) +│ │ ├── GalleryTemplateRepository.swift # Provider + Repository protocols +│ │ └── Resources/ # Mustache template files +│ │ ├── screen.mustache # Full screen: text, devices, badges, decorations +│ │ ├── wireframe.mustache # Phone wireframe mockup (CSS vars) +│ │ ├── theme-vars.mustache # CSS custom properties for light/dark themes +│ │ ├── keyframes.mustache # CSS animation keyframes +│ │ ├── page-wrapper.mustache # Full HTML document wrapper +│ │ ├── preview-page.mustache # Gallery preview page +│ │ └── preview-screen.mustache # Preview screen card +│ ├── AppShotTemplate.swift # Single-shot template (wraps ScreenLayout + Palette) +│ ├── TemplateRepository.swift # Single template protocols +│ ├── ScreenTheme.swift # AI theme hints + buildDesignContext() +│ ├── ThemeDesign.swift # ThemeDesign + ThemeBackground + ThemeFloat +│ ├── ThemeDesignApplier.swift # Deterministic theme applier (cqi units) +│ ├── ThemedPage.swift # Themed HTML page wrapper +│ ├── AppShotsConfig.swift # Gemini API key model +│ └── AppShotsConfigStorage.swift # Config storage protocol +├── Infrastructure/Screenshots/ +│ ├── AggregateTemplateRepository.swift # Single template aggregator +│ ├── AggregateGalleryTemplateRepository.swift # Gallery aggregator +│ └── FileAppShotsConfigStorage.swift # ~/.asc/app-shots-config.json └── ASCCommand/Commands/AppShots/ - ├── AppShotsCommand.swift # Entry point, registers subcommands - ├── AppShotsGenerate.swift # Single-file AI enhancement (direct Gemini call) - ├── AppShotsTemplates.swift # list, get, apply subcommands - ├── AppShotsConfig.swift # Gemini key management - ├── AppShotsDisplayType.swift # Device type enum with dimensions - └── AppShotsUtils.swift # resolveGeminiApiKey(), resizeImageData() + ├── AppShotsCommand.swift # Entry point + ├── AppShotsTemplates.swift # templates: list, get, apply + ├── AppShotsGalleryTemplates.swift # gallery-templates: list, get + ├── AppShotsThemes.swift # themes: list, get, design, apply-design, apply + ├── AppShotsGenerate.swift # Gemini AI enhancement + ├── AppShotsConfig.swift # Key management + ├── AppShotsDisplayType.swift # Device dimensions + └── AppShotsExport.swift # HTML → PNG rendering ``` ### Tests ``` Tests/ -├── DomainTests/ScreenshotPlans/ -│ └── AppShotsConfigTests.swift -├── InfrastructureTests/ScreenshotPlans/ -│ └── FileAppShotsConfigStorageTests.swift +├── DomainTests/Screenshots/ +│ ├── Gallery/ +│ │ ├── AppShotTests.swift # 11 tests +│ │ ├── GalleryTests.swift # 11 tests +│ │ ├── GalleryComposeTests.swift # 8 tests +│ │ ├── GalleryCodableTests.swift # 10 tests +│ │ ├── GalleryPreviewTests.swift # 4 tests +│ │ ├── GalleryPreviewOutputTests.swift # 1 test (visual verification) +│ │ ├── ScreenLayoutTests.swift # 6 tests +│ │ └── GalleryTemplateRepositoryTests.swift # 3 tests +│ │ ├── GalleryHTMLRendererTests.swift # 15 tests (Mustache-backed rendering) +│ │ └── HTMLComposerTests.swift # 19 tests (Mustache wrapper) +│ ├── AppShotTemplateTests.swift # 8 tests +│ ├── ThemeDesignTests.swift # 9 tests +│ ├── ThemeDesignApplierTests.swift # 10 tests +│ ├── TemplateApplyTests.swift +│ └── TemplateRenderTests.swift └── ASCCommandTests/Commands/AppShots/ - ├── AppShotsGenerateTests.swift ├── AppShotsTemplatesTests.swift - ├── AppShotsConfigTests.swift - └── AppShotsDisplayTypeTests.swift + ├── AppShotsThemesTests.swift # 11 tests + └── AppShotsGenerateTests.swift ``` --- @@ -443,25 +472,12 @@ Tests/ ## Testing ```bash -swift test --filter 'AppShotsGenerate' # Generate command (10) -swift test --filter 'AppShotsTemplates' # Template commands -swift test --filter 'AppShotsDisplayType' # Device types -swift test --filter 'AppShotsConfig' # Config management -swift test --filter 'AppShots' # All app-shots tests +swift test --filter 'AppShotTests' # AppShot domain (11) +swift test --filter 'GalleryTests' # Gallery domain (11) +swift test --filter 'GalleryComposeTests' # Compose flow (8) +swift test --filter 'GalleryCodableTests' # JSON round-trip (10) +swift test --filter 'AppShotTemplateTests' # Single template (8) +swift test --filter 'HTMLComposerTests' # Mustache wrapper (19) +swift test --filter 'GalleryHTMLRendererTests' # Mustache-backed renderer (15) +swift test --filter 'AppShots' # All app-shots tests ``` - ---- - -## Available Templates - -Templates are provided by plugins. The Blitz Screenshots plugin provides 23 built-in templates: - -| Category | Templates | -|----------|-----------| -| **Bold** | Top Hero, Bold CTA, Tilted Hero, Midnight Bold | -| **Minimal** | Minimal Light, Device Only | -| **Elegant** | Dark Premium, Sage Editorial, Cream Serif, Ocean Calm, Blush Editorial | -| **Professional** | Top & Bottom, Left Aligned, Bottom Text | -| **Playful** | Warm Sunset, Sky Soft, Cartoon Peach, Cartoon Mint, Cartoon Lavender | -| **Showcase** | Duo Devices, Triple Fan, Side by Side | -| **Custom** | Custom Blank |