feat: expand icon picker with ~290 icons, search, and scrollable grid

- Extract icon registry to src/utils/iconRegistry.ts with 290 curated Phosphor icons
- Add real-time search field that filters icons by name substring
- Show "No icons found" empty state when search yields no results
- Scrollable icon grid (240px max height) for browsing all icons
- Add teal and pink accent colors to the palette (8 total)
- Widen popover from 264px to 280px for better search field fit
- Update tests: search filtering, empty state, icon count validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-24 15:16:26 +01:00
parent 81195d6681
commit 4c6bc91971
7 changed files with 478 additions and 91 deletions

View File

@@ -6,7 +6,7 @@ import {
Users, CalendarBlank, Tag, FileText, StackSimple,
} from '@phosphor-icons/react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { resolveIcon } from './TypeCustomizePopover'
import { resolveIcon } from '../utils/iconRegistry'
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react'
import type { VaultEntry, SidebarSelection } from '../types'
import { resolveIcon, TypeCustomizePopover } from './TypeCustomizePopover'
import { resolveIcon } from '../utils/iconRegistry'
import { TypeCustomizePopover } from './TypeCustomizePopover'
import { useSectionVisibility } from '../hooks/useSectionVisibility'
import {
DndContext, closestCenter, KeyboardSensor, PointerSensor,

View File

@@ -1,17 +1,19 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { TypeCustomizePopover, resolveIcon, ICON_OPTIONS } from './TypeCustomizePopover'
import { TypeCustomizePopover } from './TypeCustomizePopover'
import { resolveIcon, ICON_OPTIONS } from '../utils/iconRegistry'
describe('resolveIcon', () => {
it('returns the correct icon component for known name', () => {
const Icon = resolveIcon('wrench')
expect(Icon).toBeDefined()
expect(Icon).not.toBe(ICON_OPTIONS[0].Icon) // should not be fallback (file-text)
// wrench should not be the default fallback (file-text)
const fileTextIcon = resolveIcon('file-text')
expect(Icon).not.toBe(fileTextIcon)
})
it('returns FileText fallback for null', () => {
const Icon = resolveIcon(null)
// FileText is the default
expect(Icon).toBeDefined()
})
@@ -21,6 +23,23 @@ describe('resolveIcon', () => {
})
})
describe('ICON_OPTIONS', () => {
it('contains 200+ icons', () => {
expect(ICON_OPTIONS.length).toBeGreaterThanOrEqual(200)
})
it('has unique names', () => {
const names = ICON_OPTIONS.map((o) => o.name)
expect(new Set(names).size).toBe(names.length)
})
it('uses kebab-case names', () => {
for (const option of ICON_OPTIONS) {
expect(option.name).toMatch(/^[a-z][a-z0-9-]*$/)
}
})
})
describe('TypeCustomizePopover', () => {
const onChangeIcon = vi.fn()
const onChangeColor = vi.fn()
@@ -45,6 +64,57 @@ describe('TypeCustomizePopover', () => {
expect(screen.getByText('Done')).toBeInTheDocument()
})
it('renders search input', () => {
render(
<TypeCustomizePopover
currentIcon={null}
currentColor={null}
onChangeIcon={onChangeIcon}
onChangeColor={onChangeColor}
onClose={onClose}
/>
)
expect(screen.getByPlaceholderText('Search icons…')).toBeInTheDocument()
})
it('filters icons by search query', () => {
render(
<TypeCustomizePopover
currentIcon={null}
currentColor={null}
onChangeIcon={onChangeIcon}
onChangeColor={onChangeColor}
onClose={onClose}
/>
)
const searchInput = screen.getByPlaceholderText('Search icons…')
fireEvent.change(searchInput, { target: { value: 'book' } })
// Should show book-related icons
expect(screen.getByTitle('book')).toBeInTheDocument()
expect(screen.getByTitle('book-open')).toBeInTheDocument()
// Should not show unrelated icons
expect(screen.queryByTitle('wrench')).not.toBeInTheDocument()
})
it('shows empty state when no icons match search', () => {
render(
<TypeCustomizePopover
currentIcon={null}
currentColor={null}
onChangeIcon={onChangeIcon}
onChangeColor={onChangeColor}
onClose={onClose}
/>
)
const searchInput = screen.getByPlaceholderText('Search icons…')
fireEvent.change(searchInput, { target: { value: 'zzzznonexistent' } })
expect(screen.getByText('No icons found')).toBeInTheDocument()
})
it('calls onChangeColor when a color is clicked', () => {
render(
<TypeCustomizePopover
@@ -56,8 +126,7 @@ describe('TypeCustomizePopover', () => {
/>
)
// Click the first color button (by title)
const colorButtons = screen.getAllByTitle(/red|blue|green|purple|yellow|orange/i)
const colorButtons = screen.getAllByTitle(/red|blue|green|purple|yellow|orange|teal|pink/i)
fireEvent.click(colorButtons[0])
expect(onChangeColor).toHaveBeenCalled()
@@ -74,9 +143,7 @@ describe('TypeCustomizePopover', () => {
/>
)
// Click the wrench icon
fireEvent.click(screen.getByTitle('wrench'))
expect(onChangeIcon).toHaveBeenCalledWith('wrench')
})
@@ -95,7 +162,7 @@ describe('TypeCustomizePopover', () => {
expect(onClose).toHaveBeenCalled()
})
it('renders all icon options', () => {
it('renders all color options including teal and pink', () => {
render(
<TypeCustomizePopover
currentIcon={null}
@@ -106,9 +173,7 @@ describe('TypeCustomizePopover', () => {
/>
)
// Should have buttons for each icon option
for (const option of ICON_OPTIONS.slice(0, 5)) {
expect(screen.getByTitle(option.name)).toBeInTheDocument()
}
expect(screen.getByTitle('Teal')).toBeInTheDocument()
expect(screen.getByTitle('Pink')).toBeInTheDocument()
})
})

View File

@@ -1,63 +1,13 @@
import { useState, type ComponentType } from 'react'
import {
FileText, Wrench, Flask, Target, ArrowsClockwise, Users, CalendarBlank,
Tag, StackSimple, BookOpen, CookingPot, Heart, Star, House, Lightbulb,
Briefcase, Gear, Cube, Leaf, MusicNote, Camera, Airplane, GameController,
PaintBrush, ShoppingCart, GraduationCap, Trophy, ChatCircle, Notebook,
MapPin, Code, Barbell, PawPrint, Pill, Knife,
type IconProps,
} from '@phosphor-icons/react'
import { useState, useMemo } from 'react'
import { MagnifyingGlass } from '@phosphor-icons/react'
import { ICON_OPTIONS, type IconEntry } from '../utils/iconRegistry'
import { ACCENT_COLORS } from '../utils/typeColors'
import { cn } from '@/lib/utils'
/** Curated Phosphor icons (normal weight) for type customization */
// eslint-disable-next-line react-refresh/only-export-components -- constant co-located with component
export const ICON_OPTIONS: { name: string; Icon: ComponentType<IconProps> }[] = [
{ name: 'file-text', Icon: FileText },
{ name: 'wrench', Icon: Wrench },
{ name: 'flask', Icon: Flask },
{ name: 'target', Icon: Target },
{ name: 'arrows-clockwise', Icon: ArrowsClockwise },
{ name: 'users', Icon: Users },
{ name: 'calendar-blank', Icon: CalendarBlank },
{ name: 'tag', Icon: Tag },
{ name: 'stack-simple', Icon: StackSimple },
{ name: 'book-open', Icon: BookOpen },
{ name: 'cooking-pot', Icon: CookingPot },
{ name: 'heart', Icon: Heart },
{ name: 'star', Icon: Star },
{ name: 'house', Icon: House },
{ name: 'lightbulb', Icon: Lightbulb },
{ name: 'briefcase', Icon: Briefcase },
{ name: 'gear', Icon: Gear },
{ name: 'cube', Icon: Cube },
{ name: 'leaf', Icon: Leaf },
{ name: 'music-note', Icon: MusicNote },
{ name: 'camera', Icon: Camera },
{ name: 'airplane', Icon: Airplane },
{ name: 'game-controller', Icon: GameController },
{ name: 'paint-brush', Icon: PaintBrush },
{ name: 'shopping-cart', Icon: ShoppingCart },
{ name: 'graduation-cap', Icon: GraduationCap },
{ name: 'trophy', Icon: Trophy },
{ name: 'chat-circle', Icon: ChatCircle },
{ name: 'notebook', Icon: Notebook },
{ name: 'map-pin', Icon: MapPin },
{ name: 'code', Icon: Code },
{ name: 'barbell', Icon: Barbell },
{ name: 'paw-print', Icon: PawPrint },
{ name: 'pill', Icon: Pill },
{ name: 'knife', Icon: Knife },
]
const ICON_MAP: Record<string, ComponentType<IconProps>> = Object.fromEntries(
ICON_OPTIONS.map((o) => [o.name, o.Icon]),
)
/** Resolves a Phosphor icon name to its component, with fallback to FileText */
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
export function resolveIcon(name: string | null): ComponentType<IconProps> {
return (name && ICON_MAP[name]) || FileText
function filterIcons(icons: IconEntry[], query: string): IconEntry[] {
if (!query) return icons
const lower = query.toLowerCase()
return icons.filter((o) => o.name.includes(lower))
}
interface TypeCustomizePopoverProps {
@@ -77,6 +27,9 @@ export function TypeCustomizePopover({
}: TypeCustomizePopoverProps) {
const [selectedColor, setSelectedColor] = useState(currentColor)
const [selectedIcon, setSelectedIcon] = useState(currentIcon)
const [search, setSearch] = useState('')
const filteredIcons = useMemo(() => filterIcons(ICON_OPTIONS, search), [search])
const handleColorClick = (key: string) => {
setSelectedColor(key)
@@ -91,13 +44,13 @@ export function TypeCustomizePopover({
return (
<div
className="bg-popover text-popover-foreground z-50 rounded-lg border shadow-md"
style={{ width: 264, padding: 12 }}
style={{ width: 280, padding: 12 }}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.stopPropagation()}
>
{/* Color section */}
<div className="font-mono-overline mb-2 text-muted-foreground">COLOR</div>
<div className="flex gap-2 mb-3">
<div className="flex gap-2 mb-3 flex-wrap">
{ACCENT_COLORS.map((c) => (
<button
key={c.key}
@@ -105,7 +58,7 @@ export function TypeCustomizePopover({
"flex items-center justify-center rounded-full border-2 cursor-pointer transition-all",
selectedColor === c.key ? "border-foreground scale-110" : "border-transparent hover:scale-105",
)}
style={{ width: 28, height: 28, backgroundColor: c.css, border: selectedColor === c.key ? '2px solid var(--foreground)' : '2px solid transparent' }}
style={{ width: 24, height: 24, backgroundColor: c.css, border: selectedColor === c.key ? '2px solid var(--foreground)' : '2px solid transparent' }}
onClick={() => handleColorClick(c.key)}
title={c.label}
/>
@@ -114,23 +67,46 @@ export function TypeCustomizePopover({
{/* Icon section */}
<div className="font-mono-overline mb-2 text-muted-foreground">ICON</div>
<div className="flex flex-wrap gap-1" style={{ maxHeight: 200, overflowY: 'auto' }}>
{ICON_OPTIONS.map(({ name, Icon }) => (
<button
key={name}
className={cn(
"flex items-center justify-center rounded cursor-pointer transition-colors",
selectedIcon === name
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
style={{ width: 30, height: 30 }}
onClick={() => handleIconClick(name)}
title={name}
>
<Icon size={16} />
</button>
))}
{/* Search input */}
<div className="relative mb-2">
<MagnifyingGlass
size={14}
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none"
/>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search icons…"
className="w-full rounded border border-border bg-background pl-7 pr-2 py-1 text-[12px] text-foreground placeholder:text-muted-foreground outline-none focus:border-primary"
/>
</div>
{/* Icon grid */}
<div className="flex flex-wrap gap-1 overflow-y-auto" style={{ maxHeight: 240 }}>
{filteredIcons.length === 0 ? (
<div className="w-full py-6 text-center text-[12px] text-muted-foreground">
No icons found
</div>
) : (
filteredIcons.map(({ name, Icon }) => (
<button
key={name}
className={cn(
"flex items-center justify-center rounded cursor-pointer transition-colors",
selectedIcon === name
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
style={{ width: 30, height: 30 }}
onClick={() => handleIconClick(name)}
title={name}
>
<Icon size={16} />
</button>
))
)}
</div>
{/* Done button */}

View File

@@ -78,6 +78,10 @@
--accent-purple-light: rgba(128, 90, 213, 0.1);
--accent-red-light: rgba(229, 62, 62, 0.1);
--accent-yellow-light: rgba(214, 158, 46, 0.1);
--accent-teal: #319795;
--accent-teal-light: rgba(49, 151, 149, 0.1);
--accent-pink: #D53F8C;
--accent-pink-light: rgba(213, 63, 140, 0.1);
--border-primary: #E9E9E7;
--border-subtle: #E9E9E7;
--border-input: #E9E9E7;

339
src/utils/iconRegistry.ts Normal file
View File

@@ -0,0 +1,339 @@
import type { ComponentType } from 'react'
import {
Acorn, AddressBook, Airplane, Alarm, Anchor, Aperture, AppWindow, Archive, Armchair, Article,
ArrowsClockwise, Atom, Baby, Backpack, Bag, Balloon, Bandaids, Bank, Barbell, Barcode,
Barn, Baseball, Basketball, Bed, Bell, Bicycle, Binoculars, Bird, Blueprint, Boat,
Bone, Book, BookBookmark, BookOpen, Bookmark, Books, BowlFood, Brain, Bread, Bridge,
Briefcase, Broadcast, Broom, Browser, Bug, Building, BuildingOffice, Buildings, Bus, Butterfly,
Cactus, Cake, Calculator, Calendar, CalendarBlank, CalendarCheck, Camera, Campfire, Car, Cat,
Certificate, Chair, Champagne, ChartBar, ChartDonut, ChartLine, ChartPie, Chat, ChatCircle, ChatDots,
Check, CheckCircle, ChefHat, Church, Circuitry, City, Clipboard, ClipboardText, Clock, Cloud,
CloudSun, Clover, Code, Coffee, Coin, Coins, Compass, Confetti, Cookie, CookingPot,
Couch, CreditCard, Crown, Cube, Database, Desktop, Detective, Diamond, Dna, Dog,
Door, Drop, Envelope, Eye, Eyeglasses, Factory, Fan, Farm, Feather, File,
FileCode, FileText, FilmReel, Fingerprint, Fire, FirstAid, Fish, Flag, Flame, Flashlight,
Flask, Flower, FlowerLotus, Folder, FolderOpen, Football, ForkKnife, Function, Funnel, GameController,
Gauge, Gavel, Gear, Ghost, Gift, GitBranch, Globe, GraduationCap, Guitar, Hammer,
Hand, Handshake, Headphones, Headset, Heart, Heartbeat, Horse, Hospital, Hourglass, House,
IceCream, IdentificationBadge, Image, Infinity as InfinityIcon, Island, Joystick, Kanban, Key, Keyboard, Knife,
Ladder, Lamp, Laptop, Leaf, Lifebuoy, Lightbulb, Lighthouse, Lightning, Link, List,
ListChecks, Lock, MagicWand, Magnet, MagnifyingGlass, Mailbox, MapPin, MaskHappy, Medal, Megaphone,
Meteor, Microphone, Microscope, Money, Monitor, Moon, Motorcycle, Mountains, MusicNote, Needle,
Network, Newspaper, Note, Notebook, Notepad, Package, PaintBrush, Palette, PaperPlane, Paperclip,
Park, PawPrint, Peace, Pen, Pencil, Person, Phone, Pi, PiggyBank, Pill,
Pizza, Planet, Plant, Playlist, Plug, PottedPlant, Prescription, Presentation, Printer, PushPin,
PuzzlePiece, QrCode, Quotes, Rabbit, Radio, Rainbow, Recycle, Robot, Rocket, Ruler,
Sailboat, Scales, Scissors, Scroll, Seal, SecurityCamera, Shield, ShieldCheck, ShootingStar, ShoppingBag,
ShoppingCart, Signature, Smiley, Sneaker, Snowflake, SoccerBall, Sparkle, Stack, StackSimple, Star,
Stethoscope, Storefront, Strategy, Student, Suitcase, Sun, Sword, TShirt, Tag, Target,
Television, Tent, Terminal, Ticket, Timer, Toolbox, Train, Translate, Trash, Tree,
TreeStructure, Trophy, Truck, Umbrella, User, UserCircle, Users, Vault, VinylRecord, Wallet,
Warning, Watch, Waves, Wind, Wine, Wrench,
type IconProps,
} from '@phosphor-icons/react'
export type { IconProps }
export type IconEntry = { name: string; Icon: ComponentType<IconProps> }
/**
* Curated set of ~290 Phosphor icons for type/section customization.
* Excludes: brand logos, UI-only icons (arrows, carets, alignment), redundant variants.
* Names are kebab-case, matching the format stored in vault frontmatter.
*/
export const ICON_OPTIONS: IconEntry[] = [
{ name: 'acorn', Icon: Acorn },
{ name: 'address-book', Icon: AddressBook },
{ name: 'airplane', Icon: Airplane },
{ name: 'alarm', Icon: Alarm },
{ name: 'anchor', Icon: Anchor },
{ name: 'aperture', Icon: Aperture },
{ name: 'app-window', Icon: AppWindow },
{ name: 'archive', Icon: Archive },
{ name: 'armchair', Icon: Armchair },
{ name: 'article', Icon: Article },
{ name: 'arrows-clockwise', Icon: ArrowsClockwise },
{ name: 'atom', Icon: Atom },
{ name: 'baby', Icon: Baby },
{ name: 'backpack', Icon: Backpack },
{ name: 'bag', Icon: Bag },
{ name: 'balloon', Icon: Balloon },
{ name: 'bandaids', Icon: Bandaids },
{ name: 'bank', Icon: Bank },
{ name: 'barbell', Icon: Barbell },
{ name: 'barcode', Icon: Barcode },
{ name: 'barn', Icon: Barn },
{ name: 'baseball', Icon: Baseball },
{ name: 'basketball', Icon: Basketball },
{ name: 'bed', Icon: Bed },
{ name: 'bell', Icon: Bell },
{ name: 'bicycle', Icon: Bicycle },
{ name: 'binoculars', Icon: Binoculars },
{ name: 'bird', Icon: Bird },
{ name: 'blueprint', Icon: Blueprint },
{ name: 'boat', Icon: Boat },
{ name: 'bone', Icon: Bone },
{ name: 'book', Icon: Book },
{ name: 'book-bookmark', Icon: BookBookmark },
{ name: 'book-open', Icon: BookOpen },
{ name: 'bookmark', Icon: Bookmark },
{ name: 'books', Icon: Books },
{ name: 'bowl-food', Icon: BowlFood },
{ name: 'brain', Icon: Brain },
{ name: 'bread', Icon: Bread },
{ name: 'bridge', Icon: Bridge },
{ name: 'briefcase', Icon: Briefcase },
{ name: 'broadcast', Icon: Broadcast },
{ name: 'broom', Icon: Broom },
{ name: 'browser', Icon: Browser },
{ name: 'bug', Icon: Bug },
{ name: 'building', Icon: Building },
{ name: 'building-office', Icon: BuildingOffice },
{ name: 'buildings', Icon: Buildings },
{ name: 'bus', Icon: Bus },
{ name: 'butterfly', Icon: Butterfly },
{ name: 'cactus', Icon: Cactus },
{ name: 'cake', Icon: Cake },
{ name: 'calculator', Icon: Calculator },
{ name: 'calendar', Icon: Calendar },
{ name: 'calendar-blank', Icon: CalendarBlank },
{ name: 'calendar-check', Icon: CalendarCheck },
{ name: 'camera', Icon: Camera },
{ name: 'campfire', Icon: Campfire },
{ name: 'car', Icon: Car },
{ name: 'cat', Icon: Cat },
{ name: 'certificate', Icon: Certificate },
{ name: 'chair', Icon: Chair },
{ name: 'champagne', Icon: Champagne },
{ name: 'chart-bar', Icon: ChartBar },
{ name: 'chart-donut', Icon: ChartDonut },
{ name: 'chart-line', Icon: ChartLine },
{ name: 'chart-pie', Icon: ChartPie },
{ name: 'chat', Icon: Chat },
{ name: 'chat-circle', Icon: ChatCircle },
{ name: 'chat-dots', Icon: ChatDots },
{ name: 'check', Icon: Check },
{ name: 'check-circle', Icon: CheckCircle },
{ name: 'chef-hat', Icon: ChefHat },
{ name: 'church', Icon: Church },
{ name: 'circuitry', Icon: Circuitry },
{ name: 'city', Icon: City },
{ name: 'clipboard', Icon: Clipboard },
{ name: 'clipboard-text', Icon: ClipboardText },
{ name: 'clock', Icon: Clock },
{ name: 'cloud', Icon: Cloud },
{ name: 'cloud-sun', Icon: CloudSun },
{ name: 'clover', Icon: Clover },
{ name: 'code', Icon: Code },
{ name: 'coffee', Icon: Coffee },
{ name: 'coin', Icon: Coin },
{ name: 'coins', Icon: Coins },
{ name: 'compass', Icon: Compass },
{ name: 'confetti', Icon: Confetti },
{ name: 'cookie', Icon: Cookie },
{ name: 'cooking-pot', Icon: CookingPot },
{ name: 'couch', Icon: Couch },
{ name: 'credit-card', Icon: CreditCard },
{ name: 'crown', Icon: Crown },
{ name: 'cube', Icon: Cube },
{ name: 'database', Icon: Database },
{ name: 'desktop', Icon: Desktop },
{ name: 'detective', Icon: Detective },
{ name: 'diamond', Icon: Diamond },
{ name: 'dna', Icon: Dna },
{ name: 'dog', Icon: Dog },
{ name: 'door', Icon: Door },
{ name: 'drop', Icon: Drop },
{ name: 'envelope', Icon: Envelope },
{ name: 'eye', Icon: Eye },
{ name: 'eyeglasses', Icon: Eyeglasses },
{ name: 'factory', Icon: Factory },
{ name: 'fan', Icon: Fan },
{ name: 'farm', Icon: Farm },
{ name: 'feather', Icon: Feather },
{ name: 'file', Icon: File },
{ name: 'file-code', Icon: FileCode },
{ name: 'file-text', Icon: FileText },
{ name: 'film-reel', Icon: FilmReel },
{ name: 'fingerprint', Icon: Fingerprint },
{ name: 'fire', Icon: Fire },
{ name: 'first-aid', Icon: FirstAid },
{ name: 'fish', Icon: Fish },
{ name: 'flag', Icon: Flag },
{ name: 'flame', Icon: Flame },
{ name: 'flashlight', Icon: Flashlight },
{ name: 'flask', Icon: Flask },
{ name: 'flower', Icon: Flower },
{ name: 'flower-lotus', Icon: FlowerLotus },
{ name: 'folder', Icon: Folder },
{ name: 'folder-open', Icon: FolderOpen },
{ name: 'football', Icon: Football },
{ name: 'fork-knife', Icon: ForkKnife },
{ name: 'function', Icon: Function },
{ name: 'funnel', Icon: Funnel },
{ name: 'game-controller', Icon: GameController },
{ name: 'gauge', Icon: Gauge },
{ name: 'gavel', Icon: Gavel },
{ name: 'gear', Icon: Gear },
{ name: 'ghost', Icon: Ghost },
{ name: 'gift', Icon: Gift },
{ name: 'git-branch', Icon: GitBranch },
{ name: 'globe', Icon: Globe },
{ name: 'graduation-cap', Icon: GraduationCap },
{ name: 'guitar', Icon: Guitar },
{ name: 'hammer', Icon: Hammer },
{ name: 'hand', Icon: Hand },
{ name: 'handshake', Icon: Handshake },
{ name: 'headphones', Icon: Headphones },
{ name: 'headset', Icon: Headset },
{ name: 'heart', Icon: Heart },
{ name: 'heartbeat', Icon: Heartbeat },
{ name: 'horse', Icon: Horse },
{ name: 'hospital', Icon: Hospital },
{ name: 'hourglass', Icon: Hourglass },
{ name: 'house', Icon: House },
{ name: 'ice-cream', Icon: IceCream },
{ name: 'identification-badge', Icon: IdentificationBadge },
{ name: 'image', Icon: Image },
{ name: 'infinity', Icon: InfinityIcon },
{ name: 'island', Icon: Island },
{ name: 'joystick', Icon: Joystick },
{ name: 'kanban', Icon: Kanban },
{ name: 'key', Icon: Key },
{ name: 'keyboard', Icon: Keyboard },
{ name: 'knife', Icon: Knife },
{ name: 'ladder', Icon: Ladder },
{ name: 'lamp', Icon: Lamp },
{ name: 'laptop', Icon: Laptop },
{ name: 'leaf', Icon: Leaf },
{ name: 'lifebuoy', Icon: Lifebuoy },
{ name: 'lightbulb', Icon: Lightbulb },
{ name: 'lighthouse', Icon: Lighthouse },
{ name: 'lightning', Icon: Lightning },
{ name: 'link', Icon: Link },
{ name: 'list', Icon: List },
{ name: 'list-checks', Icon: ListChecks },
{ name: 'lock', Icon: Lock },
{ name: 'magic-wand', Icon: MagicWand },
{ name: 'magnet', Icon: Magnet },
{ name: 'magnifying-glass', Icon: MagnifyingGlass },
{ name: 'mailbox', Icon: Mailbox },
{ name: 'map-pin', Icon: MapPin },
{ name: 'mask-happy', Icon: MaskHappy },
{ name: 'medal', Icon: Medal },
{ name: 'megaphone', Icon: Megaphone },
{ name: 'meteor', Icon: Meteor },
{ name: 'microphone', Icon: Microphone },
{ name: 'microscope', Icon: Microscope },
{ name: 'money', Icon: Money },
{ name: 'monitor', Icon: Monitor },
{ name: 'moon', Icon: Moon },
{ name: 'motorcycle', Icon: Motorcycle },
{ name: 'mountains', Icon: Mountains },
{ name: 'music-note', Icon: MusicNote },
{ name: 'needle', Icon: Needle },
{ name: 'network', Icon: Network },
{ name: 'newspaper', Icon: Newspaper },
{ name: 'note', Icon: Note },
{ name: 'notebook', Icon: Notebook },
{ name: 'notepad', Icon: Notepad },
{ name: 'package', Icon: Package },
{ name: 'paint-brush', Icon: PaintBrush },
{ name: 'palette', Icon: Palette },
{ name: 'paper-plane', Icon: PaperPlane },
{ name: 'paperclip', Icon: Paperclip },
{ name: 'park', Icon: Park },
{ name: 'paw-print', Icon: PawPrint },
{ name: 'peace', Icon: Peace },
{ name: 'pen', Icon: Pen },
{ name: 'pencil', Icon: Pencil },
{ name: 'person', Icon: Person },
{ name: 'phone', Icon: Phone },
{ name: 'pi', Icon: Pi },
{ name: 'piggy-bank', Icon: PiggyBank },
{ name: 'pill', Icon: Pill },
{ name: 'pizza', Icon: Pizza },
{ name: 'planet', Icon: Planet },
{ name: 'plant', Icon: Plant },
{ name: 'playlist', Icon: Playlist },
{ name: 'plug', Icon: Plug },
{ name: 'potted-plant', Icon: PottedPlant },
{ name: 'prescription', Icon: Prescription },
{ name: 'presentation', Icon: Presentation },
{ name: 'printer', Icon: Printer },
{ name: 'push-pin', Icon: PushPin },
{ name: 'puzzle-piece', Icon: PuzzlePiece },
{ name: 'qr-code', Icon: QrCode },
{ name: 'quotes', Icon: Quotes },
{ name: 'rabbit', Icon: Rabbit },
{ name: 'radio', Icon: Radio },
{ name: 'rainbow', Icon: Rainbow },
{ name: 'recycle', Icon: Recycle },
{ name: 'robot', Icon: Robot },
{ name: 'rocket', Icon: Rocket },
{ name: 'ruler', Icon: Ruler },
{ name: 'sailboat', Icon: Sailboat },
{ name: 'scales', Icon: Scales },
{ name: 'scissors', Icon: Scissors },
{ name: 'scroll', Icon: Scroll },
{ name: 'seal', Icon: Seal },
{ name: 'security-camera', Icon: SecurityCamera },
{ name: 'shield', Icon: Shield },
{ name: 'shield-check', Icon: ShieldCheck },
{ name: 'shooting-star', Icon: ShootingStar },
{ name: 'shopping-bag', Icon: ShoppingBag },
{ name: 'shopping-cart', Icon: ShoppingCart },
{ name: 'signature', Icon: Signature },
{ name: 'smiley', Icon: Smiley },
{ name: 'sneaker', Icon: Sneaker },
{ name: 'snowflake', Icon: Snowflake },
{ name: 'soccer-ball', Icon: SoccerBall },
{ name: 'sparkle', Icon: Sparkle },
{ name: 'stack', Icon: Stack },
{ name: 'stack-simple', Icon: StackSimple },
{ name: 'star', Icon: Star },
{ name: 'stethoscope', Icon: Stethoscope },
{ name: 'storefront', Icon: Storefront },
{ name: 'strategy', Icon: Strategy },
{ name: 'student', Icon: Student },
{ name: 'suitcase', Icon: Suitcase },
{ name: 'sun', Icon: Sun },
{ name: 'sword', Icon: Sword },
{ name: 't-shirt', Icon: TShirt },
{ name: 'tag', Icon: Tag },
{ name: 'target', Icon: Target },
{ name: 'television', Icon: Television },
{ name: 'tent', Icon: Tent },
{ name: 'terminal', Icon: Terminal },
{ name: 'ticket', Icon: Ticket },
{ name: 'timer', Icon: Timer },
{ name: 'toolbox', Icon: Toolbox },
{ name: 'train', Icon: Train },
{ name: 'translate', Icon: Translate },
{ name: 'trash', Icon: Trash },
{ name: 'tree', Icon: Tree },
{ name: 'tree-structure', Icon: TreeStructure },
{ name: 'trophy', Icon: Trophy },
{ name: 'truck', Icon: Truck },
{ name: 'umbrella', Icon: Umbrella },
{ name: 'user', Icon: User },
{ name: 'user-circle', Icon: UserCircle },
{ name: 'users', Icon: Users },
{ name: 'vault', Icon: Vault },
{ name: 'vinyl-record', Icon: VinylRecord },
{ name: 'wallet', Icon: Wallet },
{ name: 'warning', Icon: Warning },
{ name: 'watch', Icon: Watch },
{ name: 'waves', Icon: Waves },
{ name: 'wind', Icon: Wind },
{ name: 'wine', Icon: Wine },
{ name: 'wrench', Icon: Wrench },
]
const ICON_MAP: Record<string, ComponentType<IconProps>> = Object.fromEntries(
ICON_OPTIONS.map((o) => [o.name, o.Icon]),
)
/** Resolves a Phosphor icon name to its component, with fallback to FileText */
export function resolveIcon(name: string | null): ComponentType<IconProps> {
return (name && ICON_MAP[name]) || FileText
}

View File

@@ -36,6 +36,8 @@ export const ACCENT_COLORS: { key: string; label: string; css: string; cssLight:
{ key: 'green', label: 'Green', css: 'var(--accent-green)', cssLight: 'var(--accent-green-light)' },
{ key: 'blue', label: 'Blue', css: 'var(--accent-blue)', cssLight: 'var(--accent-blue-light)' },
{ key: 'purple', label: 'Purple', css: 'var(--accent-purple)', cssLight: 'var(--accent-purple-light)' },
{ key: 'teal', label: 'Teal', css: 'var(--accent-teal)', cssLight: 'var(--accent-teal-light)' },
{ key: 'pink', label: 'Pink', css: 'var(--accent-pink)', cssLight: 'var(--accent-pink-light)' },
]
const COLOR_KEY_TO_CSS: Record<string, string> = Object.fromEntries(