Skip to content
Merged
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
5 changes: 5 additions & 0 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ program
.option('--vision', 'Run AI vision analysis on each screen with GPT-4o (requires PROTOSCAN_API_KEY or OPENAI_API_KEY)')
// 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)')
.option('--score', 'Compute the 0-100 usability score with per-dimension breakdown (implies --metrics)')
.option('--ergonomics', 'Alias for --score — show the per-dimension usability breakdown')
.action(async (input: string, options) => {
// Parse Figma URL or raw file key
const { fileKey, pageIds: urlPageIds } = parseFigmaInput(input);
Expand Down Expand Up @@ -208,6 +211,8 @@ 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),
});

const formatters: Record<string, (r: typeof result) => string> = {
Expand Down
39 changes: 39 additions & 0 deletions packages/core/scripts/calibrate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Band calibration (Fase B.5)

The 0-100 usability score ships immediately, but the maturity **bands**
(Excellent / Acceptable / Weak …) stay hidden (`getBand` returns `undefined`)
until they are calibrated against a corpus of real prototypes. This is a
one-time **statistical calibration** — not ML "training": it derives label
cutoffs from the distribution of real-world scores so the labels are
descriptive rather than arbitrary.

## Steps

1. Populate `corpus.json` with 30–50 real Figma file keys. Figma Community
files are duplicatable and readable via the REST API with your token.
Optionally tag a handful as `"label": "good"` or `"label": "bad"`.

2. Measure (needs a built core + a token):
```sh
npm run build -w @protoscan/core
FIGMA_TOKEN=figd_xxx node packages/core/scripts/calibrate/run.mjs
```
→ writes `calibration-data.json`.

3. Fit the cutoffs:
```sh
node packages/core/scripts/calibrate/fit.mjs
```
→ writes `bands.json` (percentile cutoffs, refined by good/bad separation
if labels exist).

4. Activate: paste the cutoffs from `bands.json` into `PROVISIONAL_BANDS` in
`src/scoring/dimensions.ts`, set `BANDS_CALIBRATED = true` and
`BANDS_VERSION = '<date>'`, rebuild. The score now renders maturity bands,
and `_meta.bandsVersion` in the JSON report records which calibration was used.

## Improving over time

Post-launch, opt-in telemetry of *just the numbers* (never user files) can be
appended to `calibration-data.json` and `fit.mjs` re-run, so the bands get more
grounded as usage grows.
4 changes: 4 additions & 0 deletions packages/core/scripts/calibrate/corpus.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"note": "Calibration corpus for the usability-score maturity bands (Fase B.5). Add 30-50 real Figma file keys — Figma Community files are duplicatable and readable via the REST API with your FIGMA_TOKEN. Optionally label each 'good' or 'bad' to refine cutoffs by separation. Then run: npm run build -w @protoscan/core && node packages/core/scripts/calibrate/run.mjs && node packages/core/scripts/calibrate/fit.mjs",
"files": []
}
53 changes: 53 additions & 0 deletions packages/core/scripts/calibrate/fit.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env node
// Calibration step 2 — fit: derive band cutoffs from the measured corpus.
// Usage: node packages/core/scripts/calibrate/fit.mjs
// Method: percentile cutoffs (relative), refined by good/bad separation if labels exist.
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const rows = JSON.parse(readFileSync(join(__dirname, 'calibration-data.json'), 'utf8'));
const scores = rows.map((r) => r.global).sort((a, b) => a - b);

if (scores.length < 5) {
console.error(`Need >= 5 measured files to fit bands (have ${scores.length}). Add more to corpus.json and re-run run.mjs.`);
process.exit(1);
}

const pct = (p) => scores[Math.floor((p / 100) * (scores.length - 1))];

const cutoffs = [
{ min: pct(80), label: 'Excellent', emoji: '🟢' },
{ min: pct(55), label: 'Good', emoji: '🟢' },
{ min: pct(30), label: 'Acceptable', emoji: '🟡' },
{ min: pct(10), label: 'Weak', emoji: '🟠' },
{ min: 0, label: 'Critical', emoji: '🔴' },
];

