Fix icon SVG primitives, harden describe issues, ban justify=evenly

- iconify: convert <circle>, <rect>, <ellipse>, <line>, <polygon>,
  <polyline> to path d-strings. Fixes lucide:search (missing circle),
  lucide:user (missing head), lucide:mail (missing envelope), etc.
- describe: show justify/items/sizing in layout description, detect
  icon role, recurse issues into children at every depth level
- describe-issues: split layout checks into describe-layout-issues.ts
- New checks: fill without flex parent, absolute in flex, nested flex
  without fill, duplicate sibling names, stroke color/weight mismatch,
  circular frame without clip, excessive nesting, same fill as parent,
  image placeholder without content, sibling height inconsistency,
  text wrapping detection, alignment issues (between+HUG, between<2,
  stretch with all fixed, equal children packed without gap)
- system-prompt: remove justify="evenly" — not supported
This commit is contained in:
Anton A S 2026-03-12 21:18:14 +03:00
parent 160d5474c9
commit fed6ea5734
5 changed files with 597 additions and 260 deletions

View file

@ -73,13 +73,88 @@ function attrValue(tag: string, attr: string): string | null {
return m ? m[1] : null
}
function num(tag: string, attr: string, fallback = 0): number {
const v = attrValue(tag, attr)
return v !== null ? parseFloat(v) : fallback
}
function circleToD(tag: string): string | null {
const cx = num(tag, 'cx')
const cy = num(tag, 'cy')
const r = num(tag, 'r')
if (r <= 0) return null
return `M${cx - r},${cy}A${r},${r},0,1,0,${cx + r},${cy}A${r},${r},0,1,0,${cx - r},${cy}Z`
}
function ellipseToD(tag: string): string | null {
const cx = num(tag, 'cx')
const cy = num(tag, 'cy')
const rx = num(tag, 'rx')
const ry = num(tag, 'ry')
if (rx <= 0 || ry <= 0) return null
return `M${cx - rx},${cy}A${rx},${ry},0,1,0,${cx + rx},${cy}A${rx},${ry},0,1,0,${cx - rx},${cy}Z`
}
function rectToD(tag: string): string | null {
const x = num(tag, 'x')
const y = num(tag, 'y')
const w = num(tag, 'width')
const h = num(tag, 'height')
if (w <= 0 || h <= 0) return null
const rx = Math.min(num(tag, 'rx'), w / 2)
const ry = Math.min(num(tag, 'ry', rx), h / 2)
if (rx > 0 || ry > 0) {
const arx = rx > 0 ? rx : ry
const ary = ry > 0 ? ry : rx
return `M${x + arx},${y}H${x + w - arx}A${arx},${ary},0,0,1,${x + w},${y + ary}V${y + h - ary}A${arx},${ary},0,0,1,${x + w - arx},${y + h}H${x + arx}A${arx},${ary},0,0,1,${x},${y + h - ary}V${y + ary}A${arx},${ary},0,0,1,${x + arx},${y}Z`
}
return `M${x},${y}H${x + w}V${y + h}H${x}Z`
}
function lineToD(tag: string): string | null {
const x1 = num(tag, 'x1')
const y1 = num(tag, 'y1')
const x2 = num(tag, 'x2')
const y2 = num(tag, 'y2')
return `M${x1},${y1}L${x2},${y2}`
}
function polyToD(tag: string, close: boolean): string | null {
const points = attrValue(tag, 'points')
if (!points) return null
const nums = points.trim().split(/[\s,]+/).map(Number)
if (nums.length < 4) return null
let d = `M${nums[0]},${nums[1]}`
for (let i = 2; i < nums.length; i += 2) {
d += `L${nums[i]},${nums[i + 1]}`
}
if (close) d += 'Z'
return d
}
const SHAPE_CONVERTERS: Record<string, (tag: string) => string | null> = {
circle: circleToD,
ellipse: ellipseToD,
rect: rectToD,
line: lineToD,
polygon: (tag) => polyToD(tag, true),
polyline: (tag) => polyToD(tag, false)
}
function extractPaths(svgBody: string): PathInfo[] {
const result: PathInfo[] = []
const pathRe = /<path\b[^>]*>/g
const shapeRe = /<(path|circle|ellipse|rect|line|polygon|polyline)\b[^>]*>/g
let match
while ((match = pathRe.exec(svgBody)) !== null) {
while ((match = shapeRe.exec(svgBody)) !== null) {
const tag = match[0]
const d = attrValue(tag, 'd')
const tagName = match[1]
let d: string | null
if (tagName === 'path') {
d = attrValue(tag, 'd')
} else {
d = SHAPE_CONVERTERS[tagName](tag)
}
if (!d) continue
const fillAttr = attrValue(tag, 'fill')

View file

@ -1,5 +1,7 @@
import { colorDistance, colorToHex } from '../color'
import { detectLayoutIssues } from './describe-layout-issues'
import type { Color } from '../types'
import type { SceneGraph, SceneNode } from '../scene-graph'
@ -50,7 +52,7 @@ function checkEmptyIcon(node: SceneNode, graph: SceneGraph, issues: DescribeIssu
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" to the icon or its children'
suggestion: 'Add bg="#hex" or stroke="#hex"'
})
}
}
@ -81,6 +83,94 @@ function detectStructuralIssues(node: SceneNode, gridSize: number, graph: SceneG
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 {
@ -88,16 +178,16 @@ function detectVisibilityIssues(node: SceneNode, graph: SceneGraph, issues: Desc
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)}% opacity`,
suggestion: `Increase to at least ${Math.round(MIN_FILL_OPACITY * 100)}%`
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)}% opacity`,
suggestion: `Increase to at least ${Math.round(MIN_STROKE_OPACITY * 100)}%`
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
@ -109,243 +199,26 @@ function detectVisibilityIssues(node: SceneNode, graph: SceneGraph, issues: Desc
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 between text and background'
suggestion: 'Increase color difference'
})
}
}
const DARK_BG_LUMINANCE = 0.35
function rgbLuminance(c: Color): number {
return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b
}
interface LayoutContext {
node: SceneNode
graph: SceneGraph
isRow: boolean
children: SceneNode[]
issues: DescribeIssue[]
}
function checkDividerOrientation(ctx: LayoutContext): void {
for (const child of ctx.children) {
const isVerticalDivider = child.width <= 2 && child.height > 10 && child.type === 'RECTANGLE'
const isHorizontalDivider = child.height <= 2 && child.width > 10 && child.type === 'RECTANGLE'
if (isVerticalDivider && !ctx.isRow) {
ctx.issues.push({
message: `Vertical divider "${child.name}" (${child.width}×${child.height}) is inside a column layout — should be inside flex="row"`,
suggestion: 'Move the divider inside the row container it separates, or change to a horizontal divider'
})
}
if (isHorizontalDivider && ctx.isRow) {
ctx.issues.push({
message: `Horizontal divider "${child.name}" (${child.width}×${child.height}) is inside a row layout — should be inside flex="col"`,
suggestion: 'Move the divider inside the column container it separates'
})
}
}
}
function checkGrowInHug(ctx: LayoutContext): void {
const { node, isRow, children, issues } = ctx
if (node.primaryAxisSizing !== 'HUG') return
for (const child of children) {
if (child.layoutGrow > 0) {
issues.push({
message: `"${child.name}" has grow=${child.layoutGrow} but parent "${node.name}" is HUG on ${isRow ? 'horizontal' : 'vertical'} axis`,
suggestion: `Set parent to fixed size, or remove grow from "${child.name}"`
})
}
}
}
function checkGrowSizeConflict(ctx: LayoutContext): void {
for (const child of ctx.children) {
if (child.layoutGrow > 0 && child.layoutMode === 'NONE') {
const fixedDim = ctx.isRow ? child.width : child.height
if (fixedDim > 0 && fixedDim !== 100) {
ctx.issues.push({
message: `"${child.name}" has both fixed ${ctx.isRow ? 'width' : 'height'}=${fixedDim} and grow=${child.layoutGrow} — grow overrides the size`,
suggestion: 'Remove the fixed size or remove grow'
})
}
}
}
}
function checkChildOverflow(ctx: LayoutContext): void {
const { node, graph, isRow, children, issues } = ctx
for (const child of children) {
if (child.layoutPositioning === 'ABSOLUTE') continue
for (const grandchildId of child.childIds) {
const gc = graph.getNode(grandchildId)
if (!gc?.visible) continue
const gcDim = isRow ? gc.width : gc.height
const parentDim = isRow ? child.width : child.height
if (gcDim > parentDim + 1 && !child.clipsContent && parentDim > 0) {
issues.push({
message: `"${gc.name}" (${Math.round(gcDim)}px) overflows parent "${child.name}" (${Math.round(parentDim)}px)`,
suggestion: `Reduce size or add overflow="hidden" on "${child.name}"`
})
}
}
}
if (node.primaryAxisSizing === 'FIXED' && !node.clipsContent) {
const pad = isRow
? node.paddingLeft + node.paddingRight
: node.paddingTop + node.paddingBottom
const spacing = children.length > 1 ? (children.length - 1) * node.itemSpacing : 0
const available = (isRow ? node.width : node.height) - pad - spacing
let totalChildren = 0
for (const child of children) {
totalChildren += isRow ? child.width : child.height
}
if (totalChildren > available + 1) {
issues.push({
message: `Children total ${Math.round(totalChildren)}px exceeds available ${Math.round(available)}px on ${isRow ? 'horizontal' : 'vertical'} axis`,
suggestion: 'Use grow/fill for flexible sizing, reduce child sizes, or set overflow="hidden"'
})
}
}
}
function checkHugCollapse(ctx: LayoutContext): void {
const { node, isRow, children, issues } = ctx
if (children.length === 0) return
if (node.primaryAxisSizing === 'HUG') {
const allGrow = children.every((c) => c.layoutGrow > 0)
if (allGrow) {
issues.push({
message: `"${node.name}" is HUG but all children use grow — no child provides a concrete size`,
suggestion: 'Give at least one child a fixed size, or set parent to fixed size'
})
}
}
if (node.counterAxisSizing === 'HUG') {
const allStretch = children.every(
(c) => c.layoutAlignSelf === 'STRETCH' || (node.counterAxisAlign === 'STRETCH' && c.layoutAlignSelf === 'AUTO')
)
const noConcreteChild = children.every((c) => {
const dim = isRow ? c.height : c.width
return dim <= 0
})
if (allStretch && noConcreteChild) {
issues.push({
message: `"${node.name}" is HUG on cross axis but all children stretch — no child provides a concrete size`,
suggestion: 'Give at least one child a fixed cross-axis size, or set parent cross-axis to fixed'
})
}
}
}
function checkTextVisibility(ctx: LayoutContext): void {
const { node, graph, issues } = ctx
for (const childId of node.childIds) {
const child = graph.getNode(childId)
if (!child?.visible || child.type !== 'TEXT') continue
const textFill = child.fills.find((f) => f.visible && f.type === 'SOLID')
if (!textFill) {
issues.push({
message: `"${child.name || child.text.slice(0, 20) || 'Text'}" has no color — invisible`,
suggestion: 'Add color="#hex" to the Text element'
})
continue
}
const textLum = rgbLuminance(textFill.color)
if (textLum > DARK_BG_LUMINANCE) continue
const bg = findAncestorBackground(child, graph)
if (!bg) continue
if (rgbLuminance(bg) < DARK_BG_LUMINANCE) {
issues.push({
message: `"${child.name || child.text.slice(0, 20) || 'Text'}" is dark text (${colorToHex(textFill.color)}) on dark background (${colorToHex(bg)})`,
suggestion: 'Set an explicit light color on the text'
})
}
}
}
function checkTextOverflow(ctx: LayoutContext): void {
const { node, children, issues } = ctx
const parentAvailableW = node.width - node.paddingLeft - node.paddingRight
for (const child of children) {
if (child.type !== 'TEXT' || !child.visible) continue
if (child.textAutoResize === 'WIDTH_AND_HEIGHT' && child.width > parentAvailableW + 1) {
issues.push({
message: `Text "${child.text.slice(0, 25)}..." is ${Math.round(child.width)}px wide but parent "${node.name}" has ${Math.round(parentAvailableW)}px available`,
suggestion: `Add w={${Math.round(parentAvailableW)}} to the Text or use w="fill"`
})
}
}
}
function checkCrossAxisOverflow(ctx: LayoutContext): void {
const { node, isRow, children, issues } = ctx
if (node.clipsContent) return
const crossPad = isRow
? node.paddingTop + node.paddingBottom
: node.paddingLeft + node.paddingRight
const crossAvailable = (isRow ? node.height : node.width) - crossPad
for (const child of children) {
const childCross = isRow ? child.height : child.width
if (childCross > crossAvailable + 1 && child.layoutAlignSelf !== 'STRETCH') {
issues.push({
message: `"${child.name}" is ${Math.round(childCross)}px on cross axis but parent has ${Math.round(crossAvailable)}px available`,
suggestion: 'Reduce child size, use fill sizing, or set overflow="hidden" on parent'
})
}
}
}
function detectLayoutIssues(node: SceneNode, graph: SceneGraph, issues: DescribeIssue[]): void {
if (!CONTAINER_TYPES.has(node.type)) return
const isAutoLayout = node.layoutMode !== 'NONE'
const isRow = node.layoutMode === 'HORIZONTAL'
const children = node.childIds
.map((id) => graph.getNode(id))
.filter((c): c is SceneNode => c?.visible === true && c.layoutPositioning !== 'ABSOLUTE')
const ctx: LayoutContext = { node, graph, isRow, children, issues }
checkTextVisibility(ctx)
if (!isAutoLayout) return
if (node.layoutWrap === 'WRAP' && node.counterAxisSpacing <= 0 && children.length > 1) {
issues.push({
message: `"${node.name}" uses wrap but has no rowGap — wrapped rows will stick together`,
suggestion: 'Add rowGap={8} or similar spacing'
})
}
checkDividerOrientation(ctx)
checkGrowInHug(ctx)
checkGrowSizeConflict(ctx)
checkChildOverflow(ctx)
checkHugCollapse(ctx)
checkTextOverflow(ctx)
checkCrossAxisOverflow(ctx)
}
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) continue
if (child.layoutPositioning === 'ABSOLUTE') continue
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} exceeds parent "${node.name}" radius ${node.cornerRadius}`,
suggestion: `Use rounded={${expectedInner}} (parent ${node.cornerRadius} − padding ${minPad})`
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({
@ -363,11 +236,10 @@ function detectTypographyIssues(node: SceneNode, graph: SceneGraph, issues: Desc
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 — uppercase is for small labels (≤${UPPERCASE_MAX_SIZE}px)`,
suggestion: `Reduce size to ≤${UPPERCASE_MAX_SIZE}px or use mixed case`
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`
})
}
}
@ -377,7 +249,6 @@ 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')
@ -385,7 +256,7 @@ function detectSpacingIssues(node: SceneNode, graph: SceneGraph, gridSize: numbe
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} is much larger than padding ${minPad} in "${node.name}"`,
message: `Gap ${node.itemSpacing} >> padding ${minPad} in "${node.name}"`,
suggestion: 'Gap should usually be ≤ padding'
})
}
@ -394,10 +265,9 @@ function detectSpacingIssues(node: SceneNode, graph: SceneGraph, gridSize: numbe
.filter((v) => v > 0)
for (const v of spacingValues) {
if (v % SPACING_GRID !== 0) {
const nearest = Math.round(v / SPACING_GRID) * SPACING_GRID
issues.push({
message: `Spacing value ${v} in "${node.name}" is not on ${SPACING_GRID}px grid`,
suggestion: `Use ${nearest}`
message: `Spacing ${v} in "${node.name}" off ${SPACING_GRID}px grid`,
suggestion: `Use ${Math.round(v / SPACING_GRID) * SPACING_GRID}`
})
break
}
@ -407,18 +277,17 @@ function detectSpacingIssues(node: SceneNode, graph: SceneGraph, gridSize: numbe
if (flexChildren.length >= 2) {
const paddings = flexChildren.map((c) => c.paddingTop + c.paddingRight + c.paddingBottom + c.paddingLeft)
const gaps = flexChildren.map((c) => c.itemSpacing)
const uniquePads = new Set(paddings)
const uniqueGaps = new Set(gaps.filter((g) => g > 0))
if (uniquePads.size > 2) {
if (new Set(paddings).size > 2) {
issues.push({
message: `Inconsistent padding across sibling containers in "${node.name}" (${[...uniquePads].join(', ')})`,
suggestion: 'Use the same padding for similar containers'
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 sibling containers in "${node.name}" (${[...uniqueGaps].join(', ')})`,
suggestion: 'Use the same gap for similar containers'
message: `Inconsistent gaps across siblings in "${node.name}" (${[...uniqueGaps].join(', ')})`,
suggestion: 'Use same gap for similar containers'
})
}
}

View file

@ -0,0 +1,363 @@
import { colorToHex } from '../color'
import type { Color } from '../types'
import type { SceneGraph, SceneNode } from '../scene-graph'
import type { DescribeIssue } from './describe-issues'
const CONTAINER_TYPES = new Set(['FRAME', 'COMPONENT', 'INSTANCE'])
const DARK_BG_LUMINANCE = 0.35
function rgbLuminance(c: Color): number {
return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b
}
function findAncestorBackground(node: SceneNode, graph: SceneGraph): Color | null {
let current = node.parentId ? graph.getNode(node.parentId) : null
while (current) {
const solidFill = current.fills.find((f) => f.visible && f.type === 'SOLID' && f.opacity > 0.5)
if (solidFill) return solidFill.color
current = current.parentId ? graph.getNode(current.parentId) : null
}
return null
}
interface LayoutContext {
node: SceneNode
graph: SceneGraph
isRow: boolean
children: SceneNode[]
issues: DescribeIssue[]
}
function checkAlignmentIssues(ctx: LayoutContext): void {
const { node, isRow, children, issues } = ctx
if (node.primaryAxisAlign === 'SPACE_BETWEEN' && children.length < 2) {
issues.push({
message: `justify="between" on "${node.name}" but only ${children.length} child — needs ≥2`,
suggestion: 'Use justify="center" or "start"'
})
}
if (node.primaryAxisAlign === 'SPACE_BETWEEN' && node.primaryAxisSizing === 'HUG') {
issues.push({
message: `justify="between" on "${node.name}" with HUG sizing — no effect when parent shrinks to fit`,
suggestion: 'Set a fixed size or use w="fill"'
})
}
if (node.counterAxisAlign === 'STRETCH') {
const allFixed = children.length > 0 && children.every((c) =>
c.layoutAlignSelf === 'AUTO' && (isRow ? c.height > 0 : c.width > 0)
)
if (allFixed) {
issues.push({
message: `items="stretch" on "${node.name}" but all children have fixed ${isRow ? 'height' : 'width'} — stretch ignored`,
suggestion: 'Remove fixed sizes or change items to "center"/"start"'
})
}
}
const allSameSize = children.length >= 3 && children.every((c) => {
const dim = isRow ? c.width : c.height
return Math.abs(dim - (isRow ? children[0].width : children[0].height)) < 2
})
if (allSameSize && node.primaryAxisAlign === 'MIN' && node.itemSpacing === 0) {
const total = children.reduce((s, c) => s + (isRow ? c.width : c.height), 0)
const pad = isRow ? node.paddingLeft + node.paddingRight : node.paddingTop + node.paddingBottom
if (total < ((isRow ? node.width : node.height) - pad) * 0.7) {
issues.push({
message: `${children.length} equal children packed at start with no gap in "${node.name}"`,
suggestion: 'Add justify="between" or gap={N}'
})
}
}
}
function checkDividerOrientation(ctx: LayoutContext): void {
for (const child of ctx.children) {
if (child.type !== 'RECTANGLE') continue
if (child.width <= 2 && child.height > 10 && !ctx.isRow) {
ctx.issues.push({
message: `Vertical divider "${child.name}" inside column layout`,
suggestion: 'Move to a flex="row" container or change to horizontal divider'
})
}
if (child.height <= 2 && child.width > 10 && ctx.isRow) {
ctx.issues.push({
message: `Horizontal divider "${child.name}" inside row layout`,
suggestion: 'Move to a flex="col" container'
})
}
}
}
function checkGrowInHug(ctx: LayoutContext): void {
const { node, isRow, children, issues } = ctx
if (node.primaryAxisSizing !== 'HUG') return
for (const child of children) {
if (child.layoutGrow > 0) {
issues.push({
message: `"${child.name}" grow=${child.layoutGrow} inside HUG parent "${node.name}"`,
suggestion: 'Set parent to fixed size, or remove grow'
})
}
}
}
function checkGrowSizeConflict(ctx: LayoutContext): void {
for (const child of ctx.children) {
if (child.layoutGrow > 0 && child.layoutMode === 'NONE') {
const fixedDim = ctx.isRow ? child.width : child.height
if (fixedDim > 0 && fixedDim !== 100) {
ctx.issues.push({
message: `"${child.name}" has fixed ${ctx.isRow ? 'width' : 'height'}=${fixedDim} and grow=${child.layoutGrow} — grow overrides`,
suggestion: 'Remove the fixed size or remove grow'
})
}
}
}
}
function checkChildOverflow(ctx: LayoutContext): void {
const { node, graph, isRow, children, issues } = ctx
for (const child of children) {
if (child.layoutPositioning === 'ABSOLUTE') continue
for (const grandchildId of child.childIds) {
const gc = graph.getNode(grandchildId)
if (!gc?.visible) continue
const gcDim = isRow ? gc.width : gc.height
const parentDim = isRow ? child.width : child.height
if (gcDim > parentDim + 1 && !child.clipsContent && parentDim > 0) {
issues.push({
message: `"${gc.name}" (${Math.round(gcDim)}px) overflows "${child.name}" (${Math.round(parentDim)}px)`,
suggestion: `Reduce size or add overflow="hidden" on "${child.name}"`
})
}
}
}
if (node.primaryAxisSizing === 'FIXED' && !node.clipsContent) {
const pad = isRow ? node.paddingLeft + node.paddingRight : node.paddingTop + node.paddingBottom
const spacing = children.length > 1 ? (children.length - 1) * node.itemSpacing : 0
const available = (isRow ? node.width : node.height) - pad - spacing
let totalChildren = 0
for (const child of children) totalChildren += isRow ? child.width : child.height
if (totalChildren > available + 1) {
issues.push({
message: `Children total ${Math.round(totalChildren)}px > available ${Math.round(available)}px on ${isRow ? 'horizontal' : 'vertical'} axis`,
suggestion: 'Use grow/fill, reduce sizes, or set overflow="hidden"'
})
}
}
}
function checkHugCollapse(ctx: LayoutContext): void {
const { node, isRow, children, issues } = ctx
if (children.length === 0) return
if (node.primaryAxisSizing === 'HUG' && children.every((c) => c.layoutGrow > 0)) {
issues.push({
message: `"${node.name}" is HUG but all children use grow — collapses to zero`,
suggestion: 'Give at least one child a fixed size, or set parent to fixed'
})
}
if (node.counterAxisSizing === 'HUG') {
const allStretch = children.every(
(c) => c.layoutAlignSelf === 'STRETCH' || (node.counterAxisAlign === 'STRETCH' && c.layoutAlignSelf === 'AUTO')
)
const noConcreteChild = children.every((c) => (isRow ? c.height : c.width) <= 0)
if (allStretch && noConcreteChild) {
issues.push({
message: `"${node.name}" is HUG on cross axis but all children stretch — collapses`,
suggestion: 'Give at least one child a fixed cross-axis size'
})
}
}
}
function checkTextVisibility(ctx: LayoutContext): void {
const { node, graph, issues } = ctx
for (const childId of node.childIds) {
const child = graph.getNode(childId)
if (!child?.visible || child.type !== 'TEXT') continue
const textFill = child.fills.find((f) => f.visible && f.type === 'SOLID')
if (!textFill) {
issues.push({
message: `"${child.name || child.text.slice(0, 20) || 'Text'}" has no color — invisible`,
suggestion: 'Add color="#hex"'
})
continue
}
const textLum = rgbLuminance(textFill.color)
if (textLum > DARK_BG_LUMINANCE) continue
const bg = findAncestorBackground(child, graph)
if (!bg) continue
if (rgbLuminance(bg) < DARK_BG_LUMINANCE) {
issues.push({
message: `"${child.name || child.text.slice(0, 20) || 'Text'}" dark on dark (${colorToHex(textFill.color)} on ${colorToHex(bg)})`,
suggestion: 'Use a light color'
})
}
}
}
function checkTextOverflow(ctx: LayoutContext): void {
const { node, children, issues } = ctx
const parentAvailableW = node.width - node.paddingLeft - node.paddingRight
for (const child of children) {
if (child.type !== 'TEXT' || !child.visible) continue
if (child.textAutoResize === 'WIDTH_AND_HEIGHT' && child.width > parentAvailableW + 1) {
issues.push({
message: `Text "${child.text.slice(0, 25)}…" is ${Math.round(child.width)}px wide, parent has ${Math.round(parentAvailableW)}px`,
suggestion: 'Use w="fill" or constrain width'
})
}
if (child.textAutoResize === 'HEIGHT' && child.height > child.fontSize * 1.8 && child.maxLines === 0) {
const approxLines = Math.round(child.height / (child.fontSize * 1.3))
issues.push({
message: `Text "${child.text.slice(0, 25)}" wraps to ~${approxLines} lines in ${Math.round(child.width)}px`,
suggestion: 'Widen container, use maxLines={1}, or shorten text'
})
}
}
}
function checkSiblingHeightConsistency(ctx: LayoutContext): void {
const { isRow, children, issues } = ctx
const containers = children.filter((c) => CONTAINER_TYPES.has(c.type))
if (containers.length < 2) return
const dim = isRow ? 'height' : 'width'
const sizes = containers.map((c) => c[dim]).sort((a, b) => a - b)
const majority = sizes[Math.floor(sizes.length / 2)]
for (const c of containers) {
if (Math.abs(c[dim] - majority) > 2) {
issues.push({
message: `"${c.name}" is ${Math.round(c[dim])}px ${dim} while siblings are ~${Math.round(majority)}px`,
suggestion: `Check text overflow inside "${c.name}"`
})
}
}
}
function checkCrossAxisOverflow(ctx: LayoutContext): void {
const { node, isRow, children, issues } = ctx
if (node.clipsContent) return
const crossPad = isRow ? node.paddingTop + node.paddingBottom : node.paddingLeft + node.paddingRight
const crossAvailable = (isRow ? node.height : node.width) - crossPad
for (const child of children) {
const childCross = isRow ? child.height : child.width
if (childCross > crossAvailable + 1 && child.layoutAlignSelf !== 'STRETCH') {
issues.push({
message: `"${child.name}" ${Math.round(childCross)}px on cross axis, parent has ${Math.round(crossAvailable)}px`,
suggestion: 'Reduce size, use fill, or set overflow="hidden"'
})
}
}
}
function checkFillWithoutFlex(ctx: LayoutContext): void {
const { node, graph, issues } = ctx
if (node.layoutMode !== 'NONE') return
for (const childId of node.childIds) {
const child = graph.getNode(childId)
if (!child?.visible) continue
if (!CONTAINER_TYPES.has(child.type)) continue
if (child.primaryAxisSizing === 'FILL' || child.counterAxisSizing === 'FILL') {
issues.push({
message: `"${child.name}" uses fill sizing but parent "${node.name}" has no auto-layout`,
suggestion: 'Add flex="col" or flex="row" to the parent'
})
}
}
}
function checkAbsoluteInFlex(ctx: LayoutContext): void {
const { node, graph, issues } = ctx
if (node.layoutMode === 'NONE') return
for (const childId of node.childIds) {
const child = graph.getNode(childId)
if (!child?.visible || child.layoutPositioning !== 'ABSOLUTE') continue
if (child.type === 'TEXT' || CONTAINER_TYPES.has(child.type)) {
issues.push({
message: `"${child.name}" is absolutely positioned inside flex "${node.name}" — excluded from layout flow`,
suggestion: 'Remove x/y to return to flex flow, or wrap in a separate absolute container'
})
}
}
}
function checkNestedFlexWithoutFill(ctx: LayoutContext): void {
const { node, isRow, graph, children, issues } = ctx
if (node.layoutMode === 'NONE') return
for (const child of children) {
if (child.layoutMode === 'NONE') continue
const crossDim = isRow ? child.width : child.height
const crossSizing = isRow ? child.counterAxisSizing : child.primaryAxisSizing
if (crossDim <= 0 && crossSizing !== 'FILL') continue
const mainSizing = isRow ? child.primaryAxisSizing : child.counterAxisSizing
if (mainSizing === 'FIXED') continue
const needsFill = isRow
? child.width < node.width * 0.3 && child.counterAxisSizing !== 'FILL' && child.layoutGrow <= 0
: child.height < node.height * 0.3 && child.primaryAxisSizing !== 'FILL' && child.layoutGrow <= 0
if (needsFill && child.childIds.length > 0) {
issues.push({
message: `Nested flex "${child.name}" may collapse — no fill or grow in "${node.name}"`,
suggestion: 'Add w="fill" or grow={1}'
})
}
}
}
function checkDuplicateNames(ctx: LayoutContext): void {
const { node, graph, issues } = ctx
const nameCounts = new Map<string, number>()
for (const childId of node.childIds) {
const child = graph.getNode(childId)
if (!child?.visible) continue
nameCounts.set(child.name, (nameCounts.get(child.name) ?? 0) + 1)
}
for (const [name, count] of nameCounts) {
if (count > 1 && name !== 'path') {
issues.push({
message: `${count} children named "${name}" in "${node.name}" — ambiguous for node operations`,
suggestion: 'Give unique names to distinguish siblings'
})
}
}
}
export function detectLayoutIssues(node: SceneNode, graph: SceneGraph, issues: DescribeIssue[]): void {
if (!CONTAINER_TYPES.has(node.type)) return
const isRow = node.layoutMode === 'HORIZONTAL'
const children = node.childIds
.map((id) => graph.getNode(id))
.filter((c): c is SceneNode => c?.visible === true && c.layoutPositioning !== 'ABSOLUTE')
const ctx: LayoutContext = { node, graph, isRow, children, issues }
checkTextVisibility(ctx)
checkDuplicateNames(ctx)
checkFillWithoutFlex(ctx)
checkAbsoluteInFlex(ctx)
if (node.layoutMode === 'NONE') return
if (node.layoutWrap === 'WRAP' && node.counterAxisSpacing <= 0 && children.length > 1) {
issues.push({
message: `"${node.name}" uses wrap but no rowGap — rows stick together`,
suggestion: 'Add rowGap={8}'
})
}
checkAlignmentIssues(ctx)
checkDividerOrientation(ctx)
checkGrowInHug(ctx)
checkGrowSizeConflict(ctx)
checkChildOverflow(ctx)
checkHugCollapse(ctx)
checkTextOverflow(ctx)
checkCrossAxisOverflow(ctx)
checkSiblingHeightConsistency(ctx)
checkNestedFlexWithoutFill(ctx)
}

View file

@ -1,11 +1,15 @@
import { colorToHex } from '../color'
import { detectIssues } from './describe-issues'
import type { DescribeIssue } from './describe-issues'
import { defineTool } from './schema'
import type { SceneGraph, SceneNode } from '../scene-graph'
const NAME_ROLE_PATTERNS: { pattern: RegExp; role: string }[] = [
{ pattern: /^icon$/i, role: 'icon' },
{ pattern: /^icon[-_]/i, role: 'icon' },
{ pattern: /^button$/i, role: 'button' },
{ pattern: /^btn[-_\s]/i, role: 'button' },
{ pattern: /[-_\s]btn$/i, role: 'button' },
@ -49,7 +53,7 @@ const NAME_ROLE_PATTERNS: { pattern: RegExp; role: string }[] = [
function detectRoleFromName(name: string): string | null {
const base = (name.split(/[/,=]/)[0] ?? name).trim()
for (const { pattern, role } of NAME_ROLE_PATTERNS) {
if (pattern.test(base)) return role
if (pattern.test(base) || pattern.test(name)) return role
}
return null
}
@ -99,16 +103,28 @@ function describeVisual(node: SceneNode): string {
return parts.join(', ') || 'no visual styles'
}
const JUSTIFY_LABELS: Record<string, string> = {
MIN: 'start', CENTER: 'center', MAX: 'end', SPACE_BETWEEN: 'between'
}
const ITEMS_LABELS: Record<string, string> = {
MIN: 'start', CENTER: 'center', MAX: 'end', STRETCH: 'stretch', BASELINE: 'baseline'
}
function describeLayout(node: SceneNode): string | null {
if (node.layoutMode === 'NONE') return null
const dir = node.layoutMode === 'HORIZONTAL' ? 'horizontal' : 'vertical'
const parts = [dir]
if (node.primaryAxisAlign !== 'MIN') parts.push(`justify=${JUSTIFY_LABELS[node.primaryAxisAlign] ?? node.primaryAxisAlign}`)
if (node.counterAxisAlign !== 'MIN') parts.push(`items=${ITEMS_LABELS[node.counterAxisAlign] ?? node.counterAxisAlign}`)
if (node.itemSpacing > 0) parts.push(`${node.itemSpacing}px gap`)
const pad = [node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft]
const allSame = pad.every((p) => p === pad[0])
const first = pad[0]
if (allSame && first > 0) parts.push(`${first}px padding`)
else if (pad.some((p) => p > 0)) parts.push(`padding ${pad.join('/')}`)
if (node.primaryAxisSizing !== 'FIXED') parts.push(`${node.primaryAxisSizing.toLowerCase()} main`)
if (node.counterAxisSizing !== 'FIXED') parts.push(`${node.counterAxisSizing.toLowerCase()} cross`)
if (node.layoutWrap === 'WRAP') parts.push('wrap')
return parts.join(', ')
}
@ -130,27 +146,41 @@ interface ChildDescription {
name: string
summary: string
id: string
issues?: DescribeIssue[]
children?: ChildDescription[]
}
function summarizeContainer(node: SceneNode): string {
const parts = [`${node.width}×${node.height}`]
const fill = node.fills.find((f) => f.type === 'SOLID' && f.visible)
if (fill) parts.push(colorToHex(fill.color))
if (node.cornerRadius > 0) parts.push('rounded')
const layout = describeLayout(node)
if (layout) parts.push(layout)
return parts.join(', ')
}
function summarizeText(node: SceneNode): string {
const text = node.text.slice(0, 60)
let summary = `"${text}" ${node.fontSize}px ${node.fontFamily}`
if (node.fontWeight >= 700) summary += ' bold'
else if (node.fontWeight >= 500) summary += ' medium'
const textColor = node.fills.find((f) => f.type === 'SOLID' && f.visible)
if (textColor) summary += `, ${colorToHex(textColor.color)}`
if (node.textAutoResize === 'HEIGHT') summary += ', wraps'
else if (node.textAutoResize === 'NONE') summary += ', fixed-size'
if (node.maxLines !== null && node.maxLines > 0) summary += `, max ${node.maxLines} lines`
return summary
}
function describeChild(node: SceneNode, graph: SceneGraph, depth: number, gridSize: number): ChildDescription {
const role = detectRole(node)
let summary = ''
if (node.type === 'TEXT') {
const text = node.text.slice(0, 60)
summary = `"${text}" ${node.fontSize}px ${node.fontFamily}`
if (node.fontWeight >= 700) summary += ' bold'
else if (node.fontWeight >= 500) summary += ' medium'
const textColor = node.fills.find((f) => f.type === 'SOLID' && f.visible)
if (textColor) summary += `, ${colorToHex(textColor.color)}`
} else {
summary = `${node.width}×${node.height}`
const fill = node.fills.find((f) => f.type === 'SOLID' && f.visible)
if (fill) summary += `, ${colorToHex(fill.color)}`
if (node.cornerRadius > 0) summary += ', rounded'
}
const summary = node.type === 'TEXT' ? summarizeText(node) : summarizeContainer(node)
const result: ChildDescription = { role, name: node.name, summary, id: node.id }
const issues = detectIssues(node, gridSize, graph)
if (issues.length > 0) result.issues = issues
if (depth > 0 && node.childIds.length > 0) {
const kids: ChildDescription[] = []
for (const childId of node.childIds) {

View file

@ -18,7 +18,7 @@ These are ALL available props. Nothing else exists — no lineHeight, no letterS
**Sizing:** w={N}, h={N} (fixed px), w="hug"/h="hug" (shrink-to-fit, default), w="fill"/h="fill" (stretch, requires flex parent), grow={N} (flex-grow, requires flex parent with fixed size on that axis), minW={N}, maxW={N}.
**Layout:** flex="row"|"col" enables auto-layout. gap={N}, wrap, rowGap={N}. justify="start"|"end"|"center"|"between"|"evenly". items="start"|"end"|"center"|"stretch". Padding: p={N}, px={N}, py={N}, pt/pr/pb/pl={N}. Grid: grid, columns="1fr 1fr", rows="1fr", columnGap={N}, rowGap={N}, colStart={N}, rowStart={N}, colSpan={N}, rowSpan={N}. ⚠ When using `wrap`, always set `rowGap={N}` — without it, wrapped rows have zero spacing and stick together.
**Layout:** flex="row"|"col" enables auto-layout. gap={N}, wrap, rowGap={N}. justify="start"|"end"|"center"|"between" ⚠ NO "evenly" — not supported. Use "between" with equal-width children, or equal padding. items="start"|"end"|"center"|"stretch". Padding: p={N}, px={N}, py={N}, pt/pr/pb/pl={N}. Grid: grid, columns="1fr 1fr", rows="1fr", columnGap={N}, rowGap={N}, colStart={N}, rowStart={N}, colSpan={N}, rowSpan={N}. ⚠ When using `wrap`, always set `rowGap={N}` — without it, wrapped rows have zero spacing and stick together.
**Appearance:** bg="#hex", stroke="#hex", strokeWidth={N}, rounded={N}, roundedTL/TR/BL/BR={N}, cornerSmoothing={0-1}, opacity={0-1}, rotate={deg}, blendMode="multiply"|"screen"|etc, overflow="hidden", shadow="offX offY blur #color", blur={N}.