* fix(ai): add icon name aliases and fix multi-path SVG concatenation

Add 55+ common icon name aliases (burger→hamburger, sushi→fish, etc.)
to both client icon-resolver and server icon API for robust AI-generated
icon resolution. Register Lucide's own aliases for broader coverage.

Fix SVG path concatenation bug where joining multiple <path> d-values
caused incorrect rendering — a standalone <path> treats initial lowercase
"m" as absolute, but after concatenation it becomes relative to the
previous sub-path endpoint. Now ensures each sub-path starts with
absolute "M".

Add tryAsyncIconFontResolution for icon_font nodes that miss local
lookup — fetches from server API, caches result, and triggers canvas
re-render.

* fix(canvas): preserve badge/overlay absolute positioning in auto-layout

Add isBadgeOverlayNode() detector for badge, indicator, notification-dot,
and overlay nodes. These nodes now retain their x/y coordinates instead
of being stripped by layout sanitization.

Update computeLayoutPositions to exclude badge nodes from the layout flow
— they keep absolute positioning and render on top (prepended for correct
z-order in reverse iteration).

* fix(ai): prevent duplicate canvas objects and fix emoji-to-icon pipeline

Streaming path: add ensureUniqueNodeIds before inserting nodes to prevent
ID collisions across multiple AI generations. Track newly inserted IDs
so subsequent streaming nodes don't collide either.

Canvas sync: deduplicate Fabric objects sharing the same penNodeId —
keep only the one tracked in objMap, remove stale duplicates.

Badge nodes: use shared isBadgeOverlayNode() for z-order insertion
and skip x/y stripping in layout parents.

Fix emoji-to-icon pipeline: re-run applyIconPathResolution after
applyNoEmojiIconHeuristic converts emoji text nodes to path nodes,
so the icon resolver can match by name (e.g. "Pizza Emoji Path" → pizza).

* fix(canvas): add async icon resolution fallback for icon_font nodes

When lookupIconByName fails locally, queue tryAsyncIconFontResolution
to fetch from server API. Cache result in ICON_PATH_MAP and trigger
canvas re-render via store update. Store iconFontName and iconStyle
on Fabric object for sync tracking.

* fix(ai): strengthen emoji ban in prompts and improve orchestrator defaults

Update all AI prompts to explicitly ban emoji characters with concrete
examples and redirect to icon_font nodes instead of the previously
incorrect "path nodes" guidance.

Add z-order rule to orchestrator prompt: overlay elements must come
before content they overlap.

Add padding support to OrchestratorPlan rootFrame type. Default mobile
root frame gap to 16 for consistent spacing.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
This commit is contained in:
Kayshen Xu 2026-03-11 21:10:07 +08:00 committed by GitHub
parent 2b35c86618
commit 669c0dd38d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 314 additions and 23 deletions

View file

