mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
feat: design binary — real UI mockup generation for gstack skills (v0.13.0.0) (#551)
* docs: design tools v1 plan — visual mockup generation for gstack skills Full design doc covering the `design` binary that wraps OpenAI's GPT Image API to generate real UI mockups from gstack's design skills. Includes comparison board UX spec, auth model, 6 CEO expansions (design memory, mockup diffing, screenshot evolution, design intent verification, responsive variants, design-to-code prompt), and 9-commit implementation plan. Reviewed: /office-hours + /plan-eng-review (CLEARED) + /plan-ceo-review (EXPANSION, 6/6 accepted) + /plan-design-review (2/10 → 8/10). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: design tools prototype validation — GPT Image API works Prototype script sends 3 design briefs to OpenAI Responses API with image_generation tool. Results: dashboard (47s, 2.1MB), landing page (42s, 1.3MB), settings page (37s, 1.3MB) all produce real, implementable UI mockups with accurate text rendering and clean layouts. Key finding: Codex OAuth tokens lack image generation scopes. Direct API key (sk-proj-*) required, stored in ~/.gstack/openai.json. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: design binary core — generate, check, compare commands Stateless CLI (design/dist/design) wrapping OpenAI Responses API for UI mockup generation. Three working commands: - generate: brief -> PNG mockup via gpt-4o + image_generation tool - check: vision-based quality gate via GPT-4o (text readability, layout completeness, visual coherence) - compare: generates self-contained HTML comparison board with star ratings, radio Pick, per-variant feedback, regenerate controls, and Submit button that writes structured JSON for agent polling Auth reads from ~/.gstack/openai.json (0600), falls back to OPENAI_API_KEY env var. Compiled separately from browse binary (openai added to devDependencies, not runtime deps). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: design binary variants + iterate commands variants: generates N style variations with staggered parallel (1.5s between launches, exponential backoff on 429). 7 built-in style variations (bold, calm, warm, corporate, dark, playful + default). Tested: 3/3 variants in 41.6s. iterate: multi-turn design iteration using previous_response_id for conversational threading. Falls back to re-generation with accumulated feedback if threading doesn't retain visual context. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: DESIGN_SETUP + DESIGN_MOCKUP template resolvers Add generateDesignSetup() and generateDesignMockup() to the existing design.ts resolver file. Add designDir to HostPaths (claude + codex). Register DESIGN_SETUP and DESIGN_MOCKUP in the resolver index. DESIGN_SETUP: $D binary discovery (mirrors $B browse setup pattern). Falls back to DESIGN_SKETCH if binary not available. DESIGN_MOCKUP: full visual exploration workflow template — construct brief from DESIGN.md context, generate 3 variants, open comparison board in Chrome, poll for user feedback, save approved mockup to docs/designs/, generate HTML wireframe for implementation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: sync package.json version with VERSION file (0.12.2.0) Pre-existing mismatch: VERSION was 0.12.2.0 but package.json was 0.12.0.0. Also adds design binary to build script and dev:design convenience command. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: /office-hours visual design exploration integration Add {{DESIGN_MOCKUP}} to office-hours template before the existing {{DESIGN_SKETCH}}. When the design binary is available, /office-hours generates 3 visual mockup variants, opens a comparison board in Chrome, and polls for user feedback. Falls back to HTML wireframes if the design binary isn't built. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: /plan-design-review visual mockup integration Add {{DESIGN_SETUP}} to pre-review audit and "show me what 10/10 looks like" mockup generation to the 0-10 rating method. When a design dimension rates below 7/10, the review can generate a mockup showing the improved version. Falls back to text descriptions if the design binary isn't available. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: design memory — extract visual language from mockups into DESIGN.md New `$D extract` command: sends approved mockup to GPT-4o vision, extracts color palette, typography, spacing, and layout patterns, writes/updates DESIGN.md with an "Extracted Design Language" section. Progressive constraint: if DESIGN.md exists, future mockup briefs include it as style context. If no DESIGN.md, explorations run wide. readDesignConstraints() reads existing DESIGN.md for brief construction. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: mockup diffing + design intent verification New commands: - $D diff --before old.png --after new.png: visual diff using GPT-4o vision. Returns differences by area with severity (high/medium/low) and a matchScore (0-100). - $D verify --mockup approved.png --screenshot live.png: compares live site screenshot against approved design mockup. Pass if matchScore >= 70 and no high-severity differences. Used by /design-review to close the design loop: design -> implement -> verify visually. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: screenshot-to-mockup evolution ($D evolve) New command: $D evolve --screenshot current.png --brief "make it calmer" Two-step process: first analyzes the screenshot via GPT-4o vision to produce a detailed description, then generates a new mockup that keeps the existing layout structure but applies the requested changes. Starts from reality, not blank canvas. Bridges the gap between /design-review critique ("the spacing is off") and a visual proposal of the fix. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: responsive variants + design-to-code prompt Responsive variants: $D variants --viewports desktop,tablet,mobile generates mockups at 1536x1024, 1024x1024, and 1024x1536 (portrait) with viewport-appropriate layout instructions. Design-to-code prompt: $D prompt --image approved.png extracts colors, typography, layout, and components via GPT-4o vision, producing a structured implementation prompt. Reads DESIGN.md for additional constraint context. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.13.0.0) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: gstack designer as first-class tool in /plan-design-review Brand the gstack designer prominently, add Step 0.5 for proactive visual mockup generation before review passes, and update priority hierarchy. When a plan describes new UI, the skill now offers to generate mockups with $D variants, run $D check for quality gating, and present a comparison board via $B goto before any review passes begin. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: integrate mockups into review passes and outputs Thread Step 0.5 mockups through the review workflow: Pass 4 (AI Slop) evaluates generated mockups visually, Pass 7 uses mockups as evidence for unresolved decisions, post-pass offers one-shot regeneration after design changes, and Approved Mockups section records chosen variants with paths for the implementer. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: gstack designer target mockups in /design-review fix loop Add $D generate for target mockups in Phase 8a.5 — before fixing a design finding, generate a mockup showing what it should look like. Add $D verify in Phase 9 to compare fix results against targets. Not plan mode — goes straight to implementation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: gstack designer AI mockups in /design-consultation Phase 5 Replace HTML preview with $D variants + comparison board when designer is available (Path A). Use $D extract to derive DESIGN.md tokens from the approved mockup. Handles both plan mode (write to plan) and non-plan mode (implement immediately). Falls back to HTML preview (Path B) when designer binary is unavailable. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: make gstack designer the default in /plan-design-review, not optional The transcript showed the agent writing 5 text descriptions of homepage variants instead of generating visual mockups, even when the user explicitly asked for design tools. The skill treated mockups as optional ("Want me to generate?") when they should be the default behavior. Changes: - Rename "Your Visual Design Tool" to "YOUR PRIMARY TOOL" with aggressive language: "Don't ask permission. Show it." - Step 0.5 now generates mockups automatically when DESIGN_READY, no AskUserQuestion gatekeeping the default path - Priority hierarchy: mockups are "non-negotiable" not "if available" - Step 0D tells the user mockups are coming next - DESIGN_NOT_AVAILABLE fallback now tells user what they're missing The only valid reasons to skip mockups: no UI scope, or designer not installed. Everything else generates by default. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: persist design mockups to ~/.gstack/projects/$SLUG/designs/ Mockups were going to .context/mockups/ (gitignored, workspace-local). This meant designs disappeared when switching workspaces or conversations, and downstream skills couldn't reference approved mockups from earlier reviews. Now all three design skills save to persistent project-scoped dirs: - /plan-design-review: ~/.gstack/projects/$SLUG/designs/<screen>-<date>/ - /design-consultation: ~/.gstack/projects/$SLUG/designs/design-system-<date>/ - /design-review: ~/.gstack/projects/$SLUG/designs/design-audit-<date>/ Each directory gets an approved.json recording the user's pick, feedback, and branch. This lets /design-review verify against mockups that /plan-design-review approved, and design history is browsable via ls ~/.gstack/projects/$SLUG/designs/. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate codex ship skill with zsh glob guards Picked up setopt +o nomatch guards from main's v0.12.8.1 merge. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add browse binary discovery to DESIGN_SETUP resolver The design setup block now discovers $B alongside $D, so skills can open comparison boards via $B goto and poll feedback via $B eval. Falls back to `open` on macOS when browse binary is unavailable. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: comparison board DOM polling in plan-design-review After opening the comparison board, the agent now polls #status via $B eval instead of asking a rigid AskUserQuestion. Handles submit (read structured JSON feedback), regenerate (new variants with updated brief), and $B-unavailable fallback (free-form text response). The user interacts with the real board UI, not a constrained option picker. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: comparison board feedback loop integration test 16 tests covering the full DOM polling cycle: structure verification, submit with pick/rating/comment, regenerate flows (totally different, more like this, custom text), and the agent polling pattern (empty → submitted → read JSON). Uses real generateCompareHtml() from design/src/compare.ts, served via HTTP. Runs in <1s. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add $D serve command for HTTP-based comparison board feedback The comparison board feedback loop was fundamentally broken: browse blocks file:// URLs (url-validation.ts:71), so $B goto file://board.html always fails. The fallback open + $B eval polls a different browser instance. $D serve fixes this by serving the board over HTTP on localhost. The server is stateful: stays alive across regeneration rounds, exposes /api/progress for the board to poll, and accepts /api/reload from the agent to swap in new board HTML. Stdout carries feedback JSON only; stderr carries telemetry. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: dual-mode feedback + post-submit lifecycle in comparison board When __GSTACK_SERVER_URL is set (injected by $D serve), the board POSTs feedback to the server instead of only writing to hidden DOM elements. After submit: disables all inputs, shows "Return to your coding agent." After regenerate: shows spinner, polls /api/progress, auto-refreshes on ready. On POST failure: shows copyable JSON fallback. On progress timeout (5 min): shows error with /design-shotgun prompt. DOM fallback preserved for headed browser mode and tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: HTTP serve command endpoints and regeneration lifecycle 11 tests covering: HTML serving with injected server URL, /api/progress state reporting, submit → done lifecycle, regenerate → regenerating state, remix with remixSpec, malformed JSON rejection, /api/reload HTML swapping, missing file validation, and full regenerate → reload → submit round-trip. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add DESIGN_SHOTGUN_LOOP resolver + fix design artifact paths Adds generateDesignShotgunLoop() resolver for the shared comparison board feedback loop (serve via HTTP, handle regenerate/remix, AskUserQuestion fallback, feedback confirmation). Registered as {{DESIGN_SHOTGUN_LOOP}}. Fixes generateDesignMockup() to use ~/.gstack/projects/$SLUG/designs/ instead of /tmp/ and docs/designs/. Replaces broken $B goto file:// + $B eval polling with $D compare --serve (HTTP-based, stdout feedback). Adds CRITICAL PATH RULE guardrail to DESIGN_SETUP: design artifacts must go to ~/.gstack/projects/$SLUG/designs/, never .context/ or /tmp/. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add /design-shotgun standalone design exploration skill New skill for visual brainstorming: generate AI design variants, open a comparison board in the user's browser, collect structured feedback, and iterate. Features: session detection (revisit prior explorations), 5-dimension context gathering (who, job to be done, what exists, user flow, edge cases), taste memory (prior approved designs bias new generations), inline variant preview, configurable variant count, screenshot-to-variants via $D evolve. Uses {{DESIGN_SHOTGUN_LOOP}} resolver for the feedback loop. Saves all artifacts to ~/.gstack/projects/$SLUG/designs/. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate SKILL.md files for design-shotgun + resolver changes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add remix UI to comparison board Per-variant element selectors (Layout, Colors, Typography, Spacing) with radio buttons in a grid. Remix button collects selections into a remixSpec object and sends via the same HTTP POST feedback mechanism. Enabled only when at least one element is selected. Board shows regenerating spinner while agent generates the hybrid variant. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add $D gallery command for design history timeline Generates a self-contained HTML page showing all prior design explorations for a project: every variant (approved or not), feedback notes, organized by date (newest first). Images embedded as base64. Handles corrupted approved.json gracefully (skips, still shows the session). Empty state shows "No history yet" with /design-shotgun prompt. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: gallery generation — sessions, dates, corruption, empty state 7 tests: empty dir, nonexistent dir, single session with approved variant, multiple sessions sorted newest-first, corrupted approved.json handled gracefully, session without approved.json, self-contained HTML (no external dependencies). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: replace broken file:// polling with {{DESIGN_SHOTGUN_LOOP}} plan-design-review and design-consultation templates previously used $B goto file:// + $B eval polling for the comparison board feedback loop. This was broken (browse blocks file:// URLs). Both templates now use {{DESIGN_SHOTGUN_LOOP}} which serves via HTTP, handles regeneration in the same browser tab, and falls back to AskUserQuestion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add design-shotgun touchfile entries and tier classifications design-shotgun-path (gate): verify artifacts go to ~/.gstack/, not .context/ design-shotgun-session (gate): verify repeat-run detection + AskUserQuestion design-shotgun-full (periodic): full round-trip with real design binary Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate SKILL.md files for template refactor Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: comparison board UI improvements — option headers, pick confirmation, grid view Three changes to the design comparison board: 1. Pick confirmation: selecting "Pick" on Option A shows "We'll move forward with Option A" in green, plus a status line above the submit button repeating the choice. 2. Clear option headers: each variant now has "Option A" in bold with a subtitle above the image, instead of just the raw image. 3. View toggle: top-right Large/Grid buttons switch between single-column (default) and 3-across grid view. Also restructured the bottom section into a 2-column grid: submit/overall feedback on the left, regenerate controls on the right. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use 127.0.0.1 instead of localhost for serve URL Avoids DNS resolution issues on some systems where localhost may resolve to IPv6 ::1 while Bun listens on IPv4 only. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: write ALL feedback to disk so agent can poll in background mode The agent backgrounds $D serve (Claude Code can't block on a subprocess and do other work simultaneously). With stdout-only feedback delivery, the agent never sees regenerate/remix feedback. Fix: write feedback-pending.json (regenerate/remix) and feedback.json (submit) to disk next to the board HTML. Agent polls the filesystem instead of reading stdout. Both channels (stdout + disk) are always active so foreground mode still works. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: DESIGN_SHOTGUN_LOOP uses file polling instead of stdout reading Update the template resolver to instruct the agent to background $D serve and poll for feedback-pending.json / feedback.json on a 5-second loop. This matches the real-world pattern where Claude Code / Conductor agents can't block on subprocess stdout. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate SKILL.md files for file-polling feedback loop Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: null-safe DOM selectors for post-submit and regenerating states The user's layout restructure renamed .regenerate-bar → .regen-column, .submit-bar → .submit-column, and .overall-section → .bottom-section. The JS still referenced the old class names, causing querySelector to return null and showPostSubmitState() / showRegeneratingState() to silently crash. This meant Submit and Regenerate buttons appeared to work (DOM elements updated, HTTP POST succeeded) but the visual feedback (disabled inputs, spinner, success message) never appeared. Fix: use fallback selectors that check both old and new class names, with null guards so a missing element doesn't crash the function. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: end-to-end feedback roundtrip — browser click to file on disk The test that proves "changes on the website propagate to Claude Code." Opens the comparison board in a real headless browser with __GSTACK_SERVER_URL injected, simulates user clicks (Submit, Regenerate, More Like This), and verifies that feedback.json / feedback-pending.json land on disk with the correct structured data. 6 tests covering: submit → feedback.json, post-submit UI lockdown, regenerate → feedback-pending.json, more-like-this → feedback-pending.json, regenerate spinner display, and full regen → reload → submit round-trip. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: comprehensive design doc for Design Shotgun feedback loop Documents the full browser-to-agent feedback architecture: state machine, file-based polling, port discovery, post-submit lifecycle, and every known edge case (zombie forms, dead servers, stale spinners, file:// bug, double-click races, port coordination, sequential generate rule). Includes ASCII diagrams of the data flow and state transitions, complete step-by-step walkthrough of happy path and regeneration path, test coverage map with gaps, and short/medium/long-term improvement ideas. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: plan-design-review agent guardrails for feedback loop Four fixes to prevent agents from reinventing the feedback loop badly: 1. Sequential generate rule: explicit instruction that $D generate calls must run one at a time (API rate-limits concurrent image generation). 2. No-AskUserQuestion-for-feedback rule: agent reads feedback.json instead of re-asking what the user picked. 3. Remove file:// references: $B goto file:// was always rejected by url-validation.ts. The --serve flag handles everything. 4. Remove $B eval polling reference: no longer needed with HTTP POST. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: design-shotgun Step 3 progressive reveal, silent failure detection, timing estimate Three production UX bugs fixed: 1. Dead air — now shows timing estimate before generation starts 2. Silent variant drop — replaced $D variants batch with individual $D generate calls, each verified for existence and non-zero size with retry 3. No progressive reveal — each variant shown inline via Read tool immediately after generation (~60s increments instead of all at ~180s) Also: /tmp/ then cp as default output pattern (sandbox workaround), screenshot taken once for evolve path (not per-variant). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: parallel design-shotgun with concept-first confirmation Step 3 rewritten to concept-first + parallel Agent architecture: - 3a: generate text concepts (free, instant) - 3b: AskUserQuestion to confirm/modify before spending API credits - 3c: launch N Agent subagents in parallel (~60s total regardless of count) - 3d: show all results, dynamic image list for comparison board Adds Agent to allowed-tools. Softens plan-design-review sequential warning to note design-shotgun uses parallel at Tier 2+. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: update project documentation for v0.13.0.0 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: untrack .agents/skills/ — generated at setup, already gitignored These files were committed despite .agents/ being in .gitignore. They regenerate from ./setup --host codex on any machine. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate design-shotgun SKILL.md for v0.12.12.0 preamble changes Merge from main brought updated preamble resolver (conditional telemetry, local JSONL logging) but design-shotgun/SKILL.md wasn't regenerated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<CheckResult> {
|
||||
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<void> {
|
||||
const result = await checkMockup(imagePath, brief);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* 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 { variants } from "./variants";
|
||||
import { iterate } from "./iterate";
|
||||
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";
|
||||
import { serve } from "./serve";
|
||||
import { gallery } from "./gallery";
|
||||
|
||||
function parseArgs(argv: string[]): { command: string; flags: Record<string, string | boolean> } {
|
||||
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<string, string | boolean> = {};
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
const outputPath = (flags.output as string) || "/tmp/gstack-design-board.html";
|
||||
compare({ images, output: outputPath });
|
||||
// If --serve flag is set, start HTTP server for the board
|
||||
if (flags.serve) {
|
||||
await serve({
|
||||
html: outputPath,
|
||||
timeout: flags.timeout ? parseInt(flags.timeout as string) : 600,
|
||||
});
|
||||
}
|
||||
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;
|
||||
|
||||
case "variants":
|
||||
await variants({
|
||||
brief: flags.brief as string,
|
||||
briefFile: flags["brief-file"] as string,
|
||||
count: flags.count ? parseInt(flags.count as string) : 3,
|
||||
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;
|
||||
|
||||
case "iterate":
|
||||
await iterate({
|
||||
session: flags.session as string,
|
||||
feedback: flags.feedback as string,
|
||||
output: (flags.output as string) || "/tmp/gstack-iterate.png",
|
||||
});
|
||||
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": {
|
||||
const before = flags.before as string;
|
||||
const after = flags.after as string;
|
||||
if (!before || !after) {
|
||||
console.error("--before and --after are required");
|
||||
process.exit(1);
|
||||
}
|
||||
console.error(`Comparing ${before} vs ${after}...`);
|
||||
const diffResult = await diffMockups(before, after);
|
||||
console.log(JSON.stringify(diffResult, null, 2));
|
||||
break;
|
||||
}
|
||||
|
||||
case "verify": {
|
||||
const mockup = flags.mockup as string;
|
||||
const screenshot = flags.screenshot as string;
|
||||
if (!mockup || !screenshot) {
|
||||
console.error("--mockup and --screenshot are required");
|
||||
process.exit(1);
|
||||
}
|
||||
console.error(`Verifying implementation against approved mockup...`);
|
||||
const verifyResult = await verifyAgainstMockup(mockup, screenshot);
|
||||
console.error(`Match: ${verifyResult.matchScore}/100 — ${verifyResult.pass ? "PASS" : "FAIL"}`);
|
||||
console.log(JSON.stringify(verifyResult, null, 2));
|
||||
break;
|
||||
}
|
||||
|
||||
case "evolve":
|
||||
await evolve({
|
||||
screenshot: flags.screenshot as string,
|
||||
brief: flags.brief as string,
|
||||
output: (flags.output as string) || "/tmp/gstack-evolved.png",
|
||||
});
|
||||
break;
|
||||
|
||||
case "gallery":
|
||||
gallery({
|
||||
designsDir: flags["designs-dir"] as string,
|
||||
output: (flags.output as string) || "/tmp/gstack-design-gallery.html",
|
||||
});
|
||||
break;
|
||||
|
||||
case "serve":
|
||||
await serve({
|
||||
html: flags.html as string,
|
||||
timeout: flags.timeout ? parseInt(flags.timeout as string) : 600,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve image paths from a glob pattern or comma-separated list.
|
||||
*/
|
||||
async function resolveImagePaths(input: string): Promise<string[]> {
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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<string, {
|
||||
description: string;
|
||||
usage: string;
|
||||
flags?: string[];
|
||||
}>([
|
||||
["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 [--serve]",
|
||||
flags: ["--images", "--output", "--serve", "--timeout"],
|
||||
}],
|
||||
["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"],
|
||||
}],
|
||||
["prompt", {
|
||||
description: "Generate structured implementation prompt from approved mockup",
|
||||
usage: "prompt --image approved.png",
|
||||
flags: ["--image"],
|
||||
}],
|
||||
["extract", {
|
||||
description: "Extract design language from approved mockup into DESIGN.md",
|
||||
usage: "extract --image approved.png",
|
||||
flags: ["--image"],
|
||||
}],
|
||||
["gallery", {
|
||||
description: "Generate HTML timeline of all design explorations for a project",
|
||||
usage: "gallery --designs-dir ~/.gstack/projects/$SLUG/designs/ --output /path/gallery.html",
|
||||
flags: ["--designs-dir", "--output"],
|
||||
}],
|
||||
["serve", {
|
||||
description: "Serve comparison board over HTTP and collect user feedback",
|
||||
usage: "serve --html /path/board.html [--timeout 600]",
|
||||
flags: ["--html", "--timeout"],
|
||||
}],
|
||||
["setup", {
|
||||
description: "Guided API key setup + smoke test",
|
||||
usage: "setup",
|
||||
flags: [],
|
||||
}],
|
||||
]);
|
||||
@@ -0,0 +1,628 @@
|
||||
/**
|
||||
* 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 `
|
||||
<div class="variant" data-variant="${label}">
|
||||
<div class="variant-header">
|
||||
<span class="variant-label">Option ${label}</span>
|
||||
<span class="variant-desc" id="variant-desc-${label}">Design direction ${label}</span>
|
||||
</div>
|
||||
<img src="data:image/${ext};base64,${imgData}" alt="Option ${label}" />
|
||||
<div class="variant-controls">
|
||||
<label class="pick-label">
|
||||
<input type="radio" name="preferred" value="${label}" />
|
||||
<span class="pick-text">Pick</span>
|
||||
<span class="pick-confirm" style="display:none;">We'll move forward with Option ${label}</span>
|
||||
</label>
|
||||
<div class="stars" data-variant="${label}">
|
||||
${[1,2,3,4,5].map(n => `<span class="star" data-value="${n}">★</span>`).join("")}
|
||||
</div>
|
||||
<input type="text" class="feedback-input" data-variant="${label}"
|
||||
placeholder="What do you like/dislike?" />
|
||||
<button class="more-like-this" data-variant="${label}">More like this</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("\n");
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Design Exploration</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.header h1 { font-size: 16px; font-weight: 600; }
|
||||
.header .meta { font-size: 13px; color: #999; display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
}
|
||||
.view-toggle button {
|
||||
padding: 4px 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
.view-toggle button.active {
|
||||
background: #fff;
|
||||
color: #333;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.variants { max-width: 1400px; margin: 0 auto; padding: 20px 24px; }
|
||||
.variants.grid-view {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
.variants.grid-view .variant {
|
||||
border-bottom: none;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
.variants.grid-view .variant-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
.variants.grid-view .variant-controls .pick-label {
|
||||
padding: 8px 0 4px;
|
||||
}
|
||||
.variants.grid-view .feedback-input { min-width: 0; width: 100%; }
|
||||
.variants.grid-view .more-like-this { align-self: flex-start; }
|
||||
.variants.grid-view .variant-header { margin-bottom: 12px; }
|
||||
|
||||
.variant-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.variant-label {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #111;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.variant-desc {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.pick-confirm {
|
||||
font-size: 13px;
|
||||
color: #2a7d2a;
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.variant {
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
padding: 24px 0;
|
||||
}
|
||||
.variant:last-child { border-bottom: none; }
|
||||
|
||||
.variant img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.variant-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 0 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pick-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pick-label input[type="radio"] { accent-color: #000; }
|
||||
|
||||
.stars { display: flex; gap: 2px; }
|
||||
.star {
|
||||
font-size: 20px;
|
||||
color: #ddd;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color 0.1s;
|
||||
}
|
||||
.star.filled { color: #000; }
|
||||
.star:hover { color: #666; }
|
||||
|
||||
.feedback-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
.feedback-input:focus { border-color: #999; }
|
||||
.feedback-input::placeholder { color: #999; }
|
||||
|
||||
.more-like-this {
|
||||
padding: 6px 12px;
|
||||
background: none;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
}
|
||||
.more-like-this:hover { border-color: #999; color: #333; }
|
||||
|
||||
.bottom-section {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 24px 32px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.submit-column {}
|
||||
.submit-column h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #111;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.submit-column .direction-hint {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.overall-textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.overall-textarea:focus { border-color: #999; }
|
||||
.submit-status {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
margin: 12px 0;
|
||||
min-height: 20px;
|
||||
}
|
||||
.submit-btn {
|
||||
padding: 10px 24px;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
.submit-btn:hover { background: #333; }
|
||||
.submit-btn:disabled { background: #ccc; cursor: not-allowed; }
|
||||
|
||||
.regen-column {
|
||||
background: #f7f7f7;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
.regen-column h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.regen-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.regen-chiclet {
|
||||
padding: 6px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.regen-chiclet:hover { border-color: #999; }
|
||||
.regen-chiclet.active { border-color: #000; background: #f0f0f0; }
|
||||
.regen-custom {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.regen-custom:focus { border-color: #999; }
|
||||
.regen-btn {
|
||||
padding: 8px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
}
|
||||
.regen-btn:hover { border-color: #000; }
|
||||
|
||||
.success-msg {
|
||||
display: none;
|
||||
max-width: 1200px;
|
||||
margin: 24px auto;
|
||||
padding: 16px 24px;
|
||||
background: #f0f9f0;
|
||||
border: 1px solid #c3e6c3;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Hidden result elements for agent polling */
|
||||
#status, #feedback-result { display: none; }
|
||||
|
||||
/* Skeleton loading state */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
height: 400px;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>Design Exploration</h1>
|
||||
<span class="meta">
|
||||
${images.length} options
|
||||
<span class="view-toggle">
|
||||
<button class="active" data-view="list">Large</button>
|
||||
<button data-view="grid">Grid</button>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="variants">
|
||||
${variantCards}
|
||||
</div>
|
||||
|
||||
<div class="bottom-section">
|
||||
<div class="submit-column">
|
||||
<h3>Overall direction</h3>
|
||||
<p class="direction-hint">e.g. "Use A's layout with C's fox icon" or "Make it more minimal" or "I want the problem statement text but bigger"</p>
|
||||
<textarea class="overall-textarea" id="overall-feedback"
|
||||
placeholder="Combine elements, request changes, or describe what you want..."></textarea>
|
||||
<div class="submit-status" id="submit-status"></div>
|
||||
<button class="submit-btn" id="submit-btn">Take my feedback and continue →</button>
|
||||
</div>
|
||||
<div class="regen-column">
|
||||
<h3>Want to explore more?</h3>
|
||||
<div class="regen-controls">
|
||||
<button class="regen-chiclet" data-action="different">Totally different</button>
|
||||
<button class="regen-chiclet" data-action="match">Match my design</button>
|
||||
</div>
|
||||
<input type="text" class="regen-custom" id="regen-custom-input"
|
||||
placeholder="Tell us what you want different..." />
|
||||
<button class="regen-btn" id="regen-btn">Regenerate →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="success-msg" id="success-msg">
|
||||
Feedback submitted! Return to your coding agent.
|
||||
</div>
|
||||
|
||||
<!-- Hidden elements for agent polling -->
|
||||
<div id="status"></div>
|
||||
<div id="feedback-result"></div>
|
||||
|
||||
<script>
|
||||
// View toggle
|
||||
document.querySelectorAll('.view-toggle button').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.view-toggle button').forEach(function(b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
var variants = document.querySelector('.variants');
|
||||
if (btn.dataset.view === 'grid') {
|
||||
variants.classList.add('grid-view');
|
||||
} else {
|
||||
variants.classList.remove('grid-view');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Pick confirmation
|
||||
document.querySelectorAll('input[name="preferred"]').forEach(function(radio) {
|
||||
radio.addEventListener('change', function() {
|
||||
// Hide all confirmations first
|
||||
document.querySelectorAll('.pick-confirm').forEach(function(el) { el.style.display = 'none'; });
|
||||
document.querySelectorAll('.pick-text').forEach(function(el) { el.style.display = ''; });
|
||||
// Show confirmation on the selected one
|
||||
var label = radio.closest('.pick-label');
|
||||
label.querySelector('.pick-text').style.display = 'none';
|
||||
label.querySelector('.pick-confirm').style.display = '';
|
||||
// Update submit status
|
||||
document.getElementById('submit-status').textContent = "We'll run with Option " + radio.value;
|
||||
});
|
||||
});
|
||||
|
||||
// Star rating
|
||||
document.querySelectorAll('.stars').forEach(starsEl => {
|
||||
const stars = starsEl.querySelectorAll('.star');
|
||||
let rating = 0;
|
||||
|
||||
stars.forEach(star => {
|
||||
star.addEventListener('click', () => {
|
||||
rating = parseInt(star.dataset.value);
|
||||
stars.forEach(s => {
|
||||
s.classList.toggle('filled', parseInt(s.dataset.value) <= rating);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Regenerate chiclets (toggle active)
|
||||
document.querySelectorAll('.regen-chiclet').forEach(chiclet => {
|
||||
chiclet.addEventListener('click', () => {
|
||||
document.querySelectorAll('.regen-chiclet').forEach(c => c.classList.remove('active'));
|
||||
chiclet.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// More like this buttons
|
||||
document.querySelectorAll('.more-like-this').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const variant = btn.dataset.variant;
|
||||
// Set regeneration context
|
||||
document.querySelectorAll('.regen-chiclet').forEach(c => c.classList.remove('active'));
|
||||
document.getElementById('regen-custom-input').value = 'More like variant ' + variant;
|
||||
// Trigger regenerate
|
||||
submitRegenerate('more_like_' + variant);
|
||||
});
|
||||
});
|
||||
|
||||
// Regenerate button
|
||||
document.getElementById('regen-btn').addEventListener('click', () => {
|
||||
const activeChiclet = document.querySelector('.regen-chiclet.active');
|
||||
const customInput = document.getElementById('regen-custom-input').value;
|
||||
const action = activeChiclet ? activeChiclet.dataset.action : 'custom';
|
||||
const detail = customInput || action;
|
||||
submitRegenerate(detail);
|
||||
});
|
||||
|
||||
function postFeedback(feedback) {
|
||||
if (!window.__GSTACK_SERVER_URL) return Promise.resolve(null);
|
||||
return fetch(window.__GSTACK_SERVER_URL + '/api/feedback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(feedback),
|
||||
}).then(function(r) { return r.json(); }).catch(function() { return null; });
|
||||
}
|
||||
|
||||
function disableAllInputs() {
|
||||
document.querySelectorAll('input, button, textarea, .star, .regen-chiclet').forEach(function(el) {
|
||||
el.disabled = true;
|
||||
el.style.pointerEvents = 'none';
|
||||
el.style.opacity = '0.5';
|
||||
});
|
||||
}
|
||||
|
||||
function showPostSubmitState() {
|
||||
disableAllInputs();
|
||||
var _regenBar = document.querySelector('.regenerate-bar') || document.querySelector('.regen-column');
|
||||
if (_regenBar) _regenBar.style.display = 'none';
|
||||
document.getElementById('submit-btn').style.display = 'none';
|
||||
document.getElementById('success-msg').style.display = 'block';
|
||||
document.getElementById('success-msg').innerHTML =
|
||||
'Feedback received! Return to your coding agent.' +
|
||||
'<br><small style="color:#666;margin-top:8px;display:block;">Want to make more changes? Run <code>/design-shotgun</code> again.</small>';
|
||||
}
|
||||
|
||||
function showRegeneratingState() {
|
||||
disableAllInputs();
|
||||
document.querySelector('.variants').innerHTML =
|
||||
'<div style="text-align:center;padding:80px 24px;color:#666;">' +
|
||||
'<div style="font-size:24px;margin-bottom:12px;">Generating new designs...</div>' +
|
||||
'<div class="skeleton" style="width:60px;height:60px;border-radius:50%;margin:0 auto;"></div>' +
|
||||
'</div>';
|
||||
var _regenBar = document.querySelector('.regenerate-bar') || document.querySelector('.regen-column');
|
||||
if (_regenBar) _regenBar.style.display = 'none';
|
||||
var _submitBar = document.querySelector('.submit-bar') || document.querySelector('.submit-column');
|
||||
if (_submitBar) _submitBar.style.display = 'none';
|
||||
var _overallSec = document.querySelector('.overall-section') || document.querySelector('.bottom-section');
|
||||
if (_overallSec) _overallSec.style.display = 'none';
|
||||
startProgressPolling();
|
||||
}
|
||||
|
||||
function startProgressPolling() {
|
||||
if (!window.__GSTACK_SERVER_URL) return;
|
||||
var pollCount = 0;
|
||||
var maxPolls = 150; // 5 min at 2s intervals
|
||||
var pollInterval = setInterval(function() {
|
||||
pollCount++;
|
||||
if (pollCount >= maxPolls) {
|
||||
clearInterval(pollInterval);
|
||||
document.querySelector('.variants').innerHTML =
|
||||
'<div style="text-align:center;padding:80px 24px;color:#666;">' +
|
||||
'<div style="font-size:18px;margin-bottom:8px;">Something went wrong.</div>' +
|
||||
'<div>Run <code>/design-shotgun</code> again in your coding agent.</div>' +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
fetch(window.__GSTACK_SERVER_URL + '/api/progress')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.status === 'serving') {
|
||||
clearInterval(pollInterval);
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
// Server gone, stop polling
|
||||
clearInterval(pollInterval);
|
||||
document.querySelector('.variants').innerHTML =
|
||||
'<div style="text-align:center;padding:80px 24px;color:#666;">' +
|
||||
'<div style="font-size:18px;margin-bottom:8px;">Connection lost.</div>' +
|
||||
'<div>Run <code>/design-shotgun</code> again in your coding agent.</div>' +
|
||||
'</div>';
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function showPostFailure(feedback) {
|
||||
disableAllInputs();
|
||||
var json = JSON.stringify(feedback, null, 2);
|
||||
document.getElementById('success-msg').style.display = 'block';
|
||||
document.getElementById('success-msg').innerHTML =
|
||||
'<div style="color:#c00;margin-bottom:8px;">Connection lost. Copy your feedback below and paste it in your coding agent:</div>' +
|
||||
'<pre style="text-align:left;background:#f5f5f5;padding:12px;border-radius:4px;font-size:12px;overflow-x:auto;cursor:pointer;" onclick="navigator.clipboard.writeText(this.textContent)">' +
|
||||
json.replace(/</g, '<') + '</pre>' +
|
||||
'<small style="color:#666;">Click to copy</small>';
|
||||
}
|
||||
|
||||
function submitRegenerate(detail) {
|
||||
var feedback = collectFeedback();
|
||||
feedback.regenerated = true;
|
||||
feedback.regenerateAction = detail;
|
||||
document.getElementById('feedback-result').textContent = JSON.stringify(feedback);
|
||||
document.getElementById('status').textContent = 'regenerate';
|
||||
postFeedback(feedback).then(function(result) {
|
||||
if (result && result.received) {
|
||||
showRegeneratingState();
|
||||
} else if (window.__GSTACK_SERVER_URL) {
|
||||
showPostFailure(feedback);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Submit button
|
||||
document.getElementById('submit-btn').addEventListener('click', function() {
|
||||
var feedback = collectFeedback();
|
||||
feedback.regenerated = false;
|
||||
document.getElementById('feedback-result').textContent = JSON.stringify(feedback);
|
||||
document.getElementById('status').textContent = 'submitted';
|
||||
postFeedback(feedback).then(function(result) {
|
||||
if (result && result.received) {
|
||||
showPostSubmitState();
|
||||
} else if (window.__GSTACK_SERVER_URL) {
|
||||
showPostFailure(feedback);
|
||||
} else {
|
||||
// DOM-only mode (legacy / test)
|
||||
document.getElementById('submit-btn').disabled = true;
|
||||
document.getElementById('success-msg').style.display = 'block';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function collectFeedback() {
|
||||
const preferred = document.querySelector('input[name="preferred"]:checked');
|
||||
const ratings = {};
|
||||
const comments = {};
|
||||
|
||||
document.querySelectorAll('.variant').forEach(v => {
|
||||
const variant = v.dataset.variant;
|
||||
const stars = v.querySelectorAll('.star.filled');
|
||||
ratings[variant] = stars.length;
|
||||
const input = v.querySelector('.feedback-input');
|
||||
if (input && input.value) {
|
||||
comments[variant] = input.value;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
preferred: preferred ? preferred.value : null,
|
||||
ratings,
|
||||
comments,
|
||||
overall: document.getElementById('overall-feedback').value || null,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Design-to-Code Prompt Generator.
|
||||
* Extracts implementation instructions from an approved mockup via GPT-4o vision.
|
||||
* Produces a structured prompt the agent can use to implement the design.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import { requireApiKey } from "./auth";
|
||||
import { readDesignConstraints } from "./memory";
|
||||
|
||||
export interface DesignToCodeResult {
|
||||
implementationPrompt: string;
|
||||
colors: string[];
|
||||
typography: string[];
|
||||
layout: string[];
|
||||
components: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a structured implementation prompt from an approved mockup.
|
||||
*/
|
||||
export async function generateDesignToCodePrompt(
|
||||
imagePath: string,
|
||||
repoRoot?: string,
|
||||
): Promise<DesignToCodeResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Visual diff between two mockups using GPT-4o vision.
|
||||
* Identifies what changed between design iterations or between
|
||||
* an approved mockup and the live implementation.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import { requireApiKey } from "./auth";
|
||||
|
||||
export interface DiffResult {
|
||||
differences: { area: string; description: string; severity: string }[];
|
||||
summary: string;
|
||||
matchScore: number; // 0-100, how closely they match
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two images and describe the visual differences.
|
||||
*/
|
||||
export async function diffMockups(
|
||||
beforePath: string,
|
||||
afterPath: string,
|
||||
): Promise<DiffResult> {
|
||||
const apiKey = requireApiKey();
|
||||
const beforeData = fs.readFileSync(beforePath).toString("base64");
|
||||
const afterData = fs.readFileSync(afterPath).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: "text",
|
||||
text: `Compare these two UI images. The first is the BEFORE (or design intent), the second is the AFTER (or actual implementation). Return valid JSON only:
|
||||
|
||||
{
|
||||
"differences": [
|
||||
{"area": "header", "description": "Font size changed from ~32px to ~24px", "severity": "high"},
|
||||
...
|
||||
],
|
||||
"summary": "one sentence overall assessment",
|
||||
"matchScore": 85
|
||||
}
|
||||
|
||||
severity: "high" = noticeable to any user, "medium" = visible on close inspection, "low" = minor/pixel-level.
|
||||
matchScore: 100 = identical, 0 = completely different.
|
||||
Focus on layout, typography, colors, spacing, and element presence/absence. Ignore rendering differences (anti-aliasing, sub-pixel).`,
|
||||
},
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: { url: `data:image/png;base64,${beforeData}` },
|
||||
},
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: { url: `data:image/png;base64,${afterData}` },
|
||||
},
|
||||
],
|
||||
}],
|
||||
max_tokens: 600,
|
||||
response_format: { type: "json_object" },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error(`Diff API error (${response.status}): ${error.slice(0, 200)}`);
|
||||
return { differences: [], summary: "Diff unavailable", matchScore: -1 };
|
||||
}
|
||||
|
||||
const data = await response.json() as any;
|
||||
const content = data.choices?.[0]?.message?.content?.trim() || "";
|
||||
return JSON.parse(content) as DiffResult;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a live implementation against an approved design mockup.
|
||||
* Combines diff with a pass/fail gate.
|
||||
*/
|
||||
export async function verifyAgainstMockup(
|
||||
mockupPath: string,
|
||||
screenshotPath: string,
|
||||
): Promise<{ pass: boolean; matchScore: number; diff: DiffResult }> {
|
||||
const diff = await diffMockups(mockupPath, screenshotPath);
|
||||
|
||||
// Pass if matchScore >= 70 and no high-severity differences
|
||||
const highSeverity = diff.differences.filter(d => d.severity === "high");
|
||||
const pass = diff.matchScore >= 70 && highSeverity.length === 0;
|
||||
|
||||
return { pass, matchScore: diff.matchScore, diff };
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Screenshot-to-Mockup Evolution.
|
||||
* Takes a screenshot of the live site and generates a mockup showing
|
||||
* how it SHOULD look based on a design brief.
|
||||
* Starts from reality, not blank canvas.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { requireApiKey } from "./auth";
|
||||
|
||||
export interface EvolveOptions {
|
||||
screenshot: string; // Path to current site screenshot
|
||||
brief: string; // What to change ("make it calmer", "fix the hierarchy")
|
||||
output: string; // Output path for evolved mockup
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an evolved mockup from an existing screenshot + brief.
|
||||
* Sends the screenshot as context to GPT-4o with image generation,
|
||||
* asking it to produce a new version incorporating the brief's changes.
|
||||
*/
|
||||
export async function evolve(options: EvolveOptions): Promise<void> {
|
||||
const apiKey = requireApiKey();
|
||||
const screenshotData = fs.readFileSync(options.screenshot).toString("base64");
|
||||
|
||||
console.error(`Evolving ${options.screenshot} with: "${options.brief}"`);
|
||||
const startTime = Date.now();
|
||||
|
||||
// Use the Responses API with both a text prompt referencing the screenshot
|
||||
// and the image_generation tool to produce the evolved version.
|
||||
// Since we can't send reference images directly to image_generation,
|
||||
// we describe the current state in detail first via vision, then generate.
|
||||
|
||||
// Step 1: Analyze current screenshot
|
||||
const analysis = await analyzeScreenshot(apiKey, screenshotData);
|
||||
console.error(` Analyzed current design: ${analysis.slice(0, 100)}...`);
|
||||
|
||||
// Step 2: Generate evolved version using analysis + brief
|
||||
const evolvedPrompt = [
|
||||
"Generate a pixel-perfect UI mockup that is an improved version of an existing design.",
|
||||
"",
|
||||
"CURRENT DESIGN (what exists now):",
|
||||
analysis,
|
||||
"",
|
||||
"REQUESTED CHANGES:",
|
||||
options.brief,
|
||||
"",
|
||||
"Generate a new mockup that keeps the existing layout structure but applies the requested changes.",
|
||||
"The result should look like a real production UI. All text must be readable.",
|
||||
"1536x1024 pixels.",
|
||||
].join("\n");
|
||||
|
||||
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: evolvedPrompt,
|
||||
tools: [{ type: "image_generation", size: "1536x1024", quality: "high" }],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`API error (${response.status}): ${error.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(options.output), { recursive: true });
|
||||
const imageBuffer = Buffer.from(imageItem.result, "base64");
|
||||
fs.writeFileSync(options.output, imageBuffer);
|
||||
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.error(`Generated (${elapsed}s, ${(imageBuffer.length / 1024).toFixed(0)}KB) → ${options.output}`);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
outputPath: options.output,
|
||||
sourceScreenshot: options.screenshot,
|
||||
brief: options.brief,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a screenshot to produce a detailed description for re-generation.
|
||||
*/
|
||||
async function analyzeScreenshot(apiKey: string, imageBase64: string): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 30_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,${imageBase64}` },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `Describe this UI in detail for re-creation. Include: overall layout structure, color scheme (hex values), typography (sizes, weights), specific text content visible, spacing between elements, alignment patterns, and any decorative elements. Be precise enough that someone could recreate this UI from your description alone. 200 words max.`,
|
||||
},
|
||||
],
|
||||
}],
|
||||
max_tokens: 400,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return "Unable to analyze screenshot";
|
||||
}
|
||||
|
||||
const data = await response.json() as any;
|
||||
return data.choices?.[0]?.message?.content?.trim() || "Unable to analyze screenshot";
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Design history gallery — generates an HTML timeline of all design explorations
|
||||
* for a project. Shows every approved/rejected variant, feedback notes, organized
|
||||
* by date. Self-contained HTML with base64-embedded images.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export interface GalleryOptions {
|
||||
designsDir: string; // ~/.gstack/projects/$SLUG/designs/
|
||||
output: string;
|
||||
}
|
||||
|
||||
interface SessionData {
|
||||
dir: string;
|
||||
name: string;
|
||||
date: string;
|
||||
approved: any | null;
|
||||
variants: string[]; // paths to variant PNGs
|
||||
}
|
||||
|
||||
export function generateGalleryHtml(designsDir: string): string {
|
||||
const sessions: SessionData[] = [];
|
||||
|
||||
if (!fs.existsSync(designsDir)) {
|
||||
return generateEmptyGallery();
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(designsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const sessionDir = path.join(designsDir, entry.name);
|
||||
let approved: any = null;
|
||||
|
||||
// Read approved.json if it exists
|
||||
const approvedPath = path.join(sessionDir, "approved.json");
|
||||
if (fs.existsSync(approvedPath)) {
|
||||
try {
|
||||
approved = JSON.parse(fs.readFileSync(approvedPath, "utf-8"));
|
||||
} catch {
|
||||
// Corrupted JSON, skip but still show the session
|
||||
}
|
||||
}
|
||||
|
||||
// Find variant PNGs
|
||||
const variants: string[] = [];
|
||||
try {
|
||||
const files = fs.readdirSync(sessionDir);
|
||||
for (const f of files) {
|
||||
if (f.match(/variant-[A-Z]\.png$/i) || f.match(/variant-\d+\.png$/i)) {
|
||||
variants.push(path.join(sessionDir, f));
|
||||
}
|
||||
}
|
||||
variants.sort();
|
||||
} catch {
|
||||
// Can't read directory, skip
|
||||
}
|
||||
|
||||
// Extract date from directory name (e.g., homepage-20260327)
|
||||
const dateMatch = entry.name.match(/(\d{8})$/);
|
||||
const date = dateMatch
|
||||
? `${dateMatch[1].slice(0, 4)}-${dateMatch[1].slice(4, 6)}-${dateMatch[1].slice(6, 8)}`
|
||||
: approved?.date?.slice(0, 10) || "Unknown";
|
||||
|
||||
sessions.push({
|
||||
dir: sessionDir,
|
||||
name: entry.name.replace(/-\d{8}$/, "").replace(/-/g, " "),
|
||||
date,
|
||||
approved,
|
||||
variants,
|
||||
});
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return generateEmptyGallery();
|
||||
}
|
||||
|
||||
// Sort by date, newest first
|
||||
sessions.sort((a, b) => b.date.localeCompare(a.date));
|
||||
|
||||
const sessionCards = sessions.map(session => {
|
||||
const variantImgs = session.variants.map((vPath, i) => {
|
||||
try {
|
||||
const imgData = fs.readFileSync(vPath).toString("base64");
|
||||
const ext = path.extname(vPath).slice(1) || "png";
|
||||
const label = path.basename(vPath, `.${ext}`).replace("variant-", "");
|
||||
const isApproved = session.approved?.approved_variant === label;
|
||||
return `
|
||||
<div class="gallery-variant ${isApproved ? "approved" : ""}">
|
||||
<img src="data:image/${ext};base64,${imgData}" alt="Variant ${label}" />
|
||||
<div class="gallery-variant-label">
|
||||
${label}${isApproved ? ' <span class="approved-badge">approved</span>' : ""}
|
||||
</div>
|
||||
</div>`;
|
||||
} catch {
|
||||
return ""; // Skip unreadable images
|
||||
}
|
||||
}).filter(Boolean).join("\n");
|
||||
|
||||
const feedbackNote = session.approved?.feedback
|
||||
? `<div class="gallery-feedback">"${escapeHtml(String(session.approved.feedback))}"</div>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="gallery-session">
|
||||
<div class="gallery-session-header">
|
||||
<h2>${escapeHtml(session.name)}</h2>
|
||||
<span class="gallery-date">${session.date}</span>
|
||||
</div>
|
||||
${feedbackNote}
|
||||
<div class="gallery-variants">${variantImgs}</div>
|
||||
</div>`;
|
||||
}).join("\n");
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Design History</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
}
|
||||
.header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
.header h1 { font-size: 16px; font-weight: 600; }
|
||||
.header .meta { font-size: 13px; color: #999; margin-top: 4px; }
|
||||
.gallery { max-width: 1200px; margin: 0 auto; padding: 0 24px; }
|
||||
.gallery-session {
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
padding: 24px 0;
|
||||
}
|
||||
.gallery-session:last-child { border-bottom: none; }
|
||||
.gallery-session-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.gallery-session-header h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.gallery-date { font-size: 13px; color: #999; }
|
||||
.gallery-feedback {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.gallery-variants {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.gallery-variant img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.gallery-variant.approved img {
|
||||
border-color: #000;
|
||||
}
|
||||
.gallery-variant-label {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-top: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
.approved-badge {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-style: normal;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 80px 24px;
|
||||
color: #999;
|
||||
}
|
||||
.empty h2 { font-size: 18px; margin-bottom: 8px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Design History</h1>
|
||||
<div class="meta">${sessions.length} exploration${sessions.length === 1 ? "" : "s"}</div>
|
||||
</div>
|
||||
<div class="gallery">
|
||||
${sessionCards}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function generateEmptyGallery(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Design History</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
background: #fff; color: #333;
|
||||
}
|
||||
.empty { text-align: center; padding: 80px 24px; color: #999; }
|
||||
.empty h2 { font-size: 18px; margin-bottom: 8px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="empty">
|
||||
<h2>No design history yet</h2>
|
||||
<p>Run <code>/design-shotgun</code> to start exploring design directions.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gallery command: generate HTML timeline from design explorations.
|
||||
*/
|
||||
export function gallery(options: GalleryOptions): void {
|
||||
const html = generateGalleryHtml(options.designsDir);
|
||||
const outputDir = path.dirname(options.output);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
fs.writeFileSync(options.output, html);
|
||||
console.log(JSON.stringify({ outputPath: options.output }));
|
||||
}
|
||||
@@ -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<GenerateResult> {
|
||||
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!;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Multi-turn design iteration using OpenAI Responses API.
|
||||
*
|
||||
* Primary: uses previous_response_id for conversational threading.
|
||||
* Fallback: if threading doesn't retain visual context, re-generates
|
||||
* with original brief + accumulated feedback in a single prompt.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { requireApiKey } from "./auth";
|
||||
import { readSession, updateSession } from "./session";
|
||||
|
||||
export interface IterateOptions {
|
||||
session: string; // Path to session JSON file
|
||||
feedback: string; // User feedback text
|
||||
output: string; // Output path for new PNG
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate on an existing design using session state.
|
||||
*/
|
||||
export async function iterate(options: IterateOptions): Promise<void> {
|
||||
const apiKey = requireApiKey();
|
||||
const session = readSession(options.session);
|
||||
|
||||
console.error(`Iterating on session ${session.id}...`);
|
||||
console.error(` Previous iterations: ${session.feedbackHistory.length}`);
|
||||
console.error(` Feedback: "${options.feedback}"`);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Try multi-turn with previous_response_id first
|
||||
let success = false;
|
||||
let responseId = "";
|
||||
|
||||
try {
|
||||
const result = await callWithThreading(apiKey, session.lastResponseId, options.feedback);
|
||||
responseId = result.responseId;
|
||||
|
||||
fs.mkdirSync(path.dirname(options.output), { recursive: true });
|
||||
fs.writeFileSync(options.output, Buffer.from(result.imageData, "base64"));
|
||||
success = true;
|
||||
} catch (err: any) {
|
||||
console.error(` Threading failed: ${err.message}`);
|
||||
console.error(" Falling back to re-generation with accumulated feedback...");
|
||||
|
||||
// Fallback: re-generate with original brief + all feedback
|
||||
const accumulatedPrompt = buildAccumulatedPrompt(
|
||||
session.originalBrief,
|
||||
[...session.feedbackHistory, options.feedback]
|
||||
);
|
||||
|
||||
const result = await callFresh(apiKey, accumulatedPrompt);
|
||||
responseId = result.responseId;
|
||||
|
||||
fs.mkdirSync(path.dirname(options.output), { recursive: true });
|
||||
fs.writeFileSync(options.output, Buffer.from(result.imageData, "base64"));
|
||||
success = true;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
const size = fs.statSync(options.output).size;
|
||||
console.error(`Generated (${elapsed}s, ${(size / 1024).toFixed(0)}KB) → ${options.output}`);
|
||||
|
||||
// Update session
|
||||
updateSession(session, responseId, options.feedback, options.output);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
outputPath: options.output,
|
||||
sessionFile: options.session,
|
||||
responseId,
|
||||
iteration: session.feedbackHistory.length + 1,
|
||||
}, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
async function callWithThreading(
|
||||
apiKey: string,
|
||||
previousResponseId: string,
|
||||
feedback: 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: `Based on the previous design, make these changes: ${feedback}`,
|
||||
previous_response_id: previousResponseId,
|
||||
tools: [{ type: "image_generation", size: "1536x1024", quality: "high" }],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`API error (${response.status}): ${error.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
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 threaded response");
|
||||
}
|
||||
|
||||
return { responseId: data.id, imageData: imageItem.result };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function callFresh(
|
||||
apiKey: string,
|
||||
prompt: 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: "1536x1024", quality: "high" }],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`API error (${response.status}): ${error.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
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 fresh response");
|
||||
}
|
||||
|
||||
return { responseId: data.id, imageData: imageItem.result };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function buildAccumulatedPrompt(originalBrief: string, feedback: string[]): string {
|
||||
const lines = [
|
||||
originalBrief,
|
||||
"",
|
||||
"Previous feedback (apply all of these changes):",
|
||||
];
|
||||
|
||||
feedback.forEach((f, i) => {
|
||||
lines.push(`${i + 1}. ${f}`);
|
||||
});
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
"Generate a new mockup incorporating ALL the feedback above.",
|
||||
"The result should look like a real production UI, not a wireframe."
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Design Memory — extract visual language from approved mockups into DESIGN.md.
|
||||
*
|
||||
* After a mockup is approved, uses GPT-4o vision to extract:
|
||||
* - Color palette (hex values)
|
||||
* - Typography (font families, sizes, weights)
|
||||
* - Spacing patterns (padding, margins, gaps)
|
||||
* - Layout conventions (grid, alignment, hierarchy)
|
||||
*
|
||||
* If DESIGN.md exists, merges extracted patterns with existing design system.
|
||||
* If no DESIGN.md, creates one from the extracted patterns.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { requireApiKey } from "./auth";
|
||||
|
||||
export interface ExtractedDesign {
|
||||
colors: { name: string; hex: string; usage: string }[];
|
||||
typography: { role: string; family: string; size: string; weight: string }[];
|
||||
spacing: string[];
|
||||
layout: string[];
|
||||
mood: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract visual language from an approved mockup PNG.
|
||||
*/
|
||||
export async function extractDesignLanguage(imagePath: string): Promise<ExtractedDesign> {
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* HTTP server for the design comparison board feedback loop.
|
||||
*
|
||||
* Replaces the broken file:// + DOM polling approach. The server:
|
||||
* 1. Serves the comparison board HTML over HTTP
|
||||
* 2. Injects __GSTACK_SERVER_URL so the board POSTs feedback here
|
||||
* 3. Prints feedback JSON to stdout (agent reads it)
|
||||
* 4. Stays alive across regeneration rounds (stateful)
|
||||
* 5. Auto-opens in the user's default browser
|
||||
*
|
||||
* State machine:
|
||||
*
|
||||
* SERVING ──(POST submit)──► DONE ──► exit 0
|
||||
* │
|
||||
* ├──(POST regenerate/remix)──► REGENERATING
|
||||
* │ │
|
||||
* │ (POST /api/reload)
|
||||
* │ │
|
||||
* │ ▼
|
||||
* │ RELOADING ──► SERVING
|
||||
* │
|
||||
* └──(timeout)──► exit 1
|
||||
*
|
||||
* Feedback delivery (two channels, both always active):
|
||||
* Stdout: feedback JSON (one line per event) — for foreground mode
|
||||
* Disk: feedback-pending.json (regenerate/remix) or feedback.json (submit)
|
||||
* written next to the HTML file — for background mode polling
|
||||
*
|
||||
* The agent typically backgrounds $D serve and polls for feedback-pending.json.
|
||||
* When found: read it, delete it, generate new variants, POST /api/reload.
|
||||
*
|
||||
* Stderr: structured telemetry (SERVE_STARTED, SERVE_FEEDBACK_RECEIVED, etc.)
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { spawn } from "child_process";
|
||||
|
||||
export interface ServeOptions {
|
||||
html: string;
|
||||
port?: number;
|
||||
timeout?: number; // seconds, default 600 (10 min)
|
||||
}
|
||||
|
||||
type ServerState = "serving" | "regenerating" | "done";
|
||||
|
||||
export async function serve(options: ServeOptions): Promise<void> {
|
||||
const { html, port = 0, timeout = 600 } = options;
|
||||
|
||||
// Validate HTML file exists
|
||||
if (!fs.existsSync(html)) {
|
||||
console.error(`SERVE_ERROR: HTML file not found: ${html}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let htmlContent = fs.readFileSync(html, "utf-8");
|
||||
let state: ServerState = "serving";
|
||||
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Serve the comparison board HTML
|
||||
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
|
||||
// Inject the server URL so the board can POST feedback
|
||||
const injected = htmlContent.replace(
|
||||
"</head>",
|
||||
`<script>window.__GSTACK_SERVER_URL = '${url.origin}';</script>\n</head>`
|
||||
);
|
||||
return new Response(injected, {
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
// Progress polling endpoint (used by board during regeneration)
|
||||
if (req.method === "GET" && url.pathname === "/api/progress") {
|
||||
return Response.json({ status: state });
|
||||
}
|
||||
|
||||
// Feedback submission from the board
|
||||
if (req.method === "POST" && url.pathname === "/api/feedback") {
|
||||
return handleFeedback(req);
|
||||
}
|
||||
|
||||
// Reload endpoint (used by the agent to swap in new board HTML)
|
||||
if (req.method === "POST" && url.pathname === "/api/reload") {
|
||||
return handleReload(req);
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
const actualPort = server.port;
|
||||
const boardUrl = `http://127.0.0.1:${actualPort}`;
|
||||
|
||||
console.error(`SERVE_STARTED: port=${actualPort} html=${html}`);
|
||||
|
||||
// Auto-open in user's default browser
|
||||
openBrowser(boardUrl);
|
||||
|
||||
// Set timeout
|
||||
timeoutTimer = setTimeout(() => {
|
||||
console.error(`SERVE_TIMEOUT: after=${timeout}s`);
|
||||
server.stop();
|
||||
process.exit(1);
|
||||
}, timeout * 1000);
|
||||
|
||||
async function handleFeedback(req: Request): Promise<Response> {
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate expected shape
|
||||
if (typeof body !== "object" || body === null) {
|
||||
return Response.json({ error: "Expected JSON object" }, { status: 400 });
|
||||
}
|
||||
|
||||
const isSubmit = body.regenerated === false;
|
||||
const isRegenerate = body.regenerated === true;
|
||||
const action = isSubmit ? "submitted" : (body.regenerateAction || "regenerate");
|
||||
|
||||
console.error(`SERVE_FEEDBACK_RECEIVED: type=${action}`);
|
||||
|
||||
// Print feedback JSON to stdout (for foreground mode)
|
||||
console.log(JSON.stringify(body));
|
||||
|
||||
// ALWAYS write feedback to disk so the agent can poll for it
|
||||
// (agent typically backgrounds $D serve, can't read stdout)
|
||||
const feedbackDir = path.dirname(html);
|
||||
const feedbackFile = isSubmit ? "feedback.json" : "feedback-pending.json";
|
||||
const feedbackPath = path.join(feedbackDir, feedbackFile);
|
||||
fs.writeFileSync(feedbackPath, JSON.stringify(body, null, 2));
|
||||
|
||||
if (isSubmit) {
|
||||
state = "done";
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
|
||||
// Give the response time to send before exiting
|
||||
setTimeout(() => {
|
||||
server.stop();
|
||||
process.exit(0);
|
||||
}, 100);
|
||||
|
||||
return Response.json({ received: true, action: "submitted" });
|
||||
}
|
||||
|
||||
if (isRegenerate) {
|
||||
state = "regenerating";
|
||||
// Reset timeout for regeneration (agent needs time to generate new variants)
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
timeoutTimer = setTimeout(() => {
|
||||
console.error(`SERVE_TIMEOUT: after=${timeout}s (during regeneration)`);
|
||||
server.stop();
|
||||
process.exit(1);
|
||||
}, timeout * 1000);
|
||||
|
||||
return Response.json({ received: true, action: "regenerate" });
|
||||
}
|
||||
|
||||
return Response.json({ received: true, action: "unknown" });
|
||||
}
|
||||
|
||||
async function handleReload(req: Request): Promise<Response> {
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const newHtmlPath = body.html;
|
||||
if (!newHtmlPath || !fs.existsSync(newHtmlPath)) {
|
||||
return Response.json(
|
||||
{ error: `HTML file not found: ${newHtmlPath}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Swap the HTML content
|
||||
htmlContent = fs.readFileSync(newHtmlPath, "utf-8");
|
||||
state = "serving";
|
||||
|
||||
console.error(`SERVE_RELOADED: html=${newHtmlPath}`);
|
||||
|
||||
// Reset timeout
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
timeoutTimer = setTimeout(() => {
|
||||
console.error(`SERVE_TIMEOUT: after=${timeout}s`);
|
||||
server.stop();
|
||||
process.exit(1);
|
||||
}, timeout * 1000);
|
||||
|
||||
return Response.json({ reloaded: true });
|
||||
}
|
||||
|
||||
// Keep the process alive
|
||||
await new Promise(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a URL in the user's default browser.
|
||||
* Handles macOS (open), Linux (xdg-open), and headless environments.
|
||||
*/
|
||||
function openBrowser(url: string): void {
|
||||
const platform = process.platform;
|
||||
let cmd: string;
|
||||
|
||||
if (platform === "darwin") {
|
||||
cmd = "open";
|
||||
} else if (platform === "linux") {
|
||||
cmd = "xdg-open";
|
||||
} else {
|
||||
// Windows or unknown — just print the URL
|
||||
console.error(`SERVE_BROWSER_MANUAL: url=${url}`);
|
||||
console.error(`Open this URL in your browser: ${url}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const child = spawn(cmd, [url], {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
});
|
||||
child.unref();
|
||||
console.error(`SERVE_BROWSER_OPENED: url=${url}`);
|
||||
} catch {
|
||||
// open/xdg-open not available (headless CI environment)
|
||||
console.error(`SERVE_BROWSER_MANUAL: url=${url}`);
|
||||
console.error(`Open this URL in your browser: ${url}`);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Generate N design variants from a brief.
|
||||
* Uses staggered parallel: 1s delay between API calls to avoid rate limits.
|
||||
* Falls back to exponential backoff on 429s.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { requireApiKey } from "./auth";
|
||||
import { parseBrief } from "./brief";
|
||||
|
||||
export interface VariantsOptions {
|
||||
brief?: string;
|
||||
briefFile?: string;
|
||||
count: number;
|
||||
outputDir: string;
|
||||
size?: string;
|
||||
quality?: string;
|
||||
viewports?: string; // "desktop,tablet,mobile" — generates at multiple sizes
|
||||
}
|
||||
|
||||
const STYLE_VARIATIONS = [
|
||||
"", // First variant uses the brief as-is
|
||||
"Use a bolder, more dramatic visual style with stronger contrast and larger typography.",
|
||||
"Use a calmer, more minimal style with generous whitespace and subtle colors.",
|
||||
"Use a warmer, more approachable style with rounded corners and friendly typography.",
|
||||
"Use a more professional, corporate style with sharp edges and structured grid layout.",
|
||||
"Use a dark theme with light text and accent colors for key interactive elements.",
|
||||
"Use a playful, modern style with asymmetric layout and unexpected color accents.",
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate a single variant with retry on 429.
|
||||
*/
|
||||
async function generateVariant(
|
||||
apiKey: string,
|
||||
prompt: string,
|
||||
outputPath: string,
|
||||
size: string,
|
||||
quality: string,
|
||||
): Promise<{ path: string; success: boolean; error?: string }> {
|
||||
const maxRetries = 3;
|
||||
let lastError = "";
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
if (attempt > 0) {
|
||||
// Exponential backoff: 2s, 4s, 8s
|
||||
const delay = Math.pow(2, attempt) * 1000;
|
||||
console.error(` Rate limited, retrying in ${delay / 1000}s...`);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (response.status === 429) {
|
||||
lastError = "Rate limited (429)";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
return { path: outputPath, success: false, error: `API error (${response.status}): ${error.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
const data = await response.json() as any;
|
||||
const imageItem = data.output?.find((item: any) => item.type === "image_generation_call");
|
||||
|
||||
if (!imageItem?.result) {
|
||||
return { path: outputPath, success: false, error: "No image data in response" };
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, Buffer.from(imageItem.result, "base64"));
|
||||
return { path: outputPath, success: true };
|
||||
} catch (err: any) {
|
||||
clearTimeout(timeout);
|
||||
if (err.name === "AbortError") {
|
||||
return { path: outputPath, success: false, error: "Timeout (120s)" };
|
||||
}
|
||||
lastError = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
return { path: outputPath, success: false, error: lastError };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate N variants with staggered parallel execution.
|
||||
*/
|
||||
export async function variants(options: VariantsOptions): Promise<void> {
|
||||
const apiKey = requireApiKey();
|
||||
const baseBrief = options.briefFile
|
||||
? parseBrief(options.briefFile, true)
|
||||
: parseBrief(options.brief!, false);
|
||||
|
||||
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();
|
||||
|
||||
// Staggered parallel: start each call 1.5s apart
|
||||
const promises: Promise<{ path: string; success: boolean; error?: string }>[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const variation = STYLE_VARIATIONS[i] || "";
|
||||
const prompt = variation
|
||||
? `${baseBrief}\n\nStyle direction: ${variation}`
|
||||
: baseBrief;
|
||||
|
||||
const outputPath = path.join(options.outputDir, `variant-${String.fromCharCode(65 + i)}.png`);
|
||||
|
||||
// Stagger: wait 1.5s between launches
|
||||
const delay = i * 1500;
|
||||
promises.push(
|
||||
new Promise(resolve => setTimeout(resolve, delay))
|
||||
.then(() => {
|
||||
console.error(` Starting variant ${String.fromCharCode(65 + i)}...`);
|
||||
return generateVariant(apiKey, prompt, outputPath, size, quality);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
|
||||
const succeeded: string[] = [];
|
||||
const failed: string[] = [];
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled" && result.value.success) {
|
||||
const size = fs.statSync(result.value.path).size;
|
||||
console.error(` ✓ ${path.basename(result.value.path)} (${(size / 1024).toFixed(0)}KB)`);
|
||||
succeeded.push(result.value.path);
|
||||
} else {
|
||||
const error = result.status === "fulfilled" ? result.value.error : (result.reason as Error).message;
|
||||
const filePath = result.status === "fulfilled" ? result.value.path : "unknown";
|
||||
console.error(` ✗ ${path.basename(filePath)}: ${error}`);
|
||||
failed.push(path.basename(filePath));
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`\n${succeeded.length}/${count} variants generated (${elapsed}s)`);
|
||||
|
||||
// Output structured result to stdout
|
||||
console.log(JSON.stringify({
|
||||
outputDir: options.outputDir,
|
||||
count,
|
||||
succeeded: succeeded.length,
|
||||
failed: failed.length,
|
||||
paths: succeeded,
|
||||
errors: failed,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
const VIEWPORT_CONFIGS: Record<string, { size: string; suffix: string; desc: string }> = {
|
||||
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<void> {
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user