diff --git a/design/src/cli.ts b/design/src/cli.ts index 0c491941..f1f844bf 100644 --- a/design/src/cli.ts +++ b/design/src/cli.ts @@ -19,6 +19,7 @@ import { compare } from "./compare"; import { variants } from "./variants"; import { iterate } from "./iterate"; import { resolveApiKey, saveApiKey } from "./auth"; +import { extractDesignLanguage, updateDesignMd } from "./memory"; function parseArgs(argv: string[]): { command: string; flags: Record } { const args = argv.slice(2); // skip bun/node and script path @@ -160,6 +161,23 @@ async function main(): Promise { }); break; + case "extract": { + const imagePath = flags.image as string; + if (!imagePath) { + console.error("--image is required"); + process.exit(1); + } + console.error(`Extracting design language from ${imagePath}...`); + const extracted = await extractDesignLanguage(imagePath); + const proc = Bun.spawn(["git", "rev-parse", "--show-toplevel"]); + const repoRoot = (await new Response(proc.stdout).text()).trim(); + if (repoRoot) { + updateDesignMd(repoRoot, extracted, imagePath); + } + console.log(JSON.stringify(extracted, null, 2)); + break; + } + case "diff": case "evolve": case "verify": diff --git a/design/src/commands.ts b/design/src/commands.ts index 9941cb76..6ff829cc 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"); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 60_000); + + try { + 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 UI mockup and extract the design language. Return valid JSON only, no markdown: + +{ + "colors": [{"name": "primary", "hex": "#...", "usage": "buttons, links"}, ...], + "typography": [{"role": "heading", "family": "...", "size": "...", "weight": "..."}, ...], + "spacing": ["8px base unit", "16px between sections", ...], + "layout": ["left-aligned content", "max-width 1200px", ...], + "mood": "one sentence describing the overall feel" +} + +Extract real values from what you see. Be specific about hex colors and font sizes.`, + }, + ], + }], + max_tokens: 800, + response_format: { type: "json_object" }, + }), + signal: controller.signal, + }); + + if (!response.ok) { + console.error(`Vision extraction failed (${response.status})`); + return defaultDesign(); + } + + const data = await response.json() as any; + const content = data.choices?.[0]?.message?.content?.trim() || ""; + return JSON.parse(content) as ExtractedDesign; + } catch (err: any) { + console.error(`Design extraction error: ${err.message}`); + return defaultDesign(); + } finally { + clearTimeout(timeout); + } +} + +function defaultDesign(): ExtractedDesign { + return { + colors: [], + typography: [], + spacing: [], + layout: [], + mood: "Unable to extract design language", + }; +} + +/** + * Write or update DESIGN.md with extracted design patterns. + * If DESIGN.md exists, appends an "Extracted from mockup" section. + * If not, creates a new one. + */ +export function updateDesignMd( + repoRoot: string, + extracted: ExtractedDesign, + sourceMockup: string, +): void { + const designPath = path.join(repoRoot, "DESIGN.md"); + const timestamp = new Date().toISOString().split("T")[0]; + + const section = formatExtractedSection(extracted, sourceMockup, timestamp); + + if (fs.existsSync(designPath)) { + // Append to existing DESIGN.md + const existing = fs.readFileSync(designPath, "utf-8"); + + // Check if there's already an extracted section, replace it + const marker = "## Extracted Design Language"; + if (existing.includes(marker)) { + const before = existing.split(marker)[0]; + fs.writeFileSync(designPath, before.trimEnd() + "\n\n" + section); + } else { + fs.writeFileSync(designPath, existing.trimEnd() + "\n\n" + section); + } + console.error(`Updated DESIGN.md with extracted design language`); + } else { + // Create new DESIGN.md + const content = `# Design System + +${section}`; + fs.writeFileSync(designPath, content); + console.error(`Created DESIGN.md with extracted design language`); + } +} + +function formatExtractedSection( + extracted: ExtractedDesign, + sourceMockup: string, + date: string, +): string { + const lines: string[] = [ + "## Extracted Design Language", + `*Auto-extracted from approved mockup on ${date}*`, + `*Source: ${path.basename(sourceMockup)}*`, + "", + `**Mood:** ${extracted.mood}`, + "", + ]; + + if (extracted.colors.length > 0) { + lines.push("### Colors", ""); + lines.push("| Name | Hex | Usage |"); + lines.push("|------|-----|-------|"); + for (const c of extracted.colors) { + lines.push(`| ${c.name} | \`${c.hex}\` | ${c.usage} |`); + } + lines.push(""); + } + + if (extracted.typography.length > 0) { + lines.push("### Typography", ""); + lines.push("| Role | Family | Size | Weight |"); + lines.push("|------|--------|------|--------|"); + for (const t of extracted.typography) { + lines.push(`| ${t.role} | ${t.family} | ${t.size} | ${t.weight} |`); + } + lines.push(""); + } + + if (extracted.spacing.length > 0) { + lines.push("### Spacing", ""); + for (const s of extracted.spacing) { + lines.push(`- ${s}`); + } + lines.push(""); + } + + if (extracted.layout.length > 0) { + lines.push("### Layout", ""); + for (const l of extracted.layout) { + lines.push(`- ${l}`); + } + lines.push(""); + } + + return lines.join("\n"); +} + +/** + * Read DESIGN.md and return it as a constraint string for brief construction. + * If no DESIGN.md exists, returns null (explore wide). + */ +export function readDesignConstraints(repoRoot: string): string | null { + const designPath = path.join(repoRoot, "DESIGN.md"); + if (!fs.existsSync(designPath)) return null; + + const content = fs.readFileSync(designPath, "utf-8"); + // Truncate to first 2000 chars to keep brief reasonable + return content.slice(0, 2000); +}