diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index 966b215b7..d14b64654 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -4,9 +4,10 @@ import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard, - Radio, Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass, - BookOpen, Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, + Radio, Luggage, Globe, Image, CalendarDays, Bell, + Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, } from 'lucide-react' +import PluginIcon from '../shared/PluginIcon' import { adminApi } from '../../api/client' import { usePluginStore } from '../../store/pluginStore' import { useTranslation } from '../../i18n' @@ -70,6 +71,8 @@ interface RegistryItem { repo: string homepage?: string | null type: string + /** Lucide icon name from the registry entry; absent → Blocks. */ + icon?: string | null latest: string | null minTrekVersion: string | null /** The latest version's declared TREK range; null on a legacy registry entry. */ @@ -171,11 +174,6 @@ const HEALTH: Record = { incompatible: 'bg-warning', } -// Manifest `icon` is a lucide name; map the common ones, fall back to Blocks. -const ICON_MAP: Record> = { - Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass, BookOpen, Wallet, Puzzle, Blocks, -} - // Known permissions → human-readable i18n key; unknown ones render as raw code. const PERM_KEYS = [ 'db:own', 'db:read:trips', 'db:read:users', 'db:read:costs', 'db:read:packing', 'db:read:files', 'db:read:files:content', @@ -297,11 +295,6 @@ function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: s return { blocked: true, label: t('admin.plugins.incompatible'), title } } -function PluginIcon({ name, size = 20, className }: { name: string | null; size?: number; className?: string }) { - const Icon = (name && Object.prototype.hasOwnProperty.call(ICON_MAP, name) && ICON_MAP[name]) || Blocks - return -} - function ReviewedBadge({ t, compact }: { t: T; compact?: boolean }) { if (compact) return return ( @@ -1340,7 +1333,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f )}
- +
diff --git a/client/src/components/Layout/BottomNav.test.tsx b/client/src/components/Layout/BottomNav.test.tsx index 717cf8fca..a4d751f45 100644 --- a/client/src/components/Layout/BottomNav.test.tsx +++ b/client/src/components/Layout/BottomNav.test.tsx @@ -21,6 +21,7 @@ import userEvent from '@testing-library/user-event'; import { useAuthStore } from '../../store/authStore'; import { useSettingsStore } from '../../store/settingsStore'; import { useAddonStore } from '../../store/addonStore'; +import { usePluginStore } from '../../store/pluginStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildSettings } from '../../../tests/helpers/factories'; import BottomNav from './BottomNav'; @@ -113,4 +114,21 @@ describe('BottomNav', () => { await user.click(screen.getByRole('button', { name: 'Add expense' })); expect(mockNavigate).toHaveBeenCalledWith('/trips/42?create=expense'); }); + + it('FE-COMP-BOTTOMNAV-011: page plugin renders the icon its manifest declares', () => { + seedStore(usePluginStore, { + plugins: [{ id: 'trip-doctor', name: 'Trip Doctor', type: 'page', icon: 'Stethoscope' }], + }); + const { container } = render(); + expect(screen.getByText('Trip Doctor')).toBeInTheDocument(); + expect(container.querySelector('.lucide-stethoscope')).not.toBeNull(); + }); + + it('FE-COMP-BOTTOMNAV-012: page plugin with an unknown icon falls back to Blocks', () => { + seedStore(usePluginStore, { + plugins: [{ id: 'bogus', name: 'Bogus', type: 'page', icon: 'NotAnIcon' }], + }); + const { container } = render(); + expect(container.querySelector('.lucide-blocks')).not.toBeNull(); + }); }); diff --git a/client/src/components/Layout/BottomNav.tsx b/client/src/components/Layout/BottomNav.tsx index 232c09bb8..09a8c6a1b 100644 --- a/client/src/components/Layout/BottomNav.tsx +++ b/client/src/components/Layout/BottomNav.tsx @@ -3,8 +3,9 @@ import { useAddonStore } from '../../store/addonStore' import { usePluginStore } from '../../store/pluginStore' import { useSettingsStore } from '../../store/settingsStore' import { useTranslation } from '../../i18n' -import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Plus, Blocks } from 'lucide-react' +import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Plus } from 'lucide-react' import type { LucideIcon } from 'lucide-react' +import { resolvePluginIcon } from '../shared/pluginIcon' const ADDON_NAV: Record = { vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' }, @@ -65,7 +66,7 @@ export default function BottomNav() { const nav = ADDON_NAV[addon.id] return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : [] }), - ...pagePlugins.map(p => ({ to: `/plugins/${p.id}`, label: p.name, icon: Blocks })), + ...pagePlugins.map(p => ({ to: `/plugins/${p.id}`, label: p.name, icon: resolvePluginIcon(p.icon) })), ] // Split the items so the raised "+" sits dead centre. const splitAt = Math.ceil(items.length / 2) diff --git a/client/src/components/Layout/Navbar.test.tsx b/client/src/components/Layout/Navbar.test.tsx index b0f8df240..4c098eb28 100644 --- a/client/src/components/Layout/Navbar.test.tsx +++ b/client/src/components/Layout/Navbar.test.tsx @@ -6,6 +6,7 @@ import { server } from '../../../tests/helpers/msw/server'; import { useAuthStore } from '../../store/authStore'; import { useSettingsStore } from '../../store/settingsStore'; import { useAddonStore } from '../../store/addonStore'; +import { usePluginStore } from '../../store/pluginStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildSettings } from '../../../tests/helpers/factories'; import Navbar from './Navbar'; @@ -304,4 +305,21 @@ describe('Navbar', () => { await user.click(screen.getByText('adminuser')); expect(screen.getByText('Administrator')).toBeInTheDocument(); }); + + it('FE-COMP-NAVBAR-034: page plugin renders the icon its manifest declares', () => { + seedStore(usePluginStore, { + plugins: [{ id: 'trip-doctor', name: 'Trip Doctor', type: 'page', icon: 'Stethoscope' }], + }); + const { container } = render(); + expect(screen.getByRole('link', { name: /trip doctor/i })).toBeInTheDocument(); + expect(container.querySelector('.lucide-stethoscope')).not.toBeNull(); + }); + + it('FE-COMP-NAVBAR-035: page plugin with an unknown icon falls back to Blocks', () => { + seedStore(usePluginStore, { + plugins: [{ id: 'bogus', name: 'Bogus', type: 'page', icon: 'NotAnIcon' }], + }); + const { container } = render(); + expect(container.querySelector('.lucide-blocks')).not.toBeNull(); + }); }); diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index d7db52733..2fb333616 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -6,9 +6,10 @@ import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' import { usePluginStore } from '../../store/pluginStore' import { useTranslation } from '../../i18n' -import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark, Blocks } from 'lucide-react' +import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import InAppNotificationBell from './InAppNotificationBell.tsx' +import { resolvePluginIcon } from '../shared/pluginIcon' const ADDON_ICONS: Record = { CalendarDays, Briefcase, Globe, Compass, Bookmark } @@ -150,7 +151,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: > {[{ id: '__trips', path: '/dashboard', label: t('nav.myTrips'), Icon: Briefcase }, ...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays })), - ...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: Blocks })) + ...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: resolvePluginIcon(p.icon) })) ].map(tab => { const isActive = location.pathname === tab.path return ( diff --git a/client/src/components/Plugins/PluginWidgets.tsx b/client/src/components/Plugins/PluginWidgets.tsx index afa243be0..663e31d8e 100644 --- a/client/src/components/Plugins/PluginWidgets.tsx +++ b/client/src/components/Plugins/PluginWidgets.tsx @@ -1,4 +1,4 @@ -import { Blocks } from 'lucide-react' +import PluginIcon from '../shared/PluginIcon' import PluginFrame from './PluginFrame' import type { ActivePlugin } from '../../store/pluginStore' @@ -35,7 +35,7 @@ export default function PluginWidgets({ plugins, tripId = null }: { plugins: Act color: 'var(--ink-3)', }} > - + {p.name}
{/* min-height is just a pre-resize floor; trek:resize drives the real height. */} diff --git a/client/src/components/Settings/PluginSettingsTab.tsx b/client/src/components/Settings/PluginSettingsTab.tsx index c561b9629..1512db9f6 100644 --- a/client/src/components/Settings/PluginSettingsTab.tsx +++ b/client/src/components/Settings/PluginSettingsTab.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' -import { Blocks, Save, Loader2, Link2, Unlink, CheckCircle } from 'lucide-react' +import { Save, Loader2, Link2, Unlink, CheckCircle } from 'lucide-react' +import PluginIcon from '../shared/PluginIcon' import { pluginsApi, type PluginUserSettingField, type PluginAction } from '../../api/client' import { usePluginStore } from '../../store/pluginStore' import { useToast } from '../shared/Toast' @@ -57,7 +58,7 @@ const SECRET_MASK = '••••••••' * here; the field list is trusted, validated manifest data. Secrets stay write-only * (masked, never echoed back). One form per active plugin that declares user fields. */ -function PluginSettingsForm({ id, name }: { id: string; name: string }) { +function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon: string | null }) { const { t } = useTranslation() const toast = useToast() const [fields, setFields] = useState(null) @@ -134,7 +135,7 @@ function PluginSettingsForm({ id, name }: { id: string; name: string }) { return (
- +

