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>
This commit is contained in:
Garry Tan
2026-03-26 22:16:12 -06:00
parent 5a7c2bc638
commit 9c1b7096a8
3 changed files with 225 additions and 0 deletions
+18
View File
@@ -19,6 +19,7 @@ import { compare } from "./compare";
import { variants } from "./variants";
import { iterate } from "./iterate";
import { resolveApiKey, saveApiKey } from "./auth";
import { extractDesignLanguage, updateDesignMd } from "./memory";
function parseArgs(argv: string[]): { command: string; flags: Record<string, string | boolean> } {
const args = argv.slice(2); // skip bun/node and script path
@@ -160,6 +161,23 @@ async function main(): Promise<void> {
});
break;
case "extract": {
const imagePath = flags.image as string;
if (!imagePath) {
console.error("--image is required");
process.exit(1);
}
console.error(`Extracting design language from ${imagePath}...`);
const extracted = await extractDesignLanguage(imagePath);
const proc = Bun.spawn(["git", "rev-parse", "--show-toplevel"]);
const repoRoot = (await new Response(proc.stdout).text()).trim();
if (repoRoot) {
updateDesignMd(repoRoot, extracted, imagePath);
}
console.log(JSON.stringify(extracted, null, 2));
break;
}
case "diff":
case "evolve":
case "verify":
+5
View File
@@ -54,6 +54,11 @@ export const COMMANDS = new Map<string, {
usage: "verify --mockup approved.png --screenshot live.png",
flags: ["--mockup", "--screenshot", "--output"],
}],
["extract", {
description: "Extract design language from approved mockup into DESIGN.md",
usage: "extract --image approved.png",
flags: ["--image"],
}],
["setup", {
description: "Guided API key setup + smoke test",
usage: "setup",
+202
View File
@@ -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);
}