Files
gstack/design/src/diff.ts
T
Garry Tanandcapy-ai[bot] 71acd83aa6 v1.92.11.0 feat: design mockups with Gemini when there's no OpenAI key (#990) (#3768)
* design: make images and read screenshots with Gemini when there is no OpenAI key (#990)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design: test the live Gemini check; set fixture env keys without secret-shaped assignments

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (setup keeps piped keys and names the provider export; Gemini iterate fallback tests)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design: setup doc comment matches --key-stdin behavior

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: Gemini saves the final image of a thinking answer; iterate uploads only absolute-path PNGs under 15 MB; sessions store absolute output paths

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* changelog: fragment holds the entry text only

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* todos: Gemini design provider follow-ups

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: the egress fail-open list names design Gemini calls

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-01 05:17:06 +00:00

95 lines
3.3 KiB
TypeScript

/**
* Visual diff between two mockups using the picked provider's vision model.
* Identifies what changed between design iterations or between
* an approved mockup and the live implementation.
*/
import fs from "fs";
import { requireProvider } from "./auth";
import { receiptedFetch } from "./receipted-fetch";
import { visionErrorHint, visionRequest } from "./provider";
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 provider = requireProvider();
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 request = visionRequest(provider, [{
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}` },
},
],
}], 600, { response_format: { type: "json_object" } });
const response = await receiptedFetch("diff-screenshots-request", request.url, { ...request.init, signal: controller.signal }, globalThis.fetch, request.receipt);
if (!response.ok) {
const error = await response.text();
console.error(`Diff API error (${response.status}): ${error.slice(0, 200)}${visionErrorHint(provider, response.status, error)}`);
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 };
}