feat(ai): resolve brand icons during streaming with immediate fetch fallback

- icon-resolver: refactor ICON_PATH_MAP with shared IconEntry constants
  and s()/f() helpers to eliminate duplicate path strings; fix timer and
  pencil icon paths which were incorrectly aliased to clock/edit
- icon-resolver: remove BRAND_PLACEHOLDER_MAP — brand icons now go
  straight to async resolution (simple-icons) without a Feather placeholder
- icon-resolver: add tryImmediateIconResolution() — fires a 600ms-timeout
  fetch during streaming so brand icons appear without waiting for
  post-stream resolution; pending queue remains as fallback on timeout
- design-canvas-ops: normalize gradient stops in insertStreamingNode to
  catch AI-generated nodes before they reach the store
- design-canvas-ops: call resolveAllPendingIcons() after all non-streaming
  apply paths (applyNodesToCanvas, animateNodesToCanvas)
This commit is contained in:
Fini 2026-02-24 04:06:44 +08:00
parent 2dbe8aa236
commit 76f58ad239
2 changed files with 324 additions and 117 deletions

View file

@ -13,7 +13,7 @@ import {
createPhonePlaceholderDataUri,
estimateNodeIntrinsicHeight,
} from './generation-utils'
import { applyIconPathResolution, applyNoEmojiIconHeuristic, resolveAsyncIcons } from './icon-resolver'
import { applyIconPathResolution, applyNoEmojiIconHeuristic, resolveAsyncIcons, resolveAllPendingIcons } from './icon-resolver'
import {
resolveNodeRole,
resolveTreeRoles,
@ -67,11 +67,42 @@ export function getGenerationRemappedIds(): Map<string, string> {
* cannot run here because the node has no children yet during streaming.
* Use applyPostStreamingTreeHeuristics() after all subtask nodes are inserted.
*/
/**
* Normalize gradient stop offsets in all fills on a node (in-place).
* Handles stops without an offset field by auto-distributing them evenly.
* Also normalizes percentage-format offsets (>1) to the 0-1 range.
*/
function normalizeNodeFills(node: PenNode): void {
const fills = 'fill' in node ? (node as { fill?: unknown }).fill : undefined
if (!Array.isArray(fills)) return
for (const fill of fills) {
if (!fill || typeof fill !== 'object') continue
const f = fill as { type?: string; stops?: unknown[] }
if ((f.type === 'linear_gradient' || f.type === 'radial_gradient') && Array.isArray(f.stops)) {
const n = f.stops.length
f.stops = f.stops.map((s: unknown, i: number) => {
const stop = s as Record<string, unknown>
let offset = typeof stop.offset === 'number' && Number.isFinite(stop.offset)
? stop.offset
: typeof stop.position === 'number' && Number.isFinite(stop.position)
? (stop.position as number)
: null
if (offset !== null && offset > 1) offset = offset / 100
return {
color: typeof stop.color === 'string' ? stop.color : '#000000',
offset: offset !== null ? Math.max(0, Math.min(1, offset)) : i / Math.max(n - 1, 1),
}
})
}
}
}
export function insertStreamingNode(
node: PenNode,
parentId: string | null,
): void {
const { addNode, getNodeById } = useDocumentStore.getState()
normalizeNodeFills(node)
// Ensure container nodes have children array for later child insertions
if ((node.type === 'frame' || node.type === 'group') && !('children' in node)) {
@ -185,6 +216,7 @@ export function applyNodesToCanvas(nodes: PenNode[]): void {
// If canvas only has one empty frame, replace it with the generated content
if (isCanvasOnlyEmptyFrame() && preparedNodes.length === 1 && preparedNodes[0].type === 'frame') {
replaceEmptyFrame(preparedNodes[0])
resolveAllPendingIcons().catch(console.warn)
return
}
@ -196,6 +228,7 @@ export function applyNodesToCanvas(nodes: PenNode[]): void {
addNode(parentId, node)
}
adjustRootFrameHeightToContent()
resolveAllPendingIcons().catch(console.warn)
}
export function upsertNodesToCanvas(nodes: PenNode[]): number {
@ -274,6 +307,9 @@ export function animateNodesToCanvas(nodes: PenNode[]): void {
useHistoryStore.getState().startBatch(useDocumentStore.getState().document)
upsertPreparedNodes(prepared)
useHistoryStore.getState().endBatch(useDocumentStore.getState().document)
// Resolve any icons queued for async (brand logos etc.) after nodes are in the store
resolveAllPendingIcons().catch(console.warn)
}
// ---------------------------------------------------------------------------

View file

@ -13,112 +13,143 @@ import {
// Hand-picked high-frequency icons for guaranteed instant sync resolution.
// Feather icons are added at module init from the bundled @iconify-json/feather.
// ---------------------------------------------------------------------------
const ICON_PATH_MAP: Record<string, { d: string; style: 'stroke' | 'fill'; iconId?: string }> = {
// Navigation & actions
menu: { d: 'M4 6h16M4 12h16M4 18h16', style: 'stroke' },
x: { d: 'M18 6L6 18M6 6l12 12', style: 'stroke' },
close: { d: 'M18 6L6 18M6 6l12 12', style: 'stroke', iconId: 'lucide:x' },
check: { d: 'M20 6L9 17l-5-5', style: 'stroke' },
plus: { d: 'M12 5v14M5 12h14', style: 'stroke' },
add: { d: 'M12 5v14M5 12h14', style: 'stroke', iconId: 'lucide:plus' },
minus: { d: 'M5 12h14', style: 'stroke' },
search: { d: 'M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4.35-4.35', style: 'stroke' },
arrowright: { d: 'M5 12h14M12 5l7 7-7 7', style: 'stroke', iconId: 'lucide:arrow-right' },
arrowleft: { d: 'M19 12H5M12 19l-7-7 7-7', style: 'stroke', iconId: 'lucide:arrow-left' },
arrowup: { d: 'M12 19V5M5 12l7-7 7 7', style: 'stroke', iconId: 'lucide:arrow-up' },
arrowdown: { d: 'M12 5v14M19 12l-7 7-7-7', style: 'stroke', iconId: 'lucide:arrow-down' },
chevronright: { d: 'M9 18l6-6-6-6', style: 'stroke', iconId: 'lucide:chevron-right' },
chevronleft: { d: 'M15 18l-6-6 6-6', style: 'stroke', iconId: 'lucide:chevron-left' },
chevrondown: { d: 'M6 9l6 6 6-6', style: 'stroke', iconId: 'lucide:chevron-down' },
chevronup: { d: 'M18 15l-6-6-6 6', style: 'stroke', iconId: 'lucide:chevron-up' },
// People & account
star: { d: 'M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z', style: 'fill' },
heart: { d: 'M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z', style: 'stroke' },
like: { d: 'M14 9V5a3 3 0 00-3-3l-4 9v11h11.28a2 2 0 002-1.7l1.38-9a2 2 0 00-2-2.3H14zM7 22H4a2 2 0 01-2-2v-7a2 2 0 012-2h3', style: 'stroke', iconId: 'lucide:thumbs-up' },
thumbsup: { d: 'M14 9V5a3 3 0 00-3-3l-4 9v11h11.28a2 2 0 002-1.7l1.38-9a2 2 0 00-2-2.3H14zM7 22H4a2 2 0 01-2-2v-7a2 2 0 012-2h3', style: 'stroke', iconId: 'lucide:thumbs-up' },
home: { d: 'M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9zM9 22V12h6v10', style: 'stroke' },
user: { d: 'M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M16 7a4 4 0 11-8 0 4 4 0 018 0z', style: 'stroke' },
profile: { d: 'M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M16 7a4 4 0 11-8 0 4 4 0 018 0z', style: 'stroke', iconId: 'lucide:user' },
users: { d: 'M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2M9 11a4 4 0 100-8 4 4 0 000 8zM23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75', style: 'stroke', iconId: 'lucide:users' },
avatar: { d: 'M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M16 7a4 4 0 11-8 0 4 4 0 018 0z', style: 'stroke', iconId: 'lucide:user' },
// System & settings
settings: { d: 'M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2zM15 12a3 3 0 11-6 0 3 3 0 016 0z', style: 'stroke' },
gear: { d: 'M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2zM15 12a3 3 0 11-6 0 3 3 0 016 0z', style: 'stroke', iconId: 'lucide:settings' },
mail: { d: 'M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2zm16 2l-10 7L2 6', style: 'stroke' },
email: { d: 'M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2zm16 2l-10 7L2 6', style: 'stroke', iconId: 'lucide:mail' },
eye: { d: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8zM15 12a3 3 0 11-6 0 3 3 0 016 0z', style: 'stroke' },
lock: { d: 'M19 11H5a2 2 0 00-2 2v7a2 2 0 002 2h14a2 2 0 002-2v-7a2 2 0 00-2-2zM7 11V7a5 5 0 0110 0v4', style: 'stroke' },
bell: { d: 'M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 01-3.46 0', style: 'stroke' },
notification: { d: 'M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 01-3.46 0', style: 'stroke', iconId: 'lucide:bell' },
shield: { d: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', style: 'stroke', iconId: 'lucide:shield' },
zap: { d: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z', style: 'fill', iconId: 'lucide:zap' },
bolt: { d: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z', style: 'fill', iconId: 'lucide:zap' },
// Media & content
play: { d: 'M5 3l14 9-14 9V3z', style: 'fill' },
pause: { d: 'M6 4h4v16H6zM14 4h4v16h-4z', style: 'fill' },
download: { d: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3', style: 'stroke' },
upload: { d: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12', style: 'stroke' },
image: { d: 'M21 3H3a2 2 0 00-2 2v14a2 2 0 002 2h18a2 2 0 002-2V5a2 2 0 00-2-2zM8.5 10a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM21 15l-5-5L5 21', style: 'stroke', iconId: 'lucide:image' },
photo: { d: 'M21 3H3a2 2 0 00-2 2v14a2 2 0 002 2h18a2 2 0 002-2V5a2 2 0 00-2-2zM8.5 10a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM21 15l-5-5L5 21', style: 'stroke', iconId: 'lucide:image' },
camera: { d: 'M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2v11zM12 17a4 4 0 100-8 4 4 0 000 8z', style: 'stroke', iconId: 'lucide:camera' },
video: { d: 'M23 7l-7 5 7 5V7zM1 5h15a2 2 0 012 2v10a2 2 0 01-2 2H1a2 2 0 01-2-2V7a2 2 0 012-2z', style: 'stroke', iconId: 'lucide:video' },
// Communication
message: { d: 'M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2v10z', style: 'stroke', iconId: 'lucide:message-square' },
chat: { d: 'M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2v10z', style: 'stroke', iconId: 'lucide:message-square' },
phone: { d: 'M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07A19.5 19.5 0 013.07 9.81a19.79 19.79 0 01-3.07-8.63A2 2 0 012 1h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L6.09 8.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z', style: 'stroke', iconId: 'lucide:phone' },
send: { d: 'M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z', style: 'stroke' },
share: { d: 'M4 12v8a2 2 0 002 2h12a2 2 0 002-2v-8M16 6l-4-4-4 4M12 2v13', style: 'stroke', iconId: 'lucide:share' },
globe: { d: 'M12 22a10 10 0 100-20 10 10 0 000 20zM2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z', style: 'stroke' },
// Content & data
code: { d: 'M16 18l6-6-6-6M8 6l-6 6 6 6', style: 'stroke' },
bookmark: { d: 'M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2v16z', style: 'stroke', iconId: 'lucide:bookmark' },
tag: { d: 'M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82zM7 7h.01', style: 'stroke', iconId: 'lucide:tag' },
link: { d: 'M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71', style: 'stroke', iconId: 'lucide:link' },
externallink: { d: 'M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3', style: 'stroke', iconId: 'lucide:external-link' },
copy: { d: 'M20 9h-9a2 2 0 00-2 2v9a2 2 0 002 2h9a2 2 0 002-2V11a2 2 0 00-2-2zM5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1', style: 'stroke', iconId: 'lucide:copy' },
clipboard: { d: 'M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2M9 2h6a1 1 0 011 1v2a1 1 0 01-1 1H9a1 1 0 01-1-1V3a1 1 0 011-1z', style: 'stroke', iconId: 'lucide:clipboard' },
edit: { d: 'M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z', style: 'stroke', iconId: 'lucide:edit' },
pencil: { d: 'M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z', style: 'stroke', iconId: 'lucide:pencil' },
trash: { d: 'M3 6h18M8 6V4h8v2M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6M10 11v6M14 11v6', style: 'stroke', iconId: 'lucide:trash-2' },
delete: { d: 'M3 6h18M8 6V4h8v2M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6M10 11v6M14 11v6', style: 'stroke', iconId: 'lucide:trash-2' },
// Time & location
calendar: { d: 'M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z', style: 'stroke', iconId: 'lucide:calendar' },
clock: { d: 'M12 22a10 10 0 100-20 10 10 0 000 20zM12 6v6l4 2', style: 'stroke', iconId: 'lucide:clock' },
timer: { d: 'M12 22a10 10 0 100-20 10 10 0 000 20zM12 6v6l4 2', style: 'stroke', iconId: 'lucide:clock' },
mappin: { d: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0zM12 13a3 3 0 100-6 3 3 0 000 6z', style: 'stroke', iconId: 'lucide:map-pin' },
location: { d: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0zM12 13a3 3 0 100-6 3 3 0 000 6z', style: 'stroke', iconId: 'lucide:map-pin' },
map: { d: 'M1 6v16l7-4 8 4 7-4V2l-7 4-8-4-7 4zM8 2v16M16 6v16', style: 'stroke', iconId: 'lucide:map' },
// Analytics & status
barchart: { d: 'M18 20V10M12 20V4M6 20v-4', style: 'stroke', iconId: 'lucide:bar-chart-2' },
chart: { d: 'M18 20V10M12 20V4M6 20v-4', style: 'stroke', iconId: 'lucide:bar-chart-2' },
analytics: { d: 'M18 20V10M12 20V4M6 20v-4', style: 'stroke', iconId: 'lucide:bar-chart-2' },
trendingup: { d: 'M23 6l-9.5 9.5-5-5L1 18M17 6h6v6', style: 'stroke', iconId: 'lucide:trending-up' },
activity: { d: 'M22 12h-4l-3 9L9 3l-3 9H2', style: 'stroke', iconId: 'lucide:activity' },
info: { d: 'M12 22a10 10 0 100-20 10 10 0 000 20zM12 8v4M12 16h.01', style: 'stroke', iconId: 'lucide:info' },
alert: { d: 'M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0zM12 9v4M12 17h.01', style: 'stroke', iconId: 'lucide:alert-triangle' },
warning: { d: 'M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0zM12 9v4M12 17h.01', style: 'stroke', iconId: 'lucide:alert-triangle' },
help: { d: 'M12 22a10 10 0 100-20 10 10 0 000 20zM9.09 9a3 3 0 015.83 1c0 2-3 3-3 3M12 17h.01', style: 'stroke', iconId: 'lucide:help-circle' },
question: { d: 'M12 22a10 10 0 100-20 10 10 0 000 20zM9.09 9a3 3 0 015.83 1c0 2-3 3-3 3M12 17h.01', style: 'stroke', iconId: 'lucide:help-circle' },
checkcircle: { d: 'M22 11.08V12a10 10 0 11-5.93-9.14M22 4L12 14.01l-3-3', style: 'stroke', iconId: 'lucide:check-circle' },
refresh: { d: 'M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15', style: 'stroke', iconId: 'lucide:refresh-cw' },
reload: { d: 'M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15', style: 'stroke', iconId: 'lucide:refresh-cw' },
filter: { d: 'M22 3H2l8 9.46V19l4 2V12.46L22 3z', style: 'stroke', iconId: 'lucide:filter' },
// Layout & UI
grid: { d: 'M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z', style: 'stroke', iconId: 'lucide:grid' },
list: { d: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01', style: 'stroke', iconId: 'lucide:list' },
layers: { d: 'M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5', style: 'stroke', iconId: 'lucide:layers' },
// Commerce
creditcard: { d: 'M21 4H3a2 2 0 00-2 2v12a2 2 0 002 2h18a2 2 0 002-2V6a2 2 0 00-2-2zM1 10h22', style: 'stroke', iconId: 'lucide:credit-card' },
cart: { d: 'M9 22a1 1 0 100-2 1 1 0 000 2zM20 22a1 1 0 100-2 1 1 0 000 2zM1 1h4l2.68 13.39a2 2 0 002 1.61h9.72a2 2 0 002-1.61L23 6H6', style: 'stroke', iconId: 'lucide:shopping-cart' },
shoppingcart: { d: 'M9 22a1 1 0 100-2 1 1 0 000 2zM20 22a1 1 0 100-2 1 1 0 000 2zM1 1h4l2.68 13.39a2 2 0 002 1.61h9.72a2 2 0 002-1.61L23 6H6', style: 'stroke', iconId: 'lucide:shopping-cart' },
award: { d: 'M12 15a7 7 0 100-14 7 7 0 000 14zM8.21 13.89L7 23l5-3 5 3-1.21-9.12', style: 'stroke', iconId: 'lucide:award' },
// Misc
dot: { d: 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0', style: 'fill', iconId: 'lucide:circle' },
bullet: { d: 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0', style: 'fill', iconId: 'lucide:circle' },
point: { d: 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0', style: 'fill', iconId: 'lucide:circle' },
circlefill: { d: 'M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', style: 'fill', iconId: 'lucide:circle' },
type IconEntry = { d: string; style: 'stroke' | 'fill'; iconId: string }
// Helpers keep definitions concise
const s = (d: string, id: string): IconEntry => ({ d, style: 'stroke', iconId: id })
const f = (d: string, id: string): IconEntry => ({ d, style: 'fill', iconId: id })
// Shared path objects — aliases reference the same entry, avoiding duplication.
// Resolution still works because every alias key stays in the map.
const _X = s('M18 6L6 18M6 6l12 12', 'lucide:x')
const _PLUS = s('M12 5v14M5 12h14', 'lucide:plus')
const _THUMBSUP = s('M14 9V5a3 3 0 00-3-3l-4 9v11h11.28a2 2 0 002-1.7l1.38-9a2 2 0 00-2-2.3H14zM7 22H4a2 2 0 01-2-2v-7a2 2 0 012-2h3', 'lucide:thumbs-up')
const _USER = s('M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M16 7a4 4 0 11-8 0 4 4 0 018 0z', 'lucide:user')
const _SETTINGS = s('M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2zM15 12a3 3 0 11-6 0 3 3 0 016 0z', 'lucide:settings')
const _MAIL = s('M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2zm16 2l-10 7L2 6', 'lucide:mail')
const _BELL = s('M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 01-3.46 0', 'lucide:bell')
const _ZAP = f('M13 2L3 14h9l-1 8 10-12h-9l1-8z', 'lucide:zap')
const _IMAGE = s('M21 3H3a2 2 0 00-2 2v14a2 2 0 002 2h18a2 2 0 002-2V5a2 2 0 00-2-2zM8.5 10a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM21 15l-5-5L5 21', 'lucide:image')
const _MESSAGE = s('M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2v10z', 'lucide:message-square')
const _MAPPIN = s('M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0zM12 13a3 3 0 100-6 3 3 0 000 6z', 'lucide:map-pin')
const _BARCHART = s('M18 20V10M12 20V4M6 20v-4', 'lucide:bar-chart-2')
const _ALERT = s('M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0zM12 9v4M12 17h.01', 'lucide:alert-triangle')
const _HELP = s('M12 22a10 10 0 100-20 10 10 0 000 20zM9.09 9a3 3 0 015.83 1c0 2-3 3-3 3M12 17h.01', 'lucide:help-circle')
const _REFRESH = s('M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15', 'lucide:refresh-cw')
const _CART = s('M9 22a1 1 0 100-2 1 1 0 000 2zM20 22a1 1 0 100-2 1 1 0 000 2zM1 1h4l2.68 13.39a2 2 0 002 1.61h9.72a2 2 0 002-1.61L23 6H6', 'lucide:shopping-cart')
const _TRASH = s('M3 6h18M8 6V4h8v2M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6M10 11v6M14 11v6', 'lucide:trash-2')
const _DOT = f('M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0', 'lucide:circle')
const ICON_PATH_MAP: Record<string, IconEntry> = {
// ── Navigation & actions ────────────────────────────────────────────────
menu: s('M4 6h16M4 12h16M4 18h16', 'lucide:menu'),
check: s('M20 6L9 17l-5-5', 'lucide:check'),
minus: s('M5 12h14', 'lucide:minus'),
search: s('M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4.35-4.35', 'lucide:search'),
arrowright: s('M5 12h14M12 5l7 7-7 7', 'lucide:arrow-right'),
arrowleft: s('M19 12H5M12 19l-7-7 7-7', 'lucide:arrow-left'),
arrowup: s('M12 19V5M5 12l7-7 7 7', 'lucide:arrow-up'),
arrowdown: s('M12 5v14M19 12l-7 7-7-7', 'lucide:arrow-down'),
chevronright: s('M9 18l6-6-6-6', 'lucide:chevron-right'),
chevronleft: s('M15 18l-6-6 6-6', 'lucide:chevron-left'),
chevrondown: s('M6 9l6 6 6-6', 'lucide:chevron-down'),
chevronup: s('M18 15l-6-6-6 6', 'lucide:chevron-up'),
// aliases
x: _X, close: _X,
plus: _PLUS, add: _PLUS,
// ── People & account ────────────────────────────────────────────────────
star: f('M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z', 'lucide:star'),
heart: s('M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z', 'lucide:heart'),
thumbsup: _THUMBSUP,
home: s('M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9zM9 22V12h6v10', 'lucide:home'),
user: _USER,
users: s('M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2M9 11a4 4 0 100-8 4 4 0 000 8zM23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75', 'lucide:users'),
// aliases
like: _THUMBSUP, profile: _USER, avatar: _USER,
// ── System & settings ───────────────────────────────────────────────────
settings: _SETTINGS,
mail: _MAIL,
eye: s('M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8zM15 12a3 3 0 11-6 0 3 3 0 016 0z', 'lucide:eye'),
lock: s('M19 11H5a2 2 0 00-2 2v7a2 2 0 002 2h14a2 2 0 002-2v-7a2 2 0 00-2-2zM7 11V7a5 5 0 0110 0v4', 'lucide:lock'),
bell: _BELL,
shield: s('M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', 'lucide:shield'),
zap: _ZAP,
// aliases
gear: _SETTINGS, email: _MAIL, notification: _BELL, bolt: _ZAP,
// ── Media & content ─────────────────────────────────────────────────────
play: f('M5 3l14 9-14 9V3z', 'lucide:play'),
pause: f('M6 4h4v16H6zM14 4h4v16h-4z', 'lucide:pause'),
download: s('M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3', 'lucide:download'),
upload: s('M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12', 'lucide:upload'),
image: _IMAGE,
camera: s('M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2v11zM12 17a4 4 0 100-8 4 4 0 000 8z', 'lucide:camera'),
video: s('M23 7l-7 5 7 5V7zM1 5h15a2 2 0 012 2v10a2 2 0 01-2 2H1a2 2 0 01-2-2V7a2 2 0 012-2z', 'lucide:video'),
// alias
photo: _IMAGE,
// ── Communication ───────────────────────────────────────────────────────
message: _MESSAGE,
phone: s('M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07A19.5 19.5 0 013.07 9.81a19.79 19.79 0 01-3.07-8.63A2 2 0 012 1h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L6.09 8.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z', 'lucide:phone'),
send: s('M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z', 'lucide:send'),
share: s('M4 12v8a2 2 0 002 2h12a2 2 0 002-2v-8M16 6l-4-4-4 4M12 2v13', 'lucide:share'),
globe: s('M12 22a10 10 0 100-20 10 10 0 000 20zM2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z', 'lucide:globe'),
// alias
chat: _MESSAGE,
// ── Content & data ──────────────────────────────────────────────────────
code: s('M16 18l6-6-6-6M8 6l-6 6 6 6', 'lucide:code-2'),
bookmark: s('M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2v16z', 'lucide:bookmark'),
tag: s('M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82zM7 7h.01', 'lucide:tag'),
link: s('M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71', 'lucide:link'),
externallink: s('M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3', 'lucide:external-link'),
copy: s('M20 9h-9a2 2 0 00-2 2v9a2 2 0 002 2h9a2 2 0 002-2V11a2 2 0 00-2-2zM5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1', 'lucide:copy'),
clipboard: s('M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2M9 2h6a1 1 0 011 1v2a1 1 0 01-1 1H9a1 1 0 01-1-1V3a1 1 0 011-1z', 'lucide:clipboard'),
edit: s('M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z', 'lucide:edit'),
// pencil is a distinct icon (no bounding square)
pencil: s('M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z', 'lucide:pencil'),
trash: _TRASH,
// alias
delete: _TRASH,
// ── Time & location ─────────────────────────────────────────────────────
calendar: s('M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z', 'lucide:calendar'),
clock: s('M12 22a10 10 0 100-20 10 10 0 000 20zM12 6v6l4 2', 'lucide:clock'),
// timer is distinct: stopwatch with a top indicator
timer: s('M10 2h4M12 6v4l2 2M21 12a9 9 0 11-18 0 9 9 0 0118 0', 'lucide:timer'),
mappin: _MAPPIN,
map: s('M1 6v16l7-4 8 4 7-4V2l-7 4-8-4-7 4zM8 2v16M16 6v16', 'lucide:map'),
// alias
location: _MAPPIN,
// ── Analytics & status ──────────────────────────────────────────────────
barchart: _BARCHART,
trendingup: s('M23 6l-9.5 9.5-5-5L1 18M17 6h6v6', 'lucide:trending-up'),
activity: s('M22 12h-4l-3 9L9 3l-3 9H2', 'lucide:activity'),
info: s('M12 22a10 10 0 100-20 10 10 0 000 20zM12 8v4M12 16h.01', 'lucide:info'),
alert: _ALERT,
help: _HELP,
checkcircle: s('M22 11.08V12a10 10 0 11-5.93-9.14M22 4L12 14.01l-3-3', 'lucide:check-circle'),
refresh: _REFRESH,
filter: s('M22 3H2l8 9.46V19l4 2V12.46L22 3z', 'lucide:filter'),
// aliases
chart: _BARCHART, analytics: _BARCHART, warning: _ALERT, question: _HELP, reload: _REFRESH,
// ── Layout & UI ─────────────────────────────────────────────────────────
grid: s('M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z', 'lucide:grid'),
list: s('M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01', 'lucide:list'),
layers: s('M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5', 'lucide:layers'),
// ── Commerce ────────────────────────────────────────────────────────────
creditcard: s('M21 4H3a2 2 0 00-2 2v12a2 2 0 002 2h18a2 2 0 002-2V6a2 2 0 00-2-2zM1 10h22', 'lucide:credit-card'),
cart: _CART,
award: s('M12 15a7 7 0 100-14 7 7 0 000 14zM8.21 13.89L7 23l5-3 5 3-1.21-9.12', 'lucide:award'),
// alias
shoppingcart: _CART,
// ── Misc ────────────────────────────────────────────────────────────────
dot: _DOT,
circlefill: f('M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'lucide:circle'),
// aliases
bullet: _DOT, point: _DOT,
}
// Snapshot the hand-picked keys BEFORE Feather expansion so BUILTIN_ICONS
// only contains the curated Lucide/hand-written icons, not the full Feather set.
const _handPickedKeys = new Set(Object.keys(ICON_PATH_MAP))
// ---------------------------------------------------------------------------
// Feather icon set — bundled from @iconify-json/feather (286 icons, all stroke)
// Populated at module init so AI-generated designs never need async network fetches
@ -245,6 +276,59 @@ function featherBodyToPathD(body: string): string | null {
/** Maps nodeId → normalized icon name for icons that need async resolution */
const pendingIconResolutions = new Map<string, string>()
/**
* Fire an immediate icon fetch during streaming with a short timeout.
* If the server responds in time, update the node right away and remove it
* from pendingIconResolutions so post-streaming resolution can skip it.
* On timeout or failure, the node stays in pendingIconResolutions as a fallback.
*/
function tryImmediateIconResolution(nodeId: string, iconName: string): void {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 600)
fetch(`/api/ai/icon?name=${encodeURIComponent(iconName)}`, { signal: controller.signal })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
clearTimeout(timer)
const icon = data?.icon as {
d: string
style: 'stroke' | 'fill'
width: number
height: number
iconId?: string
} | null
if (!icon) return
// Still pending (post-streaming resolution hasn't claimed it yet)?
if (!pendingIconResolutions.has(nodeId)) return
pendingIconResolutions.delete(nodeId)
const { getNodeById, updateNode } = useDocumentStore.getState()
const node = getNodeById(nodeId)
if (!node || node.type !== 'path') return
const update: Partial<PenNode> = { d: icon.d }
if (icon.iconId) (update as Partial<PathNode>).iconId = icon.iconId
const existingColor =
extractPrimaryColor('fill' in node ? node.fill : undefined) ??
extractPrimaryColor(node.stroke?.fill) ??
'#64748B'
if (icon.style === 'stroke') {
const sw = toStrokeThicknessNumber(node.stroke, 0)
update.stroke = { thickness: sw > 0 ? sw : 2, fill: [{ type: 'solid', color: existingColor }] }
update.fill = []
} else {
update.fill = [{ type: 'solid', color: existingColor }]
;(update as Partial<PathNode>).stroke = undefined
}
updateNode(nodeId, update)
})
.catch(() => clearTimeout(timer))
}
/**
* 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
@ -268,18 +352,25 @@ export function applyIconPathResolution(node: PenNode): void {
}
if (!match) {
// 2. Still no match — set placeholder and queue for async.
// Use rawName if non-empty, else fall back to the full normalized name (handles
// edge case where stripping "icon"/"logo" suffix leaves empty string, e.g. "Icon").
const originalNormalized = (node.name ?? node.id ?? '').toLowerCase().replace(/[-_\s]+/g, '')
const queueName = rawName || originalNormalized
// 2. Try substring fallback: "badgecheck" → "check", "uploadcloud" → "upload"
const substringKey = findSubstringFallback(rawName)
if (substringKey) match = ICON_PATH_MAP[substringKey]
}
const originalNormalized = (node.name ?? node.id ?? '').toLowerCase().replace(/[-_\s]+/g, '')
const queueName = rawName || originalNormalized
if (!match) {
// 3. Last resort: circle from Feather, queued for async.
if (isIconLikeName(node.name ?? '', queueName)) {
node.d = GENERIC_ICON_PATH
if (!node.fill || node.fill.length === 0) {
node.fill = [{ type: 'solid', color: extractPrimaryColor(node.stroke?.fill) ?? '#64748B' }]
const fallback = ICON_PATH_MAP['circle'] ?? ICON_PATH_MAP['feather:circle']
if (fallback) {
node.d = fallback.d
node.iconId = fallback.iconId
applyIconStyle(node as import('@/types/pen').PathNode, fallback.style)
}
// Record for async resolution
pendingIconResolutions.set(node.id, queueName)
tryImmediateIconResolution(node.id, queueName)
}
return
}
@ -291,7 +382,6 @@ export function applyIconPathResolution(node: PenNode): void {
}
const EMOJI_REGEX = /[\p{Extended_Pictographic}\p{Emoji_Presentation}\uFE0F]/gu
const GENERIC_ICON_PATH = 'M12 3l2.6 5.27 5.82.84-4.2 4.09.99 5.8L12 16.9l-5.21 2.73.99-5.8-4.2-4.09 5.82-.84L12 3z'
export function applyNoEmojiIconHeuristic(node: PenNode): void {
if (node.type !== 'text') return
@ -308,14 +398,16 @@ export function applyNoEmojiIconHeuristic(node: PenNode): void {
const iconSize = clamp(toSizeNumber(node.height, toSizeNumber(node.width, node.fontSize ?? 20)), 14, 24)
const iconFill = extractPrimaryColor('fill' in node ? node.fill : undefined) ?? '#64748B'
const fallbackCircle = ICON_PATH_MAP['circle'] ?? ICON_PATH_MAP['feather:circle']
const replacement: PenNode = {
id: node.id,
type: 'path',
name: `${node.name ?? 'Icon'} Path`,
d: GENERIC_ICON_PATH,
d: fallbackCircle?.d ?? 'M 2 12 a 10 10 0 1 0 20 0 a 10 10 0 1 0 -20 0 Z',
width: iconSize,
height: iconSize,
fill: [{ type: 'solid', color: iconFill }],
stroke: fallbackCircle?.style === 'stroke' ? { thickness: 2, fill: [{ type: 'solid', color: iconFill }] } : undefined,
fill: fallbackCircle?.style === 'stroke' ? [] : [{ type: 'solid', color: iconFill }],
} as PenNode
if (typeof node.x === 'number') replacement.x = node.x
@ -344,6 +436,29 @@ export async function resolveAsyncIcons(rootNodeId: string): Promise<void> {
collectPendingInSubtree(rootNodeId, getNodeById, entries)
if (entries.length === 0) return
await fetchAndApplyIconResults(entries, getNodeById, updateNode)
}
/**
* Resolve ALL pending icons regardless of which subtree they belong to.
* Use this after non-streaming apply paths (animateNodesToCanvas, applyNodesToCanvas).
*/
export async function resolveAllPendingIcons(): Promise<void> {
if (pendingIconResolutions.size === 0) return
const { getNodeById, updateNode } = useDocumentStore.getState()
const entries = Array.from(pendingIconResolutions.entries()).map(
([nodeId, iconName]) => ({ nodeId, iconName }),
)
await fetchAndApplyIconResults(entries, getNodeById, updateNode)
}
async function fetchAndApplyIconResults(
entries: Array<{ nodeId: string; iconName: string }>,
getNodeById: ReturnType<typeof useDocumentStore.getState>['getNodeById'],
updateNode: ReturnType<typeof useDocumentStore.getState>['updateNode'],
): Promise<void> {
// Fetch all in parallel
const results = await Promise.allSettled(
entries.map(async ({ nodeId, iconName }) => {
@ -382,6 +497,8 @@ export async function resolveAsyncIcons(rootNodeId: string): Promise<void> {
update.fill = []
} else {
update.fill = [{ type: 'solid', color: existingColor }]
// Clear any stroke left over from the placeholder (brand icons are fill-only)
;(update as Partial<PathNode>).stroke = undefined
}
updateNode(nodeId, update)
@ -479,6 +596,43 @@ export const AVAILABLE_FEATHER_ICONS: readonly string[] = Object.keys(
(featherData as { icons: Record<string, unknown> }).icons,
).sort()
// ---------------------------------------------------------------------------
// Built-in icon collection export — powers the "OpenPencil" picker collection
// ---------------------------------------------------------------------------
/** A single entry in the locally bundled icon collection */
export interface BuiltinIconEntry {
/** Iconify-compatible ID, e.g. "feather:arrow-right" or "lucide:x" */
iconId: string
/** Icon name without the collection prefix, e.g. "arrow-right" */
name: string
/** Combined SVG path data (24×24 viewBox) */
d: string
style: 'stroke' | 'fill'
}
/**
* All locally bundled icons, deduplicated by iconId and sorted alphabetically.
* Covers the full Feather set (286 icons) plus hand-picked Lucide aliases.
* These icons resolve instantly without any network request.
*/
export const BUILTIN_ICONS: readonly BuiltinIconEntry[] = (() => {
const seen = new Set<string>()
const entries: BuiltinIconEntry[] = []
// Only iterate the hand-picked keys (captured before Feather expansion).
// This excludes the full Feather set which is already a separate picker collection.
for (const key of _handPickedKeys) {
const entry = ICON_PATH_MAP[key]
if (!entry) continue
const id = entry.iconId ?? `lucide:${key}`
if (seen.has(id)) continue
seen.add(id)
const name = id.includes(':') ? id.split(':')[1] : key
entries.push({ iconId: id, name, d: entry.d, style: entry.style })
}
return entries.sort((a, b) => a.iconId.localeCompare(b.iconId))
})()
/**
* Try to resolve an unknown normalized icon name by finding the longest
* known icon key that the name starts with (prefix match, min 4 chars).
@ -495,3 +649,20 @@ function findPrefixFallback(normalizedName: string): string | null {
}
return best
}
/**
* Find the longest ICON_PATH_MAP key that appears anywhere as a substring
* of the normalized name. E.g. "badgecheck" "check", "uploadcloud" "upload".
* Only keys of at least 4 characters are considered.
*/
function findSubstringFallback(normalizedName: string): string | null {
let best: string | null = null
let bestLen = 3
for (const key of Object.keys(ICON_PATH_MAP)) {
if (key.length > bestLen && normalizedName.includes(key)) {
best = key
bestLen = key.length
}
}
return best
}