Feature: Support Arbitrary Icon Variants with Presets and Custom Variants
Feature: Support Arbitrary Icon Variants with Presets and Custom Variants
Overview
Currently, the icon system only supports a limited set of hardcoded variants (light, dark, wordmark.light, wordmark.dark). This feature request aims to refactor the system to support:
- Preset variants:
default,light,dark,wordmark-default,wordmark-light,wordmark-dark - Custom variants: Any arbitrary variant name (e.g.,
monochrome,outline,filled,branded, etc.) - Bulletproof rendering: The system must be able to display any icon variant without breaking, even if the variant structure is unexpected
- Code reuse: Unify the logic for displaying icons between collection pages (
/icons/[icon]) and community pages (/community/[icon]) to reduce duplication
Current Architecture & Data Flow
How Icons Are Currently Stored
Collection Icons (Main Repository)
Collection icons are stored in the repository with the following structure:
File System: Icons are stored in three directories:
/svg/- SVG format files/png/- PNG format files/webp/- WEBP format files
Metadata Structure (
metadata.json):{ "icon-name": { "base": "svg", "aliases": ["alias1", "alias2"], "categories": ["category1"], "update": { "timestamp": "2024-01-01T00:00:00Z", "author": { "id": 12345, "login": "username" } }, "colors": { "light": "icon-name-light", "dark": "icon-name-dark" }, "wordmark": { "light": "icon-name-wordmark-light", "dark": "icon-name-wordmark-dark" } } }File Naming Convention:
- Base icon:
icon-name.svg,icon-name.png,icon-name.webp - Light variant:
icon-name-light.svg,icon-name-light.png,icon-name-light.webp - Dark variant:
icon-name-dark.svg,icon-name-dark.png,icon-name-dark.webp - Wordmark variants follow the same pattern with
-wordmark-lightand-wordmark-darksuffixes
- Base icon:
URL Generation: URLs are constructed as
${BASE_URL}/${format}/${filename}.${format}where:BASE_URL=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-iconsformat=svg,png, orwebpfilename= the icon name or variant name from metadata
Community Icons (PocketBase Submissions)
Community icons are stored in PocketBase with a different structure:
Storage: Files are uploaded to PocketBase's file storage system, which sanitizes and renames files automatically (e.g.,
icon.svgbecomesicon_abc123xyz.svg)Database Structure (
community_gallerycollection):name: Icon identifierassets: Array of sanitized filenames (e.g.,["icon_abc123.svg", "icon_xyz789.png"])extras: JSON object containing:{ "aliases": ["alias1"], "categories": ["category1"], "base": "svg", "colors": { "light": "original-light-filename.svg", "dark": "original-dark-filename.svg" }, "wordmark": { "light": "original-wordmark-light.svg", "dark": "original-wordmark-dark.svg" } }
URL Generation: URLs are full HTTP URLs to PocketBase file endpoints:
- Base:
${PB_URL}/api/files/community_gallery/${recordId}/${sanitizedFilename} - The
transformGalleryToIconfunction inweb/src/lib/community.tsconverts PocketBase records to theIconformat used by the display components
- Base:
Filename Matching: Because PocketBase sanitizes filenames, the system uses
findBestMatchingAsset()to match original filenames (stored inextras) to actual sanitized filenames (inassetsarray)
Current Display Logic
The IconDetails component (web/src/components/icon-details.tsx) is shared between both collection and community pages. It handles the differences through:
Icon Type Detection:
const isCommunityIcon = !!communityData.mainIconUrl || (typeof iconData.base === "string" && iconData.base.startsWith("http"))- Collection icons:
iconData.baseis a format string ("svg","png","webp") - Community icons:
iconData.baseis a full HTTP URL ormainIconUrlis set
- Collection icons:
Variant Rendering (
renderVariantfunction):- Collection icons: Constructs URL as
${BASE_URL}/${format}/${iconName}.${format} - Community icons: Searches
assetUrlsarray to find matching URL by filename and format - The function receives
iconName(which may be a variant filename) andtheme(optional:"light"or"dark")
- Collection icons: Constructs URL as
Variant Display Sections:
- Base icon: Always shown unless it matches a variant name
- Light variant: Only shown if
iconData.colors?.lightexists - Dark variant: Only shown if
iconData.colors?.darkexists - Wordmark section: Only shown if
iconData.wordmarkexists, then checks forlightanddarkproperties
Format Detection:
- Collection icons: Determined by
iconData.base(if"svg", shows SVG/PNG/WEBP; if"png", shows PNG/WEBP only) - Community icons: Extracted from
assetUrlsby file extension
- Collection icons: Determined by
Current Submission Flow
Form Submission (
web/src/components/advanced-icon-submission-form-tanstack.tsx):- User selects variants from hardcoded list:
base,dark,light,wordmark,wordmark_dark - Files are uploaded to form state
- On submit,
extrasobject is built with structure:{ colors: { dark: filename, light: filename }, wordmark: { light: filename, dark: filename } }
- User selects variants from hardcoded list:
PocketBase Upload:
- All files are uploaded as
assetsarray - PocketBase sanitizes filenames
- After upload, the code updates
extraswith sanitized filenames by tracking asset index
- All files are uploaded as
Import Script (
scripts/import-icon.ts):- Fetches submission from PocketBase
buildTargets()function creates target file paths based onextras.colorsandextras.wordmark- Downloads files and saves with naming convention:
icon-name-variant.${ext} buildMetadataVariants()converts targets back to metadata format with hardcoded key mapping
Problems with Current Implementation
1. Hardcoded Variant Types
Type System (web/src/types/icons.ts):
IconColorsonly haslight?: stringanddark?: stringIconWordmarkColorsonly haslight?: stringanddark?: string- Cannot represent custom variants like
monochrome,outline,filled, etc.
Impact: Any variant that isn't light or dark cannot be stored or displayed.
2. Duplicated Logic Between Collection and Community
URL Resolution:
- Collection icons: Simple string concatenation in
renderVariant - Community icons: Complex array searching and filename matching
- Both paths are embedded in the same function, making it hard to maintain
Variant Iteration:
- Collection icons: Direct property access (
iconData.colors.light,iconData.colors.dark) - Community icons: Same structure but different URL resolution
- No unified way to iterate over all variants
3. Submission Form Limitations
Hardcoded Variants:
- Form only allows selecting from 5 predefined variants
- Cannot add custom variants
- UI is tightly coupled to specific variant names
Submission Payload:
extrasstructure assumes onlycolorsandwordmarkwithlight/dark- Asset index tracking is fragile and assumes specific order
4. Import Script Limitations
Target Building:
buildTargets()only checks forcolors.light,colors.dark,wordmark.light,wordmark.dark- Cannot handle arbitrary variant names
- Hardcoded filename patterns (
icon-name-light.${ext},icon-name-wordmark-light.${ext})
Metadata Building:
buildMetadataVariants()uses hardcodedif/elsechain to map variant keys- Cannot handle custom variant names
- Assumes specific key patterns (
wordmark-light,wordmark-dark)
5. Display Component Limitations
Variant Sections:
IconVariantsSectionis called separately for each hardcoded variantWordmarkSectiononly checks forwordmark.lightandwordmark.dark- Cannot dynamically render arbitrary variants
Technical Details:
- Only displays
colors.lightandcolors.darkin the variants list - Only displays
wordmark.lightandwordmark.darkin wordmark list - Cannot show custom variants
Proposed Solution
1. Unified Variant Type System
New Type Structure:
// Variant definitions are stored in a separate file (variant-definitions.ts)
// The database stores a simple array of variant names
export type IconVariants = {
[variantName: string]: string // variant name -> filename (without extension)
}
export type Icon = {
base: string | "svg" | "png" | "webp"
aliases: string[]
categories: string[]
update: IconUpdate
variants?: IconVariants // Replaces colors - flexible object with any variant names
wordmark?: IconVariants // Replaces IconWordmarkColors - flexible object with any variant names
// Keep colors and wordmark for backward compatibility during migration
colors?: { light?: string; dark?: string }
wordmark?: { light?: string; dark?: string }
}Variant Definitions File:
Create web/src/lib/variant-definitions.ts that defines:
- Preset variants (default, light, dark, wordmark-default, wordmark-light, wordmark-dark)
- Metadata for each preset (label, description, icon component)
- Helper functions to get variant definitions (returns preset or generates default for custom variants)
Migration Strategy:
- Support both old format (
colors) and new format (variants) during transition - Variant definitions file provides metadata for preset variants
- Custom variants get default metadata generated on-the-fly
- All display code uses
getVariantDefinition()to get metadata for any variant name
2. Variant Definitions System
Create Variant Definitions File (web/src/lib/variant-definitions.ts):
This file defines preset variants and provides utilities to work with both preset and custom variants:
- Defines preset variants with metadata (label, description, icon component)
- Provides
getVariantDefinition()function that returns preset metadata or generates default for custom variants - Provides helper functions to group and categorize variants
- Allows display components to render any variant (preset or custom) with appropriate metadata
3. Unified URL Resolution Utility
Create New Utility (web/src/lib/icon-url-resolver.ts):
This utility will handle URL resolution for both collection and community icons, eliminating duplication:
interface IconUrlContext {
isCommunityIcon: boolean
baseIconName: string
baseFormat: string
// For collection icons
baseUrl?: string
// For community icons
assetUrls?: string[]
mainIconUrl?: string
}
/**
* Resolves the URL for an icon variant in a specific format
* Works for both collection and community icons
*/
export function resolveIconUrl(
context: IconUrlContext,
variantName: string | null, // null for base icon
format: string
): string | null {
if (context.isCommunityIcon) {
return resolveCommunityIconUrl(context, variantName, format)
} else {
return resolveCollectionIconUrl(context, variantName, format)
}
}
function resolveCollectionIconUrl(
context: IconUrlContext,
variantName: string | null,
format: string
): string {
const filename = variantName || context.baseIconName
return `${context.baseUrl}/${format}/${filename}.${format}`
}
function resolveCommunityIconUrl(
context: IconUrlContext,
variantName: string | null,
format: string
): string | null {
const formatExt = format === "svg" ? "svg" : format === "png" ? "png" : "webp"
if (!variantName) {
// Base icon: return mainIconUrl or find by format
if (context.mainIconUrl?.toLowerCase().endsWith(`.${formatExt}`)) {
return context.mainIconUrl
}
return context.assetUrls?.find(url =>
url.toLowerCase().endsWith(`.${formatExt}`)
) || context.mainIconUrl || null
}
// Variant: find by matching variant filename in assetUrls
// variantName is the filename (without extension) from metadata
return context.assetUrls?.find(url => {
const urlFilename = url.split('/').pop()?.replace(/\.[^.]+$/, '') || ''
return urlFilename.includes(variantName) && url.toLowerCase().endsWith(`.${formatExt}`)
}) || null
}Benefits:
- Single source of truth for URL resolution
- Easy to test and maintain
- Can be reused by both collection and community pages
- Handles edge cases in one place
4. Unified Variant Rendering
Refactor IconDetails Component:
Instead of hardcoded sections, create a unified variant rendering system that:
- Uses
getVariantDefinition()to get metadata for each variant - Renders preset variants with their defined icons and labels
- Renders custom variants with generated default metadata
- Groups variants by category (regular variants vs wordmark variants)
- Sorts variants (preset first, then custom alphabetically)
import { getVariantDefinition, groupVariantsByCategory } from "@/lib/variant-definitions"
// Get all variant names from iconData
const allVariants = Object.keys(iconData.variants || {})
const allWordmarkVariants = Object.keys(iconData.wordmark || {})
// Sort: preset variants first, then custom variants alphabetically
const sortedVariants = allVariants.sort((a, b) => {
const aDef = getVariantDefinition(a)
const bDef = getVariantDefinition(b)
if (aDef.preset && !bDef.preset) return -1
if (!aDef.preset && bDef.preset) return 1
return a.localeCompare(b)
})
const sortedWordmarkVariants = allWordmarkVariants.sort((a, b) => {
const aDef = getVariantDefinition(`wordmark-${a}`)
const bDef = getVariantDefinition(`wordmark-${b}`)
if (aDef.preset && !bDef.preset) return -1
if (!aDef.preset && bDef.preset) return 1
return a.localeCompare(b)
})
// Render all variants dynamically
{sortedVariants.map((variantName) => {
const variantDef = getVariantDefinition(variantName)
const variantFilename = iconData.variants[variantName]
return (
<IconVariantsSection
key={variantName}
title={variantDef.label}
description={variantDef.description}
iconElement={<variantDef.icon className="w-4 h-4" />}
availableFormats={availableFormats}
icon={variantFilename || icon}
iconData={iconData}
handleCopy={handleCopyUrl}
handleDownload={handleDownload}
copiedVariants={copiedVariants}
renderVariant={renderVariant}
/>
)
})}
// Similar for wordmark variants...Updated renderVariant Function:
const renderVariant = (
format: string,
variantFilename: string | null, // null for base icon
variantKey: string
) => {
const imageUrl = resolveIconUrl(urlContext, variantFilename, format)
if (!imageUrl) return null // Handle missing variants gracefully
const githubUrl = !isCommunityIcon && variantFilename
? `${REPO_PATH}/tree/main/${format}/${variantFilename}.${format}`
: ""
// ... rest of rendering logic
}Benefits:
- Single rendering path for all variants
- Automatically handles custom variants
- No hardcoded variant names
- Works for both collection and community icons
4. Enhanced Submission Form
Dynamic Variant Management:
Preset Variants: Keep quick-select options for common variants (
default,light,dark,wordmark-default,wordmark-light,wordmark-dark)Custom Variants: Add UI to:
- Enter custom variant name (with validation: alphanumeric, hyphens, underscores)
- Select variant type (regular variant or wordmark)
- Upload file for that variant
- Remove custom variants
Updated Submission Payload:
{ aliases: string[], categories: string[], base: string, variants?: { [variantName: string]: string }, // New format wordmark?: { [variantName: string]: string }, // New format // Keep old format for backward compatibility during migration colors?: { light?: string; dark?: string }, wordmark?: { light?: string; dark?: string } }Asset Index Tracking: Instead of hardcoded index tracking, use a mapping:
// After upload, create a map of original filename -> sanitized filename const filenameMap = new Map<string, string>() value.files.base?.[0] && filenameMap.set(value.files.base[0].name, record.assets[0]) // ... track all files // Then update extras by looking up in map Object.keys(extras.variants || {}).forEach(variantName => { const originalFilename = extras.variants[variantName] const sanitizedFilename = filenameMap.get(originalFilename) if (sanitizedFilename) { extras.variants[variantName] = sanitizedFilename } })
5. Enhanced Import Script
Dynamic Target Building:
function buildTargets(submission: Submission): VariantTarget[] {
const iconId = submission.name
const ext = inferBase(submission.assets, submission.extras?.base)
const targets: VariantTarget[] = [
{ key: "base", destFilename: `${iconId}.${ext}` }
]
// Handle new variants format
const variants = submission.extras?.variants || {}
Object.entries(variants).forEach(([variantName, filename]) => {
if (filename) {
targets.push({
key: variantName,
destFilename: `${iconId}-${variantName}.${ext}`,
exactFilename: filename as string
})
}
})
// Handle wordmark variants
const wordmarkVariants = submission.extras?.wordmark || {}
Object.entries(wordmarkVariants).forEach(([variantName, filename]) => {
if (filename) {
targets.push({
key: `wordmark-${variantName}`,
destFilename: `${iconId}-wordmark-${variantName}.${ext}`,
exactFilename: filename as string
})
}
})
// Migration: Support old colors format
if (submission.extras?.colors && !submission.extras?.variants) {
if (submission.extras.colors.light) {
targets.push({
key: "light",
destFilename: `${iconId}-light.${ext}`,
exactFilename: submission.extras.colors.light
})
}
if (submission.extras.colors.dark) {
targets.push({
key: "dark",
destFilename: `${iconId}-dark.${ext}`,
exactFilename: submission.extras.colors.dark
})
}
}
return targets
}Dynamic Metadata Building:
function buildMetadataVariants(assignments: VariantTarget[]): {
variants?: IconVariants
wordmark?: IconVariants
} {
const variants: IconVariants = {}
const wordmark: IconVariants = {}
for (const v of assignments) {
if (!v.sourceAsset) continue
const baseName = v.destFilename.replace(/\.[^.]+$/, "")
if (v.key === "base") {
// Base icon, skip (handled separately)
continue
} else if (v.key.startsWith("wordmark-")) {
// Wordmark variant: extract variant name
const variantName = v.key.replace("wordmark-", "")
wordmark[variantName] = baseName
} else {
// Regular variant
variants[v.key] = baseName
}
}
return {
variants: Object.keys(variants).length ? variants : undefined,
wordmark: Object.keys(wordmark).length ? wordmark : undefined
}
}6. Updated Community Library
Enhanced Transformation:
The transformGalleryToIcon function in web/src/lib/community.ts needs to handle the new variant structure:
function transformGalleryToIcon(item: CommunityGallery): any {
// ... existing code ...
// Process variants (new format) or colors (old format for migration)
const variants = item.extras?.variants ? { ...item.extras.variants } : undefined
if (variants && item.assets) {Source: homarr-labs/dashboard-icons