@ -51,9 +51,79 @@ export default defineEventHandler(async (event) => {
return { icon: result }
})
// Common name aliases for icons AI models frequently request.
// Keep in sync with commonAliases in src/services/ai/icon-resolver.ts
const NAME_ALIASES: Record<string, string> = {
burger: 'hamburger',
sushi: 'fish',
ramen: 'soup',
noodle: 'soup',
noodles: 'soup',
steak: 'beef',
meat: 'beef',
icecream: 'ice-cream-cone',
donut: 'donut',
bread: 'croissant',
fruit: 'apple',
food: 'utensils',
drink: 'cup-soda',
coffee: 'coffee',
tea: 'cup-soda',
restaurant: 'utensils-crossed',
delivery: 'truck',
order: 'clipboard-list',
recipe: 'book-open',
grocery: 'shopping-basket',
cart: 'shopping-cart',
bag: 'shopping-bag',
pay: 'credit-card',
payment: 'credit-card',
wallet: 'wallet',
money: 'banknote',
coupon: 'ticket',
discount: 'percent',
rating: 'star',
review: 'message-square',
favorite: 'heart',
favourites: 'heart',
favorites: 'heart',
notification: 'bell',
address: 'map-pin',
navigate: 'navigation',
directions: 'map',
logout: 'log-out',
login: 'log-in',
signup: 'user-plus',
account: 'user',
password: 'key',
security: 'shield',
privacy: 'eye-off',
about: 'info',
faq: 'help-circle',
support: 'headphones',
contact: 'phone',
feedback: 'message-circle',
language: 'globe',
theme: 'palette',
darkmode: 'moon',
lightmode: 'sun',
sound: 'volume-2',
mute: 'volume-x',
wifi: 'wifi',
bluetooth: 'bluetooth',
battery: 'battery',
location: 'map-pin',
gps: 'locate',
scan: 'scan',
qrcode: 'qr-code',
barcode: 'barcode',
}
function resolveIcon(name: string): IconResult | null {
const kebab = toKebabCase(name)
const candidates = kebab !== name ? [name, kebab] : [name]
const aliased = NAME_ALIASES[name] ?? NAME_ALIASES[kebab]
const candidates = new Set([name, kebab])
if (aliased) candidates.add(aliased)
// 1. Try simple-icons first (brand/product icons).
// simple-icons only contains brand logos, so a hit here is unambiguously
@ -124,6 +194,14 @@ function parseIconBody(
hasFill = true
}
// When joining multiple <path> d-values, ensure each sub-path starts with
// absolute M. A standalone <path> treats initial lowercase "m" as absolute,
// but after concatenation it becomes relative to the previous endpoint.
for (let i = 1; i < paths.length; i++) {
if (paths[i].startsWith('m')) {
paths[i] = 'M' + paths[i].slice(1)
}
}
const d = paths.join(' ')
const style: 'stroke' | 'fill' = hasStroke && !hasFill ? 'stroke' : 'fill'

View file

@ -1,4 +1,5 @@
import type { PenNode, ContainerProps } from '@/types/pen'
import { isBadgeOverlayNode } from '@/services/ai/design-node-sanitization'
import { useDocumentStore, DEFAULT_FRAME_ID } from '@/stores/document-store'
import {
parseSizing,
@ -263,6 +264,12 @@ export function computeLayoutPositions(
const layout = c.layout || inferLayout(parent)
if (!layout || layout === 'none') return visibleChildren
// Separate badge/overlay nodes from layout children — badges use absolute
// positioning and should not participate in the layout flow.
const badgeNodes = visibleChildren.filter(isBadgeOverlayNode)
const layoutChildren = visibleChildren.filter((ch) => !isBadgeOverlayNode(ch))
if (layoutChildren.length === 0) return visibleChildren
const pW = parseSizing(c.width)
const pH = parseSizing(c.height)
// When parent has no explicit dimensions (fit_content), resolve actual size
@ -279,10 +286,10 @@ export function computeLayoutPositions(
const availW = parentW - pad.left - pad.right
const availH = parentH - pad.top - pad.bottom
const availMain = isVertical ? availH : availW
const totalGapSpace = gap * Math.max(0, visibleChildren.length - 1)
const totalGapSpace = gap * Math.max(0, layoutChildren.length - 1)
// Two-pass sizing: first compute fixed sizes, then allocate remaining space for fill children
const mainSizing = visibleChildren.map((ch) => {
const mainSizing = layoutChildren.map((ch) => {
const prop = isVertical ? 'height' : 'width'
if (prop in ch) {
const s = parseSizing((ch as any)[prop])
@ -298,7 +305,7 @@ export function computeLayoutPositions(
const remainingMain = Math.max(0, availMain - fixedTotal - totalGapSpace)
const fillSize = fillCount > 0 ? remainingMain / fillCount : 0
const sizes = visibleChildren.map((ch, i) => {
const sizes = layoutChildren.map((ch, i) => {
let mainSize = mainSizing[i] === 'fill' ? fillSize : (mainSizing[i] as number)
// For single-line text in vertical layouts, use Fabric's actual rendered
// height (fontSize * 1.13) instead of fontSize * lineHeight. This ensures
@ -334,14 +341,14 @@ export function computeLayoutPositions(
break
case 'space_between':
effectiveGap =
visibleChildren.length > 1
? (availMain - totalMain) / (visibleChildren.length - 1)
layoutChildren.length > 1
? (availMain - totalMain) / (layoutChildren.length - 1)
: 0
break
case 'space_around': {
const spacing =
visibleChildren.length > 0
? (availMain - totalMain) / visibleChildren.length
layoutChildren.length > 0
? (availMain - totalMain) / layoutChildren.length
: 0
mainPos = spacing / 2
effectiveGap = spacing
@ -352,7 +359,7 @@ export function computeLayoutPositions(
break
}
return visibleChildren.map((child, i) => {
const positioned = layoutChildren.map((child, i) => {
const size = sizes[i]
const crossAvail = isVertical ? availW : availH
const childCross = isVertical ? size.w : size.h
@ -430,6 +437,14 @@ export function computeLayoutPositions(
return out as unknown as PenNode
})
// Prepend badge/overlay nodes (they keep original x/y for absolute positioning).
// flattenNodes iterates in REVERSE, so index 0 = frontmost z-order.
// Badges at the beginning render on top of layout children.
if (badgeNodes.length > 0) {
return [...badgeNodes, ...positioned]
}
return positioned
}
function normalizeJustifyContent(

View file

@ -16,7 +16,7 @@ import {
} from './canvas-constants'
import { defaultLineHeight } from './canvas-text-measure'
import { applyRotationControls } from './canvas-controls'
import { lookupIconByName } from '@/services/ai/icon-resolver'
import { lookupIconByName, tryAsyncIconFontResolution } from '@/services/ai/icon-resolver'
function angleToCoords(
angleDeg: number,
@ -468,6 +468,10 @@ export function createFabricObject(
const iconMatch = lookupIconByName(iconName)
const iconD = iconMatch?.d ?? 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0'
const iconStyle = iconMatch?.style ?? 'stroke'
// Queue async resolution when local lookup fails — result cached for future lookups
if (!iconMatch && iconName) {
tryAsyncIconFontResolution(node.id, iconName)
}
const pw = sizeToNumber(node.width, 20)
const ph = sizeToNumber(node.height, 20)
@ -495,6 +499,8 @@ export function createFabricObject(
}) as FabricObjectWithPenId
;(obj as any).__nativeWidth = obj.width
;(obj as any).__nativeHeight = obj.height
;(obj as any).__iconFontName = iconName
;(obj as any).__iconStyle = iconStyle
if (pw > 0 && ph > 0 && obj.width && obj.height) {
const uniformScale = Math.min(pw / obj.width, ph / obj.height)
obj.set({ scaleX: uniformScale, scaleY: uniformScale })

View file

@ -547,9 +547,17 @@ export function useCanvasSync() {
}
})(pageChildren)
// Remove objects that no longer exist in the document
// Remove objects that no longer exist in the document, and
// deduplicate: when multiple Fabric objects share the same penNodeId
// (e.g. from ID collisions across separate AI generations), keep only
// the one tracked in objMap and remove the rest.
for (const obj of objects) {
if (obj.penNodeId && !nodeMap.has(obj.penNodeId)) {
if (!obj.penNodeId) continue
if (!nodeMap.has(obj.penNodeId)) {
canvas.remove(obj)
} else if (objMap.get(obj.penNodeId) !== obj) {
// Duplicate — this object has the same penNodeId but isn't the one
// tracked in objMap (Map keeps the last occurrence). Remove it.
canvas.remove(obj)
}
}

View file

@ -84,7 +84,7 @@ ICONS & IMAGES:
- Icons: Use "path" nodes. Size 16-24px. CRITICAL: ONLY use names from the Feather icon library below these are bundled locally and render instantly. Convert the icon name to PascalCase + "Icon" suffix (e.g. "search" "SearchIcon", "arrow-right" "ArrowRightIcon"). Do NOT invent names outside this list.
The system auto-resolves icon names to verified SVG paths the "name" field is what matters; "d" is replaced automatically.
Available Feather icons: ${FEATHER_ICON_NAMES}
- Never use emoji characters as icons (e.g. 🧠📱). Always use path nodes for icons.
- NEVER use emoji characters as icons (e.g. 🍕🍔🔔). Always use icon_font nodes emoji cannot render on canvas.
- For app screenshot/mockup areas, use a phone placeholder frame with solid fill matching the page theme + 1px subtle stroke. cornerRadius ~32. Prefer no inner content; if a placeholder copy is needed (e.g. "APP截图占位"), keep exactly one centered text node INSIDE the phone frame (never as a sibling below it).
- Do NOT use random real-world app screenshots or dense mini-app simulations for showcase sections.
`
@ -221,7 +221,7 @@ DESIGN GUIDELINES:
- Avoid repeating the exact same palette across unrelated designs
- Navigation bars (when designing landing pages/websites): use justifyContent="space_between" with 3 child groups (logo-group | links-group | cta-button), padding=[0,80], alignItems="center". This auto-distributes them perfectly across the full width.
- Icons: use "path" nodes with Feather icon names only (full list in the ICONS & IMAGES section above). Size 16-24px.
- Never use emoji glyphs as icon substitutes. If an icon is needed, use a path node with a descriptive icon name.
- NEVER use emoji glyphs as icon substitutes (🍕🍔 etc). If an icon is needed, use an icon_font node with iconFontName (lucide name). Emoji cannot render on canvas.
- Use image nodes for generic photos/illustrations only; for app preview areas prefer phone mockup placeholders
- Phone mockup/screenshot placeholder: exactly ONE "frame" node, width 260-300, height 520-580, cornerRadius 32, solid fill matching theme + 1px subtle stroke. NEVER use ellipse or circle for mockups. If a placeholder label is used, keep exactly ONE centered text child inside the phone frame; otherwise no children. Never put the label as a sibling below the phone.
- Hero with phone mockup (desktop): prefer a two-column horizontal layout (left text/cta, right phone). Do NOT stack the phone below headline unless mobile.
@ -294,7 +294,7 @@ CRITICAL RULES:
- Phone mockup: exactly ONE "frame" node, width 260-300, height 520-580, cornerRadius 32, solid fill + 1px stroke. NEVER use ellipse. If a placeholder label is needed, allow exactly ONE centered text child inside the phone; otherwise no children. Never put placeholder text below the phone as a sibling. ONLY use phone mockups for app showcase/marketing sections. When the user says "mobile screen" / "移动端" / "手机页面", build the ACTUAL mobile UI at 375x812 NOT a desktop page with a phone mockup.
- NEVER use ellipse for decorative/placeholder shapes use frame or rectangle with cornerRadius.
- Navigation bars (when applicable): justifyContent="space_between", 3 groups (logo | links | CTA), padding=[0,80], alignItems="center".
- Never use emoji as icons; use path nodes with descriptive icon names (system auto-resolves to verified SVG paths).
- NEVER use emoji as icons (🍕🍔🔔 etc); use icon_font nodes with iconFontName. Emoji cannot render on canvas.
- TEXT IN LAYOUTS: vertical layout body text should use textGrowth="fixed-width" + width="fill_container". Horizontal layout labels/buttons should use textGrowth="auto" + width="fit_content" (or omit width). NEVER use fixed pixel widths on text.
- TEXT HEIGHT: NEVER set explicit pixel height on text nodes (e.g. height:22). OMIT the height property the engine auto-calculates from textGrowth + content. A small explicit height causes text clipping and overlap.
- Cards with images: ALWAYS set clipContent: true + cornerRadius. Use "fill_container" width on image/body/text children inside the card.

View file

@ -136,6 +136,7 @@ export interface OrchestratorPlan {
height: number
layout?: 'none' | 'vertical' | 'horizontal'
gap?: number
padding?: number | [number, number] | [number, number, number, number]
fill?: Array<{ type: string; color: string }>
}
styleGuide?: StyleGuide

View file

@ -31,6 +31,7 @@ import {
sanitizeLayoutChildPositions,
sanitizeScreenFrameBounds,
hasActiveLayout,
isBadgeOverlayNode,
} from './design-node-sanitization'
// ---------------------------------------------------------------------------
@ -127,6 +128,26 @@ export function insertStreamingNode(
const { addNode, getNodeById } = useDocumentStore.getState()
normalizeNodeFills(node)
// Ensure unique node IDs to avoid collisions with pre-existing canvas content.
// The upsert path already does this in sanitizeNodesForUpsert, but the streaming
// path was missing it — causing duplicate Fabric objects when two generations
// produce nodes with the same IDs (e.g. "header-title" in both FoodHome and Settings).
const streamCounters = new Map<string, number>()
const streamRemaps = new Map<string, string>()
ensureUniqueNodeIds(node, preExistingNodeIds, streamCounters, streamRemaps)
// Track the newly inserted IDs so subsequent streaming nodes don't collide either
const trackNewIds = (n: PenNode) => {
preExistingNodeIds.add(n.id)
if ('children' in n && Array.isArray(n.children)) {
for (const child of n.children) trackNewIds(child)
}
}
trackNewIds(node)
// Merge any remappings into the generation-wide remap table
for (const [from, to] of streamRemaps) {
generationRemappedIds.set(from, to)
}
// Ensure container nodes have children array for later child insertions
if ((node.type === 'frame' || node.type === 'group') && !('children' in node)) {
;(node as PenNode & { children: PenNode[] }).children = []
@ -141,7 +162,7 @@ export function insertStreamingNode(
? getNodeById(resolvedParent)
: null
if (parentNode && hasActiveLayout(parentNode)) {
if (parentNode && hasActiveLayout(parentNode) && !isBadgeOverlayNode(node)) {
if ('x' in node) delete (node as { x?: number }).x
if ('y' in node) delete (node as { y?: number }).y
// Text defaults inside layout frames:
@ -248,8 +269,9 @@ export function insertStreamingNode(
startNewAnimationBatch()
}
// Append (not prepend) so auto-layout children stay in generation order
addNode(insertParent, node, Infinity)
// Badge/overlay nodes prepend (index 0) so they render on top (earlier = higher z-order).
// All other nodes append to preserve auto-layout generation order.
addNode(insertParent, node, isBadgeOverlayNode(node) ? 0 : Infinity)
// When a frame is inserted into a horizontal layout, equalize sibling card widths
// to prevent overflow when multiple cards are placed in the same row.
@ -444,6 +466,12 @@ export function applyGenerationHeuristics(node: PenNode): void {
applyIconPathResolution(node)
applyNoEmojiIconHeuristic(node)
// Re-run icon resolution on nodes converted from emoji text → path by the
// heuristic above. applyNoEmojiIconHeuristic sets a circle fallback path;
// the icon resolver can often match the name (e.g. "Pizza Emoji Path" → pizza).
if (node.type === 'path') {
applyIconPathResolution(node)
}
applyImagePlaceholderHeuristic(node)
if (!('children' in node) || !Array.isArray(node.children)) return

View file

@ -69,11 +69,25 @@ export function hasActiveLayout(node: PenNode): boolean {
return node.layout === 'vertical' || node.layout === 'horizontal'
}
/**
* Check if a node is a badge/overlay that should use absolute positioning
* instead of participating in the parent's layout flow.
*/
export function isBadgeOverlayNode(node: PenNode): boolean {
if ('role' in node) {
const role = (node as { role?: string }).role
if (role === 'badge' || role === 'pill' || role === 'tag') return true
}
const name = (node.name ?? '').toLowerCase()
return /badge|indicator|notification[-_\s]?dot|overlay|floating/i.test(name)
}
export function sanitizeLayoutChildPositions(
node: PenNode,
parentHasLayout: boolean,
): void {
if (parentHasLayout) {
// Badge/overlay nodes retain their x/y for absolute positioning
if (parentHasLayout && !isBadgeOverlayNode(node)) {
if ('x' in node) delete (node as { x?: number }).x
if ('y' in node) delete (node as { y?: number }).y
}

View file

@ -248,7 +248,17 @@ function iconifyBodyToPathD(body: string): string | null {
parts.push(cmds.join(' '))
}
return parts.length > 0 ? parts.join(' ') : null
if (parts.length === 0) return null
// When joining multiple <path> d-values, ensure each sub-path starts with
// absolute M (uppercase). A standalone <path> treats initial lowercase "m"
// as absolute, but after concatenation it becomes relative to the previous
// sub-path's endpoint — drawing strokes in wrong positions.
for (let i = 1; i < parts.length; i++) {
if (parts[i].startsWith('m')) {
parts[i] = 'M' + parts[i].slice(1)
}
}
return parts.join(' ')
}
// Populate ICON_PATH_MAP with Lucide icons first (1700+), then Feather as fallback.
@ -267,6 +277,92 @@ function iconifyBodyToPathD(body: string): string | null {
if (!ICON_PATH_MAP[normalized]) ICON_PATH_MAP[normalized] = entry
}
// Lucide aliases — map alternate names to their parent icon (e.g. ice-cream → ice-cream-cone)
const lucideAliases = (lucideData as { aliases?: Record<string, { parent: string }> }).aliases
if (lucideAliases) {
for (const [alias, meta] of Object.entries(lucideAliases)) {
const parentEntry = ICON_PATH_MAP[meta.parent] ?? ICON_PATH_MAP[meta.parent.replace(/-/g, '')]
if (!parentEntry) continue
if (!ICON_PATH_MAP[alias]) ICON_PATH_MAP[alias] = parentEntry
const normalized = alias.replace(/-/g, '')
if (!ICON_PATH_MAP[normalized]) ICON_PATH_MAP[normalized] = parentEntry
}
}
// Common aliases that don't exist in Lucide/Feather but are frequently used by AI
// Keep in sync with NAME_ALIASES in server/api/ai/icon.ts
const commonAliases: Record<string, string> = {
burger: 'hamburger',
sushi: 'fish',
ramen: 'soup',
noodle: 'soup',
noodles: 'soup',
steak: 'beef',
meat: 'beef',
icecream: 'ice-cream-cone',
donut: 'donut',
bread: 'croissant',
fruit: 'apple',
food: 'utensils',
drink: 'cup-soda',
coffee: 'coffee',
tea: 'cup-soda',
restaurant: 'utensils-crossed',
delivery: 'truck',
order: 'clipboard-list',
recipe: 'book-open',
grocery: 'shopping-basket',
cart: 'shopping-cart',
bag: 'shopping-bag',
pay: 'credit-card',
payment: 'credit-card',
wallet: 'wallet',
money: 'banknote',
coupon: 'ticket',
discount: 'percent',
rating: 'star',
review: 'message-square',
favorite: 'heart',
favourites: 'heart',
favorites: 'heart',
notification: 'bell',
address: 'map-pin',
navigate: 'navigation',
directions: 'map',
logout: 'log-out',
login: 'log-in',
signup: 'user-plus',
account: 'user',
password: 'key',
security: 'shield',
privacy: 'eye-off',
about: 'info',
faq: 'help-circle',
support: 'headphones',
contact: 'phone',
feedback: 'message-circle',
language: 'globe',
theme: 'palette',
darkmode: 'moon',
lightmode: 'sun',
sound: 'volume-2',
mute: 'volume-x',
wifi: 'wifi',
bluetooth: 'bluetooth',
battery: 'battery',
location: 'map-pin',
gps: 'locate',
scan: 'scan',
qrcode: 'qr-code',
barcode: 'barcode',
}
for (const [alias, target] of Object.entries(commonAliases)) {
const targetEntry = ICON_PATH_MAP[target] ?? ICON_PATH_MAP[target.replace(/-/g, '')]
if (targetEntry && !ICON_PATH_MAP[alias]) {
ICON_PATH_MAP[alias] = targetEntry
}
}
// Feather — fallback for any names not covered by Lucide
const featherIcons = (featherData as { icons: Record<string, { body: string }> }).icons
for (const [name, icon] of Object.entries(featherIcons)) {
@ -340,6 +436,47 @@ function tryImmediateIconResolution(nodeId: string, iconName: string): void {
.catch(() => clearTimeout(timer))
}
/**
* Queue an icon_font node for async resolution when lookupIconByName fails.
* Fetches from /api/ai/icon, caches in ICON_PATH_MAP for future lookups,
* and triggers node recreation by touching the store node.
*/
export function tryAsyncIconFontResolution(nodeId: string, iconName: string): void {
const normalized = iconName.replace(/[-_\s/]+/g, '').replace(/icon$/i, '').toLowerCase()
if (!normalized || pendingIconResolutions.has(nodeId)) return
pendingIconResolutions.set(nodeId, normalized)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 1500)
fetch(`/api/ai/icon?name=${encodeURIComponent(normalized)}`, { signal: controller.signal })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
clearTimeout(timer)
if (!pendingIconResolutions.has(nodeId)) return
pendingIconResolutions.delete(nodeId)
const icon = data?.icon as {
d: string; style: 'stroke' | 'fill'; iconId?: string
} | null
if (!icon) return
// Cache in ICON_PATH_MAP so future lookups resolve instantly
const entry: IconEntry = { d: icon.d, style: icon.style, iconId: icon.iconId ?? `resolved:${normalized}` }
if (!ICON_PATH_MAP[normalized]) ICON_PATH_MAP[normalized] = entry
// Touch the node in store to trigger canvas recreation
const { getNodeById, updateNode } = useDocumentStore.getState()
const node = getNodeById(nodeId)
if (!node || node.type !== 'icon_font') return
// Update iconFontName to the resolved short name (strip "lucide:" / "feather:" prefix)
// to trigger __needsRecreation and ensure lookupIconByName resolves on next render.
const resolvedName = (icon.iconId ?? normalized).replace(/^[a-z]+:/, '')
updateNode(nodeId, { iconFontName: resolvedName } as Partial<PenNode>)
})
.catch(() => { clearTimeout(timer); pendingIconResolutions.delete(nodeId) })
}
/**
* Resolve icon path nodes by their name. When the AI generates a path node
* with a name like "SearchIcon" or "MenuIcon", look up the verified SVG path
@ -689,6 +826,7 @@ export function lookupIconByName(
name: string,
): { d: string; iconId: string; style: 'stroke' | 'fill' } | null {
const normalized = name
.replace(/^[a-z]+:/i, '') // strip icon set prefix (lucide:, feather:, resolved:)
.replace(/[-_\s/]+/g, '')
.replace(/icon$/i, '')
.toLowerCase()

View file

@ -74,12 +74,13 @@ RULES:
- clipContent: true on cards with cornerRadius + image children.
- Text: NEVER set height. Short text (titles, labels, buttons) omit textGrowth. Long text (>15 chars wrapping) textGrowth="fixed-width", width="fill_container", lineHeight=1.4-1.6.
- lineHeight: Display 40-56px 0.9-1.0. Heading 20-36px 1.0-1.2. Body 1.4-1.6. letterSpacing: -0.5 to -1 for headlines, 1-3 for uppercase.
- Icons: icon_font nodes with iconFontName (lucide names: search, bell, user, heart, star, plus, x, check, chevron-right, settings, etc). Sizes: 14/20/24px.
- Icons: ALWAYS use icon_font nodes with iconFontName (lucide names: search, bell, user, heart, star, plus, x, check, chevron-right, settings, etc). Sizes: 14/20/24px. NEVER use emoji characters (🍕🍔🔔 etc) as icon substitutes they cannot render on canvas.
- CJK fonts: "Noto Sans SC"/"Noto Sans JP"/"Noto Sans KR" for headings. CJK lineHeight: 1.3-1.4 headings, 1.6-1.8 body.
- Buttons: frame(padding=[12,24], justifyContent="center") > text. Icon+text: frame(layout="horizontal", gap=8, alignItems="center", padding=[8,16]).
- Card rows: ALL cards width="fill_container" + height="fill_container".
- FORMS: ALL inputs AND button use width="fill_container". gap=16-20.
- Phone mockup: ONE frame, w=260-300, h=520-580, cornerRadius=32, solid fill + 1px stroke.
- Z-order: Earlier siblings render on top. Overlay elements (badges, indicators, floating buttons) MUST come BEFORE the content they overlap.
FORMAT: _parent (null=root, else parent-id). Parent before children.
${BLOCK}json

View file

@ -210,7 +210,8 @@ export async function executeOrchestration(
width: plan.rootFrame.width,
height: frameHeight,
layout: plan.rootFrame.layout ?? 'vertical',
gap: plan.rootFrame.gap ?? 0,
gap: isMobile ? (plan.rootFrame.gap || 16) : (plan.rootFrame.gap ?? 16),
...(plan.rootFrame.padding != null ? { padding: plan.rootFrame.padding } : {}),
fill: defaultFill,
children: [],
}
@ -255,7 +256,8 @@ export async function executeOrchestration(
width: plan.rootFrame.width,
height: initialHeight,
layout: plan.rootFrame.layout ?? 'vertical',
gap: plan.rootFrame.gap ?? 0,
gap: isMobile ? (plan.rootFrame.gap || 16) : (plan.rootFrame.gap ?? 16),
...(plan.rootFrame.padding != null ? { padding: plan.rootFrame.padding } : {}),
fill: defaultFill,
children: [],
}