Problem Statement
Users who want to assign meaningful icons to node types must currently upload SVG or raster files manually through a file picker. This is friction-heavy: users need to find, download, and prepare icon files externally before they can style their graph. There is no browsable icon library inside the application, despite the app already shipping Lucide icons for its own UI.
Solution
Add a searchable, browsable icon picker to the node styling dialog that exposes the full Lucide icon set. Users can search by name or keyword, click to select, and the icon is applied immediately. Custom file upload remains available for domain-specific icons not covered by Lucide.
Icons selected from the library are stored as lucide:<name> references and resolved at render time to SVG data URIs. This enables the icon picker to highlight the currently selected icon and allows config files to reference icons symbolically. The Lucide icon catalog is resolved using lucide-react/dynamicIconImports which provides per-icon lazy loading with Vite-compatible static analysis.
User Stories
- As a graph explorer user, I want to pick an icon from a built-in library when styling a node type, so that I don't need to find and upload SVG files externally.
- As a graph explorer user, I want to search the icon library by keyword (e.g., "plane", "building", "person"), so that I can quickly find a relevant icon.
- As a graph explorer user, I want to see a visual preview of each icon in the picker before selecting it, so that I can compare options.
- As a graph explorer user, I want the icon picker to load quickly without slowing down initial app startup, so that the app remains responsive.
- As a graph explorer user, I want to still upload a custom SVG or raster image if the library doesn't have what I need, so that I'm not limited to the built-in set.
- As a graph explorer user, I want the selected Lucide icon to render correctly both in the graph canvas (Cytoscape) and in sidebar/search result previews, so that styling is consistent everywhere.
- As a graph explorer user, I want icons to respect the node color I've configured, so that SVG icons use currentColor and match my color scheme.
- As a graph explorer user, I want the icon I selected to persist across sessions, so that my styling choices are preserved.
- As a graph explorer user, I want to change my icon selection at any time by reopening the styling dialog, so that I can iterate on my graph's appearance.
- As a graph explorer user, I want the icon picker to indicate which icon is currently selected, so that I know what's already applied.
- As a graph explorer user, I want to be able to reset the icon back to the default after selecting a Lucide icon or uploading a custom one.
- As a graph explorer user, I want nodes with Lucide icons to render their icons on page load without needing to open the styling dialog first.
Implementation Decisions
- Storage format: The
iconUrl field in VertexPreferencesStorageModel stores "lucide:<name>" for library icons. This preserves the symbolic reference, enabling the icon picker to highlight the current selection and enabling config files to reference icons by name. No schema change needed — the field is already string | undefined.
- Icon image type for Lucide: When a Lucide icon is selected,
iconImageType is set to "image/svg+xml" since all Lucide icons are SVGs.
- Icon resolution at render time: A resolver function handles all
iconUrl formats:
lucide:<name> — extracts the icon name, calls the per-icon lazy loader from lucide-react/dynamicIconImports, builds an SVG string, and returns a data URI. Results are cached so each icon is resolved at most once per session.
data:... — passthrough (existing uploads)
- Any other string — passthrough (treated as a URL)
- Why
lucide-react/dynamicIconImports: This object maps every icon name to an explicit () => import(...) function. Because each import path is a static string literal inside the lucide-react library, Vite can statically analyze and produce a chunk per icon at build time. This avoids the need for a single large catalog chunk while still supporting fully dynamic icon name resolution at runtime.
- Render-time integration (graph canvas):
renderNode.tsx already fetches and processes SVG icons via React Query (iconQueryOptions). The resolver plugs into this existing pipeline — for lucide: references, the query function calls the resolver instead of fetching a URL. Caching (staleTime: Infinity) ensures each icon is resolved once.
- Render-time integration (UI components):
VertexIcon.tsx uses react-inlinesvg which accepts a URL or data URI as src. For lucide: references, the component resolves to a data URI before passing to the SVG renderer. This can use the same React Query cache or a local state pattern with the resolver.
- Icon picker UI: A new
IconPicker component rendered inside the existing NodeStyleDialog. It consists of a search input and a scrollable grid of icon previews. Search filters against Lucide icon names (and optionally tags/categories from Lucide metadata). Selecting an icon calls setVertexStyle({ iconUrl: "lucide:<name>", iconImageType: "image/svg+xml" }).
- Current selection highlighting: The picker reads
vertexStyle.iconUrl, checks if it starts with "lucide:", extracts the name, and highlights the matching grid item. This is only possible because the symbolic reference is preserved in storage.
- Picker icon rendering: Each grid cell resolves its icon using the same
dynamicIconImports loader. Icons load asynchronously with a placeholder shimmer while loading. HTTP/2 multiplexing handles the parallel micro-fetches adequately for the visible subset.
- Picker performance: Show a limited set initially (e.g., 50 icons), expand via search. Consider virtualization if scroll performance is an issue with the full set.
- Custom upload preserved: The existing file upload button remains in the dialog alongside the new picker, giving users an escape hatch for icons not in the Lucide set.
- Backward compatibility: Existing stored
iconUrl values (base64 data URIs from prior uploads) continue to work unchanged — the resolver treats any data: string as a passthrough.
- Bundle impact on main chunk: Only the
dynamicIconImports key map (~30KB) is imported statically. Actual icon SVG data is loaded on demand.
Testing Decisions
- Tests should verify external behavior (what icons resolve to, what the user sees), not implementation details.
- Icon resolver module: Unit tests covering all input formats (
lucide:*, data URI, plain URL passthrough), error handling for invalid/unknown Lucide icon names, correct SVG construction and data URI encoding, and caching behavior (second call for same name returns cached result). This is pure logic with a simple interface — highly testable in isolation.
- Icon picker component: Component tests verifying search/filter behavior, selection callback emits correct
lucide:<name> format, current selection is highlighted when vertexStyle.iconUrl matches a Lucide icon, and graceful handling of the loading state for icon previews.
- Cytoscape rendering: Not explicitly tested as part of this work — the existing
renderNode tests and visual verification cover downstream rendering. The resolver is the testable boundary.
- Prior art: follow patterns in existing test files under
src/core/StateProvider/ and src/modules/ that use Vitest and the project's component rendering utilities.
Out of Scope
- Server-hosted icon files or server config references to icons (separate work)
- Edge icons (edges use line styles, not icons)
- Icon animation or dynamic icon states
- User-uploadable icon packs or icon set management
- Lucide icon version pinning strategy or automatic update mechanism
- Icon color customization independent of node color (icons always inherit node color via currentColor)
- Default styling file system (defaultStyling.json mount, fetch, merge — separate issue)
- Styling import/export UI (separate issue)
Further Notes
- Lucide publishes ~1500 icons, all MIT licensed.
- This feature creates the
lucide:<name> storage convention that future work (unified server config, operator-authored default styling) can reference without additional client changes.
- The
dynamicIconImports approach from lucide-react is preferred over lucide-static because it leverages Vite's static analysis without requiring a single large catalog bundle. Each icon loads individually on demand (~1KB per icon chunk).
Problem Statement
Users who want to assign meaningful icons to node types must currently upload SVG or raster files manually through a file picker. This is friction-heavy: users need to find, download, and prepare icon files externally before they can style their graph. There is no browsable icon library inside the application, despite the app already shipping Lucide icons for its own UI.
Solution
Add a searchable, browsable icon picker to the node styling dialog that exposes the full Lucide icon set. Users can search by name or keyword, click to select, and the icon is applied immediately. Custom file upload remains available for domain-specific icons not covered by Lucide.
Icons selected from the library are stored as
lucide:<name>references and resolved at render time to SVG data URIs. This enables the icon picker to highlight the currently selected icon and allows config files to reference icons symbolically. The Lucide icon catalog is resolved usinglucide-react/dynamicIconImportswhich provides per-icon lazy loading with Vite-compatible static analysis.User Stories
Implementation Decisions
iconUrlfield inVertexPreferencesStorageModelstores"lucide:<name>"for library icons. This preserves the symbolic reference, enabling the icon picker to highlight the current selection and enabling config files to reference icons by name. No schema change needed — the field is alreadystring | undefined.iconImageTypeis set to"image/svg+xml"since all Lucide icons are SVGs.iconUrlformats:lucide:<name>— extracts the icon name, calls the per-icon lazy loader fromlucide-react/dynamicIconImports, builds an SVG string, and returns a data URI. Results are cached so each icon is resolved at most once per session.data:...— passthrough (existing uploads)lucide-react/dynamicIconImports: This object maps every icon name to an explicit() => import(...)function. Because each import path is a static string literal inside the lucide-react library, Vite can statically analyze and produce a chunk per icon at build time. This avoids the need for a single large catalog chunk while still supporting fully dynamic icon name resolution at runtime.renderNode.tsxalready fetches and processes SVG icons via React Query (iconQueryOptions). The resolver plugs into this existing pipeline — forlucide:references, the query function calls the resolver instead of fetching a URL. Caching (staleTime: Infinity) ensures each icon is resolved once.VertexIcon.tsxusesreact-inlinesvgwhich accepts a URL or data URI assrc. Forlucide:references, the component resolves to a data URI before passing to the SVG renderer. This can use the same React Query cache or a local state pattern with the resolver.IconPickercomponent rendered inside the existingNodeStyleDialog. It consists of a search input and a scrollable grid of icon previews. Search filters against Lucide icon names (and optionally tags/categories from Lucide metadata). Selecting an icon callssetVertexStyle({ iconUrl: "lucide:<name>", iconImageType: "image/svg+xml" }).vertexStyle.iconUrl, checks if it starts with"lucide:", extracts the name, and highlights the matching grid item. This is only possible because the symbolic reference is preserved in storage.dynamicIconImportsloader. Icons load asynchronously with a placeholder shimmer while loading. HTTP/2 multiplexing handles the parallel micro-fetches adequately for the visible subset.iconUrlvalues (base64 data URIs from prior uploads) continue to work unchanged — the resolver treats anydata:string as a passthrough.dynamicIconImportskey map (~30KB) is imported statically. Actual icon SVG data is loaded on demand.Testing Decisions
lucide:*, data URI, plain URL passthrough), error handling for invalid/unknown Lucide icon names, correct SVG construction and data URI encoding, and caching behavior (second call for same name returns cached result). This is pure logic with a simple interface — highly testable in isolation.lucide:<name>format, current selection is highlighted whenvertexStyle.iconUrlmatches a Lucide icon, and graceful handling of the loading state for icon previews.renderNodetests and visual verification cover downstream rendering. The resolver is the testable boundary.src/core/StateProvider/andsrc/modules/that use Vitest and the project's component rendering utilities.Out of Scope
Further Notes
lucide:<name>storage convention that future work (unified server config, operator-authored default styling) can reference without additional client changes.dynamicIconImportsapproach fromlucide-reactis preferred overlucide-staticbecause it leverages Vite's static analysis without requiring a single large catalog bundle. Each icon loads individually on demand (~1KB per icon chunk).