Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { program } from 'commander';
import { FigmaClient, FigmaApiError, scan, formatJson, formatTerminal, formatHtml, buildGraph, validateLicenseKey, type Issue } from '@protoscan/core';
import { FigmaClient, FigmaApiError, scan, formatJson, formatTerminal, formatHtml, buildGraph, validateLicenseKey, type Issue, type Metric } from '@protoscan/core';
import { writeFileSync, existsSync } from 'node:fs';

program
Expand Down Expand Up @@ -35,7 +35,8 @@ program
.option('--simulate', 'Run headless Playwright simulator to detect runtime nav failures (slow, requires @protoscan/simulator)')
.option('--record [dir]', 'Record simulator walkthrough video (requires --simulate, saves .webm)')
.option('--upload', 'Upload HTML report to GitHub Gist and return shareable URL (requires GITHUB_TOKEN)')
.option('--vision', 'Run AI vision analysis on each screen with GPT-4o (requires PROTOSCAN_API_KEY or OPENAI_API_KEY)')
.option('--vision', 'Run AI vision Tier-2 analysis on each screen (requires PROTOSCAN_API_KEY or OPENAI_API_KEY). Deepens the score to core+vision; implies --score')
.option('--provider <name>', 'Vision provider: openai (default, GPT-4o) | claude (not yet implemented)')
// PROTOSCAN_API_KEY via env var only — never pass keys as CLI args (shell history exposure)
.option('--max-vision-cost <usd>', 'Maximum USD to spend on vision analysis', '5')
.option('--metrics', 'Run ProtoScan Science metrics (WCAG contrast, Hick, Miller, Fitts, typography, readability, Gestalt, emphasis, balance)')
Expand Down Expand Up @@ -133,11 +134,12 @@ program
}

