mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-06 14:01:30 +08:00
Migrate 310 paths to kebab-case per docs/references/naming-conventions.md §4.5, aligning packages/ui with shadcn convention (all primitives, composites, icons, hooks, stories use kebab-case file/directory names; exported identifiers stay PascalCase/camelCase). Generator fixes: - scripts/svg-utils.ts: drop toCamelCase, preserve kebab basename from source SVG filenames - scripts/generate-icons.ts: fix flat-icon types import path (../types not ../../types) - scripts/codegen.ts: quote catalog keys containing dashes Path renames: - 5 primitive camelCase files (copyButton, customTag, etc.) - ErrorBoundary -> error-boundary - 24 composite directories (CodeEditor, ImagePreview, etc.) and 35 internal PascalCase .tsx files (incl. 12 test files); Input directory renamed to composite-input to align with CompositeInput export - 12 + 21 + 12 = 45 icon paths regenerated from kebab source SVGs - 2 hook files (useDndReorder, useDndState) and 2 composite hooks (useDraggableReorder, useImagePreviewTransform) - 1 utility (reorderVisibleSubset) and its co-located test - 68 Storybook story files matching their source components Barrel completeness: - packages/ui/src/components/index.ts now re-exports CustomTagProps, letting the 2 external consumers drop their deep-imports Docs: - packages/ui/README.md: add Naming Conventions section linking to docs/references/naming-conventions.md - packages/ui/docs/migration-plan.md: update examples to kebab paths - docs/references/naming-conventions.md §3.2: note packages/ui hooks use kebab-case per §4.5
612 lines
19 KiB
TypeScript
612 lines
19 KiB
TypeScript
/**
|
||
* Shared SVG utility functions for icon generation scripts.
|
||
*
|
||
* Used by generate-icons.ts, generate-mono-icons.ts, and generate-avatars.ts.
|
||
*/
|
||
|
||
import * as fs from 'fs'
|
||
import * as path from 'path'
|
||
|
||
export type LogoType = 'providers' | 'models'
|
||
|
||
export const OUTPUT_DIR_MAP: Record<LogoType, string> = {
|
||
providers: path.join(__dirname, '../src/components/icons/providers'),
|
||
models: path.join(__dirname, '../src/components/icons/models')
|
||
}
|
||
|
||
export const SVG_SOURCE_MAP: Record<LogoType, string> = {
|
||
providers: path.join(__dirname, '../icons/providers'),
|
||
models: path.join(__dirname, '../icons/models')
|
||
}
|
||
|
||
export function parseLogoTypeArg(): LogoType {
|
||
const arg = process.argv.find((item) => item.startsWith('--type='))
|
||
if (!arg) return 'providers'
|
||
const value = arg.split('=')[1]
|
||
if (value === 'providers' || value === 'models') return value
|
||
throw new Error(`Invalid --type value: ${value}. Use "providers" or "models".`)
|
||
}
|
||
|
||
/**
|
||
* Tighten the SVG root viewBox to the bounding box of its visible content.
|
||
*
|
||
* Many designer-exported SVGs (e.g. from Figma frames) carry ~10-15% of empty
|
||
* padding inside the viewBox. Combined with the Avatar wrapper's own padding,
|
||
* the rendered logo ends up only filling ~40% of the visible container.
|
||
*
|
||
* This helper unions the bounding boxes of every `<path d="...">` and `<rect>`
|
||
* element in the file. When `minimumFrameRatio` is provided, it expands that
|
||
* union to include a centered minimum frame before the coverage check, so
|
||
* icons keep intentional internal spacing instead of tightening purely to the
|
||
* visible content. It then rewrites the root viewBox to the final bounds (plus
|
||
* a tiny 1-unit margin so strokes don't get clipped).
|
||
*
|
||
* Returns the original code unchanged if it can't find a viewBox, has no
|
||
* visible geometry, or the final bounds are already a good fit (>95% coverage).
|
||
*/
|
||
export function tightenSvgViewBox(svgCode: string, options: { minimumFrameRatio?: number } = {}): string {
|
||
const vbMatch = svgCode.match(/<svg[^>]*\bviewBox="([^"]+)"/)
|
||
if (!vbMatch) return svgCode
|
||
const [vbX, vbY, vbW, vbH] = vbMatch[1].split(/[\s,]+/).map(Number)
|
||
if (![vbX, vbY, vbW, vbH].every(isFinite)) return svgCode
|
||
|
||
// Strip <defs>, <mask>, <clipPath> — those don't render directly
|
||
const stripped = svgCode
|
||
.replace(/<defs[\s\S]*?<\/defs>/gi, '')
|
||
.replace(/<mask[\s\S]*?<\/mask>/gi, '')
|
||
.replace(/<clipPath[\s\S]*?<\/clipPath>/gi, '')
|
||
|
||
const bounds: BBox = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
|
||
let foundContent = false
|
||
|
||
for (const m of stripped.matchAll(/<path\b[^>]*\bd="([^"]+)"/g)) {
|
||
const pb = parseSvgPathBounds(m[1])
|
||
if (isFinite(pb.minX)) {
|
||
bounds.minX = Math.min(bounds.minX, pb.minX)
|
||
bounds.minY = Math.min(bounds.minY, pb.minY)
|
||
bounds.maxX = Math.max(bounds.maxX, pb.maxX)
|
||
bounds.maxY = Math.max(bounds.maxY, pb.maxY)
|
||
foundContent = true
|
||
}
|
||
}
|
||
|
||
for (const m of stripped.matchAll(/<rect\b([^>]*)>/g)) {
|
||
const a = m[1]
|
||
const x = parseFloat(a.match(/\bx="([^"]+)"/)?.[1] ?? '0')
|
||
const y = parseFloat(a.match(/\by="([^"]+)"/)?.[1] ?? '0')
|
||
const w = parseFloat(a.match(/\bwidth="([^"]+)"/)?.[1] ?? 'NaN')
|
||
const h = parseFloat(a.match(/\bheight="([^"]+)"/)?.[1] ?? 'NaN')
|
||
if (isFinite(w) && isFinite(h)) {
|
||
bounds.minX = Math.min(bounds.minX, x)
|
||
bounds.minY = Math.min(bounds.minY, y)
|
||
bounds.maxX = Math.max(bounds.maxX, x + w)
|
||
bounds.maxY = Math.max(bounds.maxY, y + h)
|
||
foundContent = true
|
||
}
|
||
}
|
||
|
||
if (!foundContent) return svgCode
|
||
|
||
const { minimumFrameRatio } = options
|
||
if (minimumFrameRatio && minimumFrameRatio > 0 && minimumFrameRatio <= 1) {
|
||
const frameWidth = vbW * minimumFrameRatio
|
||
const frameHeight = vbH * minimumFrameRatio
|
||
const frameX = vbX + (vbW - frameWidth) / 2
|
||
const frameY = vbY + (vbH - frameHeight) / 2
|
||
|
||
bounds.minX = Math.min(bounds.minX, frameX)
|
||
bounds.minY = Math.min(bounds.minY, frameY)
|
||
bounds.maxX = Math.max(bounds.maxX, frameX + frameWidth)
|
||
bounds.maxY = Math.max(bounds.maxY, frameY + frameHeight)
|
||
}
|
||
|
||
// If content already fills >95% of the viewBox, leave it alone
|
||
const coverage = ((bounds.maxX - bounds.minX) * (bounds.maxY - bounds.minY)) / (vbW * vbH)
|
||
if (coverage > 0.95) return svgCode
|
||
|
||
// Add a 1-unit margin so anti-aliased strokes don't clip at the edges
|
||
const margin = 1
|
||
const nx = Math.max(vbX, bounds.minX - margin)
|
||
const ny = Math.max(vbY, bounds.minY - margin)
|
||
const nw = Math.min(vbX + vbW, bounds.maxX + margin) - nx
|
||
const nh = Math.min(vbY + vbH, bounds.maxY + margin) - ny
|
||
|
||
if (!isFinite(nw) || !isFinite(nh) || nw <= 0 || nh <= 0) return svgCode
|
||
|
||
const newViewBox = `viewBox="${nx} ${ny} ${nw} ${nh}"`
|
||
return svgCode.replace(/(<svg[^>]*\b)viewBox="[^"]+"/, `$1${newViewBox}`)
|
||
}
|
||
|
||
export function ensureViewBox(svgCode: string): string {
|
||
if (/viewBox\s*=\s*"[^"]*"/.test(svgCode)) return svgCode
|
||
|
||
const widthMatch = svgCode.match(/<svg[^>]*\bwidth="(\d+(?:\.\d+)?)"/)
|
||
const heightMatch = svgCode.match(/<svg[^>]*\bheight="(\d+(?:\.\d+)?)"/)
|
||
|
||
if (widthMatch && heightMatch) {
|
||
return svgCode.replace(/<svg\b/, `<svg viewBox="0 0 ${widthMatch[1]} ${heightMatch[1]}"`)
|
||
}
|
||
return svgCode
|
||
}
|
||
|
||
export function isImageBased(content: string): boolean {
|
||
return content.includes('<image') || content.includes('data:image')
|
||
}
|
||
|
||
export function buildSvgMap(type: LogoType): Map<string, string> {
|
||
const svgDir = SVG_SOURCE_MAP[type]
|
||
const lightDir = path.join(svgDir, 'light')
|
||
const map = new Map<string, string>()
|
||
const sourceDir = fs.existsSync(lightDir) ? lightDir : svgDir
|
||
if (!fs.existsSync(sourceDir)) return map
|
||
|
||
for (const file of fs.readdirSync(sourceDir)) {
|
||
if (!file.endsWith('.svg')) continue
|
||
map.set(file.replace(/\.svg$/, ''), path.join(sourceDir, file))
|
||
}
|
||
return map
|
||
}
|
||
|
||
export interface LightDarkSvgPair {
|
||
light: string
|
||
/** null when the logo has no dedicated dark variant (single-source logo). */
|
||
dark: string | null
|
||
}
|
||
|
||
/**
|
||
* Scan a logo source directory with light/ and (optional) dark/ subdirectories,
|
||
* returning a map keyed by kebab-case dirName → { light, dark } SVG paths.
|
||
*
|
||
* The light variant is required. The dark variant is optional — if dark/{name}.svg
|
||
* is missing, the entry has dark=null and the public CompoundIcon API falls back
|
||
* to the light SVG for `variant="dark"` without generating a duplicate dark
|
||
* component.
|
||
*/
|
||
export function buildLightDarkSvgMap(type: LogoType): Map<string, LightDarkSvgPair> {
|
||
const svgDir = SVG_SOURCE_MAP[type]
|
||
const lightDir = path.join(svgDir, 'light')
|
||
const darkDir = path.join(svgDir, 'dark')
|
||
const map = new Map<string, LightDarkSvgPair>()
|
||
if (!fs.existsSync(lightDir)) return map
|
||
|
||
for (const file of fs.readdirSync(lightDir)) {
|
||
if (!file.endsWith('.svg')) continue
|
||
const darkPath = path.join(darkDir, file)
|
||
const hasDark = fs.existsSync(darkPath)
|
||
map.set(file.replace(/\.svg$/, ''), {
|
||
light: path.join(lightDir, file),
|
||
dark: hasDark ? darkPath : null
|
||
})
|
||
}
|
||
return map
|
||
}
|
||
|
||
export function getComponentName(baseDir: string, dirName: string): string {
|
||
for (const filename of ['light.tsx', 'color.tsx']) {
|
||
const filePath = path.join(baseDir, dirName, filename)
|
||
try {
|
||
const content = fs.readFileSync(filePath, 'utf-8')
|
||
const match = content.match(/export \{ (\w+) \}/)
|
||
if (match) {
|
||
return filename === 'light.tsx' ? match[1].replace(/Light$/, '') : match[1]
|
||
}
|
||
} catch {
|
||
/* try next filename */
|
||
}
|
||
}
|
||
return dirName.charAt(0).toUpperCase() + dirName.slice(1)
|
||
}
|
||
|
||
export function collectIconDirs(baseDir: string): string[] {
|
||
return fs
|
||
.readdirSync(baseDir, { withFileTypes: true })
|
||
.filter(
|
||
(e) =>
|
||
e.isDirectory() &&
|
||
(fs.existsSync(path.join(baseDir, e.name, 'light.tsx')) ||
|
||
fs.existsSync(path.join(baseDir, e.name, 'color.tsx')))
|
||
)
|
||
.map((e) => e.name)
|
||
.sort()
|
||
}
|
||
|
||
export function readColorPrimary(baseDir: string, dirName: string): string {
|
||
const metaPath = path.join(baseDir, dirName, 'meta.ts')
|
||
if (!fs.existsSync(metaPath)) return '#000000'
|
||
const content = fs.readFileSync(metaPath, 'utf-8')
|
||
const match = content.match(/colorPrimary:\s*'([^']+)'/)
|
||
return match ? match[1] : '#000000'
|
||
}
|
||
|
||
export interface BBox {
|
||
minX: number
|
||
minY: number
|
||
maxX: number
|
||
maxY: number
|
||
}
|
||
|
||
/**
|
||
* Parse an SVG path `d` attribute and return a conservative bounding box.
|
||
* For curves the control points are included, which may slightly overestimate
|
||
* the bounds — this is acceptable for icon viewBox calculation.
|
||
*/
|
||
export function parseSvgPathBounds(d: string): BBox {
|
||
const bounds: BBox = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
|
||
let cx = 0,
|
||
cy = 0,
|
||
startX = 0,
|
||
startY = 0
|
||
|
||
const addPoint = (x: number, y: number) => {
|
||
if (isFinite(x) && isFinite(y)) {
|
||
bounds.minX = Math.min(bounds.minX, x)
|
||
bounds.minY = Math.min(bounds.minY, y)
|
||
bounds.maxX = Math.max(bounds.maxX, x)
|
||
bounds.maxY = Math.max(bounds.maxY, y)
|
||
}
|
||
}
|
||
|
||
const tokens = d.match(/[a-zA-Z]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g) || []
|
||
let i = 0
|
||
const num = () => parseFloat(tokens[i++])
|
||
const hasNum = () => i < tokens.length && /^[-+.\d]/.test(tokens[i])
|
||
|
||
// SVG arc flags (0 or 1) can be concatenated without separators (e.g. "004.496" = flag 0, flag 0, x 4.496).
|
||
// Split the leading flag digit from the rest of the token when needed.
|
||
const splitArcFlag = () => {
|
||
if (i < tokens.length && /^[01]/.test(tokens[i]) && tokens[i].length > 1) {
|
||
const token = tokens[i]
|
||
tokens.splice(i, 1, token[0], token.slice(1))
|
||
}
|
||
i++ // consume the flag
|
||
}
|
||
|
||
while (i < tokens.length) {
|
||
const cmd = tokens[i++]
|
||
switch (cmd) {
|
||
case 'M':
|
||
cx = num()
|
||
cy = num()
|
||
startX = cx
|
||
startY = cy
|
||
addPoint(cx, cy)
|
||
while (hasNum()) {
|
||
cx = num()
|
||
cy = num()
|
||
addPoint(cx, cy)
|
||
}
|
||
break
|
||
case 'm':
|
||
cx += num()
|
||
cy += num()
|
||
startX = cx
|
||
startY = cy
|
||
addPoint(cx, cy)
|
||
while (hasNum()) {
|
||
cx += num()
|
||
cy += num()
|
||
addPoint(cx, cy)
|
||
}
|
||
break
|
||
case 'L':
|
||
while (hasNum()) {
|
||
cx = num()
|
||
cy = num()
|
||
addPoint(cx, cy)
|
||
}
|
||
break
|
||
case 'l':
|
||
while (hasNum()) {
|
||
cx += num()
|
||
cy += num()
|
||
addPoint(cx, cy)
|
||
}
|
||
break
|
||
case 'H':
|
||
while (hasNum()) {
|
||
cx = num()
|
||
addPoint(cx, cy)
|
||
}
|
||
break
|
||
case 'h':
|
||
while (hasNum()) {
|
||
cx += num()
|
||
addPoint(cx, cy)
|
||
}
|
||
break
|
||
case 'V':
|
||
while (hasNum()) {
|
||
cy = num()
|
||
addPoint(cx, cy)
|
||
}
|
||
break
|
||
case 'v':
|
||
while (hasNum()) {
|
||
cy += num()
|
||
addPoint(cx, cy)
|
||
}
|
||
break
|
||
case 'C':
|
||
while (hasNum()) {
|
||
addPoint(num(), num())
|
||
addPoint(num(), num())
|
||
cx = num()
|
||
cy = num()
|
||
addPoint(cx, cy)
|
||
}
|
||
break
|
||
case 'c':
|
||
while (hasNum()) {
|
||
const ox = cx,
|
||
oy = cy
|
||
addPoint(ox + num(), oy + num())
|
||
addPoint(ox + num(), oy + num())
|
||
cx = ox + num()
|
||
cy = oy + num()
|
||
addPoint(cx, cy)
|
||
}
|
||
break
|
||
case 'S':
|
||
while (hasNum()) {
|
||
addPoint(num(), num())
|
||
cx = num()
|
||
cy = num()
|
||
addPoint(cx, cy)
|
||
}
|
||
break
|
||
case 's':
|
||
while (hasNum()) {
|
||
const ox = cx,
|
||
oy = cy
|
||
addPoint(ox + num(), oy + num())
|
||
cx = ox + num()
|
||
cy = oy + num()
|
||
addPoint(cx, cy)
|
||
}
|
||
break
|
||
case 'Q':
|
||
while (hasNum()) {
|
||
addPoint(num(), num())
|
||
cx = num()
|
||
cy = num()
|
||
addPoint(cx, cy)
|
||
}
|
||
break
|
||
case 'q':
|
||
while (hasNum()) {
|
||
const ox = cx,
|
||
oy = cy
|
||
addPoint(ox + num(), oy + num())
|
||
cx = ox + num()
|
||
cy = oy + num()
|
||
addPoint(cx, cy)
|
||
}
|
||
break
|
||
case 'T':
|
||
while (hasNum()) {
|
||
cx = num()
|
||
cy = num()
|
||
addPoint(cx, cy)
|
||
}
|
||
break
|
||
case 't':
|
||
while (hasNum()) {
|
||
cx += num()
|
||
cy += num()
|
||
addPoint(cx, cy)
|
||
}
|
||
break
|
||
case 'A':
|
||
while (hasNum()) {
|
||
num()
|
||
num() // rx, ry (skip — endpoint is sufficient for bounds)
|
||
num() // rotation
|
||
splitArcFlag()
|
||
splitArcFlag()
|
||
cx = num()
|
||
cy = num()
|
||
addPoint(cx, cy)
|
||
}
|
||
break
|
||
case 'a':
|
||
while (hasNum()) {
|
||
num()
|
||
num() // rx, ry
|
||
num() // rotation
|
||
splitArcFlag()
|
||
splitArcFlag()
|
||
cx += num()
|
||
cy += num()
|
||
addPoint(cx, cy)
|
||
}
|
||
break
|
||
case 'Z':
|
||
case 'z':
|
||
cx = startX
|
||
cy = startY
|
||
break
|
||
}
|
||
}
|
||
|
||
return bounds
|
||
}
|
||
|
||
/**
|
||
* Parse a hex color (#RGB or #RRGGBB) to normalized [r, g, b] (0–1).
|
||
* Returns null for unparseable values.
|
||
*/
|
||
function parseHexRgb(hex: string): [number, number, number] | null {
|
||
const h = hex.replace(/^#/, '')
|
||
if (h.length === 3) {
|
||
return [parseInt(h[0] + h[0], 16) / 255, parseInt(h[1] + h[1], 16) / 255, parseInt(h[2] + h[2], 16) / 255]
|
||
}
|
||
if (h.length === 6) {
|
||
return [parseInt(h.slice(0, 2), 16) / 255, parseInt(h.slice(2, 4), 16) / 255, parseInt(h.slice(4, 6), 16) / 255]
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Parse a hex color (#RGB or #RRGGBB) and return perceived luminance (0–1).
|
||
* Returns -1 for unparseable values (e.g. url(#gradient), named colors other than white/black).
|
||
*/
|
||
export function colorToLuminance(hex: string): number {
|
||
const rgb = parseHexRgb(hex)
|
||
if (rgb) return 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]
|
||
if (/^black$/i.test(hex)) return 0
|
||
if (/^white$/i.test(hex)) return 1
|
||
return -1
|
||
}
|
||
|
||
/**
|
||
* Check if a fill value is near-white (all RGB channels >= threshold).
|
||
* Default threshold 220 detects light foreground content in vectorized icons.
|
||
*/
|
||
export function isNearWhiteFill(fillValue: string, threshold = 220): boolean {
|
||
if (/^(?:white|#fff(?:fff)?)$/i.test(fillValue)) return true
|
||
const hex = fillValue.match(/^#([0-9a-f]{6})$/i)
|
||
if (hex) {
|
||
const r = parseInt(hex[1].slice(0, 2), 16)
|
||
const g = parseInt(hex[1].slice(2, 4), 16)
|
||
const b = parseInt(hex[1].slice(4, 6), 16)
|
||
return r >= threshold && g >= threshold && b >= threshold
|
||
}
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* Check if a fill value is white or near-white (all RGB channels >= 240).
|
||
*/
|
||
export function isWhiteFill(fillValue: string): boolean {
|
||
return isNearWhiteFill(fillValue, 240)
|
||
}
|
||
|
||
/**
|
||
* Check if a path's bounding box covers a large portion of the viewBox.
|
||
*/
|
||
export function isLargeShape(pathD: string, vbW: number, vbH: number, threshold = 0.3): boolean {
|
||
const bounds = parseSvgPathBounds(pathD)
|
||
if (!isFinite(bounds.minX)) return false
|
||
const pathArea = (bounds.maxX - bounds.minX) * (bounds.maxY - bounds.minY)
|
||
return pathArea > vbW * vbH * threshold
|
||
}
|
||
|
||
/**
|
||
* Parse the viewBox from an SVG element's attributes record.
|
||
* Returns { x, y, w, h } or defaults to { 0, 0, 24, 24 }.
|
||
*/
|
||
export function parseViewBox(attrs: Record<string, string>): { x: number; y: number; w: number; h: number } {
|
||
const vb = attrs.viewBox || attrs.viewbox
|
||
if (vb) {
|
||
const parts = vb.split(/[\s,]+/).map(Number)
|
||
if (parts.length === 4 && parts.every(isFinite)) {
|
||
return { x: parts[0], y: parts[1], w: parts[2], h: parts[3] }
|
||
}
|
||
}
|
||
// Fall back to width/height if present
|
||
const w = parseFloat(attrs.width)
|
||
const h = parseFloat(attrs.height)
|
||
if (isFinite(w) && isFinite(h)) {
|
||
return { x: 0, y: 0, w, h }
|
||
}
|
||
return { x: 0, y: 0, w: 24, h: 24 }
|
||
}
|
||
|
||
/**
|
||
* Parse a hex color to HSV saturation (0–1).
|
||
* Returns -1 for unparseable values.
|
||
*/
|
||
function colorToSaturation(hex: string): number {
|
||
const rgb = parseHexRgb(hex)
|
||
if (!rgb) {
|
||
if (/^(?:black|white)$/i.test(hex)) return 0
|
||
return -1
|
||
}
|
||
const max = Math.max(...rgb)
|
||
const min = Math.min(...rgb)
|
||
return max === 0 ? 0 : (max - min) / max
|
||
}
|
||
|
||
/**
|
||
* Classify an SVG as monochrome (single-color or achromatic) and whether
|
||
* it was designed for dark backgrounds (white/light content on transparent bg).
|
||
*
|
||
* Strips `<defs>...</defs>` blocks from analysis so clip-path and gradient
|
||
* fills are not counted as content fills.
|
||
*/
|
||
export function isMonochromeSvg(svgContent: string): { monochrome: boolean; darkDesigned: boolean } {
|
||
// Strip <defs>...</defs> blocks from analysis
|
||
const stripped = svgContent.replace(/<defs[\s\S]*?<\/defs>/gi, '')
|
||
|
||
// Extract all fill="..." / fill='...' and stroke="..." / stroke='...' values from content elements
|
||
const fillMatches = [...stripped.matchAll(/fill=["']([^"']+)["']/g)]
|
||
const strokeMatches = [...stripped.matchAll(/stroke=["']([^"']+)["']/g)]
|
||
const fills = fillMatches.map(([, value]) => value)
|
||
const strokes = strokeMatches.map(([, value]) => value)
|
||
const allColors = [...fills, ...strokes]
|
||
|
||
// If any content element uses gradient fills/strokes, the icon is colorful (not monochrome)
|
||
const hasGradientFill = allColors.some((f) => f.startsWith('url('))
|
||
if (hasGradientFill) {
|
||
return { monochrome: false, darkDesigned: false }
|
||
}
|
||
|
||
// Filter out non-content colors
|
||
const contentFills = allColors.filter((f) => f !== 'none' && f !== 'currentColor' && !isWhiteFill(f))
|
||
|
||
if (contentFills.length === 0) {
|
||
// No colored fills/strokes remain — all-white/transparent content
|
||
const hasWhite = allColors.some((f) => isWhiteFill(f))
|
||
return { monochrome: true, darkDesigned: hasWhite }
|
||
}
|
||
|
||
// Check if all remaining fills are perceptually achromatic.
|
||
// A color is achromatic if:
|
||
// - HSV saturation < 0.1 (true gray), OR
|
||
// - luminance < 0.15 (perceptually black regardless of hue, e.g. #231F20, #1F0909)
|
||
const allAchromatic = contentFills.every((f) => {
|
||
const sat = colorToSaturation(f)
|
||
if (sat >= 0 && sat < 0.1) return true
|
||
const lum = colorToLuminance(f)
|
||
return lum >= 0 && lum < 0.15
|
||
})
|
||
|
||
if (!allAchromatic) {
|
||
return { monochrome: false, darkDesigned: false }
|
||
}
|
||
|
||
// All achromatic — check luminance to determine darkDesigned
|
||
let totalLum = 0
|
||
let lumCount = 0
|
||
for (const f of contentFills) {
|
||
const lum = colorToLuminance(f)
|
||
if (lum >= 0) {
|
||
totalLum += lum
|
||
lumCount++
|
||
}
|
||
}
|
||
|
||
const avgLum = lumCount > 0 ? totalLum / lumCount : 0
|
||
return { monochrome: true, darkDesigned: avgLum > 0.6 }
|
||
}
|
||
|
||
/**
|
||
* Normalize a fill/color string to a canonical hex form for comparison.
|
||
* Returns the original string if it can't be normalized.
|
||
*/
|
||
export function normalizeColor(color: string): string {
|
||
if (!color || color === 'none' || color === 'currentColor' || color.startsWith('url(')) {
|
||
return color
|
||
}
|
||
// Expand 3-char hex to 6-char
|
||
const m3 = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i)
|
||
if (m3) {
|
||
return `#${m3[1]}${m3[1]}${m3[2]}${m3[2]}${m3[3]}${m3[3]}`.toUpperCase()
|
||
}
|
||
const m6 = color.match(/^#[0-9a-f]{6}$/i)
|
||
if (m6) {
|
||
return color.toUpperCase()
|
||
}
|
||
return color
|
||
}
|