diff --git a/.gitignore b/.gitignore index 770818be..e1e6ed0e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .env node_modules/ browse/dist/ +design/dist/ bin/gstack-global-discover .gstack/ .claude/skills/ diff --git a/design/src/auth.ts b/design/src/auth.ts new file mode 100644 index 00000000..a6bdc0cb --- /dev/null +++ b/design/src/auth.ts @@ -0,0 +1,63 @@ +/** + * Auth resolution for OpenAI API access. + * + * Resolution order: + * 1. ~/.gstack/openai.json → { "api_key": "sk-..." } + * 2. OPENAI_API_KEY environment variable + * 3. null (caller handles guided setup or fallback) + */ + +import fs from "fs"; +import path from "path"; + +const CONFIG_PATH = path.join(process.env.HOME || "~", ".gstack", "openai.json"); + +export function resolveApiKey(): string | null { + // 1. Check ~/.gstack/openai.json + try { + if (fs.existsSync(CONFIG_PATH)) { + const content = fs.readFileSync(CONFIG_PATH, "utf-8"); + const config = JSON.parse(content); + if (config.api_key && typeof config.api_key === "string") { + return config.api_key; + } + } + } catch { + // Fall through to env var + } + + // 2. Check environment variable + if (process.env.OPENAI_API_KEY) { + return process.env.OPENAI_API_KEY; + } + + return null; +} + +/** + * Save an API key to ~/.gstack/openai.json with 0600 permissions. + */ +export function saveApiKey(key: string): void { + const dir = path.dirname(CONFIG_PATH); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(CONFIG_PATH, JSON.stringify({ api_key: key }, null, 2)); + fs.chmodSync(CONFIG_PATH, 0o600); +} + +/** + * Get API key or exit with setup instructions. + */ +export function requireApiKey(): string { + const key = resolveApiKey(); + if (!key) { + console.error("No OpenAI API key found."); + console.error(""); + console.error("Run: $D setup"); + console.error(" or save to ~/.gstack/openai.json: { \"api_key\": \"sk-...\" }"); + console.error(" or set OPENAI_API_KEY environment variable"); + console.error(""); + console.error("Get a key at: https://platform.openai.com/api-keys"); + process.exit(1); + } + return key; +} diff --git a/design/src/brief.ts b/design/src/brief.ts new file mode 100644 index 00000000..6ebcae6c --- /dev/null +++ b/design/src/brief.ts @@ -0,0 +1,59 @@ +/** + * Structured design brief — the interface between skill prose and image generation. + */ + +export interface DesignBrief { + goal: string; // "Dashboard for coding assessment tool" + audience: string; // "Technical users, YC partners" + style: string; // "Dark theme, cream accents, minimal" + elements: string[]; // ["builder name", "score badge", "narrative letter"] + constraints?: string; // "Max width 1024px, mobile-first" + reference?: string; // DESIGN.md excerpt or style reference text + screenType: string; // "desktop-dashboard" | "mobile-app" | "landing-page" | etc. +} + +/** + * Convert a structured brief to a prompt string for image generation. + */ +export function briefToPrompt(brief: DesignBrief): string { + const lines: string[] = [ + `Generate a pixel-perfect UI mockup of a ${brief.screenType} for: ${brief.goal}.`, + `Target audience: ${brief.audience}.`, + `Visual style: ${brief.style}.`, + `Required elements: ${brief.elements.join(", ")}.`, + ]; + + if (brief.constraints) { + lines.push(`Constraints: ${brief.constraints}.`); + } + + if (brief.reference) { + lines.push(`Design reference: ${brief.reference}`); + } + + lines.push( + "The mockup should look like a real production UI, not a wireframe or concept art.", + "All text must be readable. Layout must be clean and intentional.", + "1536x1024 pixels." + ); + + return lines.join(" "); +} + +/** + * Parse a brief from either a plain text string or a JSON file path. + */ +export function parseBrief(input: string, isFile: boolean): string { + if (!isFile) { + // Plain text prompt — use directly + return input; + } + + // JSON file — parse and convert to prompt + const raw = Bun.file(input); + // We'll read it synchronously via fs since Bun.file is async + const fs = require("fs"); + const content = fs.readFileSync(input, "utf-8"); + const brief: DesignBrief = JSON.parse(content); + return briefToPrompt(brief); +} diff --git a/design/src/check.ts b/design/src/check.ts new file mode 100644 index 00000000..dd4bfe43 --- /dev/null +++ b/design/src/check.ts @@ -0,0 +1,92 @@ +/** + * Vision-based quality gate for generated mockups. + * Uses GPT-4o vision to verify text readability, layout completeness, and visual coherence. + */ + +import fs from "fs"; +import { requireApiKey } from "./auth"; + +export interface CheckResult { + pass: boolean; + issues: string; +} + +/** + * Check a generated mockup against the original brief. + */ +export async function checkMockup(imagePath: string, brief: string): Promise { + 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: [ + "You are a UI quality checker. Evaluate this mockup against the design brief.", + "", + `Brief: ${brief}`, + "", + "Check these 3 things:", + "1. TEXT READABILITY: Are all labels, headings, and body text legible? Any misspellings?", + "2. LAYOUT COMPLETENESS: Are all requested elements present? Anything missing?", + "3. VISUAL COHERENCE: Does it look like a real production UI, not AI art or a collage?", + "", + "Respond with exactly one line:", + "PASS — if all 3 checks pass", + "FAIL: [list specific issues] — if any check fails", + ].join("\n"), + }, + ], + }], + max_tokens: 200, + }), + signal: controller.signal, + }); + + if (!response.ok) { + const error = await response.text(); + // Non-blocking: if vision check fails, default to PASS with warning + console.error(`Vision check API error (${response.status}): ${error}`); + return { pass: true, issues: "Vision check unavailable — skipped" }; + } + + const data = await response.json() as any; + const content = data.choices?.[0]?.message?.content?.trim() || ""; + + if (content.startsWith("PASS")) { + return { pass: true, issues: "" }; + } + + // Extract issues after "FAIL:" + const issues = content.replace(/^FAIL:\s*/i, "").trim(); + return { pass: false, issues: issues || content }; + } finally { + clearTimeout(timeout); + } +} + +/** + * Standalone check command: check an existing image against a brief. + */ +export async function checkCommand(imagePath: string, brief: string): Promise { + const result = await checkMockup(imagePath, brief); + console.log(JSON.stringify(result, null, 2)); +} diff --git a/design/src/cli.ts b/design/src/cli.ts new file mode 100644 index 00000000..ba563fc2 --- /dev/null +++ b/design/src/cli.ts @@ -0,0 +1,181 @@ +/** + * gstack design CLI — stateless CLI for AI-powered design generation. + * + * Unlike the browse binary (persistent Chromium daemon), the design binary + * is stateless: each invocation makes API calls and writes files. Session + * state for multi-turn iteration is a JSON file in /tmp. + * + * Flow: + * 1. Parse command + flags from argv + * 2. Resolve auth (~/. gstack/openai.json → OPENAI_API_KEY → guided setup) + * 3. Execute command (API call → write PNG/HTML) + * 4. Print result JSON to stdout + */ + +import { COMMANDS } from "./commands"; +import { generate } from "./generate"; +import { checkCommand } from "./check"; +import { compare } from "./compare"; +import { resolveApiKey, saveApiKey } from "./auth"; + +function parseArgs(argv: string[]): { command: string; flags: Record } { + const args = argv.slice(2); // skip bun/node and script path + if (args.length === 0) { + printUsage(); + process.exit(0); + } + + const command = args[0]; + const flags: Record = {}; + + for (let i = 1; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith("--")) { + const key = arg.slice(2); + const next = args[i + 1]; + if (next && !next.startsWith("--")) { + flags[key] = next; + i++; + } else { + flags[key] = true; + } + } + } + + return { command, flags }; +} + +function printUsage(): void { + console.log("gstack design — AI-powered UI mockup generation\n"); + console.log("Commands:"); + for (const [name, info] of COMMANDS) { + console.log(` ${name.padEnd(12)} ${info.description}`); + console.log(` ${"".padEnd(12)} ${info.usage}`); + } + console.log("\nAuth: ~/.gstack/openai.json or OPENAI_API_KEY env var"); + console.log("Setup: $D setup"); +} + +async function runSetup(): Promise { + const existing = resolveApiKey(); + if (existing) { + console.log("Existing API key found. Running smoke test..."); + } else { + console.log("No API key found. Please enter your OpenAI API key."); + console.log("Get one at: https://platform.openai.com/api-keys"); + console.log("(Needs image generation permissions)\n"); + + // Read from stdin + process.stdout.write("API key: "); + const reader = Bun.stdin.stream().getReader(); + const { value } = await reader.read(); + reader.releaseLock(); + const key = new TextDecoder().decode(value).trim(); + + if (!key || !key.startsWith("sk-")) { + console.error("Invalid key. Must start with 'sk-'."); + process.exit(1); + } + + saveApiKey(key); + console.log("Key saved to ~/.gstack/openai.json (0600 permissions)."); + } + + // Smoke test + console.log("\nRunning smoke test (generating a simple image)..."); + try { + await generate({ + brief: "A simple blue square centered on a white background. Minimal, geometric, clean.", + output: "/tmp/gstack-design-smoke-test.png", + size: "1024x1024", + quality: "low", + }); + console.log("\nSmoke test PASSED. Design generation is working."); + } catch (err: any) { + console.error(`\nSmoke test FAILED: ${err.message}`); + console.error("Check your API key and organization verification status."); + process.exit(1); + } +} + +async function main(): Promise { + const { command, flags } = parseArgs(process.argv); + + if (!COMMANDS.has(command)) { + console.error(`Unknown command: ${command}`); + printUsage(); + process.exit(1); + } + + switch (command) { + case "generate": + await generate({ + brief: flags.brief as string, + briefFile: flags["brief-file"] as string, + output: (flags.output as string) || "/tmp/gstack-mockup.png", + check: !!flags.check, + retry: flags.retry ? parseInt(flags.retry as string) : 0, + size: flags.size as string, + quality: flags.quality as string, + }); + break; + + case "check": + await checkCommand(flags.image as string, flags.brief as string); + break; + + case "compare": { + // Parse --images as glob or multiple files + const imagesArg = flags.images as string; + const images = await resolveImagePaths(imagesArg); + compare({ + images, + output: (flags.output as string) || "/tmp/gstack-design-board.html", + }); + break; + } + + case "setup": + await runSetup(); + break; + + case "variants": + case "iterate": + case "diff": + case "evolve": + case "verify": + console.error(`Command '${command}' will be implemented in Commit 2+.`); + process.exit(1); + break; + } +} + +/** + * Resolve image paths from a glob pattern or comma-separated list. + */ +async function resolveImagePaths(input: string): Promise { + if (!input) { + console.error("--images is required. Provide glob pattern or comma-separated paths."); + process.exit(1); + } + + // Check if it's a glob pattern + if (input.includes("*")) { + const glob = new Bun.Glob(input); + const paths: string[] = []; + for await (const match of glob.scan({ absolute: true })) { + if (match.endsWith(".png") || match.endsWith(".jpg") || match.endsWith(".jpeg")) { + paths.push(match); + } + } + return paths.sort(); + } + + // Comma-separated or single path + return input.split(",").map(p => p.trim()); +} + +main().catch(err => { + console.error(err.message || err); + process.exit(1); +}); diff --git a/design/src/commands.ts b/design/src/commands.ts new file mode 100644 index 00000000..9941cb76 --- /dev/null +++ b/design/src/commands.ts @@ -0,0 +1,62 @@ +/** + * Command registry — single source of truth for all design commands. + * + * Dependency graph: + * commands.ts ──▶ cli.ts (runtime dispatch) + * ──▶ gen-skill-docs.ts (doc generation) + * ──▶ tests (validation) + * + * Zero side effects. Safe to import from build scripts and tests. + */ + +export const COMMANDS = new Map([ + ["generate", { + description: "Generate a UI mockup from a design brief", + usage: "generate --brief \"...\" --output /path.png", + flags: ["--brief", "--brief-file", "--output", "--check", "--retry", "--size", "--quality"], + }], + ["variants", { + description: "Generate N design variants from a brief", + usage: "variants --brief \"...\" --count 3 --output-dir /path/", + flags: ["--brief", "--brief-file", "--count", "--output-dir", "--size", "--quality", "--viewports"], + }], + ["iterate", { + description: "Iterate on an existing mockup with feedback", + usage: "iterate --session /path/session.json --feedback \"...\" --output /path.png", + flags: ["--session", "--feedback", "--output"], + }], + ["check", { + description: "Vision-based quality check on a mockup", + usage: "check --image /path.png --brief \"...\"", + flags: ["--image", "--brief"], + }], + ["compare", { + description: "Generate HTML comparison board for user review", + usage: "compare --images /path/*.png --output /path/board.html", + flags: ["--images", "--output"], + }], + ["diff", { + description: "Visual diff between two mockups", + usage: "diff --before old.png --after new.png", + flags: ["--before", "--after", "--output"], + }], + ["evolve", { + description: "Generate improved mockup from existing screenshot", + usage: "evolve --screenshot current.png --brief \"make it calmer\" --output /path.png", + flags: ["--screenshot", "--brief", "--output"], + }], + ["verify", { + description: "Compare live site screenshot against approved mockup", + usage: "verify --mockup approved.png --screenshot live.png", + flags: ["--mockup", "--screenshot", "--output"], + }], + ["setup", { + description: "Guided API key setup + smoke test", + usage: "setup", + flags: [], + }], +]); diff --git a/design/src/compare.ts b/design/src/compare.ts new file mode 100644 index 00000000..bcf20a55 --- /dev/null +++ b/design/src/compare.ts @@ -0,0 +1,404 @@ +/** + * Generate HTML comparison board for user review of design variants. + * Opens in headed Chrome via $B goto. User picks favorite, rates, comments, submits. + * Agent reads feedback from hidden DOM element. + * + * Design spec: single column, full-width mockups, APP UI aesthetic. + */ + +import fs from "fs"; +import path from "path"; + +export interface CompareOptions { + images: string[]; + output: string; +} + +/** + * Generate the comparison board HTML page. + */ +export function generateCompareHtml(images: string[]): string { + const variantLabels = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + const variantCards = images.map((imgPath, i) => { + const label = variantLabels[i] || `${i + 1}`; + // Embed images as base64 data URIs for self-contained HTML + const imgData = fs.readFileSync(imgPath).toString("base64"); + const ext = path.extname(imgPath).slice(1) || "png"; + + return ` +
+ Variant ${label} +
+ +
+ ${[1,2,3,4,5].map(n => ``).join("")} +
+ + +
+
`; + }).join("\n"); + + return ` + + + + +Design Exploration + + + + +
+

Design Exploration

+ ${images.length} variants +
+ +
+ ${variantCards} +
+ +
+
+ Overall direction (optional) + +
+
+ +
+
+

Want to explore more?

+
+ + + + +
+
+
+ +
+ +
+ +
+ Feedback submitted! Return to your coding agent. +
+ + +
+
+ + + + +`; +} + +/** + * Compare command: generate comparison board HTML from image files. + */ +export function compare(options: CompareOptions): void { + const html = generateCompareHtml(options.images); + const outputDir = path.dirname(options.output); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(options.output, html); + console.log(JSON.stringify({ outputPath: options.output, variants: options.images.length })); +} diff --git a/design/src/generate.ts b/design/src/generate.ts new file mode 100644 index 00000000..a34b7151 --- /dev/null +++ b/design/src/generate.ts @@ -0,0 +1,153 @@ +/** + * Generate UI mockups via OpenAI Responses API with image_generation tool. + */ + +import fs from "fs"; +import path from "path"; +import { requireApiKey } from "./auth"; +import { parseBrief } from "./brief"; +import { createSession, sessionPath } from "./session"; +import { checkMockup } from "./check"; + +export interface GenerateOptions { + brief?: string; + briefFile?: string; + output: string; + check?: boolean; + retry?: number; + size?: string; + quality?: string; +} + +export interface GenerateResult { + outputPath: string; + sessionFile: string; + responseId: string; + checkResult?: { pass: boolean; issues: string }; +} + +/** + * Call OpenAI Responses API with image_generation tool. + * Returns the response ID and base64 image data. + */ +async function callImageGeneration( + apiKey: string, + prompt: string, + size: string, + quality: string, +): Promise<{ responseId: string; imageData: string }> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 120_000); + + try { + const response = await fetch("https://api.openai.com/v1/responses", { + method: "POST", + headers: { + "Authorization": `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-4o", + input: prompt, + tools: [{ + type: "image_generation", + size, + quality, + }], + }), + signal: controller.signal, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`API error (${response.status}): ${error}`); + } + + const data = await response.json() as any; + + const imageItem = data.output?.find((item: any) => + item.type === "image_generation_call" + ); + + if (!imageItem?.result) { + throw new Error( + `No image data in response. Output types: ${data.output?.map((o: any) => o.type).join(", ") || "none"}` + ); + } + + return { + responseId: data.id, + imageData: imageItem.result, + }; + } finally { + clearTimeout(timeout); + } +} + +/** + * Generate a single mockup from a brief. + */ +export async function generate(options: GenerateOptions): Promise { + const apiKey = requireApiKey(); + + // Parse the brief + const prompt = options.briefFile + ? parseBrief(options.briefFile, true) + : parseBrief(options.brief!, false); + + const size = options.size || "1536x1024"; + const quality = options.quality || "high"; + const maxRetries = options.retry ?? 0; + + let lastResult: GenerateResult | null = null; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (attempt > 0) { + console.error(`Retry ${attempt}/${maxRetries}...`); + } + + // Generate the image + const startTime = Date.now(); + const { responseId, imageData } = await callImageGeneration(apiKey, prompt, size, quality); + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + + // Write to disk + const outputDir = path.dirname(options.output); + fs.mkdirSync(outputDir, { recursive: true }); + const imageBuffer = Buffer.from(imageData, "base64"); + fs.writeFileSync(options.output, imageBuffer); + + // Create session + const session = createSession(responseId, prompt, options.output); + + console.error(`Generated (${elapsed}s, ${(imageBuffer.length / 1024).toFixed(0)}KB) → ${options.output}`); + + lastResult = { + outputPath: options.output, + sessionFile: sessionPath(session.id), + responseId, + }; + + // Quality check if requested + if (options.check) { + const checkResult = await checkMockup(options.output, prompt); + lastResult.checkResult = checkResult; + + if (checkResult.pass) { + console.error(`Quality check: PASS`); + break; + } else { + console.error(`Quality check: FAIL — ${checkResult.issues}`); + if (attempt < maxRetries) { + console.error("Will retry..."); + } + } + } else { + break; + } + } + + // Output result as JSON to stdout + console.log(JSON.stringify(lastResult, null, 2)); + return lastResult!; +} diff --git a/design/src/session.ts b/design/src/session.ts new file mode 100644 index 00000000..16d6f0ee --- /dev/null +++ b/design/src/session.ts @@ -0,0 +1,79 @@ +/** + * Session state management for multi-turn design iteration. + * Session files are JSON in /tmp, keyed by PID + timestamp. + */ + +import fs from "fs"; +import path from "path"; + +export interface DesignSession { + id: string; + lastResponseId: string; + originalBrief: string; + feedbackHistory: string[]; + outputPaths: string[]; + createdAt: string; + updatedAt: string; +} + +/** + * Generate a unique session ID from PID + timestamp. + */ +export function createSessionId(): string { + return `${process.pid}-${Date.now()}`; +} + +/** + * Get the file path for a session. + */ +export function sessionPath(sessionId: string): string { + return path.join("/tmp", `design-session-${sessionId}.json`); +} + +/** + * Create a new session after initial generation. + */ +export function createSession( + responseId: string, + brief: string, + outputPath: string, +): DesignSession { + const id = createSessionId(); + const session: DesignSession = { + id, + lastResponseId: responseId, + originalBrief: brief, + feedbackHistory: [], + outputPaths: [outputPath], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + fs.writeFileSync(sessionPath(id), JSON.stringify(session, null, 2)); + return session; +} + +/** + * Read an existing session from disk. + */ +export function readSession(sessionFilePath: string): DesignSession { + const content = fs.readFileSync(sessionFilePath, "utf-8"); + return JSON.parse(content); +} + +/** + * Update a session with new iteration data. + */ +export function updateSession( + session: DesignSession, + responseId: string, + feedback: string, + outputPath: string, +): void { + session.lastResponseId = responseId; + session.feedbackHistory.push(feedback); + session.outputPaths.push(outputPath); + session.updatedAt = new Date().toISOString(); + + fs.writeFileSync(sessionPath(session.id), JSON.stringify(session, null, 2)); +} diff --git a/package.json b/package.json index de2b664f..ff4030cb 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "browse": "./browse/dist/browse" }, "scripts": { - "build": "bun run gen:skill-docs && bun run gen:skill-docs --host codex && bun build --compile browse/src/cli.ts --outfile browse/dist/browse && bun build --compile browse/src/find-browse.ts --outfile browse/dist/find-browse && bun build --compile bin/gstack-global-discover.ts --outfile bin/gstack-global-discover && bash browse/scripts/build-node-server.sh && git rev-parse HEAD > browse/dist/.version && rm -f .*.bun-build || true", + "build": "bun run gen:skill-docs && bun run gen:skill-docs --host codex && bun build --compile browse/src/cli.ts --outfile browse/dist/browse && bun build --compile browse/src/find-browse.ts --outfile browse/dist/find-browse && bun build --compile design/src/cli.ts --outfile design/dist/design && bun build --compile bin/gstack-global-discover.ts --outfile bin/gstack-global-discover && bash browse/scripts/build-node-server.sh && git rev-parse HEAD > browse/dist/.version && git rev-parse HEAD > design/dist/.version && rm -f .*.bun-build || true", + "dev:design": "bun run design/src/cli.ts", "gen:skill-docs": "bun run scripts/gen-skill-docs.ts", "dev": "bun run browse/src/cli.ts", "server": "bun run browse/src/server.ts",