Add built-in Lucide icon library for node styling#1777
Conversation
Stores icon selections as symbolic lucide:<name> references rather than data URIs, so config files remain human-readable and the picker can highlight the current selection without reverse-engineering the blob. Resolution happens at render time via two paths: - React components (VertexIcon): useResolvedIconUrl hook backed by React Query (staleTime: Infinity) converts lucide:<name> to a base64 SVG data URI on first use and caches the result. - Cytoscape pipeline (renderNode): getLucideSvgString resolves directly from the lucide-react dynamic-import map, skipping any fetch round-trip. Custom-uploaded icons (data: URIs and plain URLs) are unchanged -- they pass through both resolvers untouched. The new IconPicker component (searchable popover, 8-column grid, 50-icon default window) is wired into NodeStyleDialog alongside the existing file upload button so users can choose a Lucide icon or keep uploading their own. Also fixes a pre-existing lint-staged misconfiguration where the root oxlint --fix task matched vitest.config.ts, which oxlint then ignored (matching its own ignorePatterns), causing exit code 1. The root lint-staged config now mirrors the oxlint ignorePatterns by routing *.config.* files to oxfmt only and restricting oxlint to packages/. Pre-flight: pnpm checks, pnpm test (1738/1738), pnpm coverage all pass. This is Slice 1 of the 3-slice split of aws#1589 per @kmcginnes review.
kmcginnes
left a comment
There was a problem hiding this comment.
This is looking pretty good. Thanks for splitting it up for me. I've provided a bunch of changes you can easily take, but there is likely some cleanup needed afterward. I think once these changes go in this PR will be much simpler. And feel free to push back on any of my suggested changes if you disagree.
One other feature that would be nice, but is not strictly necessary, is paging within the icon picker. I don't expect many people to browse through 60 pages of icons, but if they are searching and get 100 results I could see paging being useful.
Long term (not right now), I imagine we will add icon categories so the user can more easily narrow in on a specific icon they might want without having to know the name, similar to Apple's emoji picker. And if we wanted to be extra fancy we could do vertical scrolling in the grid with a virtualized list for performance instead of using paging. But paging is simpler and more pragmatic for now.
| useEffect(() => { | ||
| if (open) { | ||
| const timer = setTimeout(() => inputRef.current?.focus(), 100); | ||
| return () => clearTimeout(timer); | ||
| } | ||
| }, [open]); |
There was a problem hiding this comment.
| useEffect(() => { | |
| if (open) { | |
| const timer = setTimeout(() => inputRef.current?.focus(), 100); | |
| return () => clearTimeout(timer); | |
| } | |
| }, [open]); |
I tried opening the picker in Chrome, Safari, and Firefox without this useEffect and they all had the search input focused by default. Was there a specific reason this was added?
|
|
||
| import { Button, Input, Popover, PopoverContent, PopoverTrigger } from "."; | ||
|
|
||
| const allIconNames = Object.keys(dynamicIconImports).sort(); |
There was a problem hiding this comment.
| const allIconNames = Object.keys(dynamicIconImports).sort(); | |
| type IconName = keyof typeof dynamicIconImports; | |
| const allIconNames = (Object.keys(dynamicIconImports) as IconName[]).toSorted(); |
Strongly type the icon names and use toSorted() since we want the sorted values to be returned.
| selected, | ||
| onSelect, | ||
| }: { | ||
| name: string; |
There was a problem hiding this comment.
| name: string; | |
| name: IconName; |
Strongly type the icon name
| ); | ||
| } | ||
|
|
||
| function filterIcons(search: string): string[] { |
There was a problem hiding this comment.
| function filterIcons(search: string): string[] { | |
| function filterIcons(search: string) { |
Let the strongly typed names flow through.
| {src ? ( | ||
| <img src={src} alt={name} className="size-5" /> | ||
| ) : ( | ||
| <div className="bg-background-contrast-secondary size-5 animate-pulse rounded" /> | ||
| )} |
There was a problem hiding this comment.
| {src ? ( | |
| <img src={src} alt={name} className="size-5" /> | |
| ) : ( | |
| <div className="bg-background-contrast-secondary size-5 animate-pulse rounded" /> | |
| )} | |
| <DynamicIcon name={name} /> |
We should be able to use the Lucide provided DynamicIcon component so that we don't have to work so hard to show the icon here.
|
|
||
| function VertexIcon({ vertexStyle, className, alt }: Props) { | ||
| const altText = alt ?? `${vertexStyle.displayLabel ?? vertexStyle.type} icon`; | ||
| const resolvedSrc = useResolvedIconUrl(vertexStyle.iconUrl); |
There was a problem hiding this comment.
| const resolvedSrc = useResolvedIconUrl(vertexStyle.iconUrl); | |
| const lucideIconName = getLucideName(vertexStyle.iconUrl); | |
| if (lucideIconName) { | |
| if (isValidLucideIconName(lucideIconName)) { | |
| return <DynamicIcon name={lucideIconName} className="size-6 shrink-0" />; | |
| } else { | |
| return null; | |
| } | |
| } |
Instead of building the SVG string for the VertexIcon, we can simply use DynamicIcon from Lucide React. We just have to narrow the name type to a valid lucide icon name, otherwise we should show nothing (i.e. null).
This removes the only use of useResolvedIconUrl() so we can clean that up along with some of the helpers.
| return ( | ||
| <img | ||
| src={vertexStyle.iconUrl} | ||
| src={resolvedSrc} |
There was a problem hiding this comment.
| src={resolvedSrc} | |
| src={vertexStyle.iconUrl} |
Since the Lucide case returns early, these are just the iconUrl passed through.
| return ( | ||
| <SVG | ||
| src={vertexStyle.iconUrl} | ||
| src={resolvedSrc} |
There was a problem hiding this comment.
| src={resolvedSrc} | |
| src={vertexStyle.iconUrl} |
Since the Lucide case returns early, these are just the iconUrl passed through.
| - **Display name attribute** allows you to choose the attribute on the node that is used to uniquely label the node in the graph visualization and search | ||
| - **Display description attribute** allows you to choose the attribute on the node that is used to describe the node in search | ||
| - **Custom symbol** can be uploaded in the form of an SVG icon | ||
| - **Icon** can be picked from the built-in Lucide library via the **Browse** button, or uploaded as a custom SVG/raster image. Picked Lucide icons are stored as `lucide:<name>` references and resolved at render time, so the picker highlights the currently selected icon when you reopen the dialog. |
There was a problem hiding this comment.
| - **Icon** can be picked from the built-in Lucide library via the **Browse** button, or uploaded as a custom SVG/raster image. Picked Lucide icons are stored as `lucide:<name>` references and resolved at render time, so the picker highlights the currently selected icon when you reopen the dialog. | |
| - **Icon** can be picked from the built-in Lucide library via the **Browse** button, or uploaded as a custom SVG/raster image. |
Simplified to just the user facing behavior.
| export async function getLucideSvgString( | ||
| iconName: string, | ||
| ): Promise<string | null> { | ||
| const iconNode = await getLucideIconNode(iconName); | ||
| if (!iconNode) return null; | ||
| return buildSvgString(iconNode); | ||
| } |
There was a problem hiding this comment.
| export async function getLucideSvgString( | |
| iconName: string, | |
| ): Promise<string | null> { | |
| const iconNode = await getLucideIconNode(iconName); | |
| if (!iconNode) return null; | |
| return buildSvgString(iconNode); | |
| } | |
| export async function getLucideSvgString( | |
| iconName: string, | |
| ): Promise<string | null> { | |
| if (!isValidLucideIconName(iconName)) { | |
| return null; | |
| } | |
| const { default: Icon } = await dynamicIconImports[iconName](); | |
| return renderToStaticMarkup(createElement(Icon)); | |
| } |
So Claude taught me a new trick I had never considered. We can use the React server API renderToStaticMarkup() to essentially create the SVG for us from the React element that dynamicIconImports gives us. This eliminates all the SVG building code you had before. This also removes the need for the iconCache.
Vite's tree shaking will ensure we just pull in renderToStaticMarkup to the final bundle, so we don't get the entire react-dom/server module bloating our bundle size.
Description
Adds the Lucide icon library as a built-in source for node icons, introducing a searchable picker in the Node Styling panel alongside the existing custom-upload button.
Key architectural decision (per #1774): icon selections are stored as symbolic
lucide:<name>references rather than data URIs. Resolution to a renderable form happens at render time, keeping config and export files human-readable and allowing the picker to highlight the current selection without reverse-engineering a blob.Resolution at render time uses two paths depending on the consumer:
VertexIcon) — a newuseResolvedIconUrlhook backed by React Query (staleTime: Infinity) convertslucide:<name>to a base64 SVG data URI on first use and caches the result per session. Plain URLs anddata:URIs pass through viainitialData, so they render immediately without an async round-trip.renderNode) —getLucideSvgStringresolves the icon directly from thelucide-reactdynamic-import map, skipping any network fetch.Custom-uploaded icons (
data:URIs) are completely unaffected — both resolvers pass them through unchanged.The
IconPickercomponent renders a searchable popover (8-column icon grid, 50-icon default window, type-to-filter). It emits the symboliclucide:<name>ref so NodeStyleDialog stores the portable reference.This commit also fixes a pre-existing lint-staged misconfiguration where the root
oxlint --fixtask matchedvitest.config.ts, which oxlint then silently ignored (per its ownignorePatterns), causing a spurious exit-code 1 on pre-commit. The root lint-staged config now mirrors the oxlint ignore patterns.This is Slice 1 of the 3-slice split of #1589, per @kmcginnes review.
Validation
All four local gates pass:
pnpm run checks— oxlint, oxfmt, and TypeScript type-check all cleanpnpm test— 1738 / 1738 tests pass (45 new tests across 4 new test files)pnpm coverage— all thresholds met; new files have ~100% line coverage (branches floor bumped 44 to 45 by the new branch coverage)pnpm build— production build completes without warningsTo validate the picker manually: open the Node Styling panel for any vertex type, click "Browse", search for an icon name (e.g. "plane"), select it, and confirm the node label updates in the graph view. Re-opening the dialog should show the selected icon highlighted in the grid.
Related Issues
Addresses #1774
Slice 1 of #1589
Check List
pnpm checkspasses with no errors.pnpm testpasses with no failures.docs/features/graph-view.md)