From c57e636dcbf183a24dacd3f4a240934479f0b2f2 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 28 Mar 2026 16:33:40 +0300 Subject: [PATCH] Port design linter from figma-use --- README.md | 11 ++ packages/cli/src/commands/lint.ts | 86 ++++++++++ packages/cli/src/index.ts | 2 + packages/core/src/index.ts | 1 + packages/core/src/lint/index.ts | 15 ++ packages/core/src/lint/linter.ts | 147 ++++++++++++++++++ packages/core/src/lint/presets.ts | 56 +++++++ packages/core/src/lint/rule.ts | 13 ++ .../core/src/lint/rules/color-contrast.ts | 32 ++++ .../core/src/lint/rules/consistent-radius.ts | 18 +++ .../core/src/lint/rules/consistent-spacing.ts | 33 ++++ .../src/lint/rules/effect-style-required.ts | 10 ++ packages/core/src/lint/rules/index.ts | 57 +++++++ packages/core/src/lint/rules/min-text-size.ts | 11 ++ .../core/src/lint/rules/no-deeply-nested.ts | 26 ++++ .../core/src/lint/rules/no-default-names.ts | 21 +++ .../src/lint/rules/no-detached-instances.ts | 34 ++++ .../core/src/lint/rules/no-empty-frames.ts | 21 +++ packages/core/src/lint/rules/no-groups.ts | 16 ++ .../src/lint/rules/no-hardcoded-colors.ts | 16 ++ .../core/src/lint/rules/no-hidden-layers.ts | 16 ++ .../core/src/lint/rules/no-mixed-styles.ts | 11 ++ packages/core/src/lint/rules/pixel-perfect.ts | 24 +++ .../core/src/lint/rules/prefer-auto-layout.ts | 20 +++ .../src/lint/rules/text-style-required.ts | 11 ++ .../core/src/lint/rules/touch-target-size.ts | 39 +++++ packages/core/src/lint/types.ts | 94 +++++++++++ packages/core/src/lint/utils.ts | 44 ++++++ packages/docs/programmable/cli/inspecting.md | 15 +- packages/docs/programmable/index.md | 2 +- tests/engine/lint.test.ts | 38 +++++ 31 files changed, 938 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/commands/lint.ts create mode 100644 packages/core/src/lint/index.ts create mode 100644 packages/core/src/lint/linter.ts create mode 100644 packages/core/src/lint/presets.ts create mode 100644 packages/core/src/lint/rule.ts create mode 100644 packages/core/src/lint/rules/color-contrast.ts create mode 100644 packages/core/src/lint/rules/consistent-radius.ts create mode 100644 packages/core/src/lint/rules/consistent-spacing.ts create mode 100644 packages/core/src/lint/rules/effect-style-required.ts create mode 100644 packages/core/src/lint/rules/index.ts create mode 100644 packages/core/src/lint/rules/min-text-size.ts create mode 100644 packages/core/src/lint/rules/no-deeply-nested.ts create mode 100644 packages/core/src/lint/rules/no-default-names.ts create mode 100644 packages/core/src/lint/rules/no-detached-instances.ts create mode 100644 packages/core/src/lint/rules/no-empty-frames.ts create mode 100644 packages/core/src/lint/rules/no-groups.ts create mode 100644 packages/core/src/lint/rules/no-hardcoded-colors.ts create mode 100644 packages/core/src/lint/rules/no-hidden-layers.ts create mode 100644 packages/core/src/lint/rules/no-mixed-styles.ts create mode 100644 packages/core/src/lint/rules/pixel-perfect.ts create mode 100644 packages/core/src/lint/rules/prefer-auto-layout.ts create mode 100644 packages/core/src/lint/rules/text-style-required.ts create mode 100644 packages/core/src/lint/rules/touch-target-size.ts create mode 100644 packages/core/src/lint/types.ts create mode 100644 packages/core/src/lint/utils.ts create mode 100644 tests/engine/lint.test.ts diff --git a/README.md b/README.md index db94f766a..2fb88f168 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,17 @@ open-pencil convert design.pen output.fig # Convert between docume ``` +### Lint design files + +Catch naming, layout, structure, and accessibility issues from the terminal: + +```sh +open-pencil lint design.fig +open-pencil lint design.pen --preset strict +open-pencil lint design.fig --rule color-contrast +open-pencil lint design.fig --list-rules +``` + ### Analyze design tokens Audit an entire design system from the terminal — find inconsistencies, extract the real palette, spot components waiting to be extracted: diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts new file mode 100644 index 000000000..fcc4cce0e --- /dev/null +++ b/packages/cli/src/commands/lint.ts @@ -0,0 +1,86 @@ +import { defineCommand } from 'citty' + +import { allRules, createLinter, presets, type LintMessage } from '@open-pencil/core' + +import { bold, dim, fail, fmtList, ok } from '../format' +import { loadDocument } from '../headless' + +function formatSeverity(severity: LintMessage['severity']) { + if (severity === 'error') return fail('error') + if (severity === 'warning') return fail('warn') + return ok('info') +} + +function formatMessage(message: LintMessage) { + return { + header: `${formatSeverity(message.severity)} ${bold(message.ruleId)} ${dim(message.nodePath.join(' / '))}`, + details: { + message: message.message, + node: `${message.nodeName} (${message.nodeId})`, + suggest: message.suggest + } + } +} + +export default defineCommand({ + meta: { + name: 'lint', + description: 'Lint design documents for consistency, structure, and accessibility issues' + }, + args: { + file: { + type: 'positional', + required: true, + description: 'Design document to lint (.fig, .pen)' + }, + preset: { + type: 'string', + default: 'recommended', + description: 'Preset: recommended, strict, accessibility' + }, + rule: { type: 'string', description: 'Run specific rule(s) only (repeatable)' }, + json: { type: 'boolean', default: false, description: 'Output as JSON' }, + 'list-rules': { type: 'boolean', default: false, description: 'List rules and exit' } + }, + async run({ args }) { + if (args['list-rules']) { + console.log('') + console.log(bold('Available rules')) + console.log('') + console.log( + fmtList( + Object.entries(allRules).map(([id, rule]) => ({ + header: bold(id), + details: { category: rule.meta.category, description: rule.meta.description } + })) + ) + ) + console.log('') + console.log(bold(`Presets: ${Object.keys(presets).join(', ')}`)) + console.log('') + return + } + + const graph = await loadDocument(args.file) + const rules = args.rule ? (Array.isArray(args.rule) ? args.rule : [args.rule]) : undefined + const result = createLinter({ preset: args.preset, rules }).lintGraph(graph) + + if (args.json) { + console.log(JSON.stringify(result, null, 2)) + } else if (result.messages.length === 0) { + console.log(ok('No lint issues found.')) + } else { + console.log('') + console.log( + bold( + `Lint issues: ${result.errorCount} errors, ${result.warningCount} warnings, ${result.infoCount} info` + ) + ) + console.log('') + console.log(fmtList(result.messages.map(formatMessage))) + console.log('') + } + + if (result.errorCount > 0) process.exit(1) + } +}) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 03c0e0eb2..58ab40bbe 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -8,6 +8,7 @@ import exportCmd from './commands/export' import find from './commands/find' import formats from './commands/formats' import info from './commands/info' +import lint from './commands/lint' import node from './commands/node' import pages from './commands/pages' import query from './commands/query' @@ -30,6 +31,7 @@ const main = defineCommand({ find, formats, info, + lint, query, node, pages, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1a83d6128..2b85e5c78 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -347,6 +347,7 @@ export { } from './kiwi' export * from './io' +export * from './lint' export { CODEGEN_PROMPT } from './tools/prompts/codegen-prompt' export { diff --git a/packages/core/src/lint/index.ts b/packages/core/src/lint/index.ts new file mode 100644 index 000000000..8da9db27a --- /dev/null +++ b/packages/core/src/lint/index.ts @@ -0,0 +1,15 @@ +export { Linter, createLinter } from './linter' +export { defineRule } from './rule' +export { allRules } from './rules' +export { presets, recommended, strict, accessibility } from './presets' +export type { + Rule, + RuleMeta, + RuleContext, + LintNode, + LintMessage, + LintResult, + LintConfig, + Severity, + Category +} from './types' diff --git a/packages/core/src/lint/linter.ts b/packages/core/src/lint/linter.ts new file mode 100644 index 000000000..5be82919b --- /dev/null +++ b/packages/core/src/lint/linter.ts @@ -0,0 +1,147 @@ +import { presets } from './presets' +import { allRules } from './rules' +import { getNodePath } from './utils' + +import type { SceneGraph, SceneNode } from '../scene-graph' +import type { + LintConfig, + LintMessage, + LintNode, + LintResult, + Rule, + RuleContext, + Severity +} from './types' + +export class Linter { + private rules = new Map() + private ruleConfigs = new Map }>() + private messages: LintMessage[] = [] + private nodes = new Map() + + constructor(options: { config?: LintConfig; preset?: string; rules?: string[] } = {}) { + let baseConfig: Record< + string, + Severity | { severity: Severity; options?: Record } + > = {} + if (options.preset) baseConfig = { ...presets[options.preset].rules } + if (options.config?.extends) { + const ids = Array.isArray(options.config.extends) + ? options.config.extends + : [options.config.extends] + for (const id of ids) baseConfig = { ...baseConfig, ...presets[id].rules } + } + if (options.config?.rules) baseConfig = { ...baseConfig, ...options.config.rules } + const rulesToLoad = options.rules ?? Object.keys(baseConfig) + for (const ruleId of rulesToLoad) { + const rule = allRules[ruleId] + const config = baseConfig[ruleId] + if (config === 'off') continue + this.rules.set(ruleId, rule) + if (typeof config === 'string') this.ruleConfigs.set(ruleId, { severity: config }) + else this.ruleConfigs.set(ruleId, config) + } + } + + lintGraph(graph: SceneGraph, rootIds?: string[]): LintResult { + this.messages = [] + this.nodes.clear() + const roots = rootIds && rootIds.length > 0 ? rootIds : graph.getPages().map((p) => p.id) + for (const id of roots) this.capture(graph, id, undefined) + for (const id of roots) this.lintNode(id) + return { + messages: this.messages, + errorCount: this.messages.filter((m) => m.severity === 'error').length, + warningCount: this.messages.filter((m) => m.severity === 'warning').length, + infoCount: this.messages.filter((m) => m.severity === 'info').length + } + } + + private capture(graph: SceneGraph, id: string, parent?: LintNode) { + const raw = graph.getNode(id) + if (!raw) return + const node = this.toLintNode(raw) + ;(node as LintNode & { parent?: LintNode }).parent = parent + this.nodes.set(id, node as LintNode & { parent?: LintNode }) + for (const childId of raw.childIds) this.capture(graph, childId, node) + } + + private toLintNode(raw: SceneNode): LintNode { + return { + id: raw.id, + name: raw.name, + type: raw.type, + width: raw.width, + height: raw.height, + x: raw.x, + y: raw.y, + rotation: raw.rotation, + visible: raw.visible, + locked: raw.locked, + layoutMode: raw.layoutMode, + itemSpacing: raw.itemSpacing, + paddingTop: raw.paddingTop, + paddingRight: raw.paddingRight, + paddingBottom: raw.paddingBottom, + paddingLeft: raw.paddingLeft, + cornerRadius: raw.cornerRadius, + childIds: raw.childIds.slice(), + componentId: raw.componentId || undefined, + text: raw.text, + fontSize: raw.fontSize, + styleRunCount: raw.styleRuns.length, + boundVariables: raw.boundVariables, + fills: raw.fills.map((f) => ({ + type: f.type, + visible: f.visible, + opacity: f.opacity, + color: f.type === 'SOLID' ? f.color : undefined + })), + strokes: raw.strokes.map((stroke) => ({ + visible: stroke.visible, + opacity: stroke.opacity, + color: stroke.color + })), + effects: raw.effects.map((effect) => ({ + type: effect.type, + visible: effect.visible, + radius: effect.radius + })) + } + } + + private lintNode(id: string) { + const node = this.nodes.get(id) + if (!node) return + for (const [ruleId, rule] of this.rules) { + if (rule.match && !rule.match.includes(node.type)) continue + const config = this.ruleConfigs.get(ruleId) + if (!config || config.severity === 'off') continue + const context: RuleContext = { + report: ({ node, message, suggest }) => { + this.messages.push({ + ruleId, + severity: config.severity as Exclude, + message, + nodeId: node.id, + nodeName: node.name, + nodePath: getNodePath(this.nodes.get(node.id) as LintNode & { parent?: LintNode }), + suggest + }) + }, + getConfig: () => config.options, + getParent: (node) => this.nodes.get(node.id)?.parent ?? null, + getChildren: (node) => + node.childIds + .map((childId) => this.nodes.get(childId)) + .filter((child): child is LintNode => !!child) + } + rule.check(node, context) + } + for (const childId of node.childIds) this.lintNode(childId) + } +} + +export function createLinter(options?: { config?: LintConfig; preset?: string; rules?: string[] }) { + return new Linter(options) +} diff --git a/packages/core/src/lint/presets.ts b/packages/core/src/lint/presets.ts new file mode 100644 index 000000000..4a1e0cb96 --- /dev/null +++ b/packages/core/src/lint/presets.ts @@ -0,0 +1,56 @@ +import type { Severity } from './types' + +type RuleConfig = Severity | { severity: Severity; options?: Record } +export interface Preset { + rules: Record +} + +export const recommended: Preset = { + rules: { + 'no-hardcoded-colors': 'warning', + 'no-default-names': 'info', + 'prefer-auto-layout': 'info', + 'consistent-spacing': 'warning', + 'consistent-radius': 'info', + 'color-contrast': 'error', + 'touch-target-size': 'warning', + 'text-style-required': 'info', + 'min-text-size': 'warning', + 'no-hidden-layers': 'info', + 'no-deeply-nested': 'warning', + 'no-empty-frames': 'info', + 'pixel-perfect': 'info', + 'no-groups': 'info', + 'effect-style-required': 'info', + 'no-mixed-styles': 'warning', + 'no-detached-instances': 'off' + } +} + +export const strict: Preset = { + rules: Object.fromEntries( + Object.keys(recommended.rules).map((id) => [id, id === 'color-contrast' ? 'error' : 'warning']) + ) +} +export const accessibility: Preset = { + rules: { + 'color-contrast': 'error', + 'touch-target-size': 'error', + 'min-text-size': 'error', + 'no-hardcoded-colors': 'off', + 'no-default-names': 'off', + 'prefer-auto-layout': 'off', + 'consistent-spacing': 'off', + 'consistent-radius': 'off', + 'text-style-required': 'off', + 'no-hidden-layers': 'off', + 'no-deeply-nested': 'off', + 'no-empty-frames': 'off', + 'pixel-perfect': 'off', + 'no-groups': 'off', + 'effect-style-required': 'off', + 'no-mixed-styles': 'off', + 'no-detached-instances': 'off' + } +} +export const presets: Record = { recommended, strict, accessibility } diff --git a/packages/core/src/lint/rule.ts b/packages/core/src/lint/rule.ts new file mode 100644 index 000000000..f89dca930 --- /dev/null +++ b/packages/core/src/lint/rule.ts @@ -0,0 +1,13 @@ +import type { Rule, RuleContext, RuleMeta, LintNode } from './types' + +export function defineRule(definition: { + meta: Omit & { severity?: RuleMeta['severity'] } + match?: string[] + check: (node: LintNode, context: RuleContext) => void +}): Rule { + return { + meta: { severity: 'warning', ...definition.meta }, + match: definition.match, + check: definition.check + } +} diff --git a/packages/core/src/lint/rules/color-contrast.ts b/packages/core/src/lint/rules/color-contrast.ts new file mode 100644 index 000000000..4caa8f3a6 --- /dev/null +++ b/packages/core/src/lint/rules/color-contrast.ts @@ -0,0 +1,32 @@ +import { defineRule } from '../rule' +import { contrastRatio } from '../utils' + +export default defineRule({ + meta: { + id: 'color-contrast', + category: 'accessibility', + severity: 'error', + description: 'Text must have sufficient contrast against its background' + }, + match: ['TEXT'], + check(node, context) { + const textFill = node.fills.find((f) => f.type === 'SOLID' && f.visible && f.color) + const textColor = textFill?.color + if (!textColor) return + let parent = context.getParent(node) + while (parent) { + const bg = parent.fills.find((f) => f.type === 'SOLID' && f.visible && f.color)?.color + if (bg) { + const ratio = contrastRatio(textColor, bg) + if (ratio < 4.5) + context.report({ + node, + message: `Contrast ratio ${ratio.toFixed(2)}:1 is below WCAG AA`, + suggest: 'Increase contrast between text and background' + }) + return + } + parent = context.getParent(parent) + } + } +}) diff --git a/packages/core/src/lint/rules/consistent-radius.ts b/packages/core/src/lint/rules/consistent-radius.ts new file mode 100644 index 000000000..5bea350b1 --- /dev/null +++ b/packages/core/src/lint/rules/consistent-radius.ts @@ -0,0 +1,18 @@ +import { defineRule } from '../rule' +const SCALE = [0, 2, 4, 6, 8, 12, 16, 20, 24, 32, 9999] +export default defineRule({ + meta: { + id: 'consistent-radius', + category: 'layout', + description: 'Corner radius should follow the radius scale' + }, + match: ['RECTANGLE', 'FRAME', 'COMPONENT', 'INSTANCE'], + check(node, context) { + if (node.cornerRadius > 0 && !SCALE.includes(node.cornerRadius)) + context.report({ + node, + message: `Corner radius ${node.cornerRadius}px is not in scale`, + suggest: 'Use a radius token or a scale value' + }) + } +}) diff --git a/packages/core/src/lint/rules/consistent-spacing.ts b/packages/core/src/lint/rules/consistent-spacing.ts new file mode 100644 index 000000000..248450acc --- /dev/null +++ b/packages/core/src/lint/rules/consistent-spacing.ts @@ -0,0 +1,33 @@ +import { defineRule } from '../rule' +import { isMultipleOf, SPACING_SCALE } from '../utils' + +export default defineRule({ + meta: { + id: 'consistent-spacing', + category: 'layout', + description: 'Spacing should follow the spacing scale' + }, + match: ['FRAME', 'COMPONENT'], + check(node, context) { + if (node.layoutMode === 'NONE') return + const config = context.getConfig() as { base?: number } | undefined + const base = config?.base ?? 8 + const valid = (value: number) => SPACING_SCALE.includes(value) || isMultipleOf(value, base) + const values = [ + ['gap', node.itemSpacing], + ['paddingTop', node.paddingTop], + ['paddingRight', node.paddingRight], + ['paddingBottom', node.paddingBottom], + ['paddingLeft', node.paddingLeft] + ] as const + for (const [name, value] of values) { + if (value > 0 && !valid(value)) { + context.report({ + node, + message: `${name} ${value}px is not in spacing scale`, + suggest: 'Use a spacing token or 8pt-grid multiple' + }) + } + } + } +}) diff --git a/packages/core/src/lint/rules/effect-style-required.ts b/packages/core/src/lint/rules/effect-style-required.ts new file mode 100644 index 000000000..6b759a064 --- /dev/null +++ b/packages/core/src/lint/rules/effect-style-required.ts @@ -0,0 +1,10 @@ +import { defineRule } from '../rule' + +export default defineRule({ + meta: { id: 'effect-style-required', category: 'design-tokens', description: 'Effects should use shared effect presets or tokens' }, + check(node, context) { + const visibleEffects = node.effects.filter((effect) => effect.visible) + if (visibleEffects.length === 0) return + context.report({ node, message: `Effect without shared style: ${visibleEffects.map((effect) => `${effect.type} ${effect.radius}px`).join(', ')}`, suggest: 'Extract reusable shadows and blurs into shared presets or variables' }) + } +}) diff --git a/packages/core/src/lint/rules/index.ts b/packages/core/src/lint/rules/index.ts new file mode 100644 index 000000000..8be8088f0 --- /dev/null +++ b/packages/core/src/lint/rules/index.ts @@ -0,0 +1,57 @@ +export { default as noHardcodedColors } from './no-hardcoded-colors' +export { default as noDefaultNames } from './no-default-names' +export { default as preferAutoLayout } from './prefer-auto-layout' +export { default as consistentSpacing } from './consistent-spacing' +export { default as consistentRadius } from './consistent-radius' +export { default as colorContrast } from './color-contrast' +export { default as touchTargetSize } from './touch-target-size' +export { default as textStyleRequired } from './text-style-required' +export { default as minTextSize } from './min-text-size' +export { default as noHiddenLayers } from './no-hidden-layers' +export { default as noDeeplyNested } from './no-deeply-nested' +export { default as noEmptyFrames } from './no-empty-frames' +export { default as pixelPerfect } from './pixel-perfect' +export { default as noGroups } from './no-groups' +export { default as effectStyleRequired } from './effect-style-required' +export { default as noMixedStyles } from './no-mixed-styles' +export { default as noDetachedInstances } from './no-detached-instances' + +import colorContrast from './color-contrast' +import consistentRadius from './consistent-radius' +import consistentSpacing from './consistent-spacing' +import effectStyleRequired from './effect-style-required' +import minTextSize from './min-text-size' +import noDeeplyNested from './no-deeply-nested' +import noDefaultNames from './no-default-names' +import noDetachedInstances from './no-detached-instances' +import noEmptyFrames from './no-empty-frames' +import noGroups from './no-groups' +import noHardcodedColors from './no-hardcoded-colors' +import noHiddenLayers from './no-hidden-layers' +import noMixedStyles from './no-mixed-styles' +import pixelPerfect from './pixel-perfect' +import preferAutoLayout from './prefer-auto-layout' +import textStyleRequired from './text-style-required' +import touchTargetSize from './touch-target-size' + +import type { Rule } from '../types' + +export const allRules: Record = { + 'no-hardcoded-colors': noHardcodedColors, + 'no-default-names': noDefaultNames, + 'prefer-auto-layout': preferAutoLayout, + 'consistent-spacing': consistentSpacing, + 'consistent-radius': consistentRadius, + 'color-contrast': colorContrast, + 'touch-target-size': touchTargetSize, + 'text-style-required': textStyleRequired, + 'min-text-size': minTextSize, + 'no-hidden-layers': noHiddenLayers, + 'no-deeply-nested': noDeeplyNested, + 'no-empty-frames': noEmptyFrames, + 'pixel-perfect': pixelPerfect, + 'no-groups': noGroups, + 'effect-style-required': effectStyleRequired, + 'no-mixed-styles': noMixedStyles, + 'no-detached-instances': noDetachedInstances +} diff --git a/packages/core/src/lint/rules/min-text-size.ts b/packages/core/src/lint/rules/min-text-size.ts new file mode 100644 index 000000000..ec5964456 --- /dev/null +++ b/packages/core/src/lint/rules/min-text-size.ts @@ -0,0 +1,11 @@ +import { defineRule } from '../rule' + +export default defineRule({ + meta: { id: 'min-text-size', category: 'accessibility', description: 'Text should be large enough to be readable (minimum 12px)' }, + match: ['TEXT'], + check(node, context) { + const config = context.getConfig() as { minSize?: number } | undefined + const minSize = config?.minSize ?? 12 + if (node.fontSize < minSize) context.report({ node, message: `Text size ${node.fontSize}px is below minimum ${minSize}px`, suggest: `Increase to at least ${minSize}px for readability` }) + } +}) diff --git a/packages/core/src/lint/rules/no-deeply-nested.ts b/packages/core/src/lint/rules/no-deeply-nested.ts new file mode 100644 index 000000000..b44f0a16c --- /dev/null +++ b/packages/core/src/lint/rules/no-deeply-nested.ts @@ -0,0 +1,26 @@ +import { defineRule } from '../rule' + +export default defineRule({ + meta: { + id: 'no-deeply-nested', + category: 'structure', + description: 'Avoid deeply nested layers' + }, + check(node, context) { + const config = context.getConfig() as { maxDepth?: number } | undefined + const maxDepth = config?.maxDepth ?? 6 + let depth = 0 + let current = context.getParent(node) + while (current) { + depth++ + current = context.getParent(current) + } + if (depth > maxDepth) { + context.report({ + node, + message: `Layer nested ${depth} levels deep (max ${maxDepth})`, + suggest: 'Flatten structure or extract a component' + }) + } + } +}) diff --git a/packages/core/src/lint/rules/no-default-names.ts b/packages/core/src/lint/rules/no-default-names.ts new file mode 100644 index 000000000..13edd6331 --- /dev/null +++ b/packages/core/src/lint/rules/no-default-names.ts @@ -0,0 +1,21 @@ +import { defineRule } from '../rule' +import { isDefaultName } from '../utils' + +export default defineRule({ + meta: { + id: 'no-default-names', + category: 'naming', + description: 'Layers should have descriptive names' + }, + check(node, context) { + if (!isDefaultName(node.name)) return + const isSmallDecorative = + ['RECTANGLE', 'ELLIPSE', 'LINE'].includes(node.type) && node.width < 24 && node.height < 24 + if (isSmallDecorative) return + context.report({ + node, + message: `Default layer name "${node.name}" is not descriptive`, + suggest: 'Rename to describe the layer purpose' + }) + } +}) diff --git a/packages/core/src/lint/rules/no-detached-instances.ts b/packages/core/src/lint/rules/no-detached-instances.ts new file mode 100644 index 000000000..f1eee1ca1 --- /dev/null +++ b/packages/core/src/lint/rules/no-detached-instances.ts @@ -0,0 +1,34 @@ +import { defineRule } from '../rule' +const PATTERNS = [ + /^(button|btn)/i, + /^(input|field|text-?field)/i, + /^(card|modal|dialog)/i, + /^(icon|avatar|badge)/i, + /^(nav|menu|tab)/i, + /^(header|footer|sidebar)/i, + /^(list|item|row)/i, + /^(chip|tag|label)/i, + /^(tooltip|popover|dropdown)/i +] +export default defineRule({ + meta: { + id: 'no-detached-instances', + category: 'components', + description: 'Frames that look like components should be instances, not detached copies' + }, + match: ['FRAME'], + check(node, context) { + if ( + node.componentId || + !PATTERNS.some((p) => p.test(node.name)) || + context.getChildren(node).length === 0 || + node.layoutMode === 'NONE' + ) + return + context.report({ + node, + message: `Frame "${node.name}" looks like a component but isn't an instance`, + suggest: 'Use a component instance instead of a detached frame' + }) + } +}) diff --git a/packages/core/src/lint/rules/no-empty-frames.ts b/packages/core/src/lint/rules/no-empty-frames.ts new file mode 100644 index 000000000..561f314d4 --- /dev/null +++ b/packages/core/src/lint/rules/no-empty-frames.ts @@ -0,0 +1,21 @@ +import { defineRule } from '../rule' +export default defineRule({ + meta: { + id: 'no-empty-frames', + category: 'structure', + description: 'Frames should not be empty unless used as spacers' + }, + match: ['FRAME'], + check(node, context) { + if (context.getChildren(node).length > 0) return + const isSpacer = + node.name.toLowerCase().includes('spacer') || node.width <= 1 || node.height <= 1 + const hasFill = node.fills.some((f) => f.visible && f.type === 'SOLID') + if (!isSpacer && !hasFill) + context.report({ + node, + message: 'Empty frame with no fill', + suggest: 'Delete if unused, or add content/fill' + }) + } +}) diff --git a/packages/core/src/lint/rules/no-groups.ts b/packages/core/src/lint/rules/no-groups.ts new file mode 100644 index 000000000..cf5408ed3 --- /dev/null +++ b/packages/core/src/lint/rules/no-groups.ts @@ -0,0 +1,16 @@ +import { defineRule } from '../rule' +export default defineRule({ + meta: { + id: 'no-groups', + category: 'structure', + description: 'Use frames instead of groups for better layout control' + }, + match: ['GROUP'], + check(node, context) { + context.report({ + node, + message: 'Group should be converted to Frame', + suggest: 'Groups cannot use auto layout. Convert to Frame for better control.' + }) + } +}) diff --git a/packages/core/src/lint/rules/no-hardcoded-colors.ts b/packages/core/src/lint/rules/no-hardcoded-colors.ts new file mode 100644 index 000000000..f095d8435 --- /dev/null +++ b/packages/core/src/lint/rules/no-hardcoded-colors.ts @@ -0,0 +1,16 @@ +import { defineRule } from '../rule' + +export default defineRule({ + meta: { id: 'no-hardcoded-colors', category: 'design-tokens', description: 'Colors should use variables instead of hardcoded values' }, + match: ['RECTANGLE','ELLIPSE','FRAME','TEXT','VECTOR','LINE','POLYGON','STAR','COMPONENT','INSTANCE'], + check(node, context) { + const checkPaints = (paints: typeof node.fills, field: 'fills' | 'strokes') => { + for (const paint of paints) { + if (paint.type !== 'SOLID' || !paint.visible || !paint.color || node.boundVariables[field]) continue + context.report({ node, message: `Hardcoded ${field === 'fills' ? 'fill' : 'stroke'} color detected`, suggest: 'Bind this color to a design variable for consistency' }) + } + } + checkPaints(node.fills, 'fills') + checkPaints(node.strokes as typeof node.fills, 'strokes') + } +}) diff --git a/packages/core/src/lint/rules/no-hidden-layers.ts b/packages/core/src/lint/rules/no-hidden-layers.ts new file mode 100644 index 000000000..1819a834d --- /dev/null +++ b/packages/core/src/lint/rules/no-hidden-layers.ts @@ -0,0 +1,16 @@ +import { defineRule } from '../rule' +export default defineRule({ + meta: { + id: 'no-hidden-layers', + category: 'structure', + description: 'Hidden layers may indicate unused elements' + }, + check(node, context) { + if (!node.visible) + context.report({ + node, + message: 'Hidden layer detected', + suggest: 'Delete if unused or keep only if required for component states' + }) + } +}) diff --git a/packages/core/src/lint/rules/no-mixed-styles.ts b/packages/core/src/lint/rules/no-mixed-styles.ts new file mode 100644 index 000000000..5e98ac3df --- /dev/null +++ b/packages/core/src/lint/rules/no-mixed-styles.ts @@ -0,0 +1,11 @@ +import { defineRule } from '../rule' + +export default defineRule({ + meta: { id: 'no-mixed-styles', category: 'typography', description: 'Text layers should not mix multiple styles in one node' }, + match: ['TEXT'], + check(node, context) { + if (node.text.length > 1 && node.styleRunCount > 0) { + context.report({ node, message: 'Text layer has mixed font styles', suggest: 'Split into separate text layers or unify the text style' }) + } + } +}) diff --git a/packages/core/src/lint/rules/pixel-perfect.ts b/packages/core/src/lint/rules/pixel-perfect.ts new file mode 100644 index 000000000..4990d1239 --- /dev/null +++ b/packages/core/src/lint/rules/pixel-perfect.ts @@ -0,0 +1,24 @@ +import { defineRule } from '../rule' + +export default defineRule({ + meta: { + id: 'pixel-perfect', + category: 'layout', + description: 'Elements should align to whole pixels' + }, + check(node, context) { + const values: Array<[string, number]> = [ + ['x', node.x], + ['y', node.y], + ['width', node.width], + ['height', node.height] + ] + const subpixel = values.filter(([, value]) => Math.abs(value - Math.round(value)) >= 0.01) + if (subpixel.length === 0) return + context.report({ + node, + message: `Subpixel values: ${subpixel.map(([k, v]) => `${k}: ${v}`).join(', ')}`, + suggest: 'Round to whole pixels for crisp rendering' + }) + } +}) diff --git a/packages/core/src/lint/rules/prefer-auto-layout.ts b/packages/core/src/lint/rules/prefer-auto-layout.ts new file mode 100644 index 000000000..a1796d497 --- /dev/null +++ b/packages/core/src/lint/rules/prefer-auto-layout.ts @@ -0,0 +1,20 @@ +import { defineRule } from '../rule' + +export default defineRule({ + meta: { + id: 'prefer-auto-layout', + category: 'layout', + description: 'Frames with multiple children should use auto layout' + }, + match: ['FRAME', 'COMPONENT'], + check(node, context) { + const config = context.getConfig() as { minChildren?: number } | undefined + const minChildren = config?.minChildren ?? 2 + if (node.layoutMode !== 'NONE' || context.getChildren(node).length < minChildren) return + context.report({ + node, + message: `Frame with ${context.getChildren(node).length} children doesn't use auto layout`, + suggest: 'Add horizontal or vertical auto layout' + }) + } +}) diff --git a/packages/core/src/lint/rules/text-style-required.ts b/packages/core/src/lint/rules/text-style-required.ts new file mode 100644 index 000000000..92080f7ae --- /dev/null +++ b/packages/core/src/lint/rules/text-style-required.ts @@ -0,0 +1,11 @@ +import { defineRule } from '../rule' + +export default defineRule({ + meta: { id: 'text-style-required', category: 'typography', description: 'Text layers should use shared typography tokens or styles' }, + match: ['TEXT'], + check(node, context) { + if (node.text.length <= 2) return + if (node.boundVariables.fontSize || node.boundVariables.fontFamily) return + context.report({ node, message: 'Text layer without typography variable bindings', suggest: 'Bind font size or font family to a shared text token when possible' }) + } +}) diff --git a/packages/core/src/lint/rules/touch-target-size.ts b/packages/core/src/lint/rules/touch-target-size.ts new file mode 100644 index 000000000..c8075cd0b --- /dev/null +++ b/packages/core/src/lint/rules/touch-target-size.ts @@ -0,0 +1,39 @@ +import { defineRule } from '../rule' +const PATTERNS = [ + /button/i, + /btn/i, + /link/i, + /cta/i, + /icon/i, + /checkbox/i, + /radio/i, + /switch/i, + /toggle/i, + /input/i, + /select/i, + /dropdown/i, + /menu/i, + /tab/i, + /chip/i, + /tag/i, + /close/i, + /dismiss/i, + /action/i +] +export default defineRule({ + meta: { + id: 'touch-target-size', + category: 'accessibility', + description: 'Interactive elements should be at least 44x44px' + }, + match: ['FRAME', 'COMPONENT', 'INSTANCE', 'RECTANGLE', 'ELLIPSE'], + check(node, context) { + if (!PATTERNS.some((p) => p.test(node.name))) return + if (node.width >= 44 && node.height >= 44) return + context.report({ + node, + message: `Touch target too small: ${node.width}×${node.height}px`, + suggest: 'Resize to at least 44×44px or add padding' + }) + } +}) diff --git a/packages/core/src/lint/types.ts b/packages/core/src/lint/types.ts new file mode 100644 index 000000000..e99512b29 --- /dev/null +++ b/packages/core/src/lint/types.ts @@ -0,0 +1,94 @@ +export type Severity = 'error' | 'warning' | 'info' | 'off' + +export type Category = + | 'layout' + | 'accessibility' + | 'naming' + | 'structure' + | 'components' + | 'design-tokens' + | 'typography' + +export interface RuleMeta { + id: string + severity: Severity + category: Category + description: string +} + +export interface LintMessage { + ruleId: string + severity: Exclude + message: string + nodeId: string + nodeName: string + nodePath: string[] + suggest?: string +} + +export interface LintResult { + messages: LintMessage[] + errorCount: number + warningCount: number + infoCount: number +} + +export interface LintConfig { + extends?: string | string[] + rules: Record }> +} + +export interface LintNode { + id: string + name: string + type: string + width: number + height: number + x: number + y: number + rotation: number + visible: boolean + locked: boolean + layoutMode: string + itemSpacing: number + paddingTop: number + paddingRight: number + paddingBottom: number + paddingLeft: number + cornerRadius: number + childIds: string[] + componentId?: string + text: string + fontSize: number + styleRunCount: number + boundVariables: Record + fills: Array<{ + type: string + visible: boolean + opacity: number + color?: { r: number; g: number; b: number } + }> + strokes: Array<{ + visible: boolean + opacity: number + color?: { r: number; g: number; b: number } + }> + effects: Array<{ + type: string + visible: boolean + radius: number + }> +} + +export interface RuleContext { + report(issue: { node: LintNode; message: string; suggest?: string }): void + getConfig(): unknown + getParent(node: LintNode): LintNode | null + getChildren(node: LintNode): LintNode[] +} + +export interface Rule { + meta: RuleMeta + match?: string[] + check(node: LintNode, context: RuleContext): void +} diff --git a/packages/core/src/lint/utils.ts b/packages/core/src/lint/utils.ts new file mode 100644 index 000000000..053e1c0d1 --- /dev/null +++ b/packages/core/src/lint/utils.ts @@ -0,0 +1,44 @@ +export function isDefaultName(name: string): boolean { + return /^(Frame|Rectangle|Ellipse|Line|Text|Group|Vector|Polygon|Star|Section|Component|Instance|Slice)\s*\d*$/i.test( + name + ) +} + +export function isMultipleOf(value: number, base: number, tolerance = 0.01): boolean { + if (base === 0) return false + const remainder = value % base + return remainder < tolerance || base - remainder < tolerance +} + +export function getNodePath(node: { + name: string + parent?: { name: string; parent?: unknown } +}): string[] { + const path: string[] = [] + let current: typeof node | undefined = node + while (current) { + path.unshift(current.name) + current = current.parent as typeof node | undefined + } + return path +} + +export function relativeLuminance(rgb: { r: number; g: number; b: number }): number { + const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((c) => + c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 + ) as [number, number, number] + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +export function contrastRatio( + a: { r: number; g: number; b: number }, + b: { r: number; g: number; b: number } +): number { + const l1 = relativeLuminance(a) + const l2 = relativeLuminance(b) + const lighter = Math.max(l1, l2) + const darker = Math.min(l1, l2) + return (lighter + 0.05) / (darker + 0.05) +} + +export const SPACING_SCALE = [0, 1, 2, 4, 8, 12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 128] diff --git a/packages/docs/programmable/cli/inspecting.md b/packages/docs/programmable/cli/inspecting.md index cad79a778..1d649326f 100644 --- a/packages/docs/programmable/cli/inspecting.md +++ b/packages/docs/programmable/cli/inspecting.md @@ -5,7 +5,7 @@ description: Browse node trees, search by name or type, and dig into properties # Inspecting Files -The CLI lets you explore `.fig` files without opening the editor. Every command also works on the live app — just omit the file argument. +The CLI lets you explore design documents without opening the editor. Every command also works on the live app — just omit the file argument. ::: tip Install ```sh @@ -148,6 +148,19 @@ open-pencil tree # inspect the live document open-pencil eval -c "..." # query the editor ``` +## Lint Designs + +Check documents for naming, layout, structure, and accessibility issues: + +```sh +open-pencil lint design.fig +open-pencil lint design.pen --preset strict +open-pencil lint design.fig --rule color-contrast +open-pencil lint design.fig --list-rules +``` + +Use `--json` for machine-readable output. + ## JSON Output All commands support `--json` for machine-readable output — pipe into `jq`, feed to CI scripts, or process with other tools: diff --git a/packages/docs/programmable/index.md b/packages/docs/programmable/index.md index 5570c2d0d..fe2ff1ea9 100644 --- a/packages/docs/programmable/index.md +++ b/packages/docs/programmable/index.md @@ -46,7 +46,7 @@ Going the other direction, export any selection back to JSX with Tailwind classe ## CLI -Inspect, export, and analyze `.fig` files without opening the editor. List pages, search nodes, extract design tokens, render to PNG — all from the terminal with machine-readable JSON output. +Inspect, lint, export, and analyze design documents without opening the editor. List pages, search nodes, extract design tokens, catch layout or accessibility issues, and render to PNG — all from the terminal with machine-readable JSON output. The CLI also connects to the running desktop app via RPC, so you can script the editor while you're using it. diff --git a/tests/engine/lint.test.ts b/tests/engine/lint.test.ts new file mode 100644 index 000000000..24def3b53 --- /dev/null +++ b/tests/engine/lint.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test' + +import { SceneGraph, createLinter } from '@open-pencil/core' + +describe('createLinter', () => { + test('reports default names and empty frames', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const frame = graph.createNode('FRAME', page.id, { name: 'Frame 1', width: 100, height: 100 }) + + const result = createLinter({ preset: 'recommended' }).lintGraph(graph, [frame.id]) + const ruleIds = result.messages.map((message) => message.ruleId) + + expect(ruleIds).toContain('no-default-names') + expect(ruleIds).toContain('no-empty-frames') + }) + + test('reports color contrast issues for low-contrast text', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const frame = graph.createNode('FRAME', page.id, { + name: 'Card', + width: 200, + height: 80, + fills: [{ type: 'SOLID', visible: true, opacity: 1, color: { r: 1, g: 1, b: 1 } }] + }) + graph.createNode('TEXT', frame.id, { + name: 'Label', + width: 80, + height: 20, + text: 'Hello', + fills: [{ type: 'SOLID', visible: true, opacity: 1, color: { r: 0.8, g: 0.8, b: 0.8 } }] + }) + + const result = createLinter({ preset: 'recommended' }).lintGraph(graph, [frame.id]) + expect(result.messages.some((message) => message.ruleId === 'color-contrast')).toBe(true) + }) +})