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
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ 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 { computeScore, annotatePointsImpact, topFixes, topFixGroups, type FixGroup } 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';
Expand Down
23 changes: 15 additions & 8 deletions packages/core/src/metrics/balance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import type { AnalyzerOptions, FigmaFile, FigmaNode, Metric, MetricAnalyzer } fr
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_IMBALANCE = 0.3; // 30% left/right weight difference
const MIN_LEAVES = 3; // need enough content to judge balance meaningfully
const MAX_DISTINCT_COLORS = 8;

function isLeaf(n: FigmaNode): boolean {
Expand All @@ -31,6 +31,7 @@ export const balanceAnalyzer: MetricAnalyzer = {

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

walk(frame, (n) => {
Expand All @@ -39,26 +40,32 @@ export const balanceAnalyzer: MetricAnalyzer = {
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;
leaves++;
const weight = box.width * box.height * (n.opacity ?? 1);
// Distribute weight PROPORTIONALLY to how much of the box lies on each
// side of the vertical centerline. A full-bleed/centered element splits
// ~50/50 instead of dumping its whole weight onto one hemisphere.
const leftFraction = Math.max(0, Math.min(1, (midX - box.x) / box.width));
left += weight * leftFraction;
right += weight * (1 - leftFraction);
});

// ── Balance ──
const total = left + right;
const imbalance = total > 0 ? Math.abs(left - right) / total : 0;
const balanceNa = !frameBox || total === 0 || leaves < MIN_LEAVES;
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',
status: balanceNa ? 'na' : imbalance > MAX_IMBALANCE ? 'fail' : 'pass',
whyItMatters: 'Strong left/right weight imbalance can feel unstable.',
recommendation:
frameBox && total > 0 && imbalance > MAX_IMBALANCE
!balanceNa && 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) },
evidence: { imbalancePct: Math.round(imbalance * 100), leftWeight: Math.round(left), rightWeight: Math.round(right), leaves },
}),
);