let visionIssues: Issue[] = [];
let visionMetrics: Metric[] = [];
if (options.vision) {
const protoscanKey = process.env.PROTOSCAN_API_KEY;
const openaiKey = process.env.OPENAI_API_KEY;
const screenCount = graph.nodes.size;
const COST_PER_SCREEN = 0.005;
const COST_PER_SCREEN = 0.009;
const estimatedCost = (screenCount * COST_PER_SCREEN).toFixed(2);
const maxCost = parseFloat(options.maxVisionCost);
const cappedScreens = Math.min(screenCount, Math.floor(maxCost / COST_PER_SCREEN));
Expand All @@ -154,14 +156,16 @@ program
try {
console.error('Running AI vision analysis via ProtoScan API...');
const { analyzeVisionProxy } = await import('@protoscan/vision');
visionIssues = await analyzeVisionProxy(graph, {
const vr = await analyzeVisionProxy(graph, {
protoscanApiKey: protoscanKey,
figmaToken: token,
fileKey,
maxCost,
maxScreens: 200,
});
console.error(`Vision: ${visionIssues.length} issue(s) found.`);
visionIssues = vr.issues;
visionMetrics = vr.metrics;
console.error(`Vision: ${vr.issues.length} issue(s), ${vr.metrics.length} metric(s) across ${vr.coverage.analyzed}/${vr.coverage.total} screens.`);
} catch (err: unknown) {
if (isModuleNotFound(err, '@protoscan/vision')) {
console.error('Error: --vision is not available in this distribution.');
Expand All @@ -182,14 +186,17 @@ program
try {
console.error('Running AI vision analysis (this may take a few minutes)...');
const { analyzeVision } = await import('@protoscan/vision');
visionIssues = await analyzeVision(graph, {
const vr = await analyzeVision(graph, {
figmaToken: token,
openaiApiKey: openaiKey,
fileKey,
maxCost,
maxScreens: 200,
provider: options.provider as 'openai' | 'claude' | undefined,
});
console.error(`Vision: ${visionIssues.length} issue(s) found.`);
visionIssues = vr.issues;
visionMetrics = vr.metrics;
console.error(`Vision: ${vr.issues.length} issue(s), ${vr.metrics.length} metric(s) across ${vr.coverage.analyzed}/${vr.coverage.total} screens.`);
} catch (err: unknown) {
if (isModuleNotFound(err, '@protoscan/vision')) {
console.error('Error: --vision is not available in this distribution.');
Expand All @@ -212,8 +219,9 @@ program
skip: options.skip?.split(',').map((s: string) => s.trim()),
pageIds: pageIds.length ? pageIds : undefined,
additionalIssues: [...simulatorIssues, ...visionIssues],
metrics: !!(options.metrics || options.score || options.ergonomics),
score: !!(options.score || options.ergonomics),
additionalMetrics: visionMetrics,
metrics: !!(options.metrics || options.score || options.ergonomics || options.vision),
score: !!(options.score || options.ergonomics || options.vision),
ignorePatterns: options.ignore?.split(',').map((s: string) => s.trim()).filter(Boolean),
});

Expand Down
58 changes: 42 additions & 16 deletions apps/web/api/vision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,33 @@ const OPENAI_API_KEY = process.env.OPENAI_API_KEY!;

const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB base64 (~3.75 MB image)

const SYSTEM_PROMPT = `You are a UX visual analyst. Analyze this Figma screen screenshot and identify visual UX issues.
// Keep in sync with packages/vision/src/prompts.ts SYSTEM_PROMPT.
const SYSTEM_PROMPT = `You are a senior UX/UI designer doing a visual QA audit of ONE mobile app prototype screen (a single static screenshot).

Return a JSON array of findings. Each finding has:
- category: "vision-contrast" | "vision-clarity" | "vision-empty-state" | "vision-overload"
- severity: "high" | "medium" | "low"
- message: brief description of the issue
- area: where on the screen (e.g., "top CTA button", "card list area")
Return STRICT JSON (no markdown) shaped exactly:
{
"findings": [ { "category": "...", "severity": "...", "message": "...", "area": "..." } ],
"dimensions": [ { "dimension": "...", "score": 0-100 | null, "note": "..." } ]
}

Rules:
- Return ONLY a JSON array, no markdown, no explanation
- Maximum 3 findings per screen
- Only flag genuine issues, not style preferences`;
FINDINGS (qualitative issues, max 3, [] if the screen is fine) — categories ONLY:
- vision-contrast: text/important elements with insufficient contrast
- vision-clarity: ambiguous CTAs, unclear labels, confusing hierarchy
- vision-empty-state: empty/broken/incomplete screen
- vision-overload: too many competing elements, no clear focal point
severity: high | medium | low. Only confident issues; no style preferences.

DIMENSIONS (grade each 0-100, higher = better) — score these 6:
- clutter: low visual density / few competing elements
- saliency: clear focal point / visual priority for the primary action
- feedback: the screen visibly communicates state/result (loading, success, error, selected)
- consistency: repeated elements (buttons, headers, spacing) look uniform
- affordance: interactive elements look tappable; what's actionable is obvious
- guidance: labels/prompts/empty-states orient the user to the next step

CRITICAL: If you CANNOT fairly judge a dimension from a SINGLE STATIC screenshot
(feedback, affordance and guidance often need interaction), set its "score" to null
instead of guessing. Include all 6 dimension objects; use null where you cannot assess.`;

function hashKey(key: string): string {
return createHash('sha256').update(key).digest('hex');
Expand Down Expand Up @@ -131,28 +146,39 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
const completion = await openai.chat.completions.create({
model: 'gpt-4o',
max_tokens: 500,
max_tokens: 900,
temperature: 0,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{
role: 'user',
content: [
{ type: 'text', text: `Analyze this screen: "${screenName ?? 'Unknown'}"` },
{ type: 'text', text: `Audit this screen: "${screenName ?? 'Unknown'}". Return the JSON object.` },
{ type: 'image_url', image_url: { url: `data:image/png;base64,${image}`, detail: 'low' } },
],
},
],
});

const raw = completion.choices[0]?.message?.content ?? '[]';
let findings: unknown[];
const raw = completion.choices[0]?.message?.content ?? '{}';
let findings: unknown[] = [];
let dimensions: unknown[] = [];
try {
findings = JSON.parse(raw);
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
findings = parsed; // legacy/defensive: model returned a bare array
} else if (parsed && typeof parsed === 'object') {
findings = Array.isArray(parsed.findings) ? parsed.findings : [];
dimensions = Array.isArray(parsed.dimensions) ? parsed.dimensions : [];
}
} catch {
findings = [];
dimensions = [];
}

return res.status(200).json({ findings });
// Additive response: old clients read `.findings` and ignore `.dimensions`.
return res.status(200).json({ findings, dimensions });
} catch (error) {
// Refund the credit since OpenAI failed — user shouldn't pay for failed analysis
await fetch(`${SUPABASE_URL}/rest/v1/rpc/refund_credit`, {
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/metrics/metric.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ const DIMENSION_ISSUE_META: Record<
balance: { severity: 'low', confidence: 'probable', category: 'balance' },
clutter: { severity: 'low', confidence: 'probable', category: 'vision' },
saliency: { severity: 'low', confidence: 'probable', category: 'vision' },
feedback: { severity: 'low', confidence: 'probable', category: 'vision' },
consistency: { severity: 'low', confidence: 'probable', category: 'vision' },
affordance: { severity: 'low', confidence: 'probable', category: 'vision' },
guidance: { severity: 'low', confidence: 'probable', category: 'vision' },
};

function round(n: number): number {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/reporters/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,10 @@ function renderScoreHero(result: ScanResult): string {
<div class="score-band">${esc(bandText)}</div>
<div class="score-tier">${tierText} · coverage ${score.coverage}%</div>
</div>
<div class="score-radar">${renderRadar(dims)}</div>
<div class="score-radar">${renderRadar(dims) || `<div style="color:var(--muted);font-size:12px;padding:24px;text-align:center;">Radar needs ≥3 scored dimensions (have ${dims.length}). See the per-dimension bars.</div>`}</div>
<div class="score-dims">${bars}</div>
</div>
<div style="color:var(--muted);font-size:11px;margin:-12px 0 20px;">core = deterministic (WCAG + peer-reviewed HCI science).${score.tier === 'core+vision' ? ' vision = AI-assessed — may vary a few points run-to-run.' : ''}</div>
${fixes ? `<div class="fix-box"><h3>Top fixes → points</h3><ul class="fix-list">${fixes}</ul></div>` : ''}`;
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/reporters/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ function renderScoreBlock(result: ScanResult): string[] {
? `${score.band.emoji} ${score.band.label}`
: colors.dim('(alpha — methodology in calibration)');
out.push(colors.bold(` Usability Score: ${score.global}/100 `) + band);
out.push(colors.dim(` ${score.tier === 'core+vision' ? 'core (deterministic) + vision (AI-assessed, may vary run-to-run)' : 'core — deterministic (WCAG + HCI science)'} · coverage ${score.coverage}%`));
out.push('');
for (const d of score.byDimension) {
if (d.score === null) continue;
Expand Down
24 changes: 20 additions & 4 deletions packages/core/src/scoring/dimensions.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { MetricDimension, ScoreBand } from '../types.js';

/**
* FIXED catalog of the 10 score dimensions. The scorer ALWAYS iterates this
* full list so the global score is comparable whether or not Tier-2 ran.
* Order is the display order.
* FIXED catalog of the score dimensions (8 Tier-1 + 6 Tier-2). The scorer ALWAYS
* iterates this full list so the global score is comparable whether or not Tier-2
* ran. Order is the display order. NOTE: the free (core-only) score iterates
* TIER1_DIMENSIONS, so adding Tier-2 dims here does NOT change the free score.
*/
export const DIMENSIONS: MetricDimension[] = [
'contrast',
Expand All @@ -16,6 +17,10 @@ export const DIMENSIONS: MetricDimension[] = [
'balance',
'clutter',
'saliency',
'feedback',
'consistency',
'affordance',
'guidance',
];

export const TIER1_DIMENSIONS: MetricDimension[] = [
Expand All @@ -29,7 +34,14 @@ export const TIER1_DIMENSIONS: MetricDimension[] = [
'balance',
];

export const TIER2_DIMENSIONS: MetricDimension[] = ['clutter', 'saliency'];
export const TIER2_DIMENSIONS: MetricDimension[] = [
'clutter',
'saliency',
'feedback',
'consistency',
'affordance',
'guidance',
];

export const DIMENSION_LABELS: Record<MetricDimension, string> = {
contrast: 'Contrast',
Expand All @@ -42,6 +54,10 @@ export const DIMENSION_LABELS: Record<MetricDimension, string> = {
balance: 'Balance',
clutter: 'Visual Clutter',
saliency: 'Saliency',
feedback: 'Feedback',
consistency: 'Consistency',
affordance: 'Affordance',
guidance: 'Guidance',
};

interface BandCutoff extends ScoreBand {
Expand Down
24 changes: 23 additions & 1 deletion packages/core/src/scoring/score.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { computeScore, annotatePointsImpact, topFixGroups } from './score.js';
import { getBand, BANDS_CALIBRATED } from './dimensions.js';
import { getBand, BANDS_CALIBRATED, TIER1_DIMENSIONS, TIER2_DIMENSIONS } from './dimensions.js';
import type { Metric, MetricDimension, MetricStatus, MetricTier } from '../types.js';

let seq = 0;
Expand Down Expand Up @@ -69,6 +69,28 @@ describe('computeScore', () => {
expect(computeScore([]).global).toBe(100);
expect(computeScore([]).coverage).toBe(0);
});

it('free-score invariant: core-only score is independent of Tier-2 catalog size', () => {
// Core-only metrics across 3 Tier-1 dimensions (3 pass / 4 applicable).
const metrics = [m('contrast', 'pass'), m('contrast', 'fail'), m('fitts', 'pass'), m('typography', 'pass')];
const score = computeScore(metrics, { includeVision: false });
const dims = score.byDimension.map((d) => d.dimension);
// Only the 8 Tier-1 dims are iterated — NO Tier-2 dim (clutter/saliency/feedback/…) leaks in,
// so growing TIER2_DIMENSIONS from 2 → 6 cannot change the free score.
expect(dims).toEqual(TIER1_DIMENSIONS);
expect(dims.some((d) => TIER2_DIMENSIONS.includes(d))).toBe(false);
expect(score.tier).toBe('core');
expect(score.global).toBe(75); // 3 pass / 4 applicable
expect(score.coverage).toBe(Math.round((3 / TIER1_DIMENSIONS.length) * 100)); // 3 evaluated / 8
});

it('a single vision metric flips the tier to core+vision and fills its dimension', () => {
const metrics = [m('contrast', 'pass'), m('feedback', 'fail', 'vision')];
const score = computeScore(metrics); // auto-detects includeVision
expect(score.tier).toBe('core+vision');
const fb = score.byDimension.find((d) => d.dimension === 'feedback');
expect(fb?.score).toBe(0); // 0 pass / 1 applicable
});
});

describe('topFixGroups', () => {
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,11 @@ export type MetricDimension =
| 'emphasis'
| 'balance'
| 'clutter'
| 'saliency';
| 'saliency'
| 'feedback'
| 'consistency'
| 'affordance'
| 'guidance';

export type MetricStatus = 'pass' | 'fail' | 'na';
/** 'standard' = normative spec (WCAG). 'signal' = research-grounded heuristic. */
Expand Down
34 changes: 30 additions & 4 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { FigmaClient, FigmaApiError, scan, buildGraph, formatTerminal, formatJson, formatHtml, validateLicenseKey, topFixGroups } from '@protoscan/core';
import type { Issue, ScanResult } from '@protoscan/core';
import type { Issue, Metric, ScanResult } from '@protoscan/core';
import {
registerAppTool,
registerAppResource,
Expand Down Expand Up @@ -77,6 +77,7 @@ registerAppTool(
file_key: z.string().describe('Figma file key or full URL (e.g. "abc123" or "https://figma.com/design/abc123/Name?node-id=7-2")'),
token: z.string().optional().describe('Figma Personal Access Token. Falls back to FIGMA_TOKEN env var.'),
simulate: z.boolean().optional().default(false).describe('Run headless browser simulator to verify prototype navigation actually works. Slower (~2 min) but catches runtime issues static analysis misses.'),
vision: z.boolean().optional().default(false).describe('AI vision Tier-2 evaluation (Pro, requires PROTOSCAN_API_KEY): grades each screen across subjective dimensions (clutter, saliency, feedback, consistency, affordance, guidance), deepening the score to core+vision. Implies score.'),
score: z.boolean().optional().default(false).describe('Compute the 0-100 usability score (ProtoScan Science) with a per-dimension breakdown and top fixes. Implies metrics.'),
metrics: z.boolean().optional().default(false).describe('Run the science metric analyzers without the aggregate score.'),
ergonomics: z.boolean().optional().default(false).describe('Alias for score — emphasises the per-dimension usability breakdown. Implies metrics.'),
Expand Down Expand Up @@ -132,14 +133,39 @@ registerAppTool(
}
}

// Run AI vision Tier-2 if requested (Pro — requires PROTOSCAN_API_KEY)
let visionIssues: Issue[] = [];
let visionMetrics: Metric[] = [];
if (args.vision) {
const proKey = process.env.PROTOSCAN_API_KEY;
const license = proKey ? await validateLicenseKey(proKey) : { valid: false };
if (!proKey || !license.valid) {
console.error(`[protoscan] vision requires valid PROTOSCAN_API_KEY — ${!proKey ? 'not set' : 'invalid key'}`);
} else {
try {
const { analyzeVisionProxy } = await import('@protoscan/vision');
const graph = buildGraph(file, { pageIds: pageIds.length ? pageIds : undefined });
console.error('[protoscan] Running AI vision analysis via ProtoScan API...');
const vr = await analyzeVisionProxy(graph, { protoscanApiKey: proKey, figmaToken: token, fileKey });
visionIssues = vr.issues;
visionMetrics = vr.metrics;
console.error(`[protoscan] Vision: ${vr.issues.length} issue(s), ${vr.metrics.length} metric(s) across ${vr.coverage.analyzed}/${vr.coverage.total} screens`);
} catch (visionError) {
console.error(`[protoscan] Vision error: ${visionError instanceof Error ? visionError.message : String(visionError)}`);
}
}
}

const mergedIssues = [...simulatorIssues, ...visionIssues];
const result = await scan(file, {
fileKey,
minTouchTarget: args.min_touch_target,
skip: args.skip,
pageIds: pageIds.length ? pageIds : undefined,
additionalIssues: simulatorIssues.length ? simulatorIssues : undefined,
metrics: !!(args.metrics || args.score || args.ergonomics),
score: !!(args.score || args.ergonomics),
additionalIssues: mergedIssues.length ? mergedIssues : undefined,
additionalMetrics: visionMetrics.length ? visionMetrics : undefined,
metrics: !!(args.metrics || args.score || args.ergonomics || args.vision),
score: !!(args.score || args.ergonomics || args.vision),
ignorePatterns: args.ignore?.filter(Boolean),
});

Expand Down
Loading
Loading