// Optional refinement: if good/bad labels exist, anchor the Acceptable cutoff
// at the midpoint of the two class means.
const mean = (a) => (a.length ? a.reduce((s, x) => s + x, 0) / a.length : null);
const good = mean(rows.filter((r) => r.label === 'good').map((r) => r.global));
const bad = mean(rows.filter((r) => r.label === 'bad').map((r) => r.global));
let separation = null;
if (good != null && bad != null) {
separation = Math.round((good + bad) / 2);
cutoffs[2].min = separation; // Acceptable threshold anchored to good/bad split
}

const version = new Date().toISOString().slice(0, 10);
const out = {
version,
method: separation != null ? 'percentile+separation' : 'percentile',
n: rows.length,
separation,
cutoffs,
};

writeFileSync(join(__dirname, 'bands.json'), JSON.stringify(out, null, 2));
console.error('Wrote bands.json:');
console.error(JSON.stringify(out, null, 2));
console.error('\nTo activate: paste these cutoffs into PROVISIONAL_BANDS in');
console.error(' packages/core/src/scoring/dimensions.ts');
console.error(`then set BANDS_CALIBRATED = true and BANDS_VERSION = '${version}', rebuild, and the score will show maturity bands.`);
43 changes: 43 additions & 0 deletions packages/core/scripts/calibrate/run.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env node
// Calibration step 1 — measure: run the engine over the corpus and record scores.
// Usage: FIGMA_TOKEN=... node packages/core/scripts/calibrate/run.mjs
// Requires a built core: npm run build -w @protoscan/core
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { FigmaClient, scan } from '../../dist/index.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const token = process.env.FIGMA_TOKEN;
if (!token) {
console.error('Set FIGMA_TOKEN to fetch the corpus files.');
process.exit(1);
}

const corpus = JSON.parse(readFileSync(join(__dirname, 'corpus.json'), 'utf8'));
if (!corpus.files?.length) {
console.error('corpus.json has no files. Add Figma file keys first (see the note field).');
process.exit(1);
}

const client = new FigmaClient(token);
const rows = [];
for (const entry of corpus.files) {
try {
const file = await client.getFile(entry.fileKey);
const result = await scan(file, { fileKey: entry.fileKey, metrics: true, score: true });
const score = result.summary.score;
rows.push({
fileKey: entry.fileKey,
label: entry.label ?? null,
global: score.global,
byDimension: Object.fromEntries(score.byDimension.map((d) => [d.dimension, d.score])),
});
console.error(`${entry.fileKey}: ${score.global}/100`);
} catch (e) {
console.error(`skip ${entry.fileKey}: ${e instanceof Error ? e.message : e}`);
}
}

writeFileSync(join(__dirname, 'calibration-data.json'), JSON.stringify(rows, null, 2));
console.error(`\nWrote ${rows.length} rows → calibration-data.json. Next: node fit.mjs`);
24 changes: 24 additions & 0 deletions packages/core/src/graph/analyzer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,30 @@ describe('missing back-nav detection', () => {
const backNav = issues.filter(i => i.category === 'back-nav');
expect(backNav.length).toBe(1);
expect(backNav[0].screenName).toBe('Details');
expect(backNav[0].confidence).toBe('probable');
});

it('downgrades onboarding/welcome destinations to low confidence (H6)', async () => {
const file = makeFile([{
id: '1:0', name: 'Page', type: 'CANVAS',
flowStartingPoints: [{ nodeId: '2:0', name: 'Flow' }],
children: [
{
id: '2:0', name: 'Splash', type: 'FRAME',
children: [{
id: '3:0', name: 'Btn', type: 'RECTANGLE',
interactions: [{ trigger: { type: 'ON_CLICK' }, actions: [{ type: 'NODE' as const, destinationId: '2:1', navigation: 'NAVIGATE' as const }] }],
}],
},
{ id: '2:1', name: 'Bienvenida-02', type: 'FRAME' },
],
}]);

const issues = await graphAnalyzer.analyze(file, {});
const backNav = issues.filter(i => i.category === 'back-nav');
expect(backNav.length).toBe(1);
expect(backNav[0].screenName).toBe('Bienvenida-02');
expect(backNav[0].confidence).toBe('low');
});