Expand Down
84 changes: 79 additions & 5 deletions packages/core/src/metrics/metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,21 +270,50 @@ describe('emphasis', () => {
// ─── Balance ───

describe('balance', () => {
it('fails a left-heavy layout', async () => {
it('fails a left-heavy layout (≥3 left elements)', async () => {
const file = makeFile([
frameNode({
name: 'Lopsided',
box: box(0, 0, 400, 800),
children: [
{ id: 'l', name: 'Big', type: 'RECTANGLE', absoluteBoundingBox: box(0, 0, 180, 600), fills: [solidFill(0.2, 0.2, 0.2)] },
{ id: 'r', name: 'Tiny', type: 'RECTANGLE', absoluteBoundingBox: box(360, 0, 20, 20), fills: [solidFill(0.2, 0.2, 0.2)] },
{ id: 'a', name: 'A', type: 'RECTANGLE', absoluteBoundingBox: box(0, 0, 150, 200), fills: [solidFill(0.2, 0.2, 0.2)] },
{ id: 'b', name: 'B', type: 'RECTANGLE', absoluteBoundingBox: box(0, 220, 150, 200), fills: [solidFill(0.2, 0.2, 0.2)] },
{ id: 'c', name: 'C', type: 'RECTANGLE', absoluteBoundingBox: box(0, 440, 150, 200), fills: [solidFill(0.2, 0.2, 0.2)] },
],
}),
]);
const balance = (await balanceAnalyzer.analyze(file, {})).find((m) => (m.evidence as any)?.imbalancePct !== undefined);
expect(balance?.status).toBe('fail');
});

it('passes a full-bleed background split proportionally (50/50)', async () => {
const file = makeFile([
frameNode({
name: 'Centered',
box: box(0, 0, 400, 800),
children: [
{ id: 'bg', name: 'BG', type: 'RECTANGLE', absoluteBoundingBox: box(0, 0, 400, 800), fills: [solidFill(0.95, 0.95, 0.95)] },
{ id: 'l', name: 'L', type: 'RECTANGLE', absoluteBoundingBox: box(40, 40, 120, 80), fills: [solidFill(0.2, 0.2, 0.2)] },
{ id: 'r', name: 'R', type: 'RECTANGLE', absoluteBoundingBox: box(240, 40, 120, 80), fills: [solidFill(0.2, 0.2, 0.2)] },
],
}),
]);
const balance = (await balanceAnalyzer.analyze(file, {})).find((m) => (m.evidence as any)?.imbalancePct !== undefined);
expect(balance?.status).toBe('pass');
});

it('marks balance na when there are fewer than 3 leaves', async () => {
const file = makeFile([
frameNode({
name: 'Sparse',
box: box(0, 0, 400, 800),
children: [{ id: 'x', name: 'X', type: 'RECTANGLE', absoluteBoundingBox: box(0, 0, 150, 200), fills: [solidFill(0.2, 0.2, 0.2)] }],
}),
]);
const balance = (await balanceAnalyzer.analyze(file, {})).find((m) => (m.evidence as any)?.imbalancePct !== undefined);
expect(balance?.status).toBe('na');
});

it('flags too many distinct colours', async () => {
const children = Array.from({ length: 10 }, (_, i) => ({
id: `c${i}`,
Expand All @@ -303,11 +332,15 @@ describe('balance', () => {
frameNode({
name: 'Left',
box: box(0, 0, 400, 800),
children: [{ id: 'l', name: 'Big', type: 'RECTANGLE', absoluteBoundingBox: box(0, 0, 180, 600), fills: [solidFill(0.2, 0.2, 0.2)] }],
children: [
{ id: 'a', name: 'A', type: 'RECTANGLE', absoluteBoundingBox: box(0, 0, 150, 200), fills: [solidFill(0.2, 0.2, 0.2)] },
{ id: 'b', name: 'B', type: 'RECTANGLE', absoluteBoundingBox: box(0, 220, 150, 200), fills: [solidFill(0.2, 0.2, 0.2)] },
{ id: 'c', name: 'C', type: 'RECTANGLE', absoluteBoundingBox: box(0, 440, 150, 200), fills: [solidFill(0.2, 0.2, 0.2)] },
],
}),
]);
const b = (await balanceAnalyzer.analyze(file, {})).find((m) => (m.evidence as any)?.imbalancePct !== undefined);
expect(b?.value).toBe(100);
expect(b?.value).toBe(100); // all weight left of centerline
expect(b?.status).toBe('fail');
});
});
Expand Down Expand Up @@ -429,4 +462,45 @@ describe('edge cases', () => {
expect(metrics.find((m) => m.framework.startsWith('Hick'))?.status).toBe('na');
expect(metrics.find((m) => m.framework.startsWith('Miller'))?.status).toBe('pass');
});

it('skips archived/backup frames entirely (no metrics, no score pollution)', async () => {
const file = makeFile([
frameNode({
name: 'BACKUP — Home 22 May',
box: box(0, 0, 375, 800),
fills: [solidFill(1, 1, 1)],
children: [textNode({ characters: 'x', fontSize: 16, fills: [solidFill(0.7, 0.7, 0.7)], box: box(0, 0, 100, 20) })],
}),
frameNode({
name: 'Home',
box: box(0, 0, 375, 800),
fills: [solidFill(1, 1, 1)],
children: [textNode({ characters: 'y', fontSize: 16, fills: [solidFill(0.7, 0.7, 0.7)], box: box(0, 0, 100, 20) })],
}),
]);
const m = await contrastAnalyzer.analyze(file, {});
expect(m.length).toBe(1); // only the active 'Home' frame; BACKUP is skipped
expect(m[0].screenName).toBe('Home');
});

it('recurses into SECTION nodes to find screens (not just top-level frames)', async () => {
const file = makeFile([
{
id: 'sec',
name: 'Flow A',
type: 'SECTION',
children: [
frameNode({
name: 'Inside Section',
box: box(0, 0, 375, 800),
fills: [solidFill(1, 1, 1)],
children: [textNode({ characters: 'z', fontSize: 16, fills: [solidFill(0.7, 0.7, 0.7)], box: box(0, 0, 100, 20) })],
}),
],
},
]);
const m = await contrastAnalyzer.analyze(file, {});
expect(m.length).toBe(1);
expect(m[0].screenName).toBe('Inside Section');
});
});
28 changes: 19 additions & 9 deletions packages/core/src/metrics/walk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,29 @@ export interface Screen {
frame: FigmaNode;
id: string;
name: string;
archived: boolean;
}

/** Iterate prototype screens (top-level frames, skipping DS/annotation frames). */
/**
* Iterate ACTIVE prototype screens. Recurses into SECTION nodes (mirrors the
* graph builder) so screens organised inside Figma Sections are covered, not
* just top-level frames. Skips DS/annotation frames AND archived/backup frames
* (ARCHIVADO/BACKUP, bilingual) — those are not part of the live prototype, so
* they must not pollute the score or emit science findings.
*/
export function forEachScreen(file: FigmaFile, cb: (s: Screen) => void): void {
for (const page of file.document.children ?? []) {
for (const frame of page.children ?? []) {
if (
(frame.type === 'FRAME' || frame.type === 'COMPONENT' || frame.type === 'COMPONENT_SET') &&
!isNonPrototypeFrame(frame.name)
) {
cb({ frame, id: frame.id, name: frame.name, archived: isArchivedFrame(frame.name) });
}
collectScreensInto(page, cb);
}
}

function collectScreensInto(parent: FigmaNode, cb: (s: Screen) => void): void {
for (const child of parent.children ?? []) {
if (child.type === 'SECTION') {
if (isNonPrototypeFrame(child.name)) continue;
collectScreensInto(child, cb); // screens can be nested inside sections
} else if (child.type === 'FRAME' || child.type === 'COMPONENT' || child.type === 'COMPONENT_SET') {
if (isNonPrototypeFrame(child.name) || isArchivedFrame(child.name)) continue;
cb({ frame: child, id: child.id, name: child.name });
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/reporters/html.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Issue, MetricDimensionScore, ScanResult } from '../types.js';
import { topFixes } from '../scoring/score.js';
import { topFixGroups } from '../scoring/score.js';

export function formatHtml(result: ScanResult): string {
const { file, summary, issues, duration, skippedChecks } = result;
Expand Down Expand Up @@ -278,10 +278,10 @@ function renderScoreHero(result: ScanResult): string {
)
.join('\n');

const fixes = topFixes(result.metrics, 5)
const fixes = topFixGroups(result.metrics, 6)
.map(
(f) =>
`<li><span class="pts">+${f.pointsImpact ?? 0}</span> ${esc(f.recommendation ?? f.whyItMatters ?? f.framework)} <span class="cite">${esc(f.framework)}, ${esc(f.citation)}</span></li>`,
`<li><span class="pts">+${f.pointsImpact}</span> <strong>${esc(f.label)}</strong>${f.count > 1 ? ` — ${f.count} issues` : ''}${f.example ? ` <span class="cite">e.g. ${esc(f.example)}</span>` : ''}</li>`,
)
.join('\n');

Expand Down
11 changes: 6 additions & 5 deletions packages/core/src/reporters/terminal.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Issue, ScanResult } from '../types.js';
import { topFixes } from '../scoring/score.js';
import { topFixGroups } from '../scoring/score.js';

const NO_COLOR = !!process.env.NO_COLOR;

Expand Down Expand Up @@ -118,13 +118,14 @@ function renderScoreBlock(result: ScanResult): string[] {
if (d.score === null) continue;
out.push(` ${d.label.padEnd(16)} ${scoreBar(d.score)} ${String(d.score).padStart(3)}`);
}
const fixes = topFixes(result.metrics, 3);
const fixes = topFixGroups(result.metrics, 3);
if (fixes.length > 0) {
out.push('');
out.push(colors.dim(' Top fixes:'));
out.push(colors.dim(' Top fixes (points recovered if fully addressed):'));
for (const f of fixes) {
const pts = f.pointsImpact ? `+${f.pointsImpact}` : '';
out.push(` ${colors.bold(pts.padStart(5))} ${f.recommendation ?? f.whyItMatters ?? f.framework}`);
const pts = `+${f.pointsImpact}`;
const detail = f.count > 1 ? `${f.label} — ${f.count} issues` : f.label;
out.push(` ${colors.bold(pts.padStart(6))} ${detail}${f.example ? colors.dim(` (e.g. ${f.example})`) : ''}`);
}
}
out.push('');
Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,20 @@ export async function scan(file: FigmaFile, options: ScanOptions): Promise<ScanR
metrics.push(...options.additionalMetrics);
}
annotatePointsImpact(metrics);
// Dual-emit failed metrics as Issues so existing reporters render them.
// Dual-emit failed metrics as Issues so existing reporters render them — but
// CAP per category. On large files a deterministic per-node scan yields
// thousands of metric failures; flooding the issue list makes the report
// unusable. The full picture lives in the score breakdown + topFixGroups
// (computed from the complete `metrics` array, not the capped issues).
const METRIC_ISSUE_CAP = 25;
const perCategory = new Map<string, number>();
for (const m of metrics) {
const issue = metricToIssue(m);
if (issue) issues.push(issue);
if (!issue) continue;
const seen = perCategory.get(issue.category) ?? 0;
if (seen >= METRIC_ISSUE_CAP) continue;
perCategory.set(issue.category, seen + 1);
issues.push(issue);
}
}

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

Expand Down Expand Up @@ -71,6 +71,27 @@ describe('computeScore', () => {
});
});

describe('topFixGroups', () => {
it('aggregates failing metrics by dimension with global points impact', () => {
const metrics = [
...Array(3).fill(0).map(() => m('contrast', 'fail')),
m('fitts', 'fail'),
...Array(6).fill(0).map(() => m('contrast', 'pass')),
];
metrics.forEach((x, i) => {
x.recommendation = `${x.dimension} fix ${i}`;
});
const groups = topFixGroups(metrics, 3);
// applicable = 10 (4 fail + 6 pass): contrast 3 fails → +30, fitts 1 → +10
expect(groups[0].dimension).toBe('contrast');
expect(groups[0].count).toBe(3);
expect(groups[0].pointsImpact).toBe(30);
expect(groups[1].dimension).toBe('fitts');
expect(groups[1].pointsImpact).toBe(10);
expect(groups[0].example).toBeTruthy();
});
});

describe('annotatePointsImpact', () => {
it('sets uniform global points impact on failed metrics', () => {
const metrics = [m('contrast', 'pass'), m('contrast', 'fail'), m('fitts', 'fail'), m('gestalt', 'na')];
Expand Down
45 changes: 44 additions & 1 deletion packages/core/src/scoring/score.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Metric, MetricDimensionScore, UsabilityScore } from '../types.js';
import type { Metric, MetricDimension, MetricDimensionScore, UsabilityScore } from '../types.js';
import { DIMENSIONS, TIER1_DIMENSIONS, DIMENSION_LABELS, getBand } from './dimensions.js';

export interface ScoreOptions {
Expand Down Expand Up @@ -76,3 +76,46 @@ export function topFixes(metrics: Metric[], limit = 3): Metric[] {
.sort((a, b) => (b.pointsImpact ?? 0) - (a.pointsImpact ?? 0))
.slice(0, limit);
}

export interface FixGroup {
dimension: MetricDimension;
label: string;
count: number;
/** Global score points recovered if ALL failing metrics in this dimension are fixed. */
pointsImpact: number;
/** A representative recommendation from the group. */
example?: string;
}

/**
* Top fixes AGGREGATED by dimension — more meaningful than per-node impact on
* large files (where each single fix is a tiny fraction). pointsImpact is the
* global gain from fixing the whole group.
*/
export function topFixGroups(metrics: Metric[], limit = 3, opts: ScoreOptions = {}): FixGroup[] {
const includeVision = opts.includeVision ?? metrics.some((m) => m.tier === 'vision');
const pool = metrics.filter((m) => includeVision || m.tier !== 'vision');
let applicable = 0;
for (const m of pool) if (m.status === 'pass' || m.status === 'fail') applicable++;
if (applicable === 0) return [];

const groups = new Map<MetricDimension, { count: number; example?: string }>();
for (const m of pool) {
if (m.status !== 'fail') continue;
const g = groups.get(m.dimension) ?? { count: 0, example: undefined };
g.count++;
if (!g.example) g.example = m.recommendation ?? m.whyItMatters;
groups.set(m.dimension, g);
}

return [...groups.entries()]
.map(([dimension, g]) => ({
dimension,
label: DIMENSION_LABELS[dimension],
count: g.count,
pointsImpact: Math.round((100 * g.count) / applicable * 10) / 10,
example: g.example,
}))
.sort((a, b) => b.pointsImpact - a.pointsImpact)
.slice(0, limit);
}
Loading
Loading