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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ Options:
--min-touch-target <px> Minimum touch target size (default: 44)
--skip <checks> Comma-separated checks to skip
--pages <ids> Comma-separated page IDs to scan
--ignore <patterns> Comma-separated screen-name globs to exclude from
the whole scan, e.g. "* — Dev,* — Demo,[FIX]*"
(only * is a wildcard; everything else literal)
--metrics Run ProtoScan Science metrics (WCAG contrast, Hick,
Miller, Fitts, typography, readability, Gestalt, ...)
--score Compute the 0-100 usability score with per-dimension
breakdown + top fixes (implies --metrics)
--ergonomics Alias for --score
--simulate Run Playwright E2E simulator (slow, finds runtime bugs)
--record [dir] Record simulator walkthrough video (requires --simulate)
--upload Upload HTML report to GitHub Gist (requires GITHUB_TOKEN)
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ program
.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')
.option('--ignore <patterns>', 'Comma-separated screen-name globs to exclude from the whole scan, e.g. "* — Dev,* — Demo,[FIX]*" (only * is a wildcard)')
.action(async (input: string, options) => {
// Parse Figma URL or raw file key
const { fileKey, pageIds: urlPageIds } = parseFigmaInput(input);
Expand Down Expand Up @@ -213,6 +214,7 @@ program
additionalIssues: [...simulatorIssues, ...visionIssues],
metrics: !!(options.metrics || options.score || options.ergonomics),
score: !!(options.score || options.ergonomics),
ignorePatterns: options.ignore?.split(',').map((s: string) => s.trim()).filter(Boolean),
});

