openpencil/packages/core/src/tools/describe-issues.ts
Anton A S d688d04bef Address PR #94 review: dedup, replace hand-rolled code, cleanup
- Replace calc.ts (150 lines) with expr-eval library
- Replace rgbLuminance with culori wcagLuminance
- Use @iconify/utils iconToSVG for viewBox normalization in iconify.ts
- Extract describe-shared.ts: findAncestorBackground, looksLikeButton, CONTAINER_TYPES
- Extract icon-render.ts: shared createIconFromPaths for create.ts and renderer.ts
- Inline codegen prompt as TS constant, remove runtime fs.readFile from MCP server
- Fix blur effect color alpha (a:1 → a:0)
- Encapsulate module-level mutable state in src/ai/tools.ts into RunState class
- Add network check to iconify tests (skip gracefully offline)
- Remove Caddyfile.dev and components.d.ts from tracking, add to .gitignore
2026-03-13 14:46:18 +03:00

282 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { colorDistance, colorToHex } from '../color'
import { CONTAINER_TYPES, findAncestorBackground, looksLikeButton } from './describe-shared'
import { detectLayoutIssues } from './describe-layout-issues'
import type { SceneGraph, SceneNode } from '../scene-graph'
const MIN_FILL_OPACITY = 0.15
const MIN_STROKE_OPACITY = 0.20
const LOW_CONTRAST_THRESHOLD = 15
const SHAPE_TYPES = new Set(['RECTANGLE', 'ELLIPSE', 'STAR', 'POLYGON', 'LINE'])
const ICON_MAX_SIZE = 48
export interface DescribeIssue {
message: string
suggestion?: string
}
function checkEmptyIcon(node: SceneNode, graph: SceneGraph, issues: DescribeIssue[]): void {
if (!CONTAINER_TYPES.has(node.type)) return
if (node.width > ICON_MAX_SIZE || node.height > ICON_MAX_SIZE || node.childIds.length === 0) return
if (node.fills.some((f) => f.visible) || node.strokes.some((s) => s.visible)) return
const hasVisibleChild = node.childIds.some((id) => {
const c = graph.getNode(id)
return c?.visible && (c.fills.some((f) => f.visible) || c.strokes.some((s) => s.visible) || c.type === 'TEXT')
})
if (!hasVisibleChild) {
issues.push({
message: `Icon-sized frame "${node.name}" (${node.width}×${node.height}) has no visible content`,
suggestion: 'Add bg="#hex" or stroke="#hex"'
})
}
}
function detectStructuralIssues(node: SceneNode, gridSize: number, graph: SceneGraph, issues: DescribeIssue[]): void {
if (node.x % 1 !== 0 || node.y % 1 !== 0) {
issues.push({
message: `Subpixel position (${node.x}, ${node.y})`,
suggestion: `(${Math.round(node.x)}, ${Math.round(node.y)})`
})
}
if (CONTAINER_TYPES.has(node.type) && node.fills.length === 0 && node.childIds.length === 0) {
issues.push({ message: 'Empty frame with no fill' })
}
const hasVisibleFill = node.fills.some((f) => f.visible)
const hasVisibleStroke = node.strokes.some((s) => s.visible)
if (SHAPE_TYPES.has(node.type) && !hasVisibleFill && !hasVisibleStroke) {
issues.push({
message: `${node.type} "${node.name}" has no fill and no stroke — invisible`,
suggestion: 'Add bg="#hex" or stroke="#hex"'
})
}
checkEmptyIcon(node, graph, issues)
if (looksLikeButton(node) && node.width < 44) {
issues.push({ message: `Touch target too small (${node.width}×${node.height})`, suggestion: 'Min 44×44' })
}
if (node.itemSpacing > 0 && node.itemSpacing % gridSize !== 0) {
const nearest = Math.round(node.itemSpacing / gridSize) * gridSize
issues.push({ message: `Gap ${node.itemSpacing} not on ${gridSize}px grid`, suggestion: `${nearest}` })
}
checkStrokeMismatch(node, issues)
checkRoundedWithoutClip(node, graph, issues)
checkExcessiveNesting(node, graph, issues)
checkSameFillAsParent(node, graph, issues)
checkImagePlaceholder(node, graph, issues)
}
function checkStrokeMismatch(node: SceneNode, issues: DescribeIssue[]): void {
const hasStrokeColor = node.strokes.some((s) => s.visible)
const hasStrokeWeight = node.strokes.some((s) => s.weight > 0)
if (hasStrokeColor && !hasStrokeWeight) {
issues.push({ message: `"${node.name}" has stroke color but zero weight`, suggestion: 'Set strokeWidth={1} or remove stroke' })
}
if (!hasStrokeColor && hasStrokeWeight && node.strokes.length > 0) {
issues.push({ message: `"${node.name}" has stroke weight but no visible stroke`, suggestion: 'Add stroke="#hex" or remove strokeWidth' })
}
}
function checkRoundedWithoutClip(node: SceneNode, graph: SceneGraph, issues: DescribeIssue[]): void {
if (!CONTAINER_TYPES.has(node.type)) return
if (node.cornerRadius <= 0 || node.clipsContent) return
const minDim = Math.min(node.width, node.height)
if (node.cornerRadius < minDim / 2 - 1) return
const hasImageChild = node.childIds.some((id) => {
const c = graph.getNode(id)
return c?.visible && c.fills.some((f) => f.visible && f.type === 'IMAGE')
})
if (hasImageChild) {
issues.push({
message: `"${node.name}" is circular/pill but not clipping — image children will overflow rounded corners`,
suggestion: 'Add overflow="hidden"'
})
}
}
function checkExcessiveNesting(node: SceneNode, graph: SceneGraph, issues: DescribeIssue[]): void {
if (!CONTAINER_TYPES.has(node.type)) return
let depth = 0
let current: SceneNode | undefined = node as SceneNode | undefined
while (current && CONTAINER_TYPES.has(current.type) && current.childIds.length === 1) {
const child = graph.getNode(current.childIds[0])
if (!child || !CONTAINER_TYPES.has(child.type)) break
if (current.fills.some((f) => f.visible) || current.cornerRadius > 0) break
depth++
current = child
}
if (depth >= 3) {
issues.push({
message: `${depth} levels of single-child wrapper frames starting at "${node.name}"`,
suggestion: 'Flatten — apply styles directly to the inner content'
})
}
}
function checkSameFillAsParent(node: SceneNode, graph: SceneGraph, issues: DescribeIssue[]): void {
if (node.type === 'TEXT') return
if (!node.parentId) return
const nodeFill = node.fills.find((f) => f.visible && f.type === 'SOLID')
if (!nodeFill) return
const parent = graph.getNode(node.parentId)
if (!parent) return
const parentFill = parent.fills.find((f) => f.visible && f.type === 'SOLID')
if (!parentFill) return
if (colorDistance(nodeFill.color, parentFill.color) < 3) {
issues.push({
message: `"${node.name}" fill ${colorToHex(nodeFill.color)} matches parent "${parent.name}" — invisible border`,
suggestion: 'Use a different fill color or remove fill'
})
}
}
function checkImagePlaceholder(node: SceneNode, graph: SceneGraph, issues: DescribeIssue[]): void {
if (!CONTAINER_TYPES.has(node.type)) return
if (!node.clipsContent) return
if (!/poster|avatar|image|thumb|photo|cover|banner/i.test(node.name)) return
const hasImage = node.fills.some((f) => f.visible && f.type === 'IMAGE')
if (hasImage) return
const childHasImage = node.childIds.some((id) => {
const c = graph.getNode(id)
return c?.visible && c.fills.some((f) => f.visible && f.type === 'IMAGE')
})
if (!childHasImage && !node.fills.some((f) => f.visible)) {
issues.push({
message: `"${node.name}" looks like an image container but has no image or placeholder fill`,
suggestion: 'Add bg="#hex" as placeholder color'
})
}
}
function detectVisibilityIssues(node: SceneNode, graph: SceneGraph, issues: DescribeIssue[]): void {
for (const fill of node.fills) {
if (!fill.visible || fill.type !== 'SOLID') continue
if (fill.opacity < MIN_FILL_OPACITY) {
issues.push({
message: `Near-invisible fill ${colorToHex(fill.color)} at ${Math.round(fill.opacity * 100)}%`,
suggestion: `Increase to ≥${Math.round(MIN_FILL_OPACITY * 100)}%`
})
}
}
for (const stroke of node.strokes) {
if (!stroke.visible || stroke.opacity >= MIN_STROKE_OPACITY) continue
issues.push({
message: `Near-invisible stroke at ${Math.round(stroke.opacity * 100)}%`,
suggestion: `Increase to ≥${Math.round(MIN_STROKE_OPACITY * 100)}%`
})
}
if (node.type !== 'TEXT' || !node.parentId) return
const textFill = node.fills.find((f) => f.visible && f.type === 'SOLID')
if (!textFill) return
const parentBg = findAncestorBackground(node, graph)
if (!parentBg) return
const dist = colorDistance(textFill.color, parentBg)
if (dist < LOW_CONTRAST_THRESHOLD) {
issues.push({
message: `Low contrast: text ${colorToHex(textFill.color)} on ${colorToHex(parentBg)} (distance ${Math.round(dist)})`,
suggestion: 'Increase color difference'
})
}
}
const RADIUS_TOLERANCE = 2
function detectRadiusIssues(node: SceneNode, graph: SceneGraph, issues: DescribeIssue[]): void {
if (node.cornerRadius <= 0 || node.layoutMode === 'NONE') return
const minPad = Math.min(node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft)
if (minPad <= 0) return
const expectedInner = Math.max(0, node.cornerRadius - minPad)
for (const childId of node.childIds) {
const child = graph.getNode(childId)
if (!child?.visible || child.cornerRadius <= 0 || child.layoutPositioning === 'ABSOLUTE') continue
if (child.cornerRadius > node.cornerRadius + RADIUS_TOLERANCE) {
issues.push({
message: `"${child.name}" radius ${child.cornerRadius} > parent ${node.cornerRadius}`,
suggestion: `Use rounded={${expectedInner}}`
})
} else if (child.cornerRadius > expectedInner + RADIUS_TOLERANCE && expectedInner < node.cornerRadius) {
issues.push({
message: `"${child.name}" radius ${child.cornerRadius} should be ${expectedInner} (parent ${node.cornerRadius} − padding ${minPad})`,
suggestion: `Use rounded={${expectedInner}}`
})
}
}
}
const UPPERCASE_MAX_SIZE = 13
function detectTypographyIssues(node: SceneNode, graph: SceneGraph, issues: DescribeIssue[]): void {
for (const childId of node.childIds) {
const child = graph.getNode(childId)
if (child?.type !== 'TEXT' || !child.visible) continue
const text = child.text
if (text === text.toUpperCase() && text.length > 1 && /[A-ZА-ЯЁ]/.test(text) && child.fontSize > UPPERCASE_MAX_SIZE) {
issues.push({
message: `"${text.slice(0, 30)}" is uppercase at ${child.fontSize}px — only for small labels ≤${UPPERCASE_MAX_SIZE}px`,
suggestion: `Reduce size or use mixed case`
})
}
}
}
const SPACING_GRID = 4
function detectSpacingIssues(node: SceneNode, graph: SceneGraph, gridSize: number, issues: DescribeIssue[]): void {
if (node.layoutMode === 'NONE') return
const children = node.childIds
.map((id) => graph.getNode(id))
.filter((c): c is SceneNode => c?.visible === true && c.layoutPositioning !== 'ABSOLUTE')
const minPad = Math.min(node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft)
if (node.itemSpacing > 0 && minPad > 0 && node.itemSpacing > minPad * 2) {
issues.push({
message: `Gap ${node.itemSpacing} >> padding ${minPad} in "${node.name}"`,
suggestion: 'Gap should usually be ≤ padding'
})
}
const spacingValues = [node.itemSpacing, node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft]
.filter((v) => v > 0)
for (const v of spacingValues) {
if (v % SPACING_GRID !== 0) {
issues.push({
message: `Spacing ${v} in "${node.name}" off ${SPACING_GRID}px grid`,
suggestion: `Use ${Math.round(v / SPACING_GRID) * SPACING_GRID}`
})
break
}
}
const flexChildren = children.filter((c) => c.layoutMode !== 'NONE')
if (flexChildren.length >= 3) {
const paddings = flexChildren.map((c) => c.paddingTop + c.paddingRight + c.paddingBottom + c.paddingLeft)
const gaps = flexChildren.map((c) => c.itemSpacing)
if (new Set(paddings).size > 2) {
issues.push({
message: `Inconsistent padding across siblings in "${node.name}" (${[...new Set(paddings)].join(', ')})`,
suggestion: 'Use same padding for similar containers'
})
}
const uniqueGaps = new Set(gaps.filter((g) => g > 0))
if (uniqueGaps.size > 2) {
issues.push({
message: `Inconsistent gaps across siblings in "${node.name}" (${[...uniqueGaps].join(', ')})`,
suggestion: 'Use same gap for similar containers'
})
}
}
}
export function detectIssues(node: SceneNode, gridSize: number, graph: SceneGraph): DescribeIssue[] {
const issues: DescribeIssue[] = []
detectStructuralIssues(node, gridSize, graph, issues)
detectVisibilityIssues(node, graph, issues)
detectLayoutIssues(node, graph, issues)
detectRadiusIssues(node, graph, issues)
detectTypographyIssues(node, graph, issues)
detectSpacingIssues(node, graph, gridSize, issues)
return issues
}