From fed6ea57347f3c8eea6d80751e887d28c15b7a56 Mon Sep 17 00:00:00 2001 From: Anton A S Date: Thu, 12 Mar 2026 21:18:14 +0300 Subject: [PATCH] Fix icon SVG primitives, harden describe issues, ban justify=evenly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - iconify: convert , , , , , 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 --- packages/core/src/iconify.ts | 81 +++- packages/core/src/tools/describe-issues.ts | 351 ++++++----------- .../core/src/tools/describe-layout-issues.ts | 363 ++++++++++++++++++ packages/core/src/tools/describe.ts | 60 ++- src/ai/system-prompt.md | 2 +- 5 files changed, 597 insertions(+), 260 deletions(-) create mode 100644 packages/core/src/tools/describe-layout-issues.ts diff --git a/packages/core/src/iconify.ts b/packages/core/src/iconify.ts index a205a1a03..c495d9c2f 100644 --- a/packages/core/src/iconify.ts +++ b/packages/core/src/iconify.ts @@ -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 | 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 = /]*>/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') diff --git a/packages/core/src/tools/describe-issues.ts b/packages/core/src/tools/describe-issues.ts index 05069cf0f..2a2b2c26a 100644 --- a/packages/core/src/tools/describe-issues.ts +++ b/packages/core/src/tools/describe-issues.ts @@ -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' }) } } diff --git a/packages/core/src/tools/describe-layout-issues.ts b/packages/core/src/tools/describe-layout-issues.ts new file mode 100644 index 000000000..f9920756d --- /dev/null +++ b/packages/core/src/tools/describe-layout-issues.ts @@ -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() + 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) +} diff --git a/packages/core/src/tools/describe.ts b/packages/core/src/tools/describe.ts index 9c46ad7fb..575041301 100644 --- a/packages/core/src/tools/describe.ts +++ b/packages/core/src/tools/describe.ts @@ -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 = { + MIN: 'start', CENTER: 'center', MAX: 'end', SPACE_BETWEEN: 'between' +} + +const ITEMS_LABELS: Record = { + 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) { diff --git a/src/ai/system-prompt.md b/src/ai/system-prompt.md index 6884bd5b8..3dcefbffd 100644 --- a/src/ai/system-prompt.md +++ b/src/ai/system-prompt.md @@ -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}.