From 9731686435cb2805288cfd2bfbfdd4b19d03409a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?I=C3=B1aki?= Date: Tue, 2 Jun 2026 01:03:39 -0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(core):=20ProtoScan=20Science=20?= =?UTF-8?q?=E2=80=94=20deterministic=20usability=20metrics=20+=200-100=20s?= =?UTF-8?q?core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier-1 (MIT, REST-only) science metrics engine + aggregate usability score, surfaced across CLI, MCP, and the three reporters. - 8 metric analyzers (packages/core/src/metrics/): WCAG contrast (sRGB-linearized), Hick-Hyman + Miller, Fitts (intra-screen), typography, Flesch-Kincaid readability, Gestalt alignment, Von Restorff emphasis, visual balance. Each cites its framework and degrades to status:'na' when inputs are insufficient. - Metric model parallel to Issue; metricToIssue() dual-emits failures so the existing reporters render science findings unchanged. - Score (packages/core/src/scoring/): fixed 10-dimension catalog, pass/(pass+fail) per dimension, tier-filtered. Maturity bands are GATED (getBand -> undefined) until calibrated; only the 0-100 number ships. - Calibration pipeline (packages/core/scripts/calibrate/): percentile + good/bad separation -> bands.json (statistical, not ML training). - CLI flags --metrics/--score/--ergonomics; MCP score/metrics/ergonomics args with score-first output; JSON _meta schema 1.1. - 83 tests incl. value pins, edge cases (opacity, visible:false, non-solid fills, Hick N=0) and score determinism. tsc --noEmit clean (core/cli/mcp). Note: overlap.ts and mcp/index.ts also carry small pre-existing uncommitted changes (intersectionArea lifted to utils/geometry.ts; an MCP version fix) that could not be hunk-split without interactive staging. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/index.ts | 5 + packages/core/scripts/calibrate/README.md | 39 ++ packages/core/scripts/calibrate/corpus.json | 4 + packages/core/scripts/calibrate/fit.mjs | 53 +++ packages/core/scripts/calibrate/run.mjs | 43 ++ packages/core/src/index.ts | 20 + packages/core/src/metrics/balance.ts | 83 ++++ packages/core/src/metrics/cognitive-load.ts | 63 +++ packages/core/src/metrics/contrast.ts | 74 ++++ packages/core/src/metrics/emphasis.ts | 76 ++++ packages/core/src/metrics/fitts.ts | 64 +++ packages/core/src/metrics/gestalt.ts | 62 +++ packages/core/src/metrics/metric.ts | 125 ++++++ packages/core/src/metrics/metrics.test.ts | 432 ++++++++++++++++++++ packages/core/src/metrics/readability.ts | 56 +++ packages/core/src/metrics/registry.ts | 21 + packages/core/src/metrics/typography.ts | 83 ++++ packages/core/src/metrics/walk.ts | 56 +++ packages/core/src/reporters/html.ts | 93 ++++- packages/core/src/reporters/json.ts | 3 + packages/core/src/reporters/terminal.ts | 35 ++ packages/core/src/scanner.test.ts | 59 +++ packages/core/src/scanner.ts | 33 +- packages/core/src/scoring/dimensions.ts | 78 ++++ packages/core/src/scoring/score.test.ts | 83 ++++ packages/core/src/scoring/score.ts | 78 ++++ packages/core/src/spatial/overlap.ts | 29 +- packages/core/src/testing/fixtures.ts | 124 ++++++ packages/core/src/types.ts | 159 ++++++- packages/core/src/utils/color.ts | 101 +++++ packages/core/src/utils/geometry.ts | 63 +++ packages/core/src/utils/text.ts | 68 +++ packages/mcp/src/index.ts | 34 +- 33 files changed, 2367 insertions(+), 32 deletions(-) create mode 100644 packages/core/scripts/calibrate/README.md create mode 100644 packages/core/scripts/calibrate/corpus.json create mode 100644 packages/core/scripts/calibrate/fit.mjs create mode 100644 packages/core/scripts/calibrate/run.mjs create mode 100644 packages/core/src/metrics/balance.ts create mode 100644 packages/core/src/metrics/cognitive-load.ts create mode 100644 packages/core/src/metrics/contrast.ts create mode 100644 packages/core/src/metrics/emphasis.ts create mode 100644 packages/core/src/metrics/fitts.ts create mode 100644 packages/core/src/metrics/gestalt.ts create mode 100644 packages/core/src/metrics/metric.ts create mode 100644 packages/core/src/metrics/metrics.test.ts create mode 100644 packages/core/src/metrics/readability.ts create mode 100644 packages/core/src/metrics/registry.ts create mode 100644 packages/core/src/metrics/typography.ts create mode 100644 packages/core/src/metrics/walk.ts create mode 100644 packages/core/src/scanner.test.ts create mode 100644 packages/core/src/scoring/dimensions.ts create mode 100644 packages/core/src/scoring/score.test.ts create mode 100644 packages/core/src/scoring/score.ts create mode 100644 packages/core/src/testing/fixtures.ts create mode 100644 packages/core/src/utils/color.ts create mode 100644 packages/core/src/utils/geometry.ts create mode 100644 packages/core/src/utils/text.ts diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 2f14aaa..2219fd4 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -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 ', '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); @@ -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> = { diff --git a/packages/core/scripts/calibrate/README.md b/packages/core/scripts/calibrate/README.md new file mode 100644 index 0000000..7e39cc8 --- /dev/null +++ b/packages/core/scripts/calibrate/README.md @@ -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 = ''`, 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. diff --git a/packages/core/scripts/calibrate/corpus.json b/packages/core/scripts/calibrate/corpus.json new file mode 100644 index 0000000..82e5ac6 --- /dev/null +++ b/packages/core/scripts/calibrate/corpus.json @@ -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": [] +} diff --git a/packages/core/scripts/calibrate/fit.mjs b/packages/core/scripts/calibrate/fit.mjs new file mode 100644 index 0000000..764fc11 --- /dev/null +++ b/packages/core/scripts/calibrate/fit.mjs @@ -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.`); diff --git a/packages/core/scripts/calibrate/run.mjs b/packages/core/scripts/calibrate/run.mjs new file mode 100644 index 0000000..a669392 --- /dev/null +++ b/packages/core/scripts/calibrate/run.mjs @@ -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`); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ef2d09a..3d0ff70 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; @@ -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'; diff --git a/packages/core/src/metrics/balance.ts b/packages/core/src/metrics/balance.ts new file mode 100644 index 0000000..6fdfcc7 --- /dev/null +++ b/packages/core/src/metrics/balance.ts @@ -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 { + 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(); + + 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; + }, +}; diff --git a/packages/core/src/metrics/cognitive-load.ts b/packages/core/src/metrics/cognitive-load.ts new file mode 100644 index 0000000..99b1650 --- /dev/null +++ b/packages/core/src/metrics/cognitive-load.ts @@ -0,0 +1,63 @@ +import type { AnalyzerOptions, FigmaFile, Metric, MetricAnalyzer, PrototypeGraph } from '../types.js'; +import { forEachScreen, collectInteractive } from './walk.js'; +import { makeMetric } from './metric.js'; + +/** Hick wants UNIQUE destinations; Miller wants visible choice count. fail > this. */ +const MAX_CHOICES = 9; +const WARN_CHOICES = 7; + +/** + * Hick–Hyman (decision time vs. number of choices) + Miller 7±2 (working-memory + * load). Both deterministic from the interaction graph + node tree. + */ +export const cognitiveLoadAnalyzer: MetricAnalyzer = { + name: 'cognitive-load', + framework: 'Hick–Hyman / Miller', + needsGraph: true, + + async analyze(file: FigmaFile, _options: AnalyzerOptions, graph?: PrototypeGraph): Promise { + const metrics: Metric[] = []; + let counter = 0; + + forEachScreen(file, ({ frame, id, name }) => { + // Hick — unique navigation destinations from this screen (deduped). + const edges = graph?.edges.get(id) ?? []; + const uniqueDestinations = new Set(edges.map((e) => e.destinationId)).size; + + // Miller — count of visible interactive elements on the screen. + const interactiveCount = collectInteractive(frame).length; + + // Hick metric (na when there is no decision to make). + metrics.push( + makeMetric('hick', `hick-${++counter}`, { screenId: id, screenName: name }, { + value: uniqueDestinations, + threshold: MAX_CHOICES, + status: uniqueDestinations === 0 ? 'na' : uniqueDestinations > MAX_CHOICES ? 'fail' : 'pass', + whyItMatters: 'More equally-weighted choices slow decisions (Hick–Hyman).', + recommendation: + uniqueDestinations > MAX_CHOICES + ? `"${name}" offers ${uniqueDestinations} navigation choices — group or progressively disclose to ≤${WARN_CHOICES}.` + : undefined, + evidence: { uniqueDestinations, warnAt: WARN_CHOICES }, + }), + ); + + // Miller metric (na when there are no interactive elements). + metrics.push( + makeMetric('miller', `miller-${++counter}`, { screenId: id, screenName: name }, { + value: interactiveCount, + threshold: MAX_CHOICES, + status: interactiveCount === 0 ? 'na' : interactiveCount > MAX_CHOICES ? 'fail' : 'pass', + whyItMatters: 'Too many simultaneous elements exceed working memory (Miller 7±2).', + recommendation: + interactiveCount > MAX_CHOICES + ? `"${name}" shows ${interactiveCount} interactive elements — chunk into ≤${WARN_CHOICES} groups.` + : undefined, + evidence: { interactiveCount, warnAt: WARN_CHOICES }, + }), + ); + }); + + return metrics; + }, +}; diff --git a/packages/core/src/metrics/contrast.ts b/packages/core/src/metrics/contrast.ts new file mode 100644 index 0000000..789043d --- /dev/null +++ b/packages/core/src/metrics/contrast.ts @@ -0,0 +1,74 @@ +import type { AnalyzerOptions, Color, FigmaFile, Metric, MetricAnalyzer } from '../types.js'; +import { forEachScreen, collectText } from './walk.js'; +import { makeMetric } from './metric.js'; +import { contrastRatio, extractSolidColor, resolveEffectiveBackground, blendOver } from '../utils/color.js'; + +const AA_NORMAL = 4.5; +const AA_LARGE = 3.0; + +/** Large text per WCAG: ≥24px, or ≥18.66px when bold (fontWeight ≥ 700). */ +function isLargeText(fontSize: number, fontWeight?: number): boolean { + if (fontSize >= 24) return true; + return fontSize >= 18.66 && (fontWeight ?? 400) >= 700; +} + +/** WCAG 2.2 text contrast. The only `standard` (normative) metric. */ +export const contrastAnalyzer: MetricAnalyzer = { + name: 'contrast', + framework: 'WCAG 2.2', + + async analyze(file: FigmaFile, _options: AnalyzerOptions): Promise { + const metrics: Metric[] = []; + let counter = 0; + + forEachScreen(file, ({ frame, id, name }) => { + for (const { node, ancestors } of collectText(frame)) { + const loc = { screenId: id, screenName: name, nodeId: node.id }; + const textColor = extractSolidColor(node.fills); + const bg = resolveEffectiveBackground(ancestors); + const fontSize = node.style?.fontSize ?? 16; + const fontWeight = node.style?.fontWeight; + + // Cannot determine text color or background (gradient/image) → na. + if (!textColor || !bg) { + metrics.push( + makeMetric('contrast', `contrast-${++counter}`, loc, { + value: 0, + threshold: isLargeText(fontSize, fontWeight) ? AA_LARGE : AA_NORMAL, + status: 'na', + evidence: { reason: !textColor ? 'no-solid-text-color' : 'non-solid-background' }, + }), + ); + continue; + } + + // Composite translucent text over the resolved background. + const fg: Color = textColor.a < 0.999 ? { ...blendOver(textColor, bg), a: 1 } : { ...textColor, a: 1 }; + const ratio = contrastRatio(fg, bg); + const threshold = isLargeText(fontSize, fontWeight) ? AA_LARGE : AA_NORMAL; + // Opacity/blend effects make the rendered contrast uncertain. + const uncertain = (node.opacity ?? 1) < 1 || (node.blendMode && node.blendMode !== 'NORMAL') || textColor.a < 0.999; + + metrics.push( + makeMetric('contrast', `contrast-${++counter}`, loc, { + value: Math.round(ratio * 100) / 100, + threshold, + status: ratio < threshold ? 'fail' : 'pass', + whyItMatters: 'Low text/background contrast fails WCAG and hurts legibility.', + recommendation: + ratio < threshold + ? `"${node.name}" contrast ${ratio.toFixed(2)}:1 — needs ≥${threshold}:1 (WCAG AA).` + : undefined, + evidence: { + ratio: Math.round(ratio * 100) / 100, + large: isLargeText(fontSize, fontWeight), + uncertain: uncertain || undefined, + }, + }), + ); + } + }); + + return metrics; + }, +}; diff --git a/packages/core/src/metrics/emphasis.ts b/packages/core/src/metrics/emphasis.ts new file mode 100644 index 0000000..38b543a --- /dev/null +++ b/packages/core/src/metrics/emphasis.ts @@ -0,0 +1,76 @@ +import type { AnalyzerOptions, BoundingBox, FigmaFile, FigmaNode, Metric, MetricAnalyzer } from '../types.js'; +import { forEachScreen, collectInteractive } from './walk.js'; +import { makeMetric } from './metric.js'; + +/** Secondary/dismissive actions are excluded when picking the primary CTA. */ +const SECONDARY_PATTERN = /\b(cancel|close|back|dismiss|skip|cerrar|cancelar|atr[aá]s|volver|omitir)\b/i; +const MIN_DISTINCTION = 1.2; // primary should be ≥1.2× the median peer area + +function area(b: BoundingBox): number { + return Math.max(0, b.width) * Math.max(0, b.height); +} + +function median(nums: number[]): number { + if (nums.length === 0) return 0; + const s = [...nums].sort((a, b) => a - b); + const mid = Math.floor(s.length / 2); + return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; +} + +/** + * Von Restorff (isolation effect): the primary CTA should stand out from peers. + * Heuristic, deterministic: primary CTA = largest-area interactive element that + * is not a cancel/close/back control; measure its area distinction vs. peers. + */ +export const emphasisAnalyzer: MetricAnalyzer = { + name: 'emphasis', + framework: 'Von Restorff', + + async analyze(file: FigmaFile, _options: AnalyzerOptions): Promise { + const metrics: Metric[] = []; + let counter = 0; + + forEachScreen(file, ({ frame, id, name }) => { + const interactive = collectInteractive(frame); + const loc = { screenId: id, screenName: name }; + const candidates = interactive.filter((n) => !SECONDARY_PATTERN.test(n.name)); + + if (interactive.length < 2 || candidates.length === 0) { + metrics.push( + makeMetric('emphasis', `emphasis-${++counter}`, loc, { + value: 0, + threshold: MIN_DISTINCTION, + status: 'na', + evidence: { reason: 'no-identifiable-cta', interactive: interactive.length }, + }), + ); + return; + } + + let cta: FigmaNode = candidates[0]; + for (const c of candidates) { + if (area(c.absoluteBoundingBox!) > area(cta.absoluteBoundingBox!)) cta = c; + } + const ctaArea = area(cta.absoluteBoundingBox!); + const peers = interactive.filter((n) => n.id !== cta.id).map((n) => area(n.absoluteBoundingBox!)); + const medianPeer = median(peers); + const ratio = medianPeer > 0 ? ctaArea / medianPeer : MIN_DISTINCTION; + + metrics.push( + makeMetric('emphasis', `emphasis-${++counter}`, { ...loc, nodeId: cta.id }, { + value: Math.round(ratio * 100) / 100, + threshold: MIN_DISTINCTION, + status: ratio < MIN_DISTINCTION ? 'fail' : 'pass', + whyItMatters: 'A primary action that does not stand out slows the next step (Von Restorff).', + recommendation: + ratio < MIN_DISTINCTION + ? `On "${name}", "${cta.name}" is not visually dominant (${ratio.toFixed(2)}× peers) — make the primary CTA larger/bolder.` + : undefined, + evidence: { primaryCta: cta.name, areaRatio: Math.round(ratio * 100) / 100 }, + }), + ); + }); + + return metrics; + }, +}; diff --git a/packages/core/src/metrics/fitts.ts b/packages/core/src/metrics/fitts.ts new file mode 100644 index 0000000..36391c0 --- /dev/null +++ b/packages/core/src/metrics/fitts.ts @@ -0,0 +1,64 @@ +import type { AnalyzerOptions, FigmaFile, Metric, MetricAnalyzer } from '../types.js'; +import { forEachScreen, collectInteractive } from './walk.js'; +import { makeMetric } from './metric.js'; +import { center } from '../utils/geometry.js'; + +/** + * Fitts's Law (target acquisition difficulty), evaluated INTRA-screen: + * D = distance from a target's center to the screen centroid, W = min(w,h). + * (The prototype graph has no ordered user flow, so cross-screen D is undefined.) + * ID = log2(2D/W + 1); we flag targets whose index of difficulty is high. + */ +const MAX_ID = 4.5; + +export const fittsAnalyzer: MetricAnalyzer = { + name: 'fitts', + framework: 'Fitts', + + async analyze(file: FigmaFile, _options: AnalyzerOptions): Promise { + const metrics: Metric[] = []; + let counter = 0; + + forEachScreen(file, ({ frame, id, name }) => { + const frameBox = frame.absoluteBoundingBox; + const targets = collectInteractive(frame); + if (!frameBox || targets.length === 0) return; + const centroid = center(frameBox); + + for (const t of targets) { + const box = t.absoluteBoundingBox!; + const w = Math.min(box.width, box.height); + const loc = { screenId: id, screenName: name, nodeId: t.id }; + if (w <= 0) { + metrics.push( + makeMetric('fitts', `fitts-${++counter}`, loc, { + value: 0, + threshold: MAX_ID, + status: 'na', + evidence: { reason: 'zero-size-target' }, + }), + ); + continue; + } + const c = center(box); + const d = Math.hypot(c.x - centroid.x, c.y - centroid.y); + const id_ = Math.log2((2 * d) / w + 1); + metrics.push( + makeMetric('fitts', `fitts-${++counter}`, loc, { + value: Math.round(id_ * 100) / 100, + threshold: MAX_ID, + status: id_ > MAX_ID ? 'fail' : 'pass', + whyItMatters: 'Small, far-from-center targets are slow/error-prone to hit (Fitts).', + recommendation: + id_ > MAX_ID + ? `"${t.name}" is hard to acquire (index ${id_.toFixed(1)}) — enlarge it or move it closer to the content center.` + : undefined, + evidence: { indexOfDifficulty: Math.round(id_ * 100) / 100, distance: Math.round(d), width: Math.round(w) }, + }), + ); + } + }); + + return metrics; + }, +}; diff --git a/packages/core/src/metrics/gestalt.ts b/packages/core/src/metrics/gestalt.ts new file mode 100644 index 0000000..3980636 --- /dev/null +++ b/packages/core/src/metrics/gestalt.ts @@ -0,0 +1,62 @@ +import type { AnalyzerOptions, FigmaFile, Metric, MetricAnalyzer } from '../types.js'; +import { forEachScreen, collectInteractive } from './walk.js'; +import { makeMetric } from './metric.js'; +import { areAligned } from '../utils/geometry.js'; + +const ALIGN_TOL = 3; // px +const MIN_ALIGNED_FRACTION = 0.6; // 60% + +/** + * Gestalt grouping via alignment: fraction of interactive elements that share an + * edge/center alignment (±3px) with at least one peer. Misaligned controls read + * as ungrouped/noisy. One metric per screen; `na` when < 2 elements. + */ +export const gestaltAnalyzer: MetricAnalyzer = { + name: 'gestalt', + framework: 'Gestalt', + + async analyze(file: FigmaFile, _options: AnalyzerOptions): Promise { + const metrics: Metric[] = []; + let counter = 0; + + forEachScreen(file, ({ frame, id, name }) => { + const els = collectInteractive(frame).map((n) => n.absoluteBoundingBox!).filter(Boolean); + const loc = { screenId: id, screenName: name }; + if (els.length < 2) { + metrics.push( + makeMetric('gestalt', `gestalt-${++counter}`, loc, { + value: 0, + threshold: MIN_ALIGNED_FRACTION * 100, + status: 'na', + evidence: { reason: 'fewer-than-2-elements', elements: els.length }, + }), + ); + return; + } + + let aligned = 0; + for (let i = 0; i < els.length; i++) { + const hasPeer = els.some((other, j) => j !== i && areAligned(els[i], other, ALIGN_TOL)); + if (hasPeer) aligned++; + } + const fraction = aligned / els.length; + const pct = Math.round(fraction * 100); + + metrics.push( + makeMetric('gestalt', `gestalt-${++counter}`, loc, { + value: pct, + threshold: MIN_ALIGNED_FRACTION * 100, + status: fraction < MIN_ALIGNED_FRACTION ? 'fail' : 'pass', + whyItMatters: 'Aligned elements read as a group (Gestalt); stray controls add noise.', + recommendation: + fraction < MIN_ALIGNED_FRACTION + ? `"${name}": only ${pct}% of interactive elements are aligned — snap controls to a shared grid.` + : undefined, + evidence: { alignedPct: pct, elements: els.length }, + }), + ); + }); + + return metrics; + }, +}; diff --git a/packages/core/src/metrics/metric.ts b/packages/core/src/metrics/metric.ts new file mode 100644 index 0000000..cbb19df --- /dev/null +++ b/packages/core/src/metrics/metric.ts @@ -0,0 +1,125 @@ +import type { + Issue, + Metric, + MetricClaimType, + MetricDimension, + MetricStatus, + MetricTier, +} from '../types.js'; + +/** Per-framework metadata — single source of truth for citation/claim/tier. */ +export interface FrameworkMeta { + framework: string; + dimension: MetricDimension; + citation: string; + claimType: MetricClaimType; + tier: MetricTier; +} + +export const FRAMEWORKS = { + contrast: { framework: 'WCAG 2.2', dimension: 'contrast', citation: 'W3C WCAG 2.2 (2023)', claimType: 'standard', tier: 'core' }, + hick: { framework: 'Hick–Hyman', dimension: 'cognitive-load', citation: 'Hick 1952; Hyman 1953', claimType: 'signal', tier: 'core' }, + miller: { framework: 'Miller 7±2', dimension: 'cognitive-load', citation: 'Miller 1956', claimType: 'signal', tier: 'core' }, + fitts: { framework: 'Fitts', dimension: 'fitts', citation: 'Fitts 1954', claimType: 'signal', tier: 'core' }, + flesch: { framework: 'Flesch–Kincaid', dimension: 'readability', citation: 'Flesch 1948; Kincaid 1975', claimType: 'signal', tier: 'core' }, + typography: { framework: 'Typography (WCAG 1.4.4)', dimension: 'typography', citation: 'WCAG 1.4.4; Bringhurst 2004', claimType: 'signal', tier: 'core' }, + gestalt: { framework: 'Gestalt', dimension: 'gestalt', citation: 'Wertheimer 1923; Koffka 1935', claimType: 'signal', tier: 'core' }, + emphasis: { framework: 'Von Restorff', dimension: 'emphasis', citation: 'Von Restorff 1933', claimType: 'signal', tier: 'core' }, + balance: { framework: 'Visual Balance', dimension: 'balance', citation: 'Miniukovich & De Angeli 2014', claimType: 'signal', tier: 'core' }, +} as const satisfies Record; + +export type FrameworkKey = keyof typeof FRAMEWORKS; + +export interface MetricLocation { + screenId: string; + screenName: string; + nodeId?: string; +} + +export interface MetricData { + value: number; + threshold: number; + status: MetricStatus; + whyItMatters?: string; + recommendation?: string; + pointsImpact?: number; + evidence?: Record; +} + +/** Build a Metric, pulling framework/dimension/citation/claim/tier from FRAMEWORKS. */ +export function makeMetric(key: FrameworkKey, id: string, loc: MetricLocation, data: MetricData): Metric { + const f = FRAMEWORKS[key]; + return { + id, + framework: f.framework, + dimension: f.dimension, + citation: f.citation, + claimType: f.claimType, + tier: f.tier, + value: data.value, + threshold: data.threshold, + status: data.status, + screenId: loc.screenId, + screenName: loc.screenName, + nodeId: loc.nodeId, + whyItMatters: data.whyItMatters, + recommendation: data.recommendation, + pointsImpact: data.pointsImpact, + evidence: data.evidence, + }; +} + +/** Deterministic dual-emit mapping per dimension (severity/confidence/category). */ +const DIMENSION_ISSUE_META: Record< + MetricDimension, + { severity: Issue['severity']; confidence: Issue['confidence']; category: Issue['category'] } +> = { + contrast: { severity: 'high', confidence: 'certain', category: 'contrast' }, + 'cognitive-load': { severity: 'medium', confidence: 'probable', category: 'cognitive-load' }, + fitts: { severity: 'low', confidence: 'probable', category: 'fitts' }, + typography: { severity: 'low', confidence: 'certain', category: 'typography' }, + readability: { severity: 'low', confidence: 'probable', category: 'readability' }, + gestalt: { severity: 'low', confidence: 'probable', category: 'gestalt' }, + emphasis: { severity: 'low', confidence: 'probable', category: 'emphasis' }, + balance: { severity: 'low', confidence: 'probable', category: 'balance' }, + clutter: { severity: 'low', confidence: 'probable', category: 'vision' }, + saliency: { severity: 'low', confidence: 'probable', category: 'vision' }, +}; + +function round(n: number): number { + return Math.round(n * 100) / 100; +} + +/** + * Map a FAILED metric to an Issue so the existing 3 reporters render science + * findings with no rewrite. pass/na metrics produce no Issue (return null). + * `evidence.uncertain` (e.g. opacity/blend on contrast) downgrades confidence. + */ +export function metricToIssue(m: Metric): Issue | null { + if (m.status !== 'fail') return null; + const meta = DIMENSION_ISSUE_META[m.dimension]; + const confidence = m.evidence?.uncertain === true ? 'probable' : meta.confidence; + const parts = [m.whyItMatters, m.recommendation].filter(Boolean) as string[]; + const message = + parts.length > 0 + ? parts.join(' ') + : `${m.framework}: ${round(m.value)} (threshold ${round(m.threshold)}).`; + return { + id: `metric-${m.id}`, + category: meta.category, + severity: meta.severity, + confidence, + screenId: m.screenId, + screenName: m.screenName, + nodeId: m.nodeId, + message, + evidence: { + framework: m.framework, + citation: m.citation, + claimType: m.claimType, + value: m.value, + threshold: m.threshold, + ...(m.evidence ?? {}), + }, + }; +} diff --git a/packages/core/src/metrics/metrics.test.ts b/packages/core/src/metrics/metrics.test.ts new file mode 100644 index 0000000..d09f2d6 --- /dev/null +++ b/packages/core/src/metrics/metrics.test.ts @@ -0,0 +1,432 @@ +import { describe, it, expect } from 'vitest'; +import { contrastAnalyzer } from './contrast.js'; +import { cognitiveLoadAnalyzer } from './cognitive-load.js'; +import { typographyAnalyzer } from './typography.js'; +import { fittsAnalyzer } from './fitts.js'; +import { readabilityAnalyzer } from './readability.js'; +import { gestaltAnalyzer } from './gestalt.js'; +import { emphasisAnalyzer } from './emphasis.js'; +import { balanceAnalyzer } from './balance.js'; +import { buildGraph } from '../graph/builder.js'; +import { contrastRatio } from '../utils/color.js'; +import { + box, + solidFill, + gradientFill, + textNode, + interactiveNode, + frameNode, + makeFile, +} from '../testing/fixtures.js'; +import type { Metric, MetricDimension } from '../types.js'; + +function byStatus(metrics: Metric[], dim: MetricDimension) { + const m = metrics.filter((x) => x.dimension === dim); + return { + pass: m.filter((x) => x.status === 'pass').length, + fail: m.filter((x) => x.status === 'fail').length, + na: m.filter((x) => x.status === 'na').length, + all: m, + }; +} + +// ─── Contrast (WCAG) ─── + +describe('contrast', () => { + it('relativeLuminance/contrastRatio: black on white = 21:1', () => { + const black = { r: 0, g: 0, b: 0, a: 1 }; + const white = { r: 1, g: 1, b: 1, a: 1 }; + expect(contrastRatio(black, white)).toBeCloseTo(21, 1); + }); + + it('passes high-contrast text (black on white frame)', async () => { + const file = makeFile([ + frameNode({ + name: 'Home', + box: box(0, 0, 375, 800), + fills: [solidFill(1, 1, 1)], + children: [textNode({ characters: 'Hello', fontSize: 16, fills: [solidFill(0, 0, 0)], box: box(0, 0, 100, 20) })], + }), + ]); + const m = byStatus(await contrastAnalyzer.analyze(file, {}), 'contrast'); + expect(m.pass).toBe(1); + expect(m.fail).toBe(0); + }); + + it('fails low-contrast text (#999 on white)', async () => { + const file = makeFile([ + frameNode({ + name: 'Home', + box: box(0, 0, 375, 800), + fills: [solidFill(1, 1, 1)], + children: [textNode({ characters: 'Faint', fontSize: 16, fills: [solidFill(0.6, 0.6, 0.6)], box: box(0, 0, 100, 20) })], + }), + ]); + const m = byStatus(await contrastAnalyzer.analyze(file, {}), 'contrast'); + expect(m.fail).toBe(1); + }); + + it('marks na when background is a gradient/image', async () => { + const file = makeFile([ + frameNode({ + name: 'Hero', + box: box(0, 0, 375, 800), + fills: [gradientFill()], + children: [textNode({ characters: 'Over image', fontSize: 16, fills: [solidFill(0, 0, 0)], box: box(0, 0, 100, 20) })], + }), + ]); + const m = byStatus(await contrastAnalyzer.analyze(file, {}), 'contrast'); + expect(m.na).toBe(1); + }); +}); + +// ─── Cognitive load (Hick + Miller) ─── + +describe('cognitive-load', () => { + it('fails Hick + Miller when a screen has 10+ choices', async () => { + const buttons = Array.from({ length: 10 }, (_, i) => + interactiveNode({ name: `B${i}`, box: box(i * 10, 0, 40, 40), destinationId: `dest:${i}` }), + ); + const file = makeFile([frameNode({ name: 'Overloaded', box: box(0, 0, 375, 800), children: buttons })]); + const graph = buildGraph(file); + const metrics = await cognitiveLoadAnalyzer.analyze(file, {}, graph); + expect(metrics.find((m) => m.framework.startsWith('Hick'))?.status).toBe('fail'); + expect(metrics.find((m) => m.framework.startsWith('Miller'))?.status).toBe('fail'); + }); + + it('passes when a screen has few choices', async () => { + const buttons = Array.from({ length: 3 }, (_, i) => + interactiveNode({ name: `B${i}`, box: box(i * 50, 0, 40, 40), destinationId: `dest:${i}` }), + ); + const file = makeFile([frameNode({ name: 'Simple', box: box(0, 0, 375, 800), children: buttons })]); + const graph = buildGraph(file); + const metrics = await cognitiveLoadAnalyzer.analyze(file, {}, graph); + expect(metrics.find((m) => m.framework.startsWith('Hick'))?.status).toBe('pass'); + }); + + it('marks na when a screen has no interactive elements', async () => { + const file = makeFile([frameNode({ name: 'Static', box: box(0, 0, 375, 800), children: [textNode({ characters: 'x', fontSize: 16 })] })]); + const graph = buildGraph(file); + const metrics = await cognitiveLoadAnalyzer.analyze(file, {}, graph); + expect(metrics.every((m) => m.status === 'na')).toBe(true); + }); +}); + +// ─── Typography ─── + +describe('typography', () => { + it('fails tiny font + tight line-height + long CPL', async () => { + const file = makeFile([ + frameNode({ + name: 'Home', + box: box(0, 0, 1200, 800), + children: [textNode({ characters: 'a'.repeat(40), fontSize: 10, lineHeightPx: 10, box: box(0, 0, 1000, 20) })], + }), + ]); + const m = byStatus(await typographyAnalyzer.analyze(file, {}), 'typography'); + // font-size fail, line-height fail, cpl fail + expect(m.fail).toBe(3); + }); + + it('passes good typography', async () => { + const file = makeFile([ + frameNode({ + name: 'Home', + box: box(0, 0, 400, 800), + children: [textNode({ characters: 'Readable', fontSize: 16, lineHeightPx: 24, box: box(0, 0, 200, 24) })], + }), + ]); + const m = byStatus(await typographyAnalyzer.analyze(file, {}), 'typography'); + expect(m.fail).toBe(0); + expect(m.pass).toBeGreaterThanOrEqual(2); + }); + + it('marks CPL na for non-Latin text', async () => { + const file = makeFile([ + frameNode({ + name: 'Home', + box: box(0, 0, 1200, 800), + children: [textNode({ characters: 'いちにさんし'.repeat(20), fontSize: 16, box: box(0, 0, 1000, 24) })], + }), + ]); + const cpl = (await typographyAnalyzer.analyze(file, {})).filter((x) => (x.evidence as any)?.subcheck === 'cpl'); + expect(cpl[0].status).toBe('na'); + }); +}); + +// ─── Fitts ─── + +describe('fitts', () => { + it('fails a tiny far-corner target and passes a large central one', async () => { + const file = makeFile([ + frameNode({ + name: 'Home', + box: box(0, 0, 1000, 1000), + children: [ + interactiveNode({ name: 'TinyCorner', box: box(0, 0, 12, 12) }), + interactiveNode({ name: 'BigCenter', box: box(400, 400, 200, 200) }), + ], + }), + ]); + const all = byStatus(await fittsAnalyzer.analyze(file, {}), 'fitts').all; + expect(all.find((m) => m.nodeId && m.evidence && (m.evidence as any).width === 12)?.status).toBe('fail'); + expect(all.some((m) => m.status === 'pass')).toBe(true); + }); + + it('marks na for zero-size targets', async () => { + const file = makeFile([ + frameNode({ name: 'Home', box: box(0, 0, 1000, 1000), children: [interactiveNode({ name: 'Z', box: box(0, 0, 0, 30) })] }), + ]); + const m = byStatus(await fittsAnalyzer.analyze(file, {}), 'fitts'); + expect(m.na).toBe(1); + }); +}); + +// ─── Readability ─── + +describe('readability', () => { + it('grades complex prose and marks microcopy na', async () => { + const complex = + 'Notwithstanding the aforementioned considerations, the comprehensive reconfiguration of the authentication subsystem necessitates extensive interdepartmental deliberation regarding numerous architectural ramifications. Consequently, stakeholders must thoroughly evaluate every conceivable implementation alternative before ultimately proceeding.'; + const file = makeFile([ + frameNode({ + name: 'Terms', + box: box(0, 0, 400, 800), + children: [ + textNode({ name: 'Prose', characters: complex }), + textNode({ name: 'Button', characters: 'Save' }), + ], + }), + ]); + const m = byStatus(await readabilityAnalyzer.analyze(file, {}), 'readability'); + expect(m.fail).toBe(1); // complex prose + expect(m.na).toBe(1); // 'Save' microcopy + }); +}); + +// ─── Gestalt ─── + +describe('gestalt', () => { + it('passes when interactive elements are aligned', async () => { + const file = makeFile([ + frameNode({ + name: 'Form', + box: box(0, 0, 400, 800), + children: [ + interactiveNode({ name: 'A', box: box(20, 0, 100, 40) }), + interactiveNode({ name: 'B', box: box(20, 60, 100, 40) }), + interactiveNode({ name: 'C', box: box(20, 120, 100, 40) }), + ], + }), + ]); + const m = byStatus(await gestaltAnalyzer.analyze(file, {}), 'gestalt'); + expect(m.pass).toBe(1); + }); + + it('na when fewer than 2 elements', async () => { + const file = makeFile([ + frameNode({ name: 'Solo', box: box(0, 0, 400, 800), children: [interactiveNode({ name: 'A', box: box(20, 0, 100, 40) })] }), + ]); + const m = byStatus(await gestaltAnalyzer.analyze(file, {}), 'gestalt'); + expect(m.na).toBe(1); + }); +}); + +// ─── Emphasis (Von Restorff) ─── + +describe('emphasis', () => { + it('passes when the primary CTA dominates, excludes Cancel', async () => { + const file = makeFile([ + frameNode({ + name: 'Dialog', + box: box(0, 0, 400, 800), + children: [ + interactiveNode({ name: 'Cancel', box: box(0, 0, 80, 40) }), + interactiveNode({ name: 'Save', box: box(100, 0, 240, 56) }), + ], + }), + ]); + const m = byStatus(await emphasisAnalyzer.analyze(file, {}), 'emphasis'); + expect(m.pass).toBe(1); + expect((m.all[0].evidence as any).primaryCta).toBe('Save'); + }); + + it('fails when all actions look the same', async () => { + const file = makeFile([ + frameNode({ + name: 'Flat', + box: box(0, 0, 400, 800), + children: [ + interactiveNode({ name: 'One', box: box(0, 0, 100, 40) }), + interactiveNode({ name: 'Two', box: box(120, 0, 100, 40) }), + ], + }), + ]); + const m = byStatus(await emphasisAnalyzer.analyze(file, {}), 'emphasis'); + expect(m.fail).toBe(1); + }); +}); + +// ─── Balance ─── + +describe('balance', () => { + it('fails a left-heavy layout', 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)] }, + ], + }), + ]); + const balance = (await balanceAnalyzer.analyze(file, {})).find((m) => (m.evidence as any)?.imbalancePct !== undefined); + expect(balance?.status).toBe('fail'); + }); + + it('flags too many distinct colours', async () => { + const children = Array.from({ length: 10 }, (_, i) => ({ + id: `c${i}`, + name: `c${i}`, + type: 'RECTANGLE', + absoluteBoundingBox: box(i * 10, 0, 8, 8), + fills: [solidFill(i / 10, 0.1, 0.9)], + })); + const file = makeFile([frameNode({ name: 'Rainbow', box: box(0, 0, 400, 800), children })]); + const colour = (await balanceAnalyzer.analyze(file, {})).find((m) => (m.evidence as any)?.distinctColors !== undefined); + expect(colour?.status).toBe('fail'); + }); + + it('pins the imbalance value for a fully left-heavy screen', async () => { + const file = makeFile([ + 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)] }], + }), + ]); + const b = (await balanceAnalyzer.analyze(file, {})).find((m) => (m.evidence as any)?.imbalancePct !== undefined); + expect(b?.value).toBe(100); + expect(b?.status).toBe('fail'); + }); +}); + +// ─── Edge cases & value pins (plan §E) ─── + +describe('edge cases', () => { + it('walk() skips visible:false subtrees (no metrics for hidden nodes)', async () => { + const file = makeFile([ + frameNode({ + name: 'Home', + box: box(0, 0, 375, 800), + children: [ + textNode({ id: 'vis', characters: 'Shown', fontSize: 16, box: box(0, 0, 100, 20) }), + textNode({ id: 'hid', characters: 'Hidden', fontSize: 8, box: box(0, 0, 100, 20), visible: false }), + ], + }), + ]); + const m = await typographyAnalyzer.analyze(file, {}); + expect(m.length).toBeGreaterThan(0); + expect(m.every((x) => x.nodeId !== 'hid')).toBe(true); + }); + + it('contrast flags uncertain when text node opacity < 1', async () => { + const file = makeFile([ + frameNode({ + name: 'Home', + box: box(0, 0, 375, 800), + fills: [solidFill(1, 1, 1)], + children: [textNode({ characters: 'Semi', fontSize: 16, fills: [solidFill(0, 0, 0)], box: box(0, 0, 100, 20), opacity: 0.5 })], + }), + ]); + const m = (await contrastAnalyzer.analyze(file, {})).filter((x) => x.dimension === 'contrast'); + expect((m[0].evidence as any).uncertain).toBe(true); + }); + + it('contrast is na when the text fill is non-solid', async () => { + const file = makeFile([ + frameNode({ + name: 'Home', + box: box(0, 0, 375, 800), + fills: [solidFill(1, 1, 1)], + children: [textNode({ characters: 'Gradient text', fontSize: 16, fills: [gradientFill()], box: box(0, 0, 100, 20) })], + }), + ]); + const m = (await contrastAnalyzer.analyze(file, {})).filter((x) => x.dimension === 'contrast'); + expect(m[0].status).toBe('na'); + }); + + it('typography CPL pins the estimated value (1000px / (10px·0.5) = 200)', async () => { + const file = makeFile([ + frameNode({ + name: 'Home', + box: box(0, 0, 1200, 800), + children: [textNode({ characters: 'a'.repeat(40), fontSize: 10, lineHeightPx: 16, box: box(0, 0, 1000, 20) })], + }), + ]); + const cpl = (await typographyAnalyzer.analyze(file, {})).find((x) => (x.evidence as any)?.subcheck === 'cpl'); + expect(cpl?.value).toBe(200); + expect(cpl?.status).toBe('fail'); + }); + + it('emphasis excludes a larger Cancel and still picks Save (then fails on ratio)', async () => { + const file = makeFile([ + frameNode({ + name: 'Dialog', + box: box(0, 0, 400, 800), + children: [ + interactiveNode({ name: 'Cancel', box: box(0, 0, 300, 50) }), // larger, but secondary + interactiveNode({ name: 'Save', box: box(0, 60, 100, 50) }), // smaller primary + ], + }), + ]); + const m = byStatus(await emphasisAnalyzer.analyze(file, {}), 'emphasis'); + expect((m.all[0].evidence as any).primaryCta).toBe('Save'); + expect(m.all[0].status).toBe('fail'); // Save (5000) / Cancel peer (15000) = 0.33 < 1.2 + expect(m.all[0].value).toBeCloseTo(0.33, 1); + }); + + it('gestalt: alignment within ±3px counts; misalignment fails at 0%', async () => { + const aligned = makeFile([ + frameNode({ + name: 'Tol', + box: box(0, 0, 400, 800), + children: [ + interactiveNode({ name: 'A', box: box(20, 0, 100, 40) }), + interactiveNode({ name: 'B', box: box(22, 60, 100, 40) }), // left edge within 3px + ], + }), + ]); + expect(byStatus(await gestaltAnalyzer.analyze(aligned, {}), 'gestalt').all[0].value).toBe(100); + + const messy = makeFile([ + frameNode({ + name: 'Messy', + box: box(0, 0, 400, 800), + children: [ + interactiveNode({ name: 'A', box: box(20, 0, 100, 40) }), + interactiveNode({ name: 'B', box: box(207, 63, 90, 37) }), // no shared edge/center within 3px + ], + }), + ]); + const m = byStatus(await gestaltAnalyzer.analyze(messy, {}), 'gestalt'); + expect(m.fail).toBe(1); + expect(m.all[0].value).toBe(0); + }); + + it('Hick is na when a screen has interactive elements but 0 destinations (BACK only)', async () => { + const backBtn = { + id: 'bk', + name: 'Back', + type: 'RECTANGLE', + absoluteBoundingBox: box(0, 0, 44, 44), + interactions: [{ trigger: { type: 'ON_CLICK' }, actions: [{ type: 'BACK' as const }] }], + }; + const file = makeFile([frameNode({ name: 'Detail', box: box(0, 0, 375, 800), children: [backBtn] })]); + const graph = buildGraph(file); + const metrics = await cognitiveLoadAnalyzer.analyze(file, {}, graph); + expect(metrics.find((m) => m.framework.startsWith('Hick'))?.status).toBe('na'); + expect(metrics.find((m) => m.framework.startsWith('Miller'))?.status).toBe('pass'); + }); +}); diff --git a/packages/core/src/metrics/readability.ts b/packages/core/src/metrics/readability.ts new file mode 100644 index 0000000..67bb742 --- /dev/null +++ b/packages/core/src/metrics/readability.ts @@ -0,0 +1,56 @@ +import type { AnalyzerOptions, FigmaFile, Metric, MetricAnalyzer } from '../types.js'; +import { forEachScreen, collectText } from './walk.js'; +import { makeMetric } from './metric.js'; +import { fleschKincaidGrade, textIsProse, isLatinText } from '../utils/text.js'; + +/** Aim for general-audience copy ≤ grade ~12. */ +const MAX_GRADE = 12; + +/** + * Flesch–Kincaid grade — ONLY for prose (>20 words, ≥2 sentences). UI microcopy + * (labels/buttons) and non-Latin text are marked `na`. + */ +export const readabilityAnalyzer: MetricAnalyzer = { + name: 'readability', + framework: 'Flesch–Kincaid', + + async analyze(file: FigmaFile, _options: AnalyzerOptions): Promise { + const metrics: Metric[] = []; + let counter = 0; + + forEachScreen(file, ({ frame, id, name }) => { + for (const { node } of collectText(frame)) { + const text = node.characters ?? ''; + const loc = { screenId: id, screenName: name, nodeId: node.id }; + const applicable = text.length > 0 && isLatinText(text) && textIsProse(text); + if (!applicable) { + metrics.push( + makeMetric('flesch', `flesch-${++counter}`, loc, { + value: 0, + threshold: MAX_GRADE, + status: 'na', + evidence: { reason: !isLatinText(text) ? 'non-latin' : 'not-prose' }, + }), + ); + continue; + } + const grade = fleschKincaidGrade(text); + metrics.push( + makeMetric('flesch', `flesch-${++counter}`, loc, { + value: Math.round(grade * 10) / 10, + threshold: MAX_GRADE, + status: grade > MAX_GRADE ? 'fail' : 'pass', + whyItMatters: 'Complex prose raises reading effort and drop-off.', + recommendation: + grade > MAX_GRADE + ? `"${node.name}" reads at grade ${grade.toFixed(1)} — simplify toward grade ≤${MAX_GRADE}.` + : undefined, + evidence: { grade: Math.round(grade * 10) / 10 }, + }), + ); + } + }); + + return metrics; + }, +}; diff --git a/packages/core/src/metrics/registry.ts b/packages/core/src/metrics/registry.ts new file mode 100644 index 0000000..2ad6646 --- /dev/null +++ b/packages/core/src/metrics/registry.ts @@ -0,0 +1,21 @@ +import type { MetricAnalyzer } from '../types.js'; +import { contrastAnalyzer } from './contrast.js'; +import { cognitiveLoadAnalyzer } from './cognitive-load.js'; +import { fittsAnalyzer } from './fitts.js'; +import { typographyAnalyzer } from './typography.js'; +import { readabilityAnalyzer } from './readability.js'; +import { gestaltAnalyzer } from './gestalt.js'; +import { emphasisAnalyzer } from './emphasis.js'; +import { balanceAnalyzer } from './balance.js'; + +/** Tier-1 (core/MIT) science metric analyzers. Run when options.metrics is set. */ +export const METRIC_CHECKS: MetricAnalyzer[] = [ + contrastAnalyzer, + cognitiveLoadAnalyzer, + fittsAnalyzer, + typographyAnalyzer, + readabilityAnalyzer, + gestaltAnalyzer, + emphasisAnalyzer, + balanceAnalyzer, +]; diff --git a/packages/core/src/metrics/typography.ts b/packages/core/src/metrics/typography.ts new file mode 100644 index 0000000..c797072 --- /dev/null +++ b/packages/core/src/metrics/typography.ts @@ -0,0 +1,83 @@ +import type { AnalyzerOptions, FigmaFile, Metric, MetricAnalyzer } from '../types.js'; +import { forEachScreen, collectText } from './walk.js'; +import { makeMetric } from './metric.js'; +import { estimateCPL, isLatinText } from '../utils/text.js'; + +const MIN_FONT_PX = 12; +const MIN_LINE_HEIGHT_RATIO = 1.4; +const MAX_CPL = 75; + +/** Typography legibility: min font size, line-height ratio, line length (CPL). */ +export const typographyAnalyzer: MetricAnalyzer = { + name: 'typography', + framework: 'Typography (WCAG 1.4.4)', + + async analyze(file: FigmaFile, _options: AnalyzerOptions): Promise { + const metrics: Metric[] = []; + let counter = 0; + + forEachScreen(file, ({ frame, id, name }) => { + for (const { node } of collectText(frame)) { + const style = node.style; + const fontSize = style?.fontSize; + const loc = { screenId: id, screenName: name, nodeId: node.id }; + + // ── Min font size ── + metrics.push( + makeMetric('typography', `type-${++counter}`, loc, { + value: fontSize ?? 0, + threshold: MIN_FONT_PX, + status: !fontSize ? 'na' : fontSize < MIN_FONT_PX ? 'fail' : 'pass', + whyItMatters: 'Body text below 12px is hard to read on mobile.', + recommendation: + fontSize && fontSize < MIN_FONT_PX + ? `"${node.name}" is ${fontSize}px — use ≥${MIN_FONT_PX}px.` + : undefined, + evidence: { subcheck: 'font-size', fontSize }, + }), + ); + + // ── Line-height ratio ── + const lineHeightPx = style?.lineHeightPx; + const lineHeightPercent = style?.lineHeightPercent; + let ratio: number | undefined; + if (lineHeightPx && fontSize && fontSize > 0) ratio = lineHeightPx / fontSize; + else if (lineHeightPercent) ratio = lineHeightPercent / 100; + metrics.push( + makeMetric('typography', `type-${++counter}`, loc, { + value: ratio ? Math.round(ratio * 100) / 100 : 0, + threshold: MIN_LINE_HEIGHT_RATIO, + status: ratio === undefined ? 'na' : ratio < MIN_LINE_HEIGHT_RATIO ? 'fail' : 'pass', + whyItMatters: 'Tight line spacing reduces readability.', + recommendation: + ratio !== undefined && ratio < MIN_LINE_HEIGHT_RATIO + ? `"${node.name}" line-height ratio ${ratio.toFixed(2)} — use ≥${MIN_LINE_HEIGHT_RATIO}.` + : undefined, + evidence: { subcheck: 'line-height', ratio }, + }), + ); + + // ── Line length (CPL) — Latin only, needs width + fontSize ── + const width = node.absoluteBoundingBox?.width; + const chars = node.characters ?? ''; + const cplApplicable = !!fontSize && !!width && chars.length > 0 && isLatinText(chars); + const cpl = cplApplicable ? estimateCPL(width!, fontSize!) : 0; + metrics.push( + makeMetric('typography', `type-${++counter}`, loc, { + value: Math.round(cpl), + threshold: MAX_CPL, + status: !cplApplicable ? 'na' : cpl > MAX_CPL ? 'fail' : 'pass', + whyItMatters: 'Lines longer than ~75 characters tire the eye.', + recommendation: + cplApplicable && cpl > MAX_CPL + ? `"${node.name}" is ~${Math.round(cpl)} chars/line — narrow the text box (≤${MAX_CPL}).` + : undefined, + evidence: { subcheck: 'cpl', cpl: Math.round(cpl), latin: cplApplicable }, + }), + ); + } + }); + + return metrics; + }, +}; diff --git a/packages/core/src/metrics/walk.ts b/packages/core/src/metrics/walk.ts new file mode 100644 index 0000000..b238fcc --- /dev/null +++ b/packages/core/src/metrics/walk.ts @@ -0,0 +1,56 @@ +import type { FigmaFile, FigmaNode } from '../types.js'; +import { isNonPrototypeFrame, isArchivedFrame } from '../utils/filters.js'; + +export interface Screen { + frame: FigmaNode; + id: string; + name: string; + archived: boolean; +} + +/** Iterate prototype screens (top-level frames, skipping DS/annotation frames). */ +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) }); + } + } + } +} + +/** + * Depth-first walk. `cb` receives the node and its ancestors NEAREST-FIRST + * (parent, grandparent, …). Invisible subtrees (visible:false) are skipped. + */ +export function walk( + node: FigmaNode, + cb: (node: FigmaNode, ancestorsNearestFirst: FigmaNode[]) => void, + ancestorsNearestFirst: FigmaNode[] = [], +): void { + if (node.visible === false) return; + cb(node, ancestorsNearestFirst); + const next = [node, ...ancestorsNearestFirst]; + for (const child of node.children ?? []) walk(child, cb, next); +} + +/** Visible interactive descendants (with a bounding box) of a frame. */ +export function collectInteractive(frame: FigmaNode): FigmaNode[] { + const out: FigmaNode[] = []; + walk(frame, (n) => { + if (n.interactions?.length && n.absoluteBoundingBox) out.push(n); + }); + return out; +} + +/** Visible TEXT descendants of a frame, paired with their ancestor chain. */ +export function collectText(frame: FigmaNode): Array<{ node: FigmaNode; ancestors: FigmaNode[] }> { + const out: Array<{ node: FigmaNode; ancestors: FigmaNode[] }> = []; + walk(frame, (n, ancestors) => { + if (n.type === 'TEXT') out.push({ node: n, ancestors }); + }); + return out; +} diff --git a/packages/core/src/reporters/html.ts b/packages/core/src/reporters/html.ts index 4b884f4..9e8775f 100644 --- a/packages/core/src/reporters/html.ts +++ b/packages/core/src/reporters/html.ts @@ -1,4 +1,5 @@ -import type { Issue, ScanResult } from '../types.js'; +import type { Issue, MetricDimensionScore, ScanResult } from '../types.js'; +import { topFixes } from '../scoring/score.js'; export function formatHtml(result: ScanResult): string { const { file, summary, issues, duration, skippedChecks } = result; @@ -55,6 +56,23 @@ export function formatHtml(result: ScanResult): string { .bar-fill.medium { background: var(--medium); color: #000; } .bar-fill.low { background: var(--low); } + /* Score hero */ + .score-hero { display: grid; grid-template-columns: auto 300px 1fr; gap: 24px; align-items: center; background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 24px; margin-bottom: 24px; } + .score-ring { text-align: center; } + .score-num { font-size: 64px; font-weight: 800; color: var(--accent); line-height: 1; } + .score-num span { font-size: 20px; color: var(--muted); font-weight: 600; } + .score-band { margin-top: 8px; font-size: 14px; color: var(--muted); } + .score-tier { margin-top: 4px; font-size: 11px; color: var(--muted); opacity: 0.7; } + .score-dims { display: flex; flex-direction: column; gap: 4px; } + .score-dims .bar-label { width: 120px; } + .fix-box { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px 20px; margin-bottom: 24px; } + .fix-box h3 { font-size: 14px; color: var(--muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; } + .fix-list { list-style: none; } + .fix-list li { padding: 4px 0; font-size: 13px; } + .fix-list .pts { display: inline-block; min-width: 38px; color: var(--green); font-weight: 700; } + .fix-list .cite { color: var(--muted); font-size: 11px; } + @media (max-width: 768px) { .score-hero { grid-template-columns: 1fr; } } + /* Filters */ .filters { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } .filter-btn { background: var(--surface); border: 1px solid var(--border); color: var(--muted); padding: 6px 14px; border-radius: 20px; font-size: 13px; cursor: pointer; transition: all 0.15s; } @@ -112,6 +130,8 @@ export function formatHtml(result: ScanResult): string { + ${renderScoreHero(result)} +
${summary.screens.total}
Screens
${summary.likelyReal}
Likely Real
@@ -234,3 +254,74 @@ function renderSeverityBars(data: Record, total: number): string return `
${sev}
${count}
`; }).join('\n'); } + +/** Colour for a 0-100 dimension score (green → red). */ +function scoreColor(score: number): string { + if (score >= 75) return 'var(--green)'; + if (score >= 60) return 'var(--medium)'; + if (score >= 40) return 'var(--high)'; + return 'var(--critical)'; +} + +/** Usability score hero: big number, radar, per-dimension bars + top fixes. */ +function renderScoreHero(result: ScanResult): string { + const score = result.summary.score; + if (!score) return ''; + const dims = score.byDimension.filter((d) => d.score !== null) as Array; + const bandText = score.band ? `${score.band.emoji} ${score.band.label}` : 'alpha — methodology in calibration'; + const tierText = score.tier === 'core+vision' ? 'core + vision' : 'core (REST-only)'; + + const bars = dims + .map( + (d) => + `
${esc(d.label)}
${d.score}
`, + ) + .join('\n'); + + const fixes = topFixes(result.metrics, 5) + .map( + (f) => + `
  • +${f.pointsImpact ?? 0} ${esc(f.recommendation ?? f.whyItMatters ?? f.framework)} ${esc(f.framework)}, ${esc(f.citation)}
  • `, + ) + .join('\n'); + + return ` +
    +
    +
    ${score.global}/100
    +
    ${esc(bandText)}
    +
    ${tierText} · coverage ${score.coverage}%
    +
    +
    ${renderRadar(dims)}
    +
    ${bars}
    +
    + ${fixes ? `

    Top fixes → points

      ${fixes}
    ` : ''}`; +} + +/** Hand-rolled radar SVG (no deps). Needs ≥3 dimensions, else returns ''. */ +function renderRadar(dims: Array): string { + const n = dims.length; + if (n < 3) return ''; + const size = 280; + const cx = size / 2; + const cy = size / 2; + const R = 100; + const angle = (i: number) => ((-90 + (360 / n) * i) * Math.PI) / 180; + const pt = (i: number, r: number): [number, number] => [cx + r * Math.cos(angle(i)), cy + r * Math.sin(angle(i))]; + + let svg = ''; + for (const ring of [0.25, 0.5, 0.75, 1]) { + const pts = dims.map((_, i) => pt(i, R * ring).map((v) => v.toFixed(1)).join(',')).join(' '); + svg += ``; + } + dims.forEach((d, i) => { + const [x, y] = pt(i, R); + svg += ``; + const [lx, ly] = pt(i, R + 18); + svg += `${esc(d.label)}`; + }); + const vpts = dims.map((d, i) => pt(i, R * (d.score / 100)).map((v) => v.toFixed(1)).join(',')).join(' '); + svg += ``; + + return `${svg}`; +} diff --git a/packages/core/src/reporters/json.ts b/packages/core/src/reporters/json.ts index 3e25505..12a5815 100644 --- a/packages/core/src/reporters/json.ts +++ b/packages/core/src/reporters/json.ts @@ -1,10 +1,13 @@ import type { ScanResult } from '../types.js'; +import { BANDS_VERSION } from '../scoring/dimensions.js'; export function formatJson(result: ScanResult): string { const output = { + _meta: { tool: 'protoscan', schema: '1.1', bandsVersion: BANDS_VERSION }, file: result.file, summary: result.summary, issues: result.issues, + metrics: result.metrics, duration: result.duration, timestamp: result.timestamp, skippedChecks: result.skippedChecks, diff --git a/packages/core/src/reporters/terminal.ts b/packages/core/src/reporters/terminal.ts index 664382b..322c7cb 100644 --- a/packages/core/src/reporters/terminal.ts +++ b/packages/core/src/reporters/terminal.ts @@ -1,4 +1,5 @@ import type { Issue, ScanResult } from '../types.js'; +import { topFixes } from '../scoring/score.js'; const NO_COLOR = !!process.env.NO_COLOR; @@ -33,6 +34,8 @@ export function formatTerminal(result: ScanResult): string { lines.push(colors.dim(`File: ${result.file.key} | Screens: ${result.summary.screens.total} | ${result.duration}ms`)); lines.push(''); + lines.push(...renderScoreBlock(result)); + if (result.issues.length === 0) { lines.push(' ✅ No issues found!'); lines.push(''); @@ -95,3 +98,35 @@ function groupBySeverity(issues: Issue[]): Record { } return groups; } + +function scoreBar(score: number): string { + const filled = Math.max(0, Math.min(10, Math.round(score / 10))); + return '█'.repeat(filled) + '░'.repeat(10 - filled); +} + +/** Usability score header: global number, per-dimension bars, top fixes. */ +function renderScoreBlock(result: ScanResult): string[] { + const score = result.summary.score; + if (!score) return []; + const out: string[] = []; + const band = score.band + ? `${score.band.emoji} ${score.band.label}` + : colors.dim('(alpha — methodology in calibration)'); + out.push(colors.bold(` Usability Score: ${score.global}/100 `) + band); + out.push(''); + for (const d of score.byDimension) { + 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); + if (fixes.length > 0) { + out.push(''); + out.push(colors.dim(' Top fixes:')); + for (const f of fixes) { + const pts = f.pointsImpact ? `+${f.pointsImpact}` : ''; + out.push(` ${colors.bold(pts.padStart(5))} ${f.recommendation ?? f.whyItMatters ?? f.framework}`); + } + } + out.push(''); + return out; +} diff --git a/packages/core/src/scanner.test.ts b/packages/core/src/scanner.test.ts new file mode 100644 index 0000000..41598d8 --- /dev/null +++ b/packages/core/src/scanner.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { scan } from './scanner.js'; +import { formatTerminal } from './reporters/terminal.js'; +import { formatJson } from './reporters/json.js'; +import { formatHtml } from './reporters/html.js'; +import { box, solidFill, textNode, interactiveNode, frameNode, makeFile } from './testing/fixtures.js'; + +function demoFile() { + return makeFile([ + frameNode({ + name: 'Home', + box: box(0, 0, 375, 800), + fills: [solidFill(1, 1, 1)], + children: [ + textNode({ characters: 'Faint label', fontSize: 10, fills: [solidFill(0.7, 0.7, 0.7)], box: box(0, 0, 200, 14) }), + ...Array.from({ length: 10 }, (_, i) => + interactiveNode({ name: `Action ${i}`, box: box((i % 5) * 70, 100 + Math.floor(i / 5) * 60, 30, 30), destinationId: `dest:${i}` }), + ), + ], + }), + ]); +} + +describe('scan() integration — metrics + score', () => { + it('produces metrics and a usability score when requested', async () => { + const result = await scan(demoFile(), { fileKey: 'abc123', metrics: true, score: true }); + + expect(result.metrics.length).toBeGreaterThan(0); + expect(result.summary.score).toBeDefined(); + const score = result.summary.score!; + expect(score.global).toBeGreaterThanOrEqual(0); + expect(score.global).toBeLessThanOrEqual(100); + expect(score.tier).toBe('core'); + expect(score.band).toBeUndefined(); // bands not calibrated yet + // contrast + cognitive-load failures dual-emitted as issues + expect(result.issues.some((i) => i.category === 'contrast')).toBe(true); + expect(result.issues.some((i) => i.category === 'cognitive-load')).toBe(true); + // failing metrics get a points impact + expect(result.metrics.some((m) => m.status === 'fail' && typeof m.pointsImpact === 'number')).toBe(true); + }); + + it('omits metrics/score when not requested (back-compat)', async () => { + const result = await scan(demoFile(), { fileKey: 'abc123' }); + expect(result.metrics).toEqual([]); + expect(result.summary.score).toBeUndefined(); + }); + + it('reporters render a scored result without throwing', async () => { + const result = await scan(demoFile(), { fileKey: 'abc123', score: true }); + const term = formatTerminal(result); + expect(term).toContain('Usability Score'); + const json = JSON.parse(formatJson(result)); + expect(json.summary.score.global).toBe(result.summary.score!.global); + expect(json._meta.schema).toBe('1.1'); + const html = formatHtml(result); + expect(html).toContain('score-hero'); + expect(html).toContain('/100'); + }); +}); diff --git a/packages/core/src/scanner.ts b/packages/core/src/scanner.ts index 9481062..90c7c1f 100644 --- a/packages/core/src/scanner.ts +++ b/packages/core/src/scanner.ts @@ -1,15 +1,20 @@ -import type { AnalyzerOptions, FigmaFile, Issue, ScanResult, ScanStats } from './types.js'; +import type { AnalyzerOptions, FigmaFile, Issue, Metric, ScanResult, ScanStats, UsabilityScore } from './types.js'; import { buildGraph } from './graph/builder.js'; import { graphAnalyzer, resetIssueCounter } from './graph/analyzer.js'; import { touchTargetAnalyzer } from './spatial/touch-targets.js'; import { overlapAnalyzer } from './spatial/overlap.js'; import { scrollAnalyzer } from './spatial/scroll.js'; import { overlayTrapAnalyzer } from './spatial/overlay-traps.js'; +import { METRIC_CHECKS } from './metrics/registry.js'; +import { metricToIssue } from './metrics/metric.js'; +import { computeScore, annotatePointsImpact } from './scoring/score.js'; export interface ScanOptions extends AnalyzerOptions { fileKey: string; /** Pre-computed issues from external analyzers (e.g. simulator) to merge into results */ additionalIssues?: Issue[]; + /** Pre-computed metrics from external analyzers (e.g. vision Tier-2) to merge into the score */ + additionalMetrics?: Metric[]; } export async function scan(file: FigmaFile, options: ScanOptions): Promise { @@ -38,17 +43,38 @@ export async function scan(file: FigmaFile, options: ScanOptions): Promise = {}; const screensWithIssues = new Set(); @@ -75,5 +101,6 @@ function buildSummary(issues: Issue[], totalScreens: number): ScanStats { bySeverity, byCategory: byCategory as ScanStats['byCategory'], screens: { total: totalScreens, withIssues: screensWithIssues.size }, + score, }; } diff --git a/packages/core/src/scoring/dimensions.ts b/packages/core/src/scoring/dimensions.ts new file mode 100644 index 0000000..5860d58 --- /dev/null +++ b/packages/core/src/scoring/dimensions.ts @@ -0,0 +1,78 @@ +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. + */ +export const DIMENSIONS: MetricDimension[] = [ + 'contrast', + 'cognitive-load', + 'fitts', + 'typography', + 'readability', + 'gestalt', + 'emphasis', + 'balance', + 'clutter', + 'saliency', +]; + +export const TIER1_DIMENSIONS: MetricDimension[] = [ + 'contrast', + 'cognitive-load', + 'fitts', + 'typography', + 'readability', + 'gestalt', + 'emphasis', + 'balance', +]; + +export const TIER2_DIMENSIONS: MetricDimension[] = ['clutter', 'saliency']; + +export const DIMENSION_LABELS: Record = { + contrast: 'Contrast', + 'cognitive-load': 'Cognitive Load', + fitts: 'Reachability', + typography: 'Typography', + readability: 'Readability', + gestalt: 'Grouping', + emphasis: 'Emphasis', + balance: 'Balance', + clutter: 'Visual Clutter', + saliency: 'Saliency', +}; + +interface BandCutoff extends ScoreBand { + min: number; +} + +/** + * Provisional band cutoffs (borrowed from the ui-ergonomics 128-rule context). + * NOT yet calibrated for the 8-metric pass-rate — see scripts/calibrate (Fase B.5). + * `getBand` returns undefined until BANDS_CALIBRATED is flipped to true, so the + * 0-100 number ships without arbitrary Excellent/Weak labels. + */ +const PROVISIONAL_BANDS: BandCutoff[] = [ + { min: 90, label: 'Excellent', emoji: '🟢' }, + { min: 75, label: 'Good', emoji: '🟢' }, + { min: 60, label: 'Acceptable', emoji: '🟡' }, + { min: 40, label: 'Weak', emoji: '🟠' }, + { min: 0, label: 'Critical', emoji: '🔴' }, +]; + +/** Flip to true ONLY after running scripts/calibrate against a real corpus. */ +export const BANDS_CALIBRATED: boolean = false; + +/** Version of the calibrated band cutoffs, recorded in report JSON. */ +export const BANDS_VERSION = 'uncalibrated'; + +/** Maturity band for a 0-100 score, or undefined when bands are not calibrated. */ +export function getBand(score: number): ScoreBand | undefined { + if (!BANDS_CALIBRATED) return undefined; + for (const b of PROVISIONAL_BANDS) { + if (score >= b.min) return { label: b.label, emoji: b.emoji }; + } + return { label: PROVISIONAL_BANDS[PROVISIONAL_BANDS.length - 1].label, emoji: PROVISIONAL_BANDS[PROVISIONAL_BANDS.length - 1].emoji }; +} diff --git a/packages/core/src/scoring/score.test.ts b/packages/core/src/scoring/score.test.ts new file mode 100644 index 0000000..39b7cc2 --- /dev/null +++ b/packages/core/src/scoring/score.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import { computeScore, annotatePointsImpact } from './score.js'; +import { getBand, BANDS_CALIBRATED } from './dimensions.js'; +import type { Metric, MetricDimension, MetricStatus, MetricTier } from '../types.js'; + +let seq = 0; +function m(dimension: MetricDimension, status: MetricStatus, tier: MetricTier = 'core'): Metric { + return { + id: `m${++seq}`, + framework: 'test', + dimension, + citation: '', + claimType: 'signal', + tier, + value: 0, + threshold: 0, + status, + screenId: 's', + screenName: 'S', + }; +} + +describe('computeScore', () => { + it('per-dimension score = pass/(pass+fail)·100, na excluded', () => { + const metrics = [ + ...Array(5).fill(0).map(() => m('contrast', 'pass')), + ...Array(2).fill(0).map(() => m('contrast', 'fail')), + m('contrast', 'na'), + ]; + const score = computeScore(metrics); + const contrast = score.byDimension.find((d) => d.dimension === 'contrast')!; + expect(contrast.score).toBe(71); // round(5/7*100) + expect(score.global).toBe(71); + }); + + it('is deterministic across repeated runs', () => { + const metrics = [m('contrast', 'pass'), m('contrast', 'fail'), m('fitts', 'pass'), m('gestalt', 'na')]; + const first = JSON.stringify(computeScore(metrics)); + for (let i = 0; i < 50; i++) { + expect(JSON.stringify(computeScore(metrics))).toBe(first); + } + }); + + it('excludes Tier-2 (vision) dimensions unless includeVision', () => { + const metrics = [m('contrast', 'pass'), m('clutter', 'fail', 'vision')]; + const core = computeScore(metrics, { includeVision: false }); + expect(core.tier).toBe('core'); + expect(core.global).toBe(100); // only contrast pass counts + // Tier-2 dims are not iterated in core-only mode (excluded entirely). + expect(core.byDimension.find((d) => d.dimension === 'clutter')).toBeUndefined(); + + const withVision = computeScore(metrics, { includeVision: true }); + expect(withVision.tier).toBe('core+vision'); + expect(withVision.global).toBe(50); // 1 pass / 2 applicable + }); + + it('a fully-na dimension is excluded from the global denominator', () => { + const metrics = [m('contrast', 'pass'), m('gestalt', 'na'), m('gestalt', 'na')]; + expect(computeScore(metrics).global).toBe(100); + }); + + it('band is undefined until calibrated', () => { + expect(BANDS_CALIBRATED).toBe(false); + expect(getBand(72)).toBeUndefined(); + expect(computeScore([m('contrast', 'fail')]).band).toBeUndefined(); + }); + + it('empty/na-only metrics → score 100, coverage 0', () => { + expect(computeScore([]).global).toBe(100); + expect(computeScore([]).coverage).toBe(0); + }); +}); + +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')]; + annotatePointsImpact(metrics); + // applicable = 3 (2 fail + 1 pass) → 100/3 ≈ 33.3 + const fails = metrics.filter((x) => x.status === 'fail'); + expect(fails.every((f) => f.pointsImpact === 33.3)).toBe(true); + expect(metrics.find((x) => x.status === 'pass')?.pointsImpact).toBeUndefined(); + }); +}); diff --git a/packages/core/src/scoring/score.ts b/packages/core/src/scoring/score.ts new file mode 100644 index 0000000..335908c --- /dev/null +++ b/packages/core/src/scoring/score.ts @@ -0,0 +1,78 @@ +import type { Metric, MetricDimensionScore, UsabilityScore } from '../types.js'; +import { DIMENSIONS, TIER1_DIMENSIONS, DIMENSION_LABELS, getBand } from './dimensions.js'; + +export interface ScoreOptions { + /** Include Tier-2 (vision) dimensions in the denominator. Default: auto-detect. */ + includeVision?: boolean; +} + +/** + * Aggregate metrics into a 0-100 usability score. Iterates a FIXED dimension + * catalog so the global number is comparable with/without Tier-2. Per-dimension + * score = pass/(pass+fail)·100 (na excluded). Headline = unweighted pass-rate. + */ +export function computeScore(metrics: Metric[], opts: ScoreOptions = {}): UsabilityScore { + const includeVision = opts.includeVision ?? metrics.some((m) => m.tier === 'vision'); + const consider = includeVision ? DIMENSIONS : TIER1_DIMENSIONS; + const pool = metrics.filter((m) => includeVision || m.tier !== 'vision'); + + const byDimension: MetricDimensionScore[] = []; + let gPass = 0; + let gApplicable = 0; + let dimsEvaluated = 0; + + for (const dim of consider) { + let pass = 0; + let fail = 0; + let na = 0; + for (const m of pool) { + if (m.dimension !== dim) continue; + if (m.status === 'pass') pass++; + else if (m.status === 'fail') fail++; + else na++; + } + const applicable = pass + fail; + const score = applicable > 0 ? Math.round((pass / applicable) * 100) : null; + if (applicable > 0) { + gPass += pass; + gApplicable += applicable; + dimsEvaluated++; + } + byDimension.push({ dimension: dim, label: DIMENSION_LABELS[dim], score, pass, fail, na }); + } + + const global = gApplicable > 0 ? Math.round((gPass / gApplicable) * 100) : 100; + const coverage = Math.round((dimsEvaluated / consider.length) * 100); + + return { + global, + band: getBand(global), + byDimension, + coverage, + tier: includeVision ? 'core+vision' : 'core', + }; +} + +/** + * Set `pointsImpact` on each FAILED metric = the global-score gain from fixing + * that one finding (uniform: 100 / total applicable). Mutates in place. + */ +export function annotatePointsImpact(metrics: Metric[], opts: ScoreOptions = {}): void { + 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 perFix = Math.round((100 / applicable) * 10) / 10; + for (const m of metrics) { + if (m.status === 'fail' && (includeVision || m.tier !== 'vision')) m.pointsImpact = perFix; + } +} + +/** Top failing metrics by points impact then dimension order (for "fix → +X pts"). */ +export function topFixes(metrics: Metric[], limit = 3): Metric[] { + return metrics + .filter((m) => m.status === 'fail') + .sort((a, b) => (b.pointsImpact ?? 0) - (a.pointsImpact ?? 0)) + .slice(0, limit); +} diff --git a/packages/core/src/spatial/overlap.ts b/packages/core/src/spatial/overlap.ts index ef47f19..73e1253 100644 --- a/packages/core/src/spatial/overlap.ts +++ b/packages/core/src/spatial/overlap.ts @@ -1,5 +1,6 @@ -import type { AnalyzerOptions, BoundingBox, FigmaFile, FigmaNode, Issue } from '../types.js'; -import { isNonPrototypeFrame } from '../utils/filters.js'; +import type { AnalyzerOptions, FigmaFile, FigmaNode, Issue } from '../types.js'; +import { isNonPrototypeFrame, isArchivedFrame } from '../utils/filters.js'; +import { intersectionArea } from '../utils/geometry.js'; let counter = 0; @@ -16,7 +17,7 @@ export const overlapAnalyzer = { (frame.type === 'FRAME' || frame.type === 'COMPONENT' || frame.type === 'COMPONENT_SET') && !isNonPrototypeFrame(frame.name) ) { - checkOverlaps(frame, frame.id, frame.name, issues); + checkOverlaps(frame, frame.id, frame.name, issues, isArchivedFrame(frame.name)); } } } @@ -30,6 +31,7 @@ function checkOverlaps( screenId: string, screenName: string, issues: Issue[], + archived: boolean, ): void { // Collect interactive children at this level const interactive: FigmaNode[] = []; @@ -38,7 +40,7 @@ function checkOverlaps( interactive.push(child); } // Recurse into children - checkOverlaps(child, screenId, screenName, issues); + checkOverlaps(child, screenId, screenName, issues, archived); } // Check all pairs of interactive siblings for overlap @@ -52,8 +54,9 @@ function checkOverlaps( issues.push({ id: `overlap-${++counter}`, category: 'overlap', - severity: 'medium', - confidence: 'certain', + // Archived/backup frames aren't part of the active prototype → not actionable. + severity: archived ? 'low' : 'medium', + confidence: archived ? 'low' : 'certain', screenId, screenName, message: `"${interactive[i].name}" and "${interactive[j].name}" overlap (${Math.round(area)}px² intersection).`, @@ -61,22 +64,10 @@ function checkOverlaps( nodeA: interactive[i].name, nodeB: interactive[j].name, overlapArea: Math.round(area), + archived, }, }); } } } } - -function intersectionArea(a: BoundingBox, b: BoundingBox): number { - const x1 = Math.max(a.x, b.x); - const y1 = Math.max(a.y, b.y); - const x2 = Math.min(a.x + a.width, b.x + b.width); - const y2 = Math.min(a.y + a.height, b.y + b.height); - - const width = x2 - x1; - const height = y2 - y1; - - if (width <= 0 || height <= 0) return 0; - return width * height; -} diff --git a/packages/core/src/testing/fixtures.ts b/packages/core/src/testing/fixtures.ts new file mode 100644 index 0000000..a2f4882 --- /dev/null +++ b/packages/core/src/testing/fixtures.ts @@ -0,0 +1,124 @@ +import type { BoundingBox, FigmaFile, FigmaNode, FlowStartingPoint, Interaction, Paint } from '../types.js'; + +let _seq = 0; +export function uid(prefix = 'n'): string { + return `${prefix}:${++_seq}`; +} + +export function box(x: number, y: number, width: number, height: number): BoundingBox { + return { x, y, width, height }; +} + +export function solidFill(r: number, g: number, b: number, a = 1): Paint { + return { type: 'SOLID', color: { r, g, b, a } }; +} + +export function gradientFill(): Paint { + return { type: 'GRADIENT_LINEAR' }; +} + +export function navInteraction(destinationId: string): Interaction { + return { + trigger: { type: 'ON_CLICK' }, + actions: [{ type: 'NODE', destinationId, navigation: 'NAVIGATE' }], + }; +} + +export function textNode(opts: { + id?: string; + name?: string; + characters?: string; + fontSize?: number; + lineHeightPx?: number; + lineHeightPercent?: number; + fontWeight?: number; + box?: BoundingBox; + fills?: Paint[]; + opacity?: number; + visible?: boolean; + blendMode?: string; +}): FigmaNode { + const hasStyle = + opts.fontSize !== undefined || + opts.lineHeightPx !== undefined || + opts.lineHeightPercent !== undefined || + opts.fontWeight !== undefined; + return { + id: opts.id ?? uid('t'), + name: opts.name ?? 'Text', + type: 'TEXT', + characters: opts.characters, + style: hasStyle + ? { + fontSize: opts.fontSize, + lineHeightPx: opts.lineHeightPx, + lineHeightPercent: opts.lineHeightPercent, + fontWeight: opts.fontWeight, + } + : undefined, + absoluteBoundingBox: opts.box, + fills: opts.fills, + opacity: opts.opacity, + visible: opts.visible, + blendMode: opts.blendMode, + }; +} + +export function interactiveNode(opts: { + id?: string; + name?: string; + box: BoundingBox; + destinationId?: string; + type?: string; + fills?: Paint[]; +}): FigmaNode { + return { + id: opts.id ?? uid('b'), + name: opts.name ?? 'Button', + type: opts.type ?? 'RECTANGLE', + absoluteBoundingBox: opts.box, + interactions: [navInteraction(opts.destinationId ?? uid('dest'))], + fills: opts.fills, + }; +} + +export function frameNode(opts: { + id?: string; + name?: string; + box?: BoundingBox; + fills?: Paint[]; + children?: FigmaNode[]; + clipsContent?: boolean; +}): FigmaNode { + return { + id: opts.id ?? uid('f'), + name: opts.name ?? 'Screen', + type: 'FRAME', + absoluteBoundingBox: opts.box, + fills: opts.fills, + clipsContent: opts.clipsContent, + children: opts.children ?? [], + }; +} + +export function makeFile(frames: FigmaNode[], flowStartingPoints?: FlowStartingPoint[]): FigmaFile { + return { + name: 'Test', + lastModified: '2026-01-01', + version: '1', + document: { + id: '0:0', + name: 'Document', + type: 'DOCUMENT', + children: [ + { + id: uid('page'), + name: 'Page', + type: 'CANVAS', + flowStartingPoints, + children: frames, + }, + ], + }, + }; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ffd41fc..7efdb09 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -11,7 +11,16 @@ export interface Issue { | 'overlay-trap' | 'incomplete-connection' | 'runtime-nav-failure' - | 'vision'; + | 'vision' + // Science metrics (ProtoScan Science — Tier-1, emitted via metricToIssue) + | 'contrast' + | 'cognitive-load' + | 'fitts' + | 'readability' + | 'typography' + | 'gestalt' + | 'emphasis' + | 'balance'; severity: 'critical' | 'high' | 'medium' | 'low'; /** How confident we are this is a real issue vs. a false positive */ confidence: 'certain' | 'probable' | 'low'; @@ -37,6 +46,10 @@ export interface AnalyzerOptions { skip?: string[]; /** Screen name glob patterns to suppress (e.g. ["[DS]*", "Annotation*"]) */ ignorePatterns?: string[]; + /** Run ProtoScan Science metric analyzers (Tier-1). */ + metrics?: boolean; + /** Compute the 0-100 usability score (implies metrics). */ + score?: boolean; } /** Simplified Figma file structure with prototype data */ @@ -62,6 +75,66 @@ export interface FigmaNode { overflowDirection?: 'NONE' | 'HORIZONTAL_SCROLLING' | 'VERTICAL_SCROLLING' | 'HORIZONTAL_AND_VERTICAL_SCROLLING'; /** Whether frame clips content */ clipsContent?: boolean; + // ── Fields used by ProtoScan Science metrics (all optional; present in the + // default GET /v1/files/:key response — no client change needed). ── + /** Paint fills (SOLID has `color`; gradients/images do not) */ + fills?: Paint[]; + /** Paint strokes */ + strokes?: Paint[]; + /** Visual effects (shadows, blurs) */ + effects?: Effect[]; + /** Node opacity 0..1 (default 1 when absent) */ + opacity?: number; + /** Whether the node is visible (default true when absent) */ + visible?: boolean; + /** Blend mode (NORMAL when absent) */ + blendMode?: string; + /** TEXT node content */ + characters?: string; + /** Typography (TEXT nodes only) */ + style?: TypeStyle; + /** Auto-layout direction */ + layoutMode?: 'NONE' | 'HORIZONTAL' | 'VERTICAL'; + itemSpacing?: number; + paddingLeft?: number; + paddingRight?: number; + paddingTop?: number; + paddingBottom?: number; +} + +/** RGBA color, channels 0..1 (Figma REST encoding) */ +export interface Color { + r: number; + g: number; + b: number; + a: number; +} + +/** A Figma paint. Only SOLID carries `color`; gradients/images do not. */ +export interface Paint { + type: 'SOLID' | 'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'GRADIENT_ANGULAR' | 'GRADIENT_DIAMOND' | 'IMAGE' | 'EMOJI' | 'VIDEO' | string; + visible?: boolean; + opacity?: number; + color?: Color; +} + +/** TEXT node typography (subset of Figma TypeStyle used by metrics) */ +export interface TypeStyle { + fontFamily?: string; + fontWeight?: number; + fontSize?: number; + lineHeightPx?: number; + lineHeightPercent?: number; + lineHeightPercentFontSize?: number; + letterSpacing?: number; + textAlignHorizontal?: string; + textAlignVertical?: string; +} + +export interface Effect { + type: 'DROP_SHADOW' | 'INNER_SHADOW' | 'LAYER_BLUR' | 'BACKGROUND_BLUR' | string; + visible?: boolean; + radius?: number; } export interface Interaction { @@ -135,6 +208,8 @@ export interface ScanResult { file: { name: string; key: string; lastModified: string }; graph: PrototypeGraph; issues: Issue[]; + /** Science metrics (empty unless options.metrics) */ + metrics: Metric[]; summary: ScanStats; duration: number; timestamp: string; @@ -148,4 +223,86 @@ export interface ScanStats { bySeverity: Record; byCategory: Record; screens: { total: number; withIssues: number }; + /** 0-100 usability score (only when options.score) */ + score?: UsabilityScore; +} + +// ───────────────────────────────────────────────────────────────────────── +// ProtoScan Science — Metric model (parallel to Issue; graded/scored signals) +// ───────────────────────────────────────────────────────────────────────── + +/** + * Fixed catalog of score dimensions. The scorer ALWAYS iterates this full set + * so the global score is comparable whether or not Tier-2 (vision) ran. + * Tier-1 (core/MIT) = first 8; Tier-2 (vision/Pro) = clutter, saliency. + */ +export type MetricDimension = + | 'contrast' + | 'cognitive-load' + | 'fitts' + | 'typography' + | 'readability' + | 'gestalt' + | 'emphasis' + | 'balance' + | 'clutter' + | 'saliency'; + +export type MetricStatus = 'pass' | 'fail' | 'na'; +/** 'standard' = normative spec (WCAG). 'signal' = research-grounded heuristic. */ +export type MetricClaimType = 'standard' | 'signal'; +export type MetricTier = 'core' | 'vision'; + +/** A graded, science-based measurement (parallel to Issue). */ +export interface Metric { + id: string; + framework: string; // "WCAG 2.2" | "Hick-Hyman" | "Miller" | "Fitts" | ... + dimension: MetricDimension; + citation: string; // short defensible ref, e.g. "Fitts, 1954" + claimType: MetricClaimType; + tier: MetricTier; // scorer excludes 'vision' when it did not run + value: number; // computed value (ratio 3.1, choices 14, grade 12.4) + threshold: number; // pass boundary + status: MetricStatus; + screenId: string; + screenName: string; + nodeId?: string; + whyItMatters?: string; + recommendation?: string; + pointsImpact?: number; // "fix this → +X pts" + evidence?: Record; +} + +/** A metric analyzer (same shape as Analyzer, but returns Metric[]). */ +export interface MetricAnalyzer { + name: string; + framework: string; + needsGraph?: boolean; + analyze(file: FigmaFile, options: AnalyzerOptions, graph?: PrototypeGraph): Promise; +} + +export interface MetricDimensionScore { + dimension: MetricDimension; + label: string; + /** 0-100 pass-rate for this dimension, or null when no applicable metrics */ + score: number | null; + pass: number; + fail: number; + na: number; +} + +export interface ScoreBand { + label: string; + emoji: string; +} + +export interface UsabilityScore { + /** 0-100, rounded */ + global: number; + /** Maturity band — undefined until bands are calibrated (see calibrate/) */ + band?: ScoreBand; + byDimension: MetricDimensionScore[]; + /** dimensions evaluated / total in catalog */ + coverage: number; + tier: 'core' | 'core+vision'; } diff --git a/packages/core/src/utils/color.ts b/packages/core/src/utils/color.ts new file mode 100644 index 0000000..11b9d62 --- /dev/null +++ b/packages/core/src/utils/color.ts @@ -0,0 +1,101 @@ +import type { Color, FigmaNode, Paint } from '../types.js'; + +/** sRGB → linear for one channel (WCAG 2.x). Channel is 0..1. */ +function linearize(c: number): number { + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +} + +/** WCAG relative luminance. Requires sRGB linearization BEFORE the weighted sum. */ +export function relativeLuminance(c: Color): number { + return 0.2126 * linearize(c.r) + 0.7152 * linearize(c.g) + 0.0722 * linearize(c.b); +} + +/** WCAG 2.2 contrast ratio between two (opaque) colors. Range 1..21. */ +export function contrastRatio(a: Color, b: Color): number { + const la = relativeLuminance(a); + const lb = relativeLuminance(b); + const hi = Math.max(la, lb); + const lo = Math.min(la, lb); + return (hi + 0.05) / (lo + 0.05); +} + +/** Composite `src` over `dst` (straight-alpha "over" operator). */ +export function blendOver(src: Color, dst: Color): Color { + const a = src.a + dst.a * (1 - src.a); + if (a === 0) return { r: 0, g: 0, b: 0, a: 0 }; + return { + r: (src.r * src.a + dst.r * dst.a * (1 - src.a)) / a, + g: (src.g * src.a + dst.g * dst.a * (1 - src.a)) / a, + b: (src.b * src.a + dst.b * dst.a * (1 - src.a)) / a, + a, + }; +} + +/** + * Topmost visible SOLID fill of a paint array as a straight-alpha Color + * (alpha folds in paint opacity). Returns null when there is no solid paint + * (gradient/image/empty) → caller marks the metric `na`. + */ +export function extractSolidColor(fills?: Paint[]): Color | null { + if (!fills) return null; + for (let i = fills.length - 1; i >= 0; i--) { + const f = fills[i]; + if (f.visible === false) continue; + if (f.type === 'SOLID' && f.color) { + const a = (f.color.a ?? 1) * (f.opacity ?? 1); + return { r: f.color.r, g: f.color.g, b: f.color.b, a }; + } + // A visible non-solid paint (gradient/image) on top blocks deterministic + // extraction — the rendered colour is not a single solid. + return null; + } + return null; +} + +/** + * Resolve the effective (opaque) background behind a text node by walking its + * ancestor chain (nearest-first), compositing semi-transparent solid fills. + * Returns: + * - an opaque Color when a solid background is found (or white fallback), + * - null when the effective background is a gradient/image (→ metric `na`). + */ +export function resolveEffectiveBackground(ancestorsNearestFirst: FigmaNode[]): Color | null { + let acc: Color | null = null; // accumulated top layer (may be translucent) + + for (const node of ancestorsNearestFirst) { + if (node.visible === false) continue; + const nodeOpacity = node.opacity ?? 1; + const fills = (node.fills ?? []).filter((f) => f.visible !== false); + if (fills.length === 0) continue; + + const top = fills[fills.length - 1]; + if (top.type !== 'SOLID' || !top.color) { + // Effective background is a gradient/image. If we already have a fully + // opaque accumulation, use it; otherwise we cannot resolve → na. + return acc && acc.a >= 0.999 ? { ...acc, a: 1 } : null; + } + + const fillA = (top.color.a ?? 1) * (top.opacity ?? 1) * nodeOpacity; + const layer: Color = { r: top.color.r, g: top.color.g, b: top.color.b, a: fillA }; + acc = acc ? blendOver(acc, layer) : layer; + if (acc.a >= 0.999) return { ...acc, a: 1 }; + } + + // No fully-opaque background found in the chain → assume white page bg. + const white: Color = { r: 1, g: 1, b: 1, a: 1 }; + return acc ? { ...blendOver(acc, white), a: 1 } : white; +} + +/** HSV saturation 0..1 (used as a visual-weight factor in balance). */ +export function saturation(c: Color): number { + const max = Math.max(c.r, c.g, c.b); + const min = Math.min(c.r, c.g, c.b); + if (max === 0) return 0; + return (max - min) / max; +} + +/** Quantized "#rrggbb" key for counting distinct dominant colors. */ +export function colorKey(c: Color): string { + const q = (v: number) => Math.round(v * 255).toString(16).padStart(2, '0'); + return `#${q(c.r)}${q(c.g)}${q(c.b)}`; +} diff --git a/packages/core/src/utils/geometry.ts b/packages/core/src/utils/geometry.ts new file mode 100644 index 0000000..ff36cab --- /dev/null +++ b/packages/core/src/utils/geometry.ts @@ -0,0 +1,63 @@ +import type { BoundingBox } from '../types.js'; + +/** Center point of a bounding box. */ +export function center(b: BoundingBox): { x: number; y: number } { + return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; +} + +/** Euclidean distance between the centers of two boxes. */ +export function centerDistance(a: BoundingBox, b: BoundingBox): number { + const ca = center(a); + const cb = center(b); + return Math.hypot(ca.x - cb.x, ca.y - cb.y); +} + +/** Area of intersection between two boxes (0 if they do not overlap). */ +export function intersectionArea(a: BoundingBox, b: BoundingBox): number { + const x1 = Math.max(a.x, b.x); + const y1 = Math.max(a.y, b.y); + const x2 = Math.min(a.x + a.width, b.x + b.width); + const y2 = Math.min(a.y + a.height, b.y + b.height); + + const width = x2 - x1; + const height = y2 - y1; + + if (width <= 0 || height <= 0) return 0; + return width * height; +} + +/** + * True if two boxes share an alignment edge (left/right/top/bottom or + * horizontal/vertical center) within `tol` px. Used by Gestalt alignment. + */ +export function areAligned(a: BoundingBox, b: BoundingBox, tol = 3): boolean { + const edges: Array<[number, number]> = [ + [a.x, b.x], // left + [a.x + a.width, b.x + b.width], // right + [a.x + a.width / 2, b.x + b.width / 2], // center-x + [a.y, b.y], // top + [a.y + a.height, b.y + b.height], // bottom + [a.y + a.height / 2, b.y + b.height / 2], // center-y + ]; + return edges.some(([p, q]) => Math.abs(p - q) <= tol); +} + +/** Ratio of the larger area to the smaller (>= 1). Used by Gestalt similarity. */ +export function sizeRatio(a: BoundingBox, b: BoundingBox): number { + const areaA = Math.max(1, a.width * a.height); + const areaB = Math.max(1, b.width * b.height); + return areaA >= areaB ? areaA / areaB : areaB / areaA; +} + +/** Smallest box containing all given boxes (null if empty). */ +export function boundingBoxOf(boxes: BoundingBox[]): BoundingBox | null { + if (boxes.length === 0) return null; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const b of boxes) { + minX = Math.min(minX, b.x); + minY = Math.min(minY, b.y); + maxX = Math.max(maxX, b.x + b.width); + maxY = Math.max(maxY, b.y + b.height); + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +} diff --git a/packages/core/src/utils/text.ts b/packages/core/src/utils/text.ts new file mode 100644 index 0000000..b06e730 --- /dev/null +++ b/packages/core/src/utils/text.ts @@ -0,0 +1,68 @@ +/** Word count (whitespace-split, ignoring empties). */ +export function wordCount(text: string): number { + const t = text.trim(); + if (!t) return 0; + return t.split(/\s+/).filter(Boolean).length; +} + +/** Sentence count (terminal punctuation runs). Minimum 1 for non-empty text. */ +export function sentenceCount(text: string): number { + const t = text.trim(); + if (!t) return 0; + const m = t.match(/[.!?…]+(\s|$)/g); + return Math.max(1, m ? m.length : 1); +} + +/** Heuristic syllable count for one English word (vowel groups, silent-e). */ +export function countSyllables(rawWord: string): number { + const word = rawWord.toLowerCase().replace(/[^a-z]/g, ''); + if (!word) return 0; + if (word.length <= 3) return 1; + const trimmed = word + .replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '') + .replace(/^y/, ''); + const groups = trimmed.match(/[aeiouy]{1,2}/g); + return groups ? groups.length : 1; +} + +/** Total syllables across a text. */ +export function syllableCount(text: string): number { + const t = text.trim(); + if (!t) return 0; + return t.split(/\s+/).reduce((n, w) => n + countSyllables(w), 0); +} + +/** Flesch–Kincaid grade level. Only meaningful for prose (see textIsProse). */ +export function fleschKincaidGrade(text: string): number { + const words = wordCount(text); + if (words === 0) return 0; + const sentences = sentenceCount(text); + const syllables = syllableCount(text); + return 0.39 * (words / sentences) + 11.8 * (syllables / words) - 15.59; +} + +/** + * True if the text is prose worth grading (Flesch–Kincaid is undefined for + * 1-word labels/buttons). Heuristic: >20 words and >=2 sentences. + */ +export function textIsProse(text: string): boolean { + return wordCount(text) > 20 && sentenceCount(text) >= 2; +} + +/** + * Estimated characters-per-line (CPL) for a text box. Crude (~0.5em average + * glyph advance), ±30%, Latin only. Used as a legibility signal. + */ +export function estimateCPL(widthPx: number, fontSizePx: number): number { + if (fontSizePx <= 0) return 0; + return widthPx / (fontSizePx * 0.5); +} + +/** + * True if the text is predominantly Latin script. CPL/syllable heuristics are + * not valid for CJK/Hangul/Arabic/Hebrew/Devanagari/Thai → caller marks `na`. + */ +export function isLatinText(text: string): boolean { + // CJK, Hangul, Arabic, Hebrew, Devanagari, Thai ranges → non-Latin. + return !/[ -鿿가-힯؀-ۿ֐-׿ऀ-ॿ฀-๿]/.test(text); +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 9bd1a89..8b2766e 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -2,8 +2,8 @@ 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 } from '@protoscan/core'; -import type { Issue } from '@protoscan/core'; +import { FigmaClient, FigmaApiError, scan, buildGraph, formatTerminal, formatJson, formatHtml, validateLicenseKey, topFixes } from '@protoscan/core'; +import type { Issue, ScanResult } from '@protoscan/core'; import { registerAppTool, registerAppResource, @@ -25,8 +25,19 @@ function parseFigmaInput(input: string): { fileKey: string; pageIds: string[] } return { fileKey: input, pageIds: [] }; } +/** One-line score headline so the agent leads with the number (demo money shot). */ +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}` : ''; + return `Usability Score: ${s.global}/100${band} (${s.tier}, coverage ${s.coverage}%).${tip}`; +} + const server = new McpServer( - { name: 'protoscan', version: '0.0.1' }, + // Keep in sync with package.json version (reported to MCP clients / Smithery). + { name: 'protoscan', version: '0.1.3' }, { capabilities: { resources: {} } }, ); @@ -61,14 +72,17 @@ registerAppTool( { title: 'Scan Figma Prototype', description: - 'Scan a Figma file for prototype navigation issues: dead-end screens, orphan screens, missing back navigation, undersized touch targets, overlapping hotspots, missing scroll, and overlay traps. Optionally run a headless browser simulator to verify clicks actually navigate. Returns a detailed report. If the client supports MCP Apps, renders an interactive HTML report inline.', + 'Scan a Figma file for prototype navigation issues: dead-end screens, orphan screens, missing back navigation, undersized touch targets, overlapping hotspots, missing scroll, and overlay traps. Optionally compute a 0-100 usability score grounded in peer-reviewed HCI science (WCAG contrast, Hick–Hyman, Miller, Fitts, readability, Gestalt, Von Restorff). Optionally run a headless browser simulator to verify clicks actually navigate. Returns a detailed report. If the client supports MCP Apps, renders an interactive HTML report inline.', inputSchema: { 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.'), + 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.'), format: z.enum(['terminal', 'json']).optional().default('terminal').describe('Output format for text response'), min_touch_target: z.number().optional().default(44).describe('Minimum touch target size in px'), - skip: z.array(z.string()).optional().describe('Checks to skip: dead-end, orphan, back-nav, touch-target, overlap, scroll, overlay-trap'), + skip: z.array(z.string()).optional().describe('Checks to skip: dead-end, orphan, back-nav, touch-target, overlap, scroll, overlay-trap, contrast, cognitive-load, fitts, typography, readability, gestalt, emphasis, balance'), }, _meta: { ui: { resourceUri: 'ui://protoscan/report' }, @@ -123,6 +137,8 @@ registerAppTool( 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), }); // Update the HTML report for the MCP Apps UI resource @@ -132,9 +148,11 @@ registerAppTool( ? formatJson(result) : formatTerminal(result); - const content: Array<{ type: 'text'; text: string }> = [ - { type: 'text' as const, text: output }, - ]; + const content: Array<{ type: 'text'; text: string }> = []; + // Score-first: lead with the headline number so the agent narrates it. + const headline = scoreHeadline(result); + if (headline) content.push({ type: 'text' as const, text: headline }); + content.push({ type: 'text' as const, text: output }); if (videoPath) { content.push({ type: 'text' as const, text: `\n🎥 Video walkthrough saved: ${videoPath}` }); } From 757ec5dae5e682119944b17f043d5930ebe5c598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?I=C3=B1aki?= Date: Tue, 2 Jun 2026 01:03:49 -0300 Subject: [PATCH 2/2] fix(core): downgrade false positives on archived/backup & onboarding frames Lower findings on archived/backup frames (ARCHIVADO/BACKUP, bilingual) and onboarding screens lacking back-nav to low confidence instead of flagging them as actionable, cutting noise on real-world Figma files. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/graph/analyzer.test.ts | 24 ++++++ packages/core/src/graph/analyzer.ts | 13 +++- packages/core/src/graph/builder.ts | 5 +- packages/core/src/spatial/scroll.ts | 13 ++-- packages/core/src/spatial/spatial.test.ts | 89 ++++++++++++++++++++++ packages/core/src/spatial/touch-targets.ts | 17 +++-- packages/core/src/utils/filters.test.ts | 53 +++++++++++++ packages/core/src/utils/filters.ts | 26 +++++++ 8 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/utils/filters.test.ts diff --git a/packages/core/src/graph/analyzer.test.ts b/packages/core/src/graph/analyzer.test.ts index ae29c1a..55ad67e 100644 --- a/packages/core/src/graph/analyzer.test.ts +++ b/packages/core/src/graph/analyzer.test.ts @@ -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 () => { diff --git a/packages/core/src/graph/analyzer.ts b/packages/core/src/graph/analyzer.ts index 6be7391..4c8f910 100644 --- a/packages/core/src/graph/analyzer.ts +++ b/packages/core/src/graph/analyzer.ts @@ -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 { @@ -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'), diff --git a/packages/core/src/graph/builder.ts b/packages/core/src/graph/builder.ts index b6c9d77..a83b497 100644 --- a/packages/core/src/graph/builder.ts +++ b/packages/core/src/graph/builder.ts @@ -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. @@ -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, { @@ -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, diff --git a/packages/core/src/spatial/scroll.ts b/packages/core/src/spatial/scroll.ts index 70057c4..5c5aa7d 100644 --- a/packages/core/src/spatial/scroll.ts +++ b/packages/core/src/spatial/scroll.ts @@ -1,5 +1,5 @@ import type { AnalyzerOptions, FigmaFile, FigmaNode, Issue } from '../types.js'; -import { isNonPrototypeFrame } from '../utils/filters.js'; +import { isNonPrototypeFrame, isArchivedFrame } from '../utils/filters.js'; let counter = 0; @@ -16,7 +16,7 @@ export const scrollAnalyzer = { (frame.type === 'FRAME' || frame.type === 'COMPONENT' || frame.type === 'COMPONENT_SET') && !isNonPrototypeFrame(frame.name) ) { - checkScroll(frame, frame.id, frame.name, issues); + checkScroll(frame, frame.id, frame.name, issues, isArchivedFrame(frame.name)); } } } @@ -30,6 +30,7 @@ function checkScroll( screenId: string, screenName: string, issues: Issue[], + archived: boolean, ): void { // Only check frames that clip content and don't have scroll enabled if ( @@ -51,8 +52,9 @@ function checkScroll( issues.push({ id: `scroll-${++counter}`, category: 'scroll', - severity: 'medium', - confidence: 'certain', + // Archived/backup frames aren't part of the active prototype → not actionable. + severity: archived ? 'low' : 'medium', + confidence: archived ? 'low' : 'certain', screenId, screenName, message: `"${node.name}" has content extending beyond frame bounds but scroll is not enabled.`, @@ -61,6 +63,7 @@ function checkScroll( frameHeight: node.absoluteBoundingBox.height, contentExtendsTo: Math.max(childBottom - node.absoluteBoundingBox.y, 0), overflowDirection: node.overflowDirection ?? 'NONE', + archived, }, }); return; // One issue per frame is enough @@ -71,7 +74,7 @@ function checkScroll( // Recurse into children (nested frames can also have scroll issues) for (const child of node.children ?? []) { if (child.type === 'FRAME') { - checkScroll(child, screenId, screenName, issues); + checkScroll(child, screenId, screenName, issues, archived); } } } diff --git a/packages/core/src/spatial/spatial.test.ts b/packages/core/src/spatial/spatial.test.ts index 4175c12..d59bfe3 100644 --- a/packages/core/src/spatial/spatial.test.ts +++ b/packages/core/src/spatial/spatial.test.ts @@ -365,3 +365,92 @@ describe('overlay-traps', () => { expect(issues.length).toBe(0); }); }); + +// ─── Archived / backup frame downgrade (H6: false-positive reduction) ─── + +describe('archived frame downgrade', () => { + it('downgrades touch-target issues on archived/backup frames to low confidence', async () => { + const file = makeFile([{ + id: '1:0', name: 'Page', type: 'CANVAS', + children: [{ + id: '2:0', name: 'ARCHIVADO (D123) — P1c — Confirmación de pago', type: 'FRAME', + children: [{ + id: '3:0', name: 'Icon', type: 'RECTANGLE', + absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 }, + interactions: [{ trigger: { type: 'ON_CLICK' }, actions: [{ type: 'NODE' as const, destinationId: '2:1', navigation: 'NAVIGATE' as const }] }], + }], + }], + }]); + + const issues = await touchTargetAnalyzer.analyze(file, {}); + expect(issues.length).toBe(1); + expect(issues[0].confidence).toBe('low'); + expect(issues[0].severity).toBe('low'); + }); + + it('keeps touch-target issues on active frames at certain/high', async () => { + const file = makeFile([{ + id: '1:0', name: 'Page', type: 'CANVAS', + children: [{ + id: '2:0', name: 'Checkout', type: 'FRAME', + children: [{ + id: '3:0', name: 'Pay', type: 'RECTANGLE', + absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 }, + interactions: [{ trigger: { type: 'ON_CLICK' }, actions: [{ type: 'NODE' as const, destinationId: '2:1', navigation: 'NAVIGATE' as const }] }], + }], + }], + }]); + + const issues = await touchTargetAnalyzer.analyze(file, {}); + expect(issues.length).toBe(1); + expect(issues[0].confidence).toBe('certain'); + expect(issues[0].severity).toBe('high'); + }); + + it('downgrades overlap issues on backup frames to low confidence', async () => { + const file = makeFile([{ + id: '1:0', name: 'Page', type: 'CANVAS', + children: [{ + id: '2:0', name: 'BACKUP — P2b Pago exitoso 31may', type: 'FRAME', + children: [ + { + id: '3:0', name: 'Icon', type: 'RECTANGLE', + absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 50 }, + interactions: [{ trigger: { type: 'ON_CLICK' }, actions: [{ type: 'NODE' as const, destinationId: '2:1', navigation: 'NAVIGATE' as const }] }], + }, + { + id: '3:1', name: 'Label', type: 'RECTANGLE', + absoluteBoundingBox: { x: 80, y: 10, width: 100, height: 50 }, + interactions: [{ trigger: { type: 'ON_CLICK' }, actions: [{ type: 'NODE' as const, destinationId: '2:2', navigation: 'NAVIGATE' as const }] }], + }, + ], + }], + }]); + + const issues = await overlapAnalyzer.analyze(file, {}); + expect(issues.length).toBe(1); + expect(issues[0].confidence).toBe('low'); + expect(issues[0].severity).toBe('low'); + }); + + it('downgrades scroll issues on backup frames to low confidence', async () => { + const file = makeFile([{ + id: '1:0', name: 'Page', type: 'CANVAS', + children: [{ + id: '2:0', name: 'BACKUP — Product Page', type: 'FRAME', + absoluteBoundingBox: { x: 0, y: 0, width: 375, height: 812 }, + clipsContent: true, + overflowDirection: 'NONE' as const, + children: [{ + id: '3:0', name: 'Long Content', type: 'RECTANGLE', + absoluteBoundingBox: { x: 0, y: 0, width: 375, height: 1400 }, + }], + }], + }]); + + const issues = await scrollAnalyzer.analyze(file, {}); + expect(issues.length).toBe(1); + expect(issues[0].confidence).toBe('low'); + expect(issues[0].severity).toBe('low'); + }); +}); diff --git a/packages/core/src/spatial/touch-targets.ts b/packages/core/src/spatial/touch-targets.ts index dd3afde..7f42b7b 100644 --- a/packages/core/src/spatial/touch-targets.ts +++ b/packages/core/src/spatial/touch-targets.ts @@ -1,5 +1,5 @@ import type { AnalyzerOptions, FigmaFile, FigmaNode, Issue } from '../types.js'; -import { isNonPrototypeFrame } from '../utils/filters.js'; +import { isNonPrototypeFrame, isArchivedFrame } from '../utils/filters.js'; let counter = 0; @@ -19,7 +19,7 @@ export const touchTargetAnalyzer = { (frame.type === 'FRAME' || frame.type === 'COMPONENT' || frame.type === 'COMPONENT_SET') && !isNonPrototypeFrame(frame.name) ) { - walkForTouchTargets(frame, frame.id, frame.name, minSize, issues); + walkForTouchTargets(frame, frame.id, frame.name, minSize, issues, isArchivedFrame(frame.name)); } } } @@ -34,27 +34,30 @@ function walkForTouchTargets( screenName: string, minSize: number, issues: Issue[], + archived: boolean, ): void { if (node.interactions?.length && node.absoluteBoundingBox) { const { width, height } = node.absoluteBoundingBox; if (width < minSize || height < minSize) { - // TEXT nodes are rarely interactive — they inherit reactions from parent containers + // TEXT nodes are rarely interactive — they inherit reactions from parent containers. + // Archived/backup frames aren't part of the active prototype → not actionable. const isTextNode = node.type === 'TEXT'; + const lowConfidence = isTextNode || archived; issues.push({ id: `touch-target-${++counter}`, category: 'touch-target', - severity: isTextNode ? 'low' : 'high', - confidence: isTextNode ? 'low' : 'certain', + severity: lowConfidence ? 'low' : 'high', + confidence: lowConfidence ? 'low' : 'certain', screenId, screenName, nodeId: node.id, message: `"${node.name}" is ${width}x${height}px (minimum: ${minSize}x${minSize}px).`, - evidence: { width, height, minSize, nodeName: node.name }, + evidence: { width, height, minSize, nodeName: node.name, archived }, }); } } for (const child of node.children ?? []) { - walkForTouchTargets(child, screenId, screenName, minSize, issues); + walkForTouchTargets(child, screenId, screenName, minSize, issues, archived); } } diff --git a/packages/core/src/utils/filters.test.ts b/packages/core/src/utils/filters.test.ts new file mode 100644 index 0000000..643083e --- /dev/null +++ b/packages/core/src/utils/filters.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { isArchivedFrame, isOnboardingScreen, isNonPrototypeFrame } from './filters.js'; + +describe('isArchivedFrame', () => { + it('matches archived/backup frames (EN + ES) — real false positives from the EDET dogfood', () => { + expect(isArchivedFrame('ARCHIVADO (D123) — P1c — Confirmación de pago')).toBe(true); + expect(isArchivedFrame('BACKUP — Home - error pago 22 May')).toBe(true); + expect(isArchivedFrame('BACKUP 1Jun coherencia — P1a — Abandon Confirmation Dialog')).toBe(true); + expect(isArchivedFrame('Copia de Login')).toBe(true); + expect(isArchivedFrame('Old Settings')).toBe(true); + expect(isArchivedFrame('Deprecated Flow')).toBe(true); + expect(isArchivedFrame('Checkout (WIP)')).toBe(true); + expect(isArchivedFrame('DO NOT USE — Profile')).toBe(true); + expect(isArchivedFrame('Pago — borrador')).toBe(true); + expect(isArchivedFrame('Pantalla obsoleta')).toBe(true); + }); + + it('does NOT over-match active prototype screens', () => { + expect(isArchivedFrame('Home')).toBe(false); + expect(isArchivedFrame('Checkout')).toBe(false); + expect(isArchivedFrame('Bold Header')).toBe(false); // "old" inside "Bold" + expect(isArchivedFrame('Folder list')).toBe(false); // "old" inside "Folder" + expect(isArchivedFrame('Copywriting screen')).toBe(false); // "copy" inside "Copywriting" + expect(isArchivedFrame('B3 — Detalle Factura (Pendiente)')).toBe(false); + }); +}); + +describe('isOnboardingScreen', () => { + it('matches onboarding/welcome/splash screens (EN + ES)', () => { + expect(isOnboardingScreen('Bienvenida-01')).toBe(true); + expect(isOnboardingScreen('Bienvenida-04')).toBe(true); + expect(isOnboardingScreen('Welcome')).toBe(true); + expect(isOnboardingScreen('Onboarding 2')).toBe(true); + expect(isOnboardingScreen('Splash')).toBe(true); + expect(isOnboardingScreen('Get Started')).toBe(true); + expect(isOnboardingScreen('Home - first time (biometric)')).toBe(true); + }); + + it('does NOT over-match regular screens', () => { + expect(isOnboardingScreen('Home')).toBe(false); + expect(isOnboardingScreen('Settings')).toBe(false); + expect(isOnboardingScreen('Introducción de datos')).toBe(false); // "intro" inside "Introducción" + }); +}); + +describe('isNonPrototypeFrame (regression — unchanged behavior)', () => { + it('still excludes DS / spec / annotation frames', () => { + expect(isNonPrototypeFrame('Foundations')).toBe(true); + expect(isNonPrototypeFrame('Components')).toBe(true); + expect(isNonPrototypeFrame('Annotations')).toBe(true); + expect(isNonPrototypeFrame('Home')).toBe(false); + }); +}); diff --git a/packages/core/src/utils/filters.ts b/packages/core/src/utils/filters.ts index 7c9a8d9..17445fe 100644 --- a/packages/core/src/utils/filters.ts +++ b/packages/core/src/utils/filters.ts @@ -18,3 +18,29 @@ export const STATE_VARIANT_PATTERN = /** Pattern for detecting tab-bar root screens (BottomNav child component name) */ export const TAB_ROOT_CHILD_PATTERN = /bottom[.\s_-]?nav|tab[.\s_-]?bar|nav[.\s_-]?bar/i; + +/** + * Pattern for archived / backup / work-in-progress frames: real screens that are NOT + * part of the active prototype. Issues on these are downgraded to 'low' confidence + * (kept visible but excluded from the "likely real" count) rather than excluded + * outright. Bilingual (EN/ES) to match real-world Figma files, e.g. "ARCHIVADO (D123)", + * "BACKUP — Home 22 May", "Copia de Login". + */ +export const ARCHIVED_PATTERN = + /\b(archived?|archivad[oa]s?|deprecated|obsolet[oae]s?|legacy|backup|respaldo|old|antigu[oa]s?|copy|cop[ií]as?|borrador|draft|wip|do[\s._-]?not[\s._-]?use|no[\s._-]?usar|delete|eliminar)\b/i; + +export function isArchivedFrame(name: string): boolean { + return ARCHIVED_PATTERN.test(name); +} + +/** + * Pattern for onboarding / welcome / splash screens, which legitimately have no back + * navigation (the user is at the start of a first-run flow). Used to downgrade + * missing-back-nav findings to 'low' confidence. Bilingual (EN/ES). + */ +export const ONBOARDING_PATTERN = + /\b(welcome|bienvenid[oa]s?|onboarding|intro|splash|get[\s._-]?started|tutorial|walkthrough|first[\s._-]?time|primera\s*vez)\b/i; + +export function isOnboardingScreen(name: string): boolean { + return ONBOARDING_PATTERN.test(name); +}