diff --git a/design/src/cli.ts b/design/src/cli.ts index d1d8eb3b..e73caca3 100644 --- a/design/src/cli.ts +++ b/design/src/cli.ts @@ -22,6 +22,7 @@ import { resolveApiKey, saveApiKey } from "./auth"; import { extractDesignLanguage, updateDesignMd } from "./memory"; import { diffMockups, verifyAgainstMockup } from "./diff"; import { evolve } from "./evolve"; +import { generateDesignToCodePrompt } from "./design-to-code"; function parseArgs(argv: string[]): { command: string; flags: Record } { const args = argv.slice(2); // skip bun/node and script path @@ -140,6 +141,20 @@ async function main(): Promise { break; } + case "prompt": { + const promptImage = flags.image as string; + if (!promptImage) { + console.error("--image is required"); + process.exit(1); + } + console.error(`Generating implementation prompt from ${promptImage}...`); + const proc2 = Bun.spawn(["git", "rev-parse", "--show-toplevel"]); + const root = (await new Response(proc2.stdout).text()).trim(); + const d2c = await generateDesignToCodePrompt(promptImage, root || undefined); + console.log(JSON.stringify(d2c, null, 2)); + break; + } + case "setup": await runSetup(); break; @@ -152,6 +167,7 @@ async function main(): Promise { outputDir: (flags["output-dir"] as string) || "/tmp/gstack-variants/", size: flags.size as string, quality: flags.quality as string, + viewports: flags.viewports as string, }); break; diff --git a/design/src/commands.ts b/design/src/commands.ts index 6ff829cc..b077d3df 100644 --- a/design/src/commands.ts +++ b/design/src/commands.ts @@ -54,6 +54,11 @@ export const COMMANDS = new Map { + const apiKey = requireApiKey(); + const imageData = fs.readFileSync(imagePath).toString("base64"); + + // Read DESIGN.md if available for additional context + const designConstraints = repoRoot ? readDesignConstraints(repoRoot) : null; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 60_000); + + try { + const contextBlock = designConstraints + ? `\n\nExisting DESIGN.md (use these as constraints):\n${designConstraints}` + : ""; + + const response = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-4o", + messages: [{ + role: "user", + content: [ + { + type: "image_url", + image_url: { url: `data:image/png;base64,${imageData}` }, + }, + { + type: "text", + text: `Analyze this approved UI mockup and generate a structured implementation prompt. Return valid JSON only: + +{ + "implementationPrompt": "A detailed paragraph telling a developer exactly how to build this UI. Include specific CSS values, layout approach (flex/grid), component structure, and interaction behaviors. Reference the specific elements visible in the mockup.", + "colors": ["#hex - usage", ...], + "typography": ["role: family, size, weight", ...], + "layout": ["description of layout pattern", ...], + "components": ["component name - description", ...] +} + +Be specific about every visual detail: exact hex colors, font sizes in px, spacing values, border-radius, shadows. The developer should be able to implement this without looking at the mockup again.${contextBlock}`, + }, + ], + }], + max_tokens: 1000, + response_format: { type: "json_object" }, + }), + signal: controller.signal, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`API error (${response.status}): ${error.slice(0, 200)}`); + } + + const data = await response.json() as any; + const content = data.choices?.[0]?.message?.content?.trim() || ""; + return JSON.parse(content) as DesignToCodeResult; + } finally { + clearTimeout(timeout); + } +} diff --git a/design/src/variants.ts b/design/src/variants.ts index 017fe564..e9d8ad77 100644 --- a/design/src/variants.ts +++ b/design/src/variants.ts @@ -16,6 +16,7 @@ export interface VariantsOptions { outputDir: string; size?: string; quality?: string; + viewports?: string; // "desktop,tablet,mobile" — generates at multiple sizes } const STYLE_VARIATIONS = [ @@ -109,12 +110,19 @@ export async function variants(options: VariantsOptions): Promise { ? parseBrief(options.briefFile, true) : parseBrief(options.brief!, false); - const count = Math.min(options.count, 7); // Cap at 7 style variations - const size = options.size || "1536x1024"; const quality = options.quality || "high"; fs.mkdirSync(options.outputDir, { recursive: true }); + // If viewports specified, generate responsive variants instead of style variants + if (options.viewports) { + await generateResponsiveVariants(apiKey, baseBrief, options.outputDir, options.viewports, quality); + return; + } + + const count = Math.min(options.count, 7); // Cap at 7 style variations + const size = options.size || "1536x1024"; + console.error(`Generating ${count} variants...`); const startTime = Date.now(); @@ -171,3 +179,68 @@ export async function variants(options: VariantsOptions): Promise { errors: failed, }, null, 2)); } + +const VIEWPORT_CONFIGS: Record = { + desktop: { size: "1536x1024", suffix: "desktop", desc: "Desktop (1536x1024)" }, + tablet: { size: "1024x1024", suffix: "tablet", desc: "Tablet (1024x1024)" }, + mobile: { size: "1024x1536", suffix: "mobile", desc: "Mobile (1024x1536, portrait)" }, +}; + +async function generateResponsiveVariants( + apiKey: string, + baseBrief: string, + outputDir: string, + viewports: string, + quality: string, +): Promise { + const viewportList = viewports.split(",").map(v => v.trim().toLowerCase()); + const configs = viewportList.map(v => VIEWPORT_CONFIGS[v]).filter(Boolean); + + if (configs.length === 0) { + console.error(`No valid viewports. Use: desktop, tablet, mobile`); + process.exit(1); + } + + console.error(`Generating responsive variants: ${configs.map(c => c.desc).join(", ")}...`); + const startTime = Date.now(); + + const promises = configs.map((config, i) => { + const prompt = `${baseBrief}\n\nViewport: ${config.desc}. Adapt the layout for this screen size. ${ + config.suffix === "mobile" ? "Use a single-column layout, larger touch targets, and mobile navigation patterns." : + config.suffix === "tablet" ? "Use a responsive layout that works for medium screens." : + "" + }`; + const outputPath = path.join(outputDir, `responsive-${config.suffix}.png`); + const delay = i * 1500; + + return new Promise<{ path: string; success: boolean; error?: string }>(resolve => + setTimeout(resolve, delay) + ).then(() => { + console.error(` Starting ${config.desc}...`); + return generateVariant(apiKey, prompt, outputPath, config.size, quality); + }); + }); + + const results = await Promise.allSettled(promises); + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + + const succeeded: string[] = []; + for (const result of results) { + if (result.status === "fulfilled" && result.value.success) { + const sz = fs.statSync(result.value.path).size; + console.error(` ✓ ${path.basename(result.value.path)} (${(sz / 1024).toFixed(0)}KB)`); + succeeded.push(result.value.path); + } else { + const error = result.status === "fulfilled" ? result.value.error : (result.reason as Error).message; + console.error(` ✗ ${error}`); + } + } + + console.error(`\n${succeeded.length}/${configs.length} responsive variants generated (${elapsed}s)`); + console.log(JSON.stringify({ + outputDir, + viewports: viewportList, + succeeded: succeeded.length, + paths: succeeded, + }, null, 2)); +}