const formatters: Record<string, (r: typeof result) => string> = {
Expand Down
70 changes: 70 additions & 0 deletions packages/core/src/scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ 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';
import { globToRegExp, matchesIgnore } from './utils/filters.js';
import type { Issue, Metric } from './types.js';

function demoFile() {
return makeFile([
Expand Down Expand Up @@ -45,6 +47,74 @@ describe('scan() integration — metrics + score', () => {
expect(result.summary.score).toBeUndefined();
});

it('ignorePatterns excludes matching screens from issues, metrics, and score', async () => {
const mk = (name: string) =>
frameNode({
name,
box: box(0, 0, 375, 800),
fills: [solidFill(1, 1, 1)],
children: [textNode({ characters: 'faint', fontSize: 16, fills: [solidFill(0.7, 0.7, 0.7)], box: box(0, 0, 200, 14) })],
});
const file = makeFile([mk('Home'), mk('Home (Dev)')]);

const result = await scan(file, { fileKey: 'abc123', score: true, ignorePatterns: ['*(Dev)'] });

const screens = new Set([...result.issues.map((i) => i.screenName), ...result.metrics.map((m) => m.screenName)]);
expect(screens.has('Home')).toBe(true);
expect(screens.has('Home (Dev)')).toBe(false); // suppressed everywhere
// score still computed from the remaining (non-ignored) screen
expect(result.summary.score).toBeDefined();
});

it('ignorePatterns also filters additionalIssues/additionalMetrics (simulator/vision path)', async () => {
const file = makeFile([frameNode({ name: 'Home', box: box(0, 0, 375, 800), children: [] })]);
const addIssue = (screenName: string): Issue => ({
id: 'sim-' + screenName,
category: 'runtime-nav-failure',
severity: 'high',
confidence: 'certain',
screenId: screenName,
screenName,
message: 'sim',
evidence: {},
});
const addMetric = (screenName: string): Metric => ({
id: 'vis-' + screenName,
framework: 'AALTO',
dimension: 'clutter',
citation: 'x',
claimType: 'signal',
tier: 'vision',
value: 1,
threshold: 0,
status: 'fail',
screenId: screenName,
screenName,
});
const result = await scan(file, {
fileKey: 'abc123',
score: true,
ignorePatterns: ['*(Dev)'],
additionalIssues: [addIssue('Home'), addIssue('Home (Dev)')],
additionalMetrics: [addMetric('Home'), addMetric('Home (Dev)')],
});
expect(result.issues.some((i) => i.screenName === 'Home (Dev)')).toBe(false);
expect(result.issues.some((i) => i.id === 'sim-Home')).toBe(true);
expect(result.metrics.some((m) => m.screenName === 'Home (Dev)')).toBe(false);
expect(result.metrics.some((m) => m.id === 'vis-Home')).toBe(true);
});

it('globToRegExp: only * is a wildcard; brackets/dashes are literal', () => {
expect(globToRegExp('*(Dev)').test('Home (Dev)')).toBe(true);
expect(globToRegExp('*(Dev)').test('Home')).toBe(false);
expect(globToRegExp('[FIX]*').test('[FIX] Home')).toBe(true);
expect(globToRegExp('[FIX]*').test('Home')).toBe(false);
expect(matchesIgnore('Login — Demo', ['* — Demo', '* — Dev'])).toBe(true);
expect(matchesIgnore('Login', ['* — Demo'])).toBe(false);
// file-level findings (empty name) are never suppressed, even by '*'
expect(matchesIgnore('', ['*'])).toBe(false);
});

it('reporters render a scored result without throwing', async () => {
const result = await scan(demoFile(), { fileKey: 'abc123', score: true });
const term = formatTerminal(result);
Expand Down
15 changes: 13 additions & 2 deletions packages/core/src/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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';
import { matchesIgnore } from './utils/filters.js';

export interface ScanOptions extends AnalyzerOptions {
fileKey: string;
Expand All @@ -23,6 +24,9 @@ export async function scan(file: FigmaFile, options: ScanOptions): Promise<ScanR

const graph = buildGraph(file, { pageIds: options.pageIds });
const skip = new Set(options.skip ?? []);
// User-supplied screen-name globs to suppress from the ENTIRE scan (issues,
// metrics, and the score) — e.g. ["* — Dev", "* — Demo"]. Not hardcoded.
const ignore = options.ignorePatterns;
const issues: Issue[] = [];

// Graph analysis
Expand Down Expand Up @@ -54,6 +58,10 @@ export async function scan(file: FigmaFile, options: ScanOptions): Promise<ScanR
if (options.additionalMetrics?.length) {
metrics.push(...options.additionalMetrics);
}
// Drop ignored screens BEFORE scoring/dual-emit so they never count.
if (ignore?.length) {
metrics = metrics.filter((m) => !matchesIgnore(m.screenName, ignore));
}
annotatePointsImpact(metrics);
// Dual-emit failed metrics as Issues so existing reporters render them — but
// CAP per category. On large files a deterministic per-node scan yields
Expand All @@ -76,14 +84,17 @@ export async function scan(file: FigmaFile, options: ScanOptions): Promise<ScanR
issues.push(...options.additionalIssues);
}

// Suppress ignored screens from the issue list too (applies to every check).
const finalIssues = ignore?.length ? issues.filter((i) => !matchesIgnore(i.screenName, ignore)) : issues;

const score: UsabilityScore | undefined = options.score ? computeScore(metrics) : undefined;
const duration = Math.round(performance.now() - start);
const summary = buildSummary(issues, graph.nodes.size, score);
const summary = buildSummary(finalIssues, graph.nodes.size, score);

return {
file: { name: file.name, key: options.fileKey, lastModified: file.lastModified },
graph,
issues,
issues: finalIssues,
metrics,
summary,
duration,
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/utils/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,23 @@ export const ONBOARDING_PATTERN =
export function isOnboardingScreen(name: string): boolean {
return ONBOARDING_PATTERN.test(name);
}

/**
* Convert a simple glob to a full-match, case-insensitive RegExp. Only `*` is a
* wildcard (→ `.*`); every other character is matched literally. So `[FIX]*`
* matches a literal "[FIX]" prefix, and `* — Dev` matches any "… — Dev" name.
*/
export function globToRegExp(glob: string): RegExp {
const pattern = glob.replace(/[.*+?^${}()|[\]\\]/g, (ch) => (ch === '*' ? '.*' : '\\' + ch));
return new RegExp('^' + pattern + '$', 'i');
}

/**
* True if `name` matches any of the user-supplied ignore glob patterns.
* An empty/missing name (file-level findings) NEVER matches — these are
* metadata diagnostics that screen-name globs (even `*`) must not suppress.
*/
export function matchesIgnore(name: string, patterns?: string[]): boolean {
if (!name || !patterns || patterns.length === 0) return false;
return patterns.some((p) => globToRegExp(p).test(name));
}
2 changes: 2 additions & 0 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ registerAppTool(
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, contrast, cognitive-load, fitts, typography, readability, gestalt, emphasis, balance'),
ignore: z.array(z.string()).optional().describe('Screen-name globs to exclude from the whole scan, e.g. ["* — Dev", "* — Demo", "[FIX]*"] (only * is a wildcard).'),
},
_meta: {
ui: { resourceUri: 'ui://protoscan/report' },
Expand Down Expand Up @@ -139,6 +140,7 @@ registerAppTool(
additionalIssues: simulatorIssues.length ? simulatorIssues : undefined,
metrics: !!(args.metrics || args.score || args.ergonomics),
score: !!(args.score || args.ergonomics),
ignorePatterns: args.ignore?.filter(Boolean),
});

// Update the HTML report for the MCP Apps UI resource
Expand Down
Loading