Add comprehensive documentation template describing the icon variant system architecture, current implementation, and proposed enhancements for supporting arbitrary icon variants.
46 KiB
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) {
Object.keys(variants).forEach((key) => {
variants[key] = findBestMatchingAsset(variants[key], item.assets || [])
})
}
// Migration: Convert colors to variants if needed
const colors = item.extras?.colors ? { ...item.extras.colors } : undefined
if (colors && item.assets && !variants) {
// Convert old format to new format
const migratedVariants: IconVariants = {}
Object.keys(colors).forEach((key) => {
const matched = findBestMatchingAsset(colors[key]!, item.assets || [])
migratedVariants[key] = matched
})
// Use migrated variants
Object.assign(variants || {}, migratedVariants)
}
// Similar logic for wordmark
const wordmark = item.extras?.wordmark ? { ...item.extras.wordmark } : undefined
if (wordmark && item.assets) {
Object.keys(wordmark).forEach((key) => {
wordmark[key] = findBestMatchingAsset(wordmark[key]!, item.assets || [])
})
}
return {
// ... existing fields ...
variants: variants,
wordmark: wordmark,
// Keep old format for backward compatibility
colors: colors,
}
}
Implementation Plan
Phase 1: Variant Definitions & Type System
- Create
web/src/lib/variant-definitions.tswith preset variant definitions - Update
web/src/types/icons.tswith newIconVariantstype - Create
web/src/lib/icon-url-resolver.tswith unified URL resolution - Add migration support for old
colorsformat
Phase 2: Display Components
- Refactor
IconDetailsto usegetVariantDefinition()for all variants - Update variant rendering to handle both preset and custom variants
- Create unified
VariantSectioncomponent that works with variant definitions - Update
renderVariantto use new URL resolver - Update technical details section to show all variants dynamically
- Ensure custom variants get appropriate default styling and labels
Phase 3: Submission Form
- Add UI for custom variant management (text input + file upload)
- Update form state to handle arbitrary variants
- Refactor submission payload building to create
variantsarray - Ensure variant array order matches asset array order (base at 0, variants start at 1)
- Validate variant names (alphanumeric, hyphens, underscores)
Phase 4: Import Script
- Refactor
buildTargets()to handlevariantsarray format - Refactor
buildMetadataVariants()to buildvariantsandwordmarkobjects from assignments - Add migration logic to convert old
colors/wordmarkformat tovariantsarray - Test with various variant combinations (preset and custom)
Phase 5: Community Library
- Update
transformGalleryToIcon()to convertvariantsarray to display format - Map variant array indices to asset array indices (variants[i] → assets[i+1])
- Add migration logic to convert old
colors/wordmarkformat tovariantsarray - Ensure backward compatibility with old format
Phase 6: Metadata Pages
- Update
web/src/app/icons/[icon]/page.tsxmetadata generation - Update
web/src/app/community/[icon]/page.tsxmetadata generation - Include all variants in OpenGraph/Twitter metadata
Phase 7: Testing & Migration
- Test with existing icons (backward compatibility)
- Test with new variant structures
- Test edge cases (missing variants, malformed data)
- Update documentation
Sub-Task: Wordmark Icon Customizer Support
Current Limitation
The icon customizer (IconCustomizerInline) currently only works with base icons. The getSvgUrl() function in IconDetails only checks for base icon or colors.light, and doesn't support wordmark variants.
Proposed Solution
-
Variant Selector UI: Add a dropdown/selector in the icon details sidebar to choose which variant to customize:
- Base icon
- All regular variants (from
variantsobject) - All wordmark variants (from
wordmarkobject)
-
Enhanced SVG URL Resolution: Refactor
getSvgUrl()to accept variant selection:function getSvgUrl( iconData: Icon, variantType: 'base' | 'variant' | 'wordmark', variantName?: string ): string | null { const context = buildIconUrlContext(iconData, icon) if (variantType === 'base') { return resolveIconUrl(context, null, 'svg') } else if (variantType === 'variant' && variantName) { const normalized = normalizeIconVariants(iconData) const filename = normalized.variants?.[variantName] return filename ? resolveIconUrl(context, filename, 'svg') : null } else if (variantType === 'wordmark' && variantName) { const normalized = normalizeIconVariants(iconData) const filename = normalized.wordmark?.[variantName] return filename ? resolveIconUrl(context, filename, 'svg') : null } return null } -
Customizer Component Updates:
- Update
IconCustomizerInlineto accept variant selection - Ensure customizer works with all variant types
- Preserve variant-specific styling when applicable
- Update
Implementation Tasks
- Add variant selector UI to icon details page
- Refactor
getSvgUrlto support variant selection - Update
IconCustomizerInlineto handle variant URLs - Test customizer with base, variant, and wordmark icons
- Update customizer to preserve variant-specific styling when applicable
Testing Requirements
- Backward Compatibility: All existing icons must continue to work with old
colorsformat - New Variants: Test with custom variant names (e.g.,
monochrome,outline,branded) - Edge Cases:
- Icons with no variants
- Icons with only custom variants
- Icons with mixed preset and custom variants
- Malformed variant data
- Missing variant files
- Community icons with mismatched filenames
- Wordmark Customizer: Test customizer with all wordmark variants
- URL Resolution: Test URL resolution for both collection and community icons with all variant types
Breaking Changes
⚠️ Note: This feature will introduce breaking changes to the metadata structure. A migration strategy must be implemented to support both old and new formats during a transition period. The system should:
- Read both old and new formats
- Normalize to new format internally
- Write new format when updating metadata
- Provide migration script to convert existing metadata.json
Code Reuse Benefits
By implementing unified utilities and components:
- Single URL Resolution Logic: Both collection and community pages use the same
resolveIconUrl()function - Unified Variant Rendering: Same rendering logic for all variants, regardless of source
- Consistent Behavior: Collection and community icons behave identically from user perspective
- Easier Maintenance: Changes to variant handling only need to be made in one place
- Better Testing: Utilities can be unit tested independently
PocketBase Database Schema Changes
Current Problem
Currently, the extras JSON field stores variant references as filenames in nested objects, which creates a fragile system:
Current Structure (from your example):
{
"wordmark": {
"dark": "broadcom_logo_dark_sasr6cj9gt.svg",
"light": "broadcom_logo_cci1fr7y81.svg"
}
}
Problems:
- PocketBase sanitizes filenames when uploading, so the original filename doesn't match the stored filename
- The code must use fragile filename matching logic (see
findBestMatchingAsset) - Asset order tracking is fragile (see asset index tracking)
- Hardcoded structure prevents custom variants
- No separation between variant names and asset references
Proposed Solution: Simple Variants Array
Store variant names as a simple array of text strings in the extras field. The order of variants in the array corresponds to the order of assets (after the base icon at index 0).
New extras Format:
{
"aliases": ["VMware", "Brocade", "Symantec"],
"base": "svg",
"categories": ["cloud", "security", "network", "storage"],
"variants": ["light", "dark", "wordmark-light", "wordmark-dark"]
}
How it works:
- Base icon is always at
assets[0] - First variant in array (
"light") corresponds toassets[1] - Second variant (
"dark") corresponds toassets[2] - Third variant (
"wordmark-light") corresponds toassets[3] - And so on...
For custom variants:
{
"variants": ["light", "dark", "monochrome", "outline", "wordmark-light", "wordmark-dark"]
}
Variant Definitions File
Create a new TypeScript file that defines preset variants and their metadata:
File: web/src/lib/variant-definitions.ts
import { FileImage, FileType, Moon, Sun, Type } from "lucide-react"
export interface VariantDefinition {
id: string
label: string
description: string
icon: React.ComponentType<{ className?: string }>
category: "variant" | "wordmark"
preset: true // Marks this as a preset variant
}
export const PRESET_VARIANTS: Record<string, VariantDefinition> = {
"default": {
id: "default",
label: "Default",
description: "Default icon variant",
icon: FileImage,
category: "variant",
preset: true
},
"light": {
id: "light",
label: "Light Theme",
description: "Icon optimized for light backgrounds",
icon: Sun,
category: "variant",
preset: true
},
"dark": {
id: "dark",
label: "Dark Theme",
description: "Icon optimized for dark backgrounds",
icon: Moon,
category: "variant",
preset: true
},
"wordmark-default": {
id: "wordmark-default",
label: "Wordmark Default",
description: "Wordmark variant with default styling",
icon: Type,
category: "wordmark",
preset: true
},
"wordmark-light": {
id: "wordmark-light",
label: "Wordmark Light",
description: "Wordmark optimized for light backgrounds",
icon: Type,
category: "wordmark",
preset: true
},
"wordmark-dark": {
id: "wordmark-dark",
label: "Wordmark Dark",
description: "Wordmark optimized for dark backgrounds",
icon: Type,
category: "wordmark",
preset: true
}
}
/**
* Get variant definition for a variant name
* Returns preset definition if exists, or generates a default one for custom variants
*/
export function getVariantDefinition(variantName: string): VariantDefinition {
if (PRESET_VARIANTS[variantName]) {
return PRESET_VARIANTS[variantName]
}
// Generate default definition for custom variants
const isWordmark = variantName.startsWith("wordmark-")
const category = isWordmark ? "wordmark" : "variant"
const displayName = variantName
.replace(/^wordmark-/, "")
.split("-")
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
return {
id: variantName,
label: isWordmark ? `Wordmark ${displayName}` : displayName,
description: `Custom ${category} variant: ${variantName}`,
icon: isWordmark ? Type : FileImage,
category,
preset: false
}
}
/**
* Check if a variant is a preset variant
*/
export function isPresetVariant(variantName: string): boolean {
return variantName in PRESET_VARIANTS
}
/**
* Get all variants grouped by category
*/
export function groupVariantsByCategory(variants: string[]): {
variants: string[]
wordmark: string[]
} {
const result = { variants: [] as string[], wordmark: [] as string[] }
variants.forEach(variant => {
if (variant.startsWith("wordmark-")) {
result.wordmark.push(variant)
} else {
result.variants.push(variant)
}
})
return result
}
Implementation Details
1. Update Submission Form
The submission form needs to track which variants are selected and store them as an array.
Current Code (web/src/components/advanced-icon-submission-form-tanstack.tsx):
The form currently builds nested objects. We need to change it to build a simple array.
New Code:
const handleConfirmedSubmit = async () => {
const value = form.state.values
setShowConfirmDialog(false)
try {
const assetFiles: File[] = []
const variantNames: string[] = []
// Base icon is always first
if (value.files.base?.[0]) {
assetFiles.push(value.files.base[0])
}
// Track variant order: must match asset order
// Order: light, dark, then wordmark variants
if (value.files.light?.[0]) {
variantNames.push("light")
assetFiles.push(value.files.light[0])
}
if (value.files.dark?.[0]) {
variantNames.push("dark")
assetFiles.push(value.files.dark[0])
}
// Add custom variants in order they were added
Object.keys(value.files).forEach(variantId => {
if (variantId !== "base" && variantId !== "light" && variantId !== "dark" &&
variantId !== "wordmark" && variantId !== "wordmark_dark") {
// Custom variant
if (value.files[variantId]?.[0]) {
variantNames.push(variantId)
assetFiles.push(value.files[variantId][0])
}
}
})
// Wordmark variants
if (value.files.wordmark?.[0]) {
variantNames.push("wordmark-light")
assetFiles.push(value.files.wordmark[0])
}
if (value.files.wordmark_dark?.[0]) {
variantNames.push("wordmark-dark")
assetFiles.push(value.files.wordmark_dark[0])
}
// Build extras with simple variants array
const extras = {
aliases: value.aliases,
categories: value.categories,
base: value.files.base[0]?.name.split(".").pop() || "svg",
variants: variantNames // Simple array of variant names
}
const submissionData = {
name: value.iconName,
assets: assetFiles,
created_by: (pb.authStore.record as any)?.id ?? pb.authStore.record?.id,
status: "pending",
description: value.description,
extras: extras,
}
const record = await pb.collection("submissions").create(submissionData)
// No need to update extras after upload - variants array is already correct!
// The order of variants matches the order of assets (base at 0, variants start at 1)
// Revalidate Next.js cache
await revalidateAllSubmissions()
// ... rest of submission logic
}
}
2. Update Community Library
The transformGalleryToIcon function needs to convert the variants array to the display format.
Current Code (web/src/lib/community.ts):
New Code:
function transformGalleryToIcon(item: CommunityGallery): any {
const pbUrl = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090"
const mainIcon = item.assets?.[0] ? `${pbUrl}/api/files/community_gallery/${item.id}/${item.assets[0]}` : ""
const mainAssetExt = item.assets?.[0]?.split(".").pop()?.toLowerCase() || "svg"
const baseFormat = mainAssetExt === "svg" ? "svg" : mainAssetExt === "png" ? "png" : "webp"
// Convert variants array to display format
// variants array: ["light", "dark", "wordmark-light"]
// assets array: [base, light_file, dark_file, wordmark_light_file]
// So variants[i] corresponds to assets[i+1]
const variantsArray = item.extras?.variants || []
const variants: Record<string, string> = {}
const wordmark: Record<string, string> = {}
variantsArray.forEach((variantName: string, index: number) => {
const assetIndex = index + 1 // +1 because base is at index 0
if (item.assets && assetIndex < item.assets.length) {
const assetFilename = item.assets[assetIndex]
if (variantName.startsWith("wordmark-")) {
const wordmarkName = variantName.replace("wordmark-", "")
wordmark[wordmarkName] = assetFilename
} else {
variants[variantName] = assetFilename
}
}
})
// Migration: Support old format (colors/wordmark objects)
if (variantsArray.length === 0) {
// Fall back to old format for backward compatibility
const colors = item.extras?.colors ? { ...item.extras.colors } : undefined
if (colors && item.assets) {
Object.keys(colors).forEach((key) => {
const k = key as keyof typeof colors
if (colors[k]) {
variants[k] = findBestMatchingAsset(colors[k]!, item.assets || [])
}
})
}
const oldWordmark = item.extras?.wordmark ? { ...item.extras.wordmark } : undefined
if (oldWordmark && item.assets) {
Object.keys(oldWordmark).forEach((key) => {
const k = key as keyof typeof wordmark
if (oldWordmark[k]) {
wordmark[k] = findBestMatchingAsset(oldWordmark[k]!, item.assets || [])
}
})
}
}
const transformed = {
name: item.name,
status: item.status,
data: {
base: mainIcon || "svg",
baseFormat,
mainIconUrl: mainIcon,
assetUrls: item.assets?.map((asset) => `${pbUrl}/api/files/community_gallery/${item.id}/${asset}`) || [],
aliases: item.extras?.aliases || [],
categories: item.extras?.categories || [],
update: {
timestamp: item.created,
author: {
id: 0,
name: item.created_by || "Community",
login: item.created_by || undefined,
github_id: item.created_by_github_id,
},
},
variants: Object.keys(variants).length > 0 ? variants : undefined,
wordmark: Object.keys(wordmark).length > 0 ? wordmark : undefined,
// Keep old format for backward compatibility
colors: Object.keys(variants).length > 0 ? variants : undefined,
},
}
return transformed
}
3. Update Display Components
The IconDetails component needs to use the variant definitions file to render both preset and custom variants.
Current Code (web/src/components/icon-details.tsx):
New Code:
import { getVariantDefinition, groupVariantsByCategory } from "@/lib/variant-definitions"
// In IconDetails component:
const normalizedVariants = iconData.variants || iconData.colors || {}
const normalizedWordmark = iconData.wordmark || {}
// Get all variant names
const allVariants = Object.keys(normalizedVariants)
const allWordmarkVariants = Object.keys(normalizedWordmark)
// Group and sort: preset variants first, then custom variants
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 = normalizedVariants[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...
4. Update Type Definitions
Update web/src/lib/pb.ts:
extras: {
aliases: string[]
categories: string[]
base?: string
// New format: simple array of variant names
variants?: string[] // e.g., ["light", "dark", "wordmark-light", "monochrome"]
// Keep old format for backward compatibility during migration
colors?: {
dark?: string
light?: string
}
wordmark?: {
dark?: string
light?: string
}
}
5. Update Import Script
The import script needs to convert the variants array to metadata format.
Update scripts/import-icon.ts:
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 format: variants array
const variantsArray = submission.extras?.variants || []
variantsArray.forEach((variantName: string, index: number) => {
const assetIndex = index + 1 // +1 because base is at index 0
if (assetIndex < submission.assets.length) {
const assetFilename = submission.assets[assetIndex]
targets.push({
key: variantName,
destFilename: `${iconId}-${variantName}.${ext}`,
exactFilename: assetFilename,
})
}
})
// Migration: Support old format
if (variantsArray.length === 0) {
// Fall back to old colors/wordmark format
if (submission.extras?.colors?.light) {
targets.push({
key: "light",
destFilename: `${iconId}-light.${ext}`,
exactFilename: submission.extras.colors.light,
})
}
// ... similar for dark, wordmark variants
}
return targets
}
function buildMetadataVariants(assignments: VariantTarget[]): {
variants?: IconVariants
wordmark?: IconVariants
} {
const variants: IconVariants = {}
const wordmark: IconVariants = {}
for (const v of assignments) {
if (!v.sourceAsset || v.key === "base") continue
const baseName = v.destFilename.replace(/\.[^.]+$/, "")
if (v.key.startsWith("wordmark-")) {
const wordmarkName = v.key.replace("wordmark-", "")
wordmark[wordmarkName] = baseName
} else {
variants[v.key] = baseName
}
}
return {
variants: Object.keys(variants).length > 0 ? variants : undefined,
wordmark: Object.keys(wordmark).length > 0 ? wordmark : undefined,
}
}
Benefits of This Approach
- Simplicity: Just an array of strings - no complex nested structures
- Flexibility: Can store any variant name, preset or custom
- Order Preservation: Array order directly maps to asset order
- Type Safety: Variant definitions file provides type safety and metadata
- Separation of Concerns: Variant metadata (labels, icons) separate from data storage
- Easy Migration: Old format can be converted to array format easily
- No Filename Matching: Direct index mapping eliminates fragile matching
Migration Strategy
- Dual Format Support: Code handles both old format (colors/wordmark objects) and new format (variants array)
- Migration on Read: Convert old format to new format when reading from database
- Migration Script: Optional batch script to convert existing submissions:
// For each submission: // 1. Read extras.colors and extras.wordmark (old format) // 2. Build variants array: ["light", "dark", "wordmark-light", "wordmark-dark"] // 3. Update extras.variants = [...] - New Submissions: Always use new format (variants array)
References
- Type definitions:
web/src/types/icons.ts - PocketBase types:
web/src/lib/pb.ts - Variant definitions:
web/src/lib/variant-definitions.ts(new file to create) - Icon display component:
web/src/components/icon-details.tsx - Submission form:
web/src/components/advanced-icon-submission-form-tanstack.tsx - Filename matching logic:
web/src/lib/community.ts - Import script:
scripts/import-icon.ts - Community library:
web/src/lib/community.ts - API library:
web/src/lib/api.ts - Metadata pages:
- Customizer components:
- PocketBase migration:
web/backend/pb_migrations/1759312839_created_submission.js