From 76f58ad239f48d2f09e420b74dd5e67a2ef1afbc Mon Sep 17 00:00:00 2001 From: Fini Date: Tue, 24 Feb 2026 04:06:44 +0800 Subject: [PATCH] feat(ai): resolve brand icons during streaming with immediate fetch fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/services/ai/design-canvas-ops.ts | 38 ++- src/services/ai/icon-resolver.ts | 403 +++++++++++++++++++-------- 2 files changed, 324 insertions(+), 117 deletions(-) diff --git a/src/services/ai/design-canvas-ops.ts b/src/services/ai/design-canvas-ops.ts index cf0eb127..d15c2081 100644 --- a/src/services/ai/design-canvas-ops.ts +++ b/src/services/ai/design-canvas-ops.ts @@ -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 { * 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 + 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) } // --------------------------------------------------------------------------- diff --git a/src/services/ai/icon-resolver.ts b/src/services/ai/icon-resolver.ts index 4d58af0b..4869917c 100644 --- a/src/services/ai/icon-resolver.ts +++ b/src/services/ai/icon-resolver.ts @@ -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 = { - // 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 = { + // ── 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() +/** + * 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 = { d: icon.d } + if (icon.iconId) (update as Partial).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).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 { 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 { + 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['getNodeById'], + updateNode: ReturnType['updateNode'], +): Promise { // 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 { 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).stroke = undefined } updateNode(nodeId, update) @@ -479,6 +596,43 @@ export const AVAILABLE_FEATHER_ICONS: readonly string[] = Object.keys( (featherData as { icons: Record }).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() + 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 +}