{name}

@@ -232,7 +233,7 @@ export default function PluginSettingsTab() {
{plugins.length === 0 ?

{t('settings.plugins.empty')}

- : plugins.map(p => )} + : plugins.map(p => )}
) diff --git a/client/src/components/shared/PluginIcon.test.tsx b/client/src/components/shared/PluginIcon.test.tsx new file mode 100644 index 000000000..f3a905f5a --- /dev/null +++ b/client/src/components/shared/PluginIcon.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import { render } from '@testing-library/react' +import { Blocks, Stethoscope } from 'lucide-react' +import { resolvePluginIcon } from './pluginIcon' +import PluginIcon from './PluginIcon' + +describe('resolvePluginIcon', () => { + it('resolves a lucide name outside the old 14-icon allowlist', () => { + expect(resolvePluginIcon('Stethoscope')).toBe(Stethoscope) + }) + + it('falls back to Blocks for a missing or unknown name', () => { + expect(resolvePluginIcon(null)).toBe(Blocks) + expect(resolvePluginIcon(undefined)).toBe(Blocks) + expect(resolvePluginIcon('')).toBe(Blocks) + expect(resolvePluginIcon('Stethscope')).toBe(Blocks) + }) + + // A manifest must not be able to reach lucide's non-icon exports. + it.each(['icons', 'createLucideIcon', 'default', 'module.exports'])( + 'falls back to Blocks for the non-icon export %s', + (name) => expect(resolvePluginIcon(name)).toBe(Blocks), + ) + + it('returns a stable reference so nav arrays do not remount the icon', () => { + expect(resolvePluginIcon('Stethoscope')).toBe(resolvePluginIcon('Stethoscope')) + }) +}) + +describe('PluginIcon', () => { + it('renders the plugin-declared icon', () => { + const { container } = render() + expect(container.querySelector('.lucide-stethoscope')).not.toBeNull() + }) + + it('renders Blocks when the plugin declares nothing', () => { + const { container } = render() + expect(container.querySelector('.lucide-blocks')).not.toBeNull() + }) +}) diff --git a/client/src/components/shared/PluginIcon.tsx b/client/src/components/shared/PluginIcon.tsx new file mode 100644 index 000000000..59f2afadf --- /dev/null +++ b/client/src/components/shared/PluginIcon.tsx @@ -0,0 +1,13 @@ +import type { CSSProperties } from 'react' +import { resolvePluginIcon } from './pluginIcon' + +/** Renders the lucide icon a plugin declares in its manifest (Blocks if unknown). */ +export default function PluginIcon({ name, size = 20, className, style }: { + name: string | null | undefined + size?: number + className?: string + style?: CSSProperties +}) { + const Icon = resolvePluginIcon(name) + return +} diff --git a/client/src/components/shared/pluginIcon.ts b/client/src/components/shared/pluginIcon.ts new file mode 100644 index 000000000..b7850be8b --- /dev/null +++ b/client/src/components/shared/pluginIcon.ts @@ -0,0 +1,22 @@ +import * as LucideIcons from 'lucide-react' +import { Blocks, type LucideIcon } from 'lucide-react' + +// Lucide names are PascalCase. Restricting the lookup to that shape keeps a manifest +// from reaching lucide's non-icon exports (`createLucideIcon`, `icons`, `default`, +// `module.exports`), which are all lowercase-initial. +const LUCIDE_ICON_NAME = /^[A-Z][A-Za-z0-9]*$/ + +/** + * Resolves a plugin manifest's `icon` (a lucide icon name, e.g. "Stethoscope") to the + * matching lucide component, falling back to Blocks for a missing or unknown name. + * + * Note lucide icons are forwardRef objects, not functions — never narrow this with + * `typeof x === 'function'`. The lookup returns a module-level reference, so the + * component identity is stable across renders and is safe to call inline while + * building nav/tab arrays. + */ +export function resolvePluginIcon(name: string | null | undefined): LucideIcon { + if (!name || !LUCIDE_ICON_NAME.test(name)) return Blocks + const icon = (LucideIcons as unknown as Record)[name] + return icon ?? Blocks +} diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index c9cdbfb05..6c2722c44 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -5,7 +5,8 @@ import { useCanDo } from '../../store/permissionsStore' import { useSettingsStore } from '../../store/settingsStore' import { getCached, fetchPhoto } from '../../services/photoService' import { useToast } from '../../components/shared/Toast' -import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train, Blocks } from 'lucide-react' +import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react' +import { resolvePluginIcon } from '../../components/shared/pluginIcon' import { useTranslation, translateApiError } from '../../i18n' import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client' import { parsedItemToDraft, isTransportItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft' @@ -127,8 +128,8 @@ export function useTripPlanner() { // Positioned plugin tabs splice in ascending order so two positions stay stable; // the rest append, exactly as before this capability existed. const positioned = tripPagePlugins.filter(p => p.tripPage?.position != null).sort((a, b) => (a.tripPage!.position! - b.tripPage!.position!)) - for (const p of positioned) TRIP_TABS.splice(Math.min(p.tripPage!.position!, TRIP_TABS.length), 0, { id: `plugin:${p.id}`, label: p.name, icon: Blocks }) - for (const p of tripPagePlugins.filter(p => p.tripPage?.position == null)) TRIP_TABS.push({ id: `plugin:${p.id}`, label: p.name, icon: Blocks }) + for (const p of positioned) TRIP_TABS.splice(Math.min(p.tripPage!.position!, TRIP_TABS.length), 0, { id: `plugin:${p.id}`, label: p.name, icon: resolvePluginIcon(p.icon) }) + for (const p of tripPagePlugins.filter(p => p.tripPage?.position == null)) TRIP_TABS.push({ id: `plugin:${p.id}`, label: p.name, icon: resolvePluginIcon(p.icon) }) const [activeTab, setActiveTab] = useState(() => { const saved = sessionStorage.getItem(`trip-tab-${tripId}`) diff --git a/plugin-sdk/scripts/gen-lucide-icon-names.mjs b/plugin-sdk/scripts/gen-lucide-icon-names.mjs new file mode 100644 index 000000000..bb6f3bd7e --- /dev/null +++ b/plugin-sdk/scripts/gen-lucide-icon-names.mjs @@ -0,0 +1,36 @@ +// Regenerates src/lucide-icon-names.ts from the lucide-react version installed at +// the repo root. Run from plugin-sdk/: `node scripts/gen-lucide-icon-names.mjs`. +// +// The SDK ships no lucide dependency of its own (it is a dependency-light CLI), so +// the name list is a checked-in snapshot rather than a runtime lookup — same shape +// as KNOWN_ADDONS in src/manifest.ts. +import { writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(join(here, '..', '..', 'package.json')); + +const dynamicIconImports = require('lucide-react/dynamicIconImports.js'); +const iconsByKebabName = dynamicIconImports.default ?? dynamicIconImports; +const { version } = require('lucide-react/package.json'); + +const toPascalCase = (kebab) => + kebab.split('-').map((part) => part[0].toUpperCase() + part.slice(1)).join(''); + +const names = Object.keys(iconsByKebabName).map(toPascalCase).sort(); + +const source = `// GENERATED — do not edit by hand. +// Snapshot of every lucide icon name (lucide-react ${version}), used by +// \`trek-plugin validate\` to WARN on a manifest \`icon\` lucide doesn't know — +// never a hard error (the host falls back to Blocks, and a plugin built for a +// newer TREK may name an icon this SDK predates). Regenerate with +// \`node scripts/gen-lucide-icon-names.mjs\`. +export const LUCIDE_ICON_NAMES: ReadonlySet = new Set([ +${names.map((n) => ' ' + JSON.stringify(n) + ',').join('\n')} +]); +`; + +writeFileSync(join(here, '..', 'src', 'lucide-icon-names.ts'), source); +console.log(`wrote ${names.length} icon names from lucide-react ${version}`); diff --git a/plugin-sdk/src/cli/entry.ts b/plugin-sdk/src/cli/entry.ts index 915fbfbed..e3ac17917 100644 --- a/plugin-sdk/src/cli/entry.ts +++ b/plugin-sdk/src/cli/entry.ts @@ -36,7 +36,7 @@ interface Version { } interface Entry { id: string; name: string; author: string; description: string; repo: string; - homepage?: string; tags?: string[]; type: string; authorPublicKey?: string; versions: Version[]; + homepage?: string; tags?: string[]; type: string; icon?: string; authorPublicKey?: string; versions: Version[]; } /** @@ -114,7 +114,11 @@ export function buildEntry(opts: { throw new Error('this plugin was published signed — sign the update too (pass --sign) or TREK will refuse it.'); } const versions = [version, ...existing.versions.filter((v) => v.version !== version.version)]; - return { ...existing, authorPublicKey: authorPublicKey ?? existing.authorPublicKey, versions }; + // Refresh the icon from the manifest when this release declares one (an author who adds + // or changes it should see it in the store), but never wipe an icon the entry already + // carries just because the manifest omits it. + const icon = typeof manifest.icon === 'string' && manifest.icon ? manifest.icon : existing.icon; + return { ...existing, icon, authorPublicKey: authorPublicKey ?? existing.authorPublicKey, versions }; } const entry: Entry = { @@ -126,6 +130,7 @@ export function buildEntry(opts: { type: String(manifest.type), versions: [version], }; + if (typeof manifest.icon === 'string' && manifest.icon) entry.icon = manifest.icon; if (typeof manifest.homepage === 'string') entry.homepage = manifest.homepage; if (Array.isArray(manifest.tags)) entry.tags = manifest.tags.map(String).slice(0, 8); if (authorPublicKey) entry.authorPublicKey = authorPublicKey; diff --git a/plugin-sdk/src/cli/validate.ts b/plugin-sdk/src/cli/validate.ts index 69b935e6f..546620c43 100644 --- a/plugin-sdk/src/cli/validate.ts +++ b/plugin-sdk/src/cli/validate.ts @@ -8,6 +8,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { validateManifest, isUnboundedRange, KNOWN_ADDONS } from '../manifest.js'; +import { LUCIDE_ICON_NAMES } from '../lucide-icon-names.js'; import { readJsonFile } from './json.js'; export interface ValidateReport { @@ -45,6 +46,14 @@ export function validatePluginDir(dir: string): ValidateReport { for (const a of res.manifest?.requiredAddons ?? []) { if (!KNOWN_ADDONS.includes(a)) warnings.push(`requiredAddons: "${a}" is not a known TREK addon on this SDK version`); } + // Same deal for `icon`: TREK resolves it against lucide at render time and falls + // back to Blocks on a name it can't find, so a typo is silent in the UI. Warn + // rather than error — the list is a snapshot, and a newer lucide may have names + // this SDK predates. + const icon = typeof raw.icon === 'string' ? raw.icon : ''; + if (icon && !LUCIDE_ICON_NAMES.has(icon)) { + warnings.push(`icon: "${icon}" is not a known lucide icon name on this SDK version — TREK will fall back to Blocks`); + } // TREK renders its UI with lucide icons only. It STRIPS emojis from the declarative // text your hooks return (badges, columns, warnings, PDF sections, map/calendar/photo // labels) and from notifications, so emojis in that text simply vanish at render. diff --git a/plugin-sdk/src/lucide-icon-names.ts b/plugin-sdk/src/lucide-icon-names.ts new file mode 100644 index 000000000..16902089c --- /dev/null +++ b/plugin-sdk/src/lucide-icon-names.ts @@ -0,0 +1,1443 @@ +// GENERATED — do not edit by hand. +// Snapshot of every lucide icon name (lucide-react 0.344.0), used by +// `trek-plugin validate` to WARN on a manifest `icon` lucide doesn't know — +// never a hard error (the host falls back to Blocks, and a plugin built for a +// newer TREK may name an icon this SDK predates). Regenerate with +// `node scripts/gen-lucide-icon-names.mjs`. +export const LUCIDE_ICON_NAMES: ReadonlySet = new Set([ + "AArrowDown", + "AArrowUp", + "ALargeSmall", + "Accessibility", + "Activity", + "ActivitySquare", + "AirVent", + "Airplay", + "AlarmClock", + "AlarmClockCheck", + "AlarmClockMinus", + "AlarmClockOff", + "AlarmClockPlus", + "AlarmSmoke", + "Album", + "AlertCircle", + "AlertOctagon", + "AlertTriangle", + "AlignCenter", + "AlignCenterHorizontal", + "AlignCenterVertical", + "AlignEndHorizontal", + "AlignEndVertical", + "AlignHorizontalDistributeCenter", + "AlignHorizontalDistributeEnd", + "AlignHorizontalDistributeStart", + "AlignHorizontalJustifyCenter", + "AlignHorizontalJustifyEnd", + "AlignHorizontalJustifyStart", + "AlignHorizontalSpaceAround", + "AlignHorizontalSpaceBetween", + "AlignJustify", + "AlignLeft", + "AlignRight", + "AlignStartHorizontal", + "AlignStartVertical", + "AlignVerticalDistributeCenter", + "AlignVerticalDistributeEnd", + "AlignVerticalDistributeStart", + "AlignVerticalJustifyCenter", + "AlignVerticalJustifyEnd", + "AlignVerticalJustifyStart", + "AlignVerticalSpaceAround", + "AlignVerticalSpaceBetween", + "Ambulance", + "Ampersand", + "Ampersands", + "Anchor", + "Angry", + "Annoyed", + "Antenna", + "Anvil", + "Aperture", + "AppWindow", + "Apple", + "Archive", + "ArchiveRestore", + "ArchiveX", + "AreaChart", + "Armchair", + "ArrowBigDown", + "ArrowBigDownDash", + "ArrowBigLeft", + "ArrowBigLeftDash", + "ArrowBigRight", + "ArrowBigRightDash", + "ArrowBigUp", + "ArrowBigUpDash", + "ArrowDown", + "ArrowDown01", + "ArrowDown10", + "ArrowDownAZ", + "ArrowDownCircle", + "ArrowDownFromLine", + "ArrowDownLeft", + "ArrowDownLeftFromCircle", + "ArrowDownLeftFromSquare", + "ArrowDownLeftSquare", + "ArrowDownNarrowWide", + "ArrowDownRight", + "ArrowDownRightFromCircle", + "ArrowDownRightFromSquare", + "ArrowDownRightSquare", + "ArrowDownSquare", + "ArrowDownToDot", + "ArrowDownToLine", + "ArrowDownUp", + "ArrowDownWideNarrow", + "ArrowDownZA", + "ArrowLeft", + "ArrowLeftCircle", + "ArrowLeftFromLine", + "ArrowLeftRight", + "ArrowLeftSquare", + "ArrowLeftToLine", + "ArrowRight", + "ArrowRightCircle", + "ArrowRightFromLine", + "ArrowRightLeft", + "ArrowRightSquare", + "ArrowRightToLine", + "ArrowUp", + "ArrowUp01", + "ArrowUp10", + "ArrowUpAZ", + "ArrowUpCircle", + "ArrowUpDown", + "ArrowUpFromDot", + "ArrowUpFromLine", + "ArrowUpLeft", + "ArrowUpLeftFromCircle", + "ArrowUpLeftFromSquare", + "ArrowUpLeftSquare", + "ArrowUpNarrowWide", + "ArrowUpRight", + "ArrowUpRightFromCircle", + "ArrowUpRightFromSquare", + "ArrowUpRightSquare", + "ArrowUpSquare", + "ArrowUpToLine", + "ArrowUpWideNarrow", + "ArrowUpZA", + "ArrowsUpFromLine", + "Asterisk", + "AsteriskSquare", + "AtSign", + "Atom", + "AudioLines", + "AudioWaveform", + "Award", + "Axe", + "Axis3d", + "Baby", + "Backpack", + "Badge", + "BadgeAlert", + "BadgeCent", + "BadgeCheck", + "BadgeDollarSign", + "BadgeEuro", + "BadgeHelp", + "BadgeIndianRupee", + "BadgeInfo", + "BadgeJapaneseYen", + "BadgeMinus", + "BadgePercent", + "BadgePlus", + "BadgePoundSterling", + "BadgeRussianRuble", + "BadgeSwissFranc", + "BadgeX", + "BaggageClaim", + "Ban", + "Banana", + "Banknote", + "BarChart", + "BarChart2", + "BarChart3", + "BarChart4", + "BarChartBig", + "BarChartHorizontal", + "BarChartHorizontalBig", + "Barcode", + "Baseline", + "Bath", + "Battery", + "BatteryCharging", + "BatteryFull", + "BatteryLow", + "BatteryMedium", + "BatteryWarning", + "Beaker", + "Bean", + "BeanOff", + "Bed", + "BedDouble", + "BedSingle", + "Beef", + "Beer", + "Bell", + "BellDot", + "BellElectric", + "BellMinus", + "BellOff", + "BellPlus", + "BellRing", + "BetweenHorizontalEnd", + "BetweenHorizontalStart", + "BetweenVerticalEnd", + "BetweenVerticalStart", + "Bike", + "Binary", + "Biohazard", + "Bird", + "Bitcoin", + "Blend", + "Blinds", + "Blocks", + "Bluetooth", + "BluetoothConnected", + "BluetoothOff", + "BluetoothSearching", + "Bold", + "Bolt", + "Bomb", + "Bone", + "Book", + "BookA", + "BookAudio", + "BookCheck", + "BookCopy", + "BookDashed", + "BookDown", + "BookHeadphones", + "BookHeart", + "BookImage", + "BookKey", + "BookLock", + "BookMarked", + "BookMinus", + "BookOpen", + "BookOpenCheck", + "BookOpenText", + "BookPlus", + "BookText", + "BookType", + "BookUp", + "BookUp2", + "BookUser", + "BookX", + "Bookmark", + "BookmarkCheck", + "BookmarkMinus", + "BookmarkPlus", + "BookmarkX", + "BoomBox", + "Bot", + "BotMessageSquare", + "Box", + "BoxSelect", + "Boxes", + "Braces", + "Brackets", + "Brain", + "BrainCircuit", + "BrainCog", + "BrickWall", + "Briefcase", + "BringToFront", + "Brush", + "Bug", + "BugOff", + "BugPlay", + "Building", + "Building2", + "Bus", + "BusFront", + "Cable", + "CableCar", + "Cake", + "CakeSlice", + "Calculator", + "Calendar", + "CalendarCheck", + "CalendarCheck2", + "CalendarClock", + "CalendarDays", + "CalendarFold", + "CalendarHeart", + "CalendarMinus", + "CalendarMinus2", + "CalendarOff", + "CalendarPlus", + "CalendarPlus2", + "CalendarRange", + "CalendarSearch", + "CalendarX", + "CalendarX2", + "Camera", + "CameraOff", + "CandlestickChart", + "Candy", + "CandyCane", + "CandyOff", + "Captions", + "CaptionsOff", + "Car", + "CarFront", + "CarTaxiFront", + "Caravan", + "Carrot", + "CaseLower", + "CaseSensitive", + "CaseUpper", + "CassetteTape", + "Cast", + "Castle", + "Cat", + "Cctv", + "Check", + "CheckCheck", + "CheckCircle", + "CheckCircle2", + "CheckSquare", + "CheckSquare2", + "ChefHat", + "Cherry", + "ChevronDown", + "ChevronDownCircle", + "ChevronDownSquare", + "ChevronFirst", + "ChevronLast", + "ChevronLeft", + "ChevronLeftCircle", + "ChevronLeftSquare", + "ChevronRight", + "ChevronRightCircle", + "ChevronRightSquare", + "ChevronUp", + "ChevronUpCircle", + "ChevronUpSquare", + "ChevronsDown", + "ChevronsDownUp", + "ChevronsLeft", + "ChevronsLeftRight", + "ChevronsRight", + "ChevronsRightLeft", + "ChevronsUp", + "ChevronsUpDown", + "Chrome", + "Church", + "Cigarette", + "CigaretteOff", + "Circle", + "CircleDashed", + "CircleDollarSign", + "CircleDot", + "CircleDotDashed", + "CircleEllipsis", + "CircleEqual", + "CircleFadingPlus", + "CircleOff", + "CircleSlash", + "CircleSlash2", + "CircleUser", + "CircleUserRound", + "CircuitBoard", + "Citrus", + "Clapperboard", + "Clipboard", + "ClipboardCheck", + "ClipboardCopy", + "ClipboardList", + "ClipboardMinus", + "ClipboardPaste", + "ClipboardPen", + "ClipboardPenLine", + "ClipboardPlus", + "ClipboardType", + "ClipboardX", + "Clock", + "Clock1", + "Clock10", + "Clock11", + "Clock12", + "Clock2", + "Clock3", + "Clock4", + "Clock5", + "Clock6", + "Clock7", + "Clock8", + "Clock9", + "Cloud", + "CloudCog", + "CloudDrizzle", + "CloudFog", + "CloudHail", + "CloudLightning", + "CloudMoon", + "CloudMoonRain", + "CloudOff", + "CloudRain", + "CloudRainWind", + "CloudSnow", + "CloudSun", + "CloudSunRain", + "Cloudy", + "Clover", + "Club", + "Code", + "Code2", + "CodeSquare", + "Codepen", + "Codesandbox", + "Coffee", + "Cog", + "Coins", + "Columns2", + "Columns3", + "Columns4", + "Combine", + "Command", + "Compass", + "Component", + "Computer", + "ConciergeBell", + "Cone", + "Construction", + "Contact", + "Contact2", + "Container", + "Contrast", + "Cookie", + "CookingPot", + "Copy", + "CopyCheck", + "CopyMinus", + "CopyPlus", + "CopySlash", + "CopyX", + "Copyleft", + "Copyright", + "CornerDownLeft", + "CornerDownRight", + "CornerLeftDown", + "CornerLeftUp", + "CornerRightDown", + "CornerRightUp", + "CornerUpLeft", + "CornerUpRight", + "Cpu", + "CreativeCommons", + "CreditCard", + "Croissant", + "Crop", + "Cross", + "Crosshair", + "Crown", + "Cuboid", + "CupSoda", + "Currency", + "Cylinder", + "Database", + "DatabaseBackup", + "DatabaseZap", + "Delete", + "Dessert", + "Diameter", + "Diamond", + "Dice1", + "Dice2", + "Dice3", + "Dice4", + "Dice5", + "Dice6", + "Dices", + "Diff", + "Disc", + "Disc2", + "Disc3", + "DiscAlbum", + "Divide", + "DivideCircle", + "DivideSquare", + "Dna", + "DnaOff", + "Dog", + "DollarSign", + "Donut", + "DoorClosed", + "DoorOpen", + "Dot", + "DotSquare", + "Download", + "DownloadCloud", + "DraftingCompass", + "Drama", + "Dribbble", + "Drill", + "Droplet", + "Droplets", + "Drum", + "Drumstick", + "Dumbbell", + "Ear", + "EarOff", + "Earth", + "EarthLock", + "Eclipse", + "Egg", + "EggFried", + "EggOff", + "Equal", + "EqualNot", + "EqualSquare", + "Eraser", + "Euro", + "Expand", + "ExternalLink", + "Eye", + "EyeOff", + "Facebook", + "Factory", + "Fan", + "FastForward", + "Feather", + "Fence", + "FerrisWheel", + "Figma", + "File", + "FileArchive", + "FileAudio", + "FileAudio2", + "FileAxis3d", + "FileBadge", + "FileBadge2", + "FileBarChart", + "FileBarChart2", + "FileBox", + "FileCheck", + "FileCheck2", + "FileClock", + "FileCode", + "FileCode2", + "FileCog", + "FileDiff", + "FileDigit", + "FileDown", + "FileHeart", + "FileImage", + "FileInput", + "FileJson", + "FileJson2", + "FileKey", + "FileKey2", + "FileLineChart", + "FileLock", + "FileLock2", + "FileMinus", + "FileMinus2", + "FileMusic", + "FileOutput", + "FilePen", + "FilePenLine", + "FilePieChart", + "FilePlus", + "FilePlus2", + "FileQuestion", + "FileScan", + "FileSearch", + "FileSearch2", + "FileSliders", + "FileSpreadsheet", + "FileStack", + "FileSymlink", + "FileTerminal", + "FileText", + "FileType", + "FileType2", + "FileUp", + "FileVideo", + "FileVideo2", + "FileVolume", + "FileVolume2", + "FileWarning", + "FileX", + "FileX2", + "Files", + "Film", + "Filter", + "FilterX", + "Fingerprint", + "FireExtinguisher", + "Fish", + "FishOff", + "FishSymbol", + "Flag", + "FlagOff", + "FlagTriangleLeft", + "FlagTriangleRight", + "Flame", + "FlameKindling", + "Flashlight", + "FlashlightOff", + "FlaskConical", + "FlaskConicalOff", + "FlaskRound", + "FlipHorizontal", + "FlipHorizontal2", + "FlipVertical", + "FlipVertical2", + "Flower", + "Flower2", + "Focus", + "FoldHorizontal", + "FoldVertical", + "Folder", + "FolderArchive", + "FolderCheck", + "FolderClock", + "FolderClosed", + "FolderCog", + "FolderDot", + "FolderDown", + "FolderGit", + "FolderGit2", + "FolderHeart", + "FolderInput", + "FolderKanban", + "FolderKey", + "FolderLock", + "FolderMinus", + "FolderOpen", + "FolderOpenDot", + "FolderOutput", + "FolderPen", + "FolderPlus", + "FolderRoot", + "FolderSearch", + "FolderSearch2", + "FolderSymlink", + "FolderSync", + "FolderTree", + "FolderUp", + "FolderX", + "Folders", + "Footprints", + "Forklift", + "FormInput", + "Forward", + "Frame", + "Framer", + "Frown", + "Fuel", + "Fullscreen", + "FunctionSquare", + "GalleryHorizontal", + "GalleryHorizontalEnd", + "GalleryThumbnails", + "GalleryVertical", + "GalleryVerticalEnd", + "Gamepad", + "Gamepad2", + "GanttChart", + "GanttChartSquare", + "Gauge", + "GaugeCircle", + "Gavel", + "Gem", + "Ghost", + "Gift", + "GitBranch", + "GitBranchPlus", + "GitCommitHorizontal", + "GitCommitVertical", + "GitCompare", + "GitCompareArrows", + "GitFork", + "GitGraph", + "GitMerge", + "GitPullRequest", + "GitPullRequestArrow", + "GitPullRequestClosed", + "GitPullRequestCreate", + "GitPullRequestCreateArrow", + "GitPullRequestDraft", + "Github", + "Gitlab", + "GlassWater", + "Glasses", + "Globe", + "GlobeLock", + "Goal", + "Grab", + "GraduationCap", + "Grape", + "Grid2x2", + "Grid3x3", + "Grip", + "GripHorizontal", + "GripVertical", + "Group", + "Guitar", + "Hammer", + "Hand", + "HandCoins", + "HandHeart", + "HandHelping", + "HandMetal", + "HandPlatter", + "Handshake", + "HardDrive", + "HardDriveDownload", + "HardDriveUpload", + "HardHat", + "Hash", + "Haze", + "HdmiPort", + "Heading", + "Heading1", + "Heading2", + "Heading3", + "Heading4", + "Heading5", + "Heading6", + "Headphones", + "Headset", + "Heart", + "HeartCrack", + "HeartHandshake", + "HeartOff", + "HeartPulse", + "Heater", + "HelpCircle", + "Hexagon", + "Highlighter", + "History", + "Home", + "Hop", + "HopOff", + "Hotel", + "Hourglass", + "IceCream", + "IceCream2", + "Image", + "ImageDown", + "ImageMinus", + "ImageOff", + "ImagePlus", + "ImageUp", + "Images", + "Import", + "Inbox", + "Indent", + "IndianRupee", + "Infinity", + "Info", + "InspectionPanel", + "Instagram", + "Italic", + "IterationCcw", + "IterationCw", + "JapaneseYen", + "Joystick", + "Kanban", + "KanbanSquare", + "KanbanSquareDashed", + "Key", + "KeyRound", + "KeySquare", + "Keyboard", + "KeyboardMusic", + "Lamp", + "LampCeiling", + "LampDesk", + "LampFloor", + "LampWallDown", + "LampWallUp", + "LandPlot", + "Landmark", + "Languages", + "Laptop", + "Laptop2", + "Lasso", + "LassoSelect", + "Laugh", + "Layers", + "Layers2", + "Layers3", + "LayoutDashboard", + "LayoutGrid", + "LayoutList", + "LayoutPanelLeft", + "LayoutPanelTop", + "LayoutTemplate", + "Leaf", + "LeafyGreen", + "Library", + "LibraryBig", + "LibrarySquare", + "LifeBuoy", + "Ligature", + "Lightbulb", + "LightbulbOff", + "LineChart", + "Link", + "Link2", + "Link2Off", + "Linkedin", + "List", + "ListChecks", + "ListCollapse", + "ListEnd", + "ListFilter", + "ListMinus", + "ListMusic", + "ListOrdered", + "ListPlus", + "ListRestart", + "ListStart", + "ListTodo", + "ListTree", + "ListVideo", + "ListX", + "Loader", + "Loader2", + "Locate", + "LocateFixed", + "LocateOff", + "Lock", + "LockKeyhole", + "LogIn", + "LogOut", + "Lollipop", + "Luggage", + "MSquare", + "Magnet", + "Mail", + "MailCheck", + "MailMinus", + "MailOpen", + "MailPlus", + "MailQuestion", + "MailSearch", + "MailWarning", + "MailX", + "Mailbox", + "Mails", + "Map", + "MapPin", + "MapPinOff", + "MapPinned", + "Martini", + "Maximize", + "Maximize2", + "Medal", + "Megaphone", + "MegaphoneOff", + "Meh", + "MemoryStick", + "Menu", + "MenuSquare", + "Merge", + "MessageCircle", + "MessageCircleCode", + "MessageCircleDashed", + "MessageCircleHeart", + "MessageCircleMore", + "MessageCircleOff", + "MessageCirclePlus", + "MessageCircleQuestion", + "MessageCircleReply", + "MessageCircleWarning", + "MessageCircleX", + "MessageSquare", + "MessageSquareCode", + "MessageSquareDashed", + "MessageSquareDiff", + "MessageSquareDot", + "MessageSquareHeart", + "MessageSquareMore", + "MessageSquareOff", + "MessageSquarePlus", + "MessageSquareQuote", + "MessageSquareReply", + "MessageSquareShare", + "MessageSquareText", + "MessageSquareWarning", + "MessageSquareX", + "MessagesSquare", + "Mic", + "Mic2", + "MicOff", + "Microscope", + "Microwave", + "Milestone", + "Milk", + "MilkOff", + "Minimize", + "Minimize2", + "Minus", + "MinusCircle", + "MinusSquare", + "Monitor", + "MonitorCheck", + "MonitorDot", + "MonitorDown", + "MonitorOff", + "MonitorPause", + "MonitorPlay", + "MonitorSmartphone", + "MonitorSpeaker", + "MonitorStop", + "MonitorUp", + "MonitorX", + "Moon", + "MoonStar", + "MoreHorizontal", + "MoreVertical", + "Mountain", + "MountainSnow", + "Mouse", + "MousePointer", + "MousePointer2", + "MousePointerClick", + "MousePointerSquare", + "MousePointerSquareDashed", + "Move", + "Move3d", + "MoveDiagonal", + "MoveDiagonal2", + "MoveDown", + "MoveDownLeft", + "MoveDownRight", + "MoveHorizontal", + "MoveLeft", + "MoveRight", + "MoveUp", + "MoveUpLeft", + "MoveUpRight", + "MoveVertical", + "Music", + "Music2", + "Music3", + "Music4", + "Navigation", + "Navigation2", + "Navigation2Off", + "NavigationOff", + "Network", + "Newspaper", + "Nfc", + "Notebook", + "NotebookPen", + "NotebookTabs", + "NotebookText", + "NotepadText", + "NotepadTextDashed", + "Nut", + "NutOff", + "Octagon", + "Option", + "Orbit", + "Outdent", + "Package", + "Package2", + "PackageCheck", + "PackageMinus", + "PackageOpen", + "PackagePlus", + "PackageSearch", + "PackageX", + "PaintBucket", + "PaintRoller", + "Paintbrush", + "Paintbrush2", + "Palette", + "Palmtree", + "PanelBottom", + "PanelBottomClose", + "PanelBottomDashed", + "PanelBottomOpen", + "PanelLeft", + "PanelLeftClose", + "PanelLeftDashed", + "PanelLeftOpen", + "PanelRight", + "PanelRightClose", + "PanelRightDashed", + "PanelRightOpen", + "PanelTop", + "PanelTopClose", + "PanelTopDashed", + "PanelTopOpen", + "PanelsLeftBottom", + "PanelsRightBottom", + "PanelsTopLeft", + "Paperclip", + "Parentheses", + "ParkingCircle", + "ParkingCircleOff", + "ParkingMeter", + "ParkingSquare", + "ParkingSquareOff", + "PartyPopper", + "Pause", + "PauseCircle", + "PauseOctagon", + "PawPrint", + "PcCase", + "Pen", + "PenLine", + "PenTool", + "Pencil", + "PencilLine", + "PencilRuler", + "Pentagon", + "Percent", + "PercentCircle", + "PercentDiamond", + "PercentSquare", + "PersonStanding", + "Phone", + "PhoneCall", + "PhoneForwarded", + "PhoneIncoming", + "PhoneMissed", + "PhoneOff", + "PhoneOutgoing", + "Pi", + "PiSquare", + "Piano", + "Pickaxe", + "PictureInPicture", + "PictureInPicture2", + "PieChart", + "PiggyBank", + "Pilcrow", + "PilcrowSquare", + "Pill", + "Pin", + "PinOff", + "Pipette", + "Pizza", + "Plane", + "PlaneLanding", + "PlaneTakeoff", + "Play", + "PlayCircle", + "PlaySquare", + "Plug", + "Plug2", + "PlugZap", + "PlugZap2", + "Plus", + "PlusCircle", + "PlusSquare", + "Pocket", + "PocketKnife", + "Podcast", + "Pointer", + "PointerOff", + "Popcorn", + "Popsicle", + "PoundSterling", + "Power", + "PowerCircle", + "PowerOff", + "PowerSquare", + "Presentation", + "Printer", + "Projector", + "Puzzle", + "Pyramid", + "QrCode", + "Quote", + "Rabbit", + "Radar", + "Radiation", + "Radical", + "Radio", + "RadioReceiver", + "RadioTower", + "Radius", + "RailSymbol", + "Rainbow", + "Rat", + "Ratio", + "Receipt", + "ReceiptCent", + "ReceiptEuro", + "ReceiptIndianRupee", + "ReceiptJapaneseYen", + "ReceiptPoundSterling", + "ReceiptRussianRuble", + "ReceiptSwissFranc", + "ReceiptText", + "RectangleHorizontal", + "RectangleVertical", + "Recycle", + "Redo", + "Redo2", + "RedoDot", + "RefreshCcw", + "RefreshCcwDot", + "RefreshCw", + "RefreshCwOff", + "Refrigerator", + "Regex", + "RemoveFormatting", + "Repeat", + "Repeat1", + "Repeat2", + "Replace", + "ReplaceAll", + "Reply", + "ReplyAll", + "Rewind", + "Ribbon", + "Rocket", + "RockingChair", + "RollerCoaster", + "Rotate3d", + "RotateCcw", + "RotateCw", + "Route", + "RouteOff", + "Router", + "Rows2", + "Rows3", + "Rows4", + "Rss", + "Ruler", + "RussianRuble", + "Sailboat", + "Salad", + "Sandwich", + "Satellite", + "SatelliteDish", + "Save", + "SaveAll", + "Scale", + "Scale3d", + "Scaling", + "Scan", + "ScanBarcode", + "ScanEye", + "ScanFace", + "ScanLine", + "ScanSearch", + "ScanText", + "ScatterChart", + "School", + "School2", + "Scissors", + "ScissorsLineDashed", + "ScissorsSquare", + "ScissorsSquareDashedBottom", + "ScreenShare", + "ScreenShareOff", + "Scroll", + "ScrollText", + "Search", + "SearchCheck", + "SearchCode", + "SearchSlash", + "SearchX", + "Send", + "SendHorizontal", + "SendToBack", + "SeparatorHorizontal", + "SeparatorVertical", + "Server", + "ServerCog", + "ServerCrash", + "ServerOff", + "Settings", + "Settings2", + "Shapes", + "Share", + "Share2", + "Sheet", + "Shell", + "Shield", + "ShieldAlert", + "ShieldBan", + "ShieldCheck", + "ShieldEllipsis", + "ShieldHalf", + "ShieldMinus", + "ShieldOff", + "ShieldPlus", + "ShieldQuestion", + "ShieldX", + "Ship", + "ShipWheel", + "Shirt", + "ShoppingBag", + "ShoppingBasket", + "ShoppingCart", + "Shovel", + "ShowerHead", + "Shrink", + "Shrub", + "Shuffle", + "Sigma", + "SigmaSquare", + "Signal", + "SignalHigh", + "SignalLow", + "SignalMedium", + "SignalZero", + "Signpost", + "SignpostBig", + "Siren", + "SkipBack", + "SkipForward", + "Skull", + "Slack", + "Slash", + "SlashSquare", + "Slice", + "Sliders", + "SlidersHorizontal", + "Smartphone", + "SmartphoneCharging", + "SmartphoneNfc", + "Smile", + "SmilePlus", + "Snail", + "Snowflake", + "Sofa", + "Soup", + "Space", + "Spade", + "Sparkle", + "Sparkles", + "Speaker", + "Speech", + "SpellCheck", + "SpellCheck2", + "Spline", + "Split", + "SplitSquareHorizontal", + "SplitSquareVertical", + "SprayCan", + "Sprout", + "Square", + "SquareDashedBottom", + "SquareDashedBottomCode", + "SquarePen", + "SquareRadical", + "SquareStack", + "SquareUser", + "SquareUserRound", + "Squircle", + "Squirrel", + "Stamp", + "Star", + "StarHalf", + "StarOff", + "StepBack", + "StepForward", + "Stethoscope", + "Sticker", + "StickyNote", + "StopCircle", + "Store", + "StretchHorizontal", + "StretchVertical", + "Strikethrough", + "Subscript", + "Sun", + "SunDim", + "SunMedium", + "SunMoon", + "SunSnow", + "Sunrise", + "Sunset", + "Superscript", + "SwatchBook", + "SwissFranc", + "SwitchCamera", + "Sword", + "Swords", + "Syringe", + "Table", + "Table2", + "TableCellsMerge", + "TableCellsSplit", + "TableColumnsSplit", + "TableProperties", + "TableRowsSplit", + "Tablet", + "TabletSmartphone", + "Tablets", + "Tag", + "Tags", + "Tally1", + "Tally2", + "Tally3", + "Tally4", + "Tally5", + "Tangent", + "Target", + "Telescope", + "Tent", + "TentTree", + "Terminal", + "TerminalSquare", + "TestTube", + "TestTube2", + "TestTubes", + "Text", + "TextCursor", + "TextCursorInput", + "TextQuote", + "TextSearch", + "TextSelect", + "Theater", + "Thermometer", + "ThermometerSnowflake", + "ThermometerSun", + "ThumbsDown", + "ThumbsUp", + "Ticket", + "TicketCheck", + "TicketMinus", + "TicketPercent", + "TicketPlus", + "TicketSlash", + "TicketX", + "Timer", + "TimerOff", + "TimerReset", + "ToggleLeft", + "ToggleRight", + "Tornado", + "Torus", + "Touchpad", + "TouchpadOff", + "TowerControl", + "ToyBrick", + "Tractor", + "TrafficCone", + "TrainFront", + "TrainFrontTunnel", + "TrainTrack", + "TramFront", + "Trash", + "Trash2", + "TreeDeciduous", + "TreePine", + "Trees", + "Trello", + "TrendingDown", + "TrendingUp", + "Triangle", + "TriangleRight", + "Trophy", + "Truck", + "Turtle", + "Tv", + "Tv2", + "Twitch", + "Twitter", + "Type", + "Umbrella", + "UmbrellaOff", + "Underline", + "Undo", + "Undo2", + "UndoDot", + "UnfoldHorizontal", + "UnfoldVertical", + "Ungroup", + "Unlink", + "Unlink2", + "Unlock", + "UnlockKeyhole", + "Unplug", + "Upload", + "UploadCloud", + "Usb", + "User", + "UserCheck", + "UserCog", + "UserMinus", + "UserPlus", + "UserRound", + "UserRoundCheck", + "UserRoundCog", + "UserRoundMinus", + "UserRoundPlus", + "UserRoundSearch", + "UserRoundX", + "UserSearch", + "UserX", + "Users", + "UsersRound", + "Utensils", + "UtensilsCrossed", + "UtilityPole", + "Variable", + "Vault", + "Vegan", + "VenetianMask", + "Vibrate", + "VibrateOff", + "Video", + "VideoOff", + "Videotape", + "View", + "Voicemail", + "Volume", + "Volume1", + "Volume2", + "VolumeX", + "Vote", + "Wallet", + "Wallet2", + "WalletCards", + "Wallpaper", + "Wand", + "Wand2", + "Warehouse", + "WashingMachine", + "Watch", + "Waves", + "Waypoints", + "Webcam", + "Webhook", + "WebhookOff", + "Weight", + "Wheat", + "WheatOff", + "WholeWord", + "Wifi", + "WifiOff", + "Wind", + "Wine", + "WineOff", + "Workflow", + "WrapText", + "Wrench", + "X", + "XCircle", + "XOctagon", + "XSquare", + "Youtube", + "Zap", + "ZapOff", + "ZoomIn", + "ZoomOut", +]); diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts index f5eabec73..ce128fdb2 100644 --- a/plugin-sdk/test/sdk.test.ts +++ b/plugin-sdk/test/sdk.test.ts @@ -409,6 +409,32 @@ describe('scaffold + validate CLIs', () => { expect(r.warnings.some((w) => /placeholder|screenshot/.test(w))).toBe(true); // README is the unfilled template }); + // TREK resolves `icon` against lucide at render time and falls back to Blocks on a + // name it can't find, so a typo is invisible in the UI — validate has to surface it. + it('warns on an icon lucide does not know, without failing validation', () => { + scaffold('icon-plug', 'widget', tmp); + const dir = path.join(tmp, 'icon-plug'); + const file = path.join(dir, 'trek-plugin.json'); + const m = JSON.parse(fs.readFileSync(file, 'utf8')); + fs.writeFileSync(file, JSON.stringify({ ...m, icon: 'Stethscope' }, null, 2)); + + const r = validatePluginDir(dir); + expect(r.ok).toBe(true); // a bad icon is never a hard error + expect(r.warnings.some((w) => /icon: "Stethscope" is not a known lucide icon name/.test(w))).toBe(true); + }); + + it('accepts any real lucide icon name, not just a curated few', () => { + scaffold('icon-plug', 'widget', tmp); + const dir = path.join(tmp, 'icon-plug'); + const file = path.join(dir, 'trek-plugin.json'); + const m = JSON.parse(fs.readFileSync(file, 'utf8')); + fs.writeFileSync(file, JSON.stringify({ ...m, icon: 'Stethoscope' }, null, 2)); + + const r = validatePluginDir(dir); + expect(r.ok).toBe(true); + expect(r.warnings.some((w) => /lucide icon name/.test(w))).toBe(false); + }); + it('scaffolds a client that opts into the design kit via the marker (source stays one line)', () => { scaffold('kit-plug', 'widget', tmp); const html = fs.readFileSync(path.join(tmp, 'kit-plug', 'client', 'index.html'), 'utf8'); @@ -618,6 +644,49 @@ describe('pack + entry (publishing automation)', () => { expect(v.nativeModules).toBe(false); }); + // The store tile reads `icon` off the registry ENTRY (the index has no manifest to consult), + // so if the entry generator drops it, every plugin in the store renders as a generic Blocks. + it('carries the manifest icon onto the registry entry', () => { + const out = path.join(tmp, 'plugin.zip'); + packPluginDir(koffi, out); + const entry = buildEntry({ + dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out, + commit: 'a'.repeat(40), now: '2026-07-04T00:00:00.000Z', + }); + expect(entry.icon).toBe('Luggage'); // koffi's trek-plugin.json declares it + }); + + it('--merge refreshes the icon from the manifest, but never wipes one the entry already carries', () => { + const out = path.join(tmp, 'plugin.zip'); + packPluginDir(koffi, out); + const prior = (icon: string) => { + const p = path.join(tmp, `prior-${icon}.json`); + fs.writeFileSync(p, JSON.stringify({ + id: 'koffi', name: 'Koffi', author: 'TREK', description: 'x', + repo: 'mauriceboe/trek-plugin-koffi', type: 'widget', icon, + versions: [{ version: '0.9.0', gitTag: 'v0.9.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://x/y.zip', sha256: 'c'.repeat(64), size: 10, apiVersion: 1, nativeModules: false, publishedAt: '2026-01-01T00:00:00.000Z' }], + })); + return p; + }; + + // koffi's manifest declares Luggage → an author who rebrands sees it in the store + const rebranded = buildEntry({ + dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out, + commit: 'a'.repeat(40), now: '2026-07-04T00:00:00.000Z', mergePath: prior('Coffee'), + }); + expect(rebranded.icon).toBe('Luggage'); + + // a manifest that declares NO icon leaves the entry's existing one alone + const noIconOpts = entryOptsFor('no-icon', '>=3.2.0 <4.0.0'); + const keepPath = path.join(tmp, 'keep.json'); + fs.writeFileSync(keepPath, JSON.stringify({ + id: 'no-icon', name: 'No Icon', author: 'TREK', description: 'x', repo: 'a/b', + type: 'integration', icon: 'Coffee', versions: [], + })); + const kept = buildEntry({ ...noIconOpts, now: '2026-07-04T00:00:00.000Z', mergePath: keepPath }); + expect(kept.icon).toBe('Coffee'); + }); + it('publishes an upper-bound-only range verbatim, with no floor to get wrong', () => { // This is why the derived floor is gone. "<4.0.0" has a first X.Y.Z of 4.0.0 but a lower // bound of 0.0.0, and the old regex published 4.0.0 as the MINIMUM — advertising a plugin diff --git a/server/src/nest/plugins/registry/registry.service.ts b/server/src/nest/plugins/registry/registry.service.ts index fd66867cb..47ab9b322 100644 --- a/server/src/nest/plugins/registry/registry.service.ts +++ b/server/src/nest/plugins/registry/registry.service.ts @@ -68,6 +68,8 @@ export interface RegistryEntry { homepage?: string; tags?: string[]; type: string; + /** A lucide icon name for the store tile; absent means the client's default (Blocks). */ + icon?: string; reviewedAt?: string | null; /** base64 minisign author public key — stable across versions, TOFU-pinned. */ authorPublicKey?: string; @@ -176,7 +178,7 @@ export class PluginRegistryService { const latest = p.versions[0] ?? null; return { id: p.id, name: p.name, author: p.author, description: p.description, repo: p.repo, - homepage: p.homepage, tags: p.tags, type: p.type, reviewedAt: p.reviewedAt ?? null, + homepage: p.homepage, tags: p.tags, type: p.type, icon: p.icon, reviewedAt: p.reviewedAt ?? null, downloadCount: p.downloadCount ?? null, latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null, requiredAddons: latest?.requiredAddons ?? [], pluginDependencies: latest?.pluginDependencies ?? [], diff --git a/server/tests/integration/plugins/registry.test.ts b/server/tests/integration/plugins/registry.test.ts index b3dd49c42..a84b2321e 100644 --- a/server/tests/integration/plugins/registry.test.ts +++ b/server/tests/integration/plugins/registry.test.ts @@ -68,7 +68,7 @@ const REGISTRY = { plugins: [ { id: 'flight-tracker', name: 'Flight', author: 'Acme', description: 'flights', repo: 'acme/trek-flight', - type: 'widget', reviewedAt: '2026-06-20', + type: 'widget', icon: 'Plane', reviewedAt: '2026-06-20', versions: [{ version: '1.0.0', gitTag: 'v1.0.0', commitSha: 'a'.repeat(40), downloadUrl: 'https://codeload.github.com/acme/trek-flight/tar.gz/aaaa', sha256: '', minTrekVersion: '3.2.0' }], }, ], @@ -124,6 +124,13 @@ describe('PluginRegistryService', () => { ]); }); + // The store tile has no manifest to read (the aggregated index carries no manifests), so + // the entry's icon is the only thing standing between it and a generic Blocks glyph. + it('browse carries the entry icon through to the store tile', async () => { + const [entry] = await svc.browse(); + expect(entry.icon).toBe('Plane'); + }); + it('browse exposes a screenshot url pinned at the reviewed commit', async () => { const list = await svc.browse(); expect(list[0].screenshotUrl).toBe(`https://raw.githubusercontent.com/acme/trek-flight/${'a'.repeat(40)}/docs/screenshot.png`);