diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3d0ff70..ee85292 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/metrics/balance.ts b/packages/core/src/metrics/balance.ts index 6fdfcc7..beaaf39 100644 --- a/packages/core/src/metrics/balance.ts +++ b/packages/core/src/metrics/balance.ts @@ -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 { @@ -31,6 +31,7 @@ export const balanceAnalyzer: MetricAnalyzer = { let left = 0; let right = 0; + let leaves = 0; const colors = new Set(); walk(frame, (n) => { @@ -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 }, }), ); diff --git a/packages/core/src/metrics/metrics.test.ts b/packages/core/src/metrics/metrics.test.ts index d09f2d6..3ae4c9f 100644 --- a/packages/core/src/metrics/metrics.test.ts +++ b/packages/core/src/metrics/metrics.test.ts @@ -270,14 +270,15 @@ 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)] }, ], }), ]); @@ -285,6 +286,34 @@ describe('balance', () => { 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}`, @@ -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'); }); }); @@ -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'); + }); }); diff --git a/packages/core/src/metrics/walk.ts b/packages/core/src/metrics/walk.ts index b238fcc..a521183 100644 --- a/packages/core/src/metrics/walk.ts +++ b/packages/core/src/metrics/walk.ts @@ -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 }); } } } diff --git a/packages/core/src/reporters/html.ts b/packages/core/src/reporters/html.ts index 9e8775f..44cacbf 100644 --- a/packages/core/src/reporters/html.ts +++ b/packages/core/src/reporters/html.ts @@ -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; @@ -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) => - `
  • +${f.pointsImpact ?? 0} ${esc(f.recommendation ?? f.whyItMatters ?? f.framework)} ${esc(f.framework)}, ${esc(f.citation)}
  • `, + `
  • +${f.pointsImpact} ${esc(f.label)}${f.count > 1 ? ` — ${f.count} issues` : ''}${f.example ? ` e.g. ${esc(f.example)}` : ''}
  • `, ) .join('\n'); diff --git a/packages/core/src/reporters/terminal.ts b/packages/core/src/reporters/terminal.ts index 322c7cb..cdee280 100644 --- a/packages/core/src/reporters/terminal.ts +++ b/packages/core/src/reporters/terminal.ts @@ -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; @@ -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(''); diff --git a/packages/core/src/scanner.ts b/packages/core/src/scanner.ts index 90c7c1f..e5c1165 100644 --- a/packages/core/src/scanner.ts +++ b/packages/core/src/scanner.ts @@ -55,10 +55,20 @@ export async function scan(file: FigmaFile, options: ScanOptions): Promise(); 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); } } diff --git a/packages/core/src/scoring/score.test.ts b/packages/core/src/scoring/score.test.ts index 39b7cc2..293b530 100644 --- a/packages/core/src/scoring/score.test.ts +++ b/packages/core/src/scoring/score.test.ts @@ -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'; @@ -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')]; diff --git a/packages/core/src/scoring/score.ts b/packages/core/src/scoring/score.ts index 335908c..9995af7 100644 --- a/packages/core/src/scoring/score.ts +++ b/packages/core/src/scoring/score.ts @@ -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 { @@ -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(); + 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); +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 8b2766e..4b533b7 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -2,7 +2,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, topFixes } from '@protoscan/core'; +import { FigmaClient, FigmaApiError, scan, buildGraph, formatTerminal, formatJson, formatHtml, validateLicenseKey, topFixGroups } from '@protoscan/core'; import type { Issue, ScanResult } from '@protoscan/core'; import { registerAppTool, @@ -30,8 +30,8 @@ function scoreHeadline(result: ScanResult): string | null { const s = result.summary.score; if (!s) return null; const band = s.band ? ` ${s.band.emoji} ${s.band.label}` : ''; - const fix = topFixes(result.metrics, 1)[0]; - const tip = fix?.recommendation ? ` Biggest win: ${fix.recommendation}` : ''; + const g = topFixGroups(result.metrics, 1)[0]; + const tip = g ? ` Biggest win: address ${g.count} ${g.label} issue${g.count > 1 ? 's' : ''} (+${g.pointsImpact} pts).` : ''; return `Usability Score: ${s.global}/100${band} (${s.tier}, coverage ${s.coverage}%).${tip}`; }