Port design linter from figma-use

This commit is contained in:
Danila Poyarkov 2026-03-28 16:33:40 +03:00
parent b072c69a2f
commit c57e636dcb
31 changed files with 938 additions and 2 deletions

View file

@ -88,6 +88,17 @@ open-pencil convert design.pen output.fig # Convert between docume
</div>
```
### 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:

View file

@ -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)
}
})

View file

@ -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,

View file

@ -347,6 +347,7 @@ export {
} from './kiwi'
export * from './io'
export * from './lint'
export { CODEGEN_PROMPT } from './tools/prompts/codegen-prompt'
export {

View file

@ -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'

View file

@ -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<string, Rule>()
private ruleConfigs = new Map<string, { severity: Severity; options?: Record<string, unknown> }>()
private messages: LintMessage[] = []
private nodes = new Map<string, LintNode & { parent?: LintNode }>()
constructor(options: { config?: LintConfig; preset?: string; rules?: string[] } = {}) {
let baseConfig: Record<
string,
Severity | { severity: Severity; options?: Record<string, unknown> }
> = {}
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<Severity, 'off'>,
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)
}

View file

@ -0,0 +1,56 @@
import type { Severity } from './types'
type RuleConfig = Severity | { severity: Severity; options?: Record<string, unknown> }
export interface Preset {
rules: Record<string, RuleConfig>
}
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<string, Preset> = { recommended, strict, accessibility }

View file

@ -0,0 +1,13 @@
import type { Rule, RuleContext, RuleMeta, LintNode } from './types'
export function defineRule(definition: {
meta: Omit<RuleMeta, 'severity'> & { severity?: RuleMeta['severity'] }
match?: string[]
check: (node: LintNode, context: RuleContext) => void
}): Rule {
return {
meta: { severity: 'warning', ...definition.meta },
match: definition.match,
check: definition.check
}
}

View file

@ -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)
}
}
})

View file

@ -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'
})
}
})

View file

@ -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'
})
}
}
}
})

View file

@ -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' })
}
})

View file

@ -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<string, Rule> = {
'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
}

View file

@ -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` })
}
})

View file

@ -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'
})
}
}
})

View file

@ -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'
})
}
})

View file

@ -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'
})
}
})

View file

@ -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'
})
}
})

View file

@ -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.'
})
}
})

View file

@ -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')
}
})

View file

@ -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'
})
}
})

View file

@ -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' })
}
}
})

View file

@ -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'
})
}
})

View file

@ -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'
})
}
})

View file

@ -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' })
}
})

View file

@ -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'
})
}
})

View file

@ -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<Severity, 'off'>
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<string, Severity | { severity: Severity; options?: Record<string, unknown> }>
}
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<string, string>
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
}

View file

@ -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]

View file

@ -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:

View file

@ -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.

38
tests/engine/lint.test.ts Normal file
View file

@ -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)
})
})