it('does not flag starting point destinations', async () => {
Expand Down
13 changes: 10 additions & 3 deletions packages/core/src/graph/analyzer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AnalyzerOptions, FigmaFile, Issue, PrototypeGraph } from '../types.js';
import { buildGraph } from './builder.js';
import { STATE_VARIANT_PATTERN } from '../utils/filters.js';
import { STATE_VARIANT_PATTERN, isOnboardingScreen } from '../utils/filters.js';

let issueCounter = 0;
function nextId(category: string): string {
Expand Down Expand Up @@ -202,9 +202,16 @@ function detectMissingBackNav(graph: PrototypeGraph): Issue[] {
const hasDirectReturn = outgoing.some((e) => sourceIds.includes(e.destinationId));

if (!hasBack && !hasDirectReturn) {
// Tab-bar roots and high-inDegree hub screens: downgrade to low confidence
// Downgrade to low confidence when missing back-nav is expected/benign:
// - tab-bar roots (back-nav doesn't apply to a tab root)
// - high-inDegree hub screens (reached from many places — BACK is ambiguous)
// - onboarding/welcome/splash screens (start of a first-run flow, no back by design)
// - archived/backup frames (not part of the active prototype)
const isHubScreen = sourceIds.length > 3;
const confidence = destNode.isTabRoot || isHubScreen ? 'low' : 'probable';
const confidence =
destNode.isTabRoot || isHubScreen || isOnboardingScreen(destNode.name) || destNode.isArchived
? 'low'
: 'probable';

issues.push({
id: nextId('back-nav'),
Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/graph/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
InteractionAction,
PrototypeGraph,
} from '../types.js';
import { isNonPrototypeFrame, TAB_ROOT_CHILD_PATTERN } from '../utils/filters.js';
import { isNonPrototypeFrame, isArchivedFrame, TAB_ROOT_CHILD_PATTERN } from '../utils/filters.js';

/**
* Build a directed graph from Figma file interactions.
Expand Down Expand Up @@ -81,7 +81,6 @@ function collectScreens(
// Skip frames whose names match non-prototype patterns
if (isNonPrototypeFrame(child.name)) continue;

const ARCHIVED_PATTERN = /\b(archived?|deprecated|old|legacy)\b/i;
const isTabRoot = detectTabRoot(child);

nodes.set(child.id, {
Expand All @@ -95,7 +94,7 @@ function collectScreens(
hasBackAction: false,
hasCloseAction: false,
nullDestinationCount: 0,
isArchived: ARCHIVED_PATTERN.test(child.name),
isArchived: isArchivedFrame(child.name),
isFlowStartingPoint: false,
isTabRoot,
boundingBox: child.absoluteBoundingBox,
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
export { type Issue, type Analyzer, type AnalyzerOptions, type FigmaFile, type FigmaNode, type PrototypeGraph, type ScanResult, type ScanStats, type BoundingBox, type GraphEdge } from './types.js';
export type {
Color,
Paint,
TypeStyle,
Effect,
Metric,
MetricAnalyzer,
MetricDimension,
MetricDimensionScore,
MetricStatus,
MetricClaimType,
MetricTier,
UsabilityScore,
ScoreBand,
} from './types.js';
export { FigmaClient, FigmaApiError } from './figma-client.js';
export { buildGraph } from './graph/builder.js';
export { graphAnalyzer } from './graph/analyzer.js';
Expand All @@ -7,6 +22,11 @@ export { overlapAnalyzer } from './spatial/overlap.js';
export { scrollAnalyzer } from './spatial/scroll.js';
export { overlayTrapAnalyzer } from './spatial/overlay-traps.js';
export { scan } from './scanner.js';
// ProtoScan Science — metrics engine
export { METRIC_CHECKS } from './metrics/registry.js';
export { metricToIssue, makeMetric, FRAMEWORKS } from './metrics/metric.js';
export { computeScore, annotatePointsImpact, topFixes } from './scoring/score.js';
export { DIMENSIONS, DIMENSION_LABELS, getBand, BANDS_CALIBRATED, BANDS_VERSION } from './scoring/dimensions.js';
export { formatJson } from './reporters/json.js';
export { formatTerminal } from './reporters/terminal.js';
export { formatHtml } from './reporters/html.js';
Expand Down
83 changes: 83 additions & 0 deletions packages/core/src/metrics/balance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { AnalyzerOptions, FigmaFile, FigmaNode, Metric, MetricAnalyzer } from '../types.js';
import { forEachScreen, walk } from './walk.js';
import { makeMetric } from './metric.js';
import { extractSolidColor, colorKey } from '../utils/color.js';
import { center } from '../utils/geometry.js';

const MAX_IMBALANCE = 0.2; // 20% left/right weight difference
const MAX_DISTINCT_COLORS = 8;

function isLeaf(n: FigmaNode): boolean {
return !n.children || n.children.length === 0;
}

/**
* Visual balance (size-based hemispheric weight) + colour harmony (distinct
* dominant colours). Both are `signal` heuristics — pixel-accurate weighting is
* the Tier-2/vision upgrade. `na` when a screen has no measurable leaves.
*/
export const balanceAnalyzer: MetricAnalyzer = {
name: 'balance',
framework: 'Visual Balance',

async analyze(file: FigmaFile, _options: AnalyzerOptions): Promise<Metric[]> {
const metrics: Metric[] = [];
let counter = 0;

forEachScreen(file, ({ frame, id, name }) => {
const frameBox = frame.absoluteBoundingBox;
const loc = { screenId: id, screenName: name };
const midX = frameBox ? frameBox.x + frameBox.width / 2 : undefined;

let left = 0;
let right = 0;
const colors = new Set<string>();

walk(frame, (n) => {
if (n.id === frame.id) return;
const box = n.absoluteBoundingBox;
const solid = extractSolidColor(n.fills);
if (solid) colors.add(colorKey(solid));
if (!isLeaf(n) || !box || box.width <= 0 || box.height <= 0) return;
const weight = box.width * box.height * (n.opacity ?? 1);
if (midX === undefined) return;
if (center(box).x < midX) left += weight;
else right += weight;
});

// ── Balance ──
const total = left + right;
const imbalance = total > 0 ? Math.abs(left - right) / total : 0;
metrics.push(
makeMetric('balance', `balance-${++counter}`, loc, {
value: Math.round(imbalance * 100),
threshold: MAX_IMBALANCE * 100,
status: !frameBox || total === 0 ? 'na' : imbalance > MAX_IMBALANCE ? 'fail' : 'pass',
whyItMatters: 'Strong left/right weight imbalance can feel unstable.',
recommendation:
frameBox && total > 0 && imbalance > MAX_IMBALANCE
? `"${name}" is ${Math.round(imbalance * 100)}% left/right imbalanced — redistribute content.`
: undefined,
evidence: { imbalancePct: Math.round(imbalance * 100), leftWeight: Math.round(left), rightWeight: Math.round(right) },
}),
);

// ── Colour harmony ──
metrics.push(
makeMetric('balance', `balance-${++counter}`, loc, {
value: colors.size,
threshold: MAX_DISTINCT_COLORS,
status: colors.size === 0 ? 'na' : colors.size > MAX_DISTINCT_COLORS ? 'fail' : 'pass',
whyItMatters: 'Too many distinct colours fragment the palette.',
recommendation:
colors.size > MAX_DISTINCT_COLORS
? `"${name}" uses ${colors.size} distinct fill colours — consolidate to ≤${MAX_DISTINCT_COLORS}.`
: undefined,
evidence: { distinctColors: colors.size },
}),
);
});

return metrics;
},
};
Loading
Loading