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:
parent
160d5474c9
commit
fed6ea5734
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
363
packages/core/src/tools/describe-layout-issues.ts
Normal file
363
packages/core/src/tools/describe-layout-issues.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue