fix(theme): preserve logo detail across theme colors (#540)

This commit is contained in:
InfinityPacer
2026-07-17 14:18:35 +08:00
committed by GitHub
parent 39dd1b9e00
commit ce32da9176
12 changed files with 418 additions and 103 deletions
+42
View File
@@ -0,0 +1,42 @@
import logoSvg from '@images/logo.svg?raw'
import { applyThemeLogoPalette, createThemeLogoPalette } from '@/utils/themeLogo'
import { describe, expect, it } from 'vitest'
describe('theme logo palette', () => {
it('reproduces the source artwork when its original primary color is selected', () => {
const result = applyThemeLogoPalette(logoSvg, createThemeLogoPalette('#8D51F9'))
expect(result).toBe(logoSvg)
})
it('replaces every original logo color without flattening its gradient structure', () => {
const palette = createThemeLogoPalette('#00BCD4')
const result = applyThemeLogoPalette(logoSvg, palette)
const originalColors = [
'rgb(141,81,249)',
'rgb(165,118,255)',
'rgb(211,187,255)',
'rgb(116,50,223)',
'rgb(110,38,217)',
'rgb(104,0,197)',
'rgb(91,0,197)',
]
expect(result.match(/<(?:linear|radial)Gradient/g)).toHaveLength(6)
expect(result.match(/<path/g)).toHaveLength(12)
expect(result).toContain('stop-opacity:1')
expect(result).toContain(palette.primary)
expect(result).toContain(palette.highlight)
expect(result).toContain(palette.deepest)
originalColors.forEach(color => expect(result).not.toContain(color))
})
it.each(['#000000', '#808080', '#FFFFFF'])('keeps visible facet contrast for neutral theme color %s', primary => {
const palette = createThemeLogoPalette(primary)
const distinctColors = new Set(Object.values(palette))
expect(distinctColors.size).toBeGreaterThanOrEqual(5)
expect(palette.primary).not.toBe(palette.highlight)
expect(palette.primary).not.toBe(palette.deepest)
})
})
+138
View File
@@ -0,0 +1,138 @@
export interface ThemeLogoPalette {
/** 标识主体色,保持与当前主题主色一致。 */
primary: string
/** 标识迎光面色阶。 */
light: string
/** 标识高光渐变色阶。 */
highlight: string
/** 标识第一层背光面色阶。 */
dark: string
/** 标识第二层背光面色阶。 */
darker: string
/** 标识内侧深色面色阶。 */
deep: string
/** 标识最深的内侧面色阶。 */
deepest: string
}
interface HslColor {
h: number
l: number
s: number
}
const sourceLogoPalette: Record<string, keyof ThemeLogoPalette> = {
'rgb(141,81,249)': 'primary',
'rgb(165,118,255)': 'light',
'rgb(211,187,255)': 'highlight',
'rgb(116,50,223)': 'dark',
'rgb(110,38,217)': 'darker',
'rgb(104,0,197)': 'deep',
'rgb(91,0,197)': 'deepest',
}
const sourceLogoRgb: Record<keyof ThemeLogoPalette, [number, number, number]> = {
primary: [141, 81, 249],
light: [165, 118, 255],
highlight: [211, 187, 255],
dark: [116, 50, 223],
darker: [110, 38, 217],
deep: [104, 0, 197],
deepest: [91, 0, 197],
}
function clamp(value: number, min = 0, max = 1) {
return Math.min(max, Math.max(min, value))
}
function parseHexColor(hexColor: string) {
const normalized = hexColor.trim().replace('#', '')
if (!/^[\da-f]{6}$/i.test(normalized)) return null
return [0, 2, 4].map(offset => Number.parseInt(normalized.slice(offset, offset + 2), 16)) as [number, number, number]
}
function rgbToHsl([red, green, blue]: [number, number, number]): HslColor {
const r = red / 255
const g = green / 255
const b = blue / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const delta = max - min
const l = (max + min) / 2
if (delta === 0) return { h: 0, l, s: 0 }
const s = delta / (1 - Math.abs(2 * l - 1))
let h = 0
if (max === r) h = ((g - b) / delta) % 6
else if (max === g) h = (b - r) / delta + 2
else h = (r - g) / delta + 4
return { h: (h * 60 + 360) % 360, l, s }
}
function hslToRgb({ h, l, s }: HslColor) {
const chroma = (1 - Math.abs(2 * l - 1)) * s
const segment = h / 60
const secondary = chroma * (1 - Math.abs((segment % 2) - 1))
let channels: [number, number, number]
if (segment < 1) channels = [chroma, secondary, 0]
else if (segment < 2) channels = [secondary, chroma, 0]
else if (segment < 3) channels = [0, chroma, secondary]
else if (segment < 4) channels = [0, secondary, chroma]
else if (segment < 5) channels = [secondary, 0, chroma]
else channels = [chroma, 0, secondary]
const offset = l - chroma / 2
const rgb = channels.map(channel => Math.round((channel + offset) * 255))
return `rgb(${rgb.join(',')})`
}
function shiftLogoTone(color: HslColor, hueOffset: number, lightnessOffset: number, saturationScale = 1) {
return hslToRgb({
h: (color.h + hueOffset + 360) % 360,
l: clamp(color.l + lightnessOffset, 0.08, 0.92),
s: clamp(color.s * saturationScale),
})
}
/**
* 从主题主色生成完整的标识明暗色阶。
* 接近黑、白的主题色会反向拉开部分色阶,避免分面收敛成同一颜色。
*/
export function createThemeLogoPalette(primaryColor: string): ThemeLogoPalette {
const rgb = parseHexColor(primaryColor) || [141, 81, 249]
const hsl = rgbToHsl(rgb)
const sourcePrimaryHsl = rgbToHsl(sourceLogoRgb.primary)
const lightDirection = hsl.l >= 0.78 ? -1 : 1
const darkDirection = hsl.l <= 0.22 ? 1 : -1
const palette = Object.fromEntries(
Object.entries(sourceLogoRgb).map(([key, sourceRgb]) => {
const paletteKey = key as keyof ThemeLogoPalette
if (paletteKey === 'primary') return [paletteKey, `rgb(${rgb.join(',')})`]
const sourceHsl = rgbToHsl(sourceRgb)
const hueOffset = sourceHsl.h - sourcePrimaryHsl.h
const sourceLightnessDelta = sourceHsl.l - sourcePrimaryHsl.l
const lightnessDelta =
Math.abs(sourceLightnessDelta) * (sourceLightnessDelta >= 0 ? lightDirection : darkDirection)
const saturationScale = sourcePrimaryHsl.s ? sourceHsl.s / sourcePrimaryHsl.s : 1
return [paletteKey, shiftLogoTone(hsl, hueOffset, lightnessDelta, saturationScale)]
}),
) as unknown as ThemeLogoPalette
return palette
}
/** 将原始品牌 SVG 的色阶逐层映射到当前主题色家族,保留路径、渐变和透明高光。 */
export function applyThemeLogoPalette(svgSource: string, palette: ThemeLogoPalette) {
return Object.entries(sourceLogoPalette).reduce(
(svg, [sourceColor, paletteKey]) => svg.replaceAll(sourceColor, palette[paletteKey]),
svgSource,
)
}
+2 -2
View File
@@ -19,7 +19,7 @@ interface ApplyDocumentThemeChromeOptions {
export const themeRootPalettes: Record<ResolvedThemeName, ThemeRootPalette> = {
light: {
background: '#F4F5FA',
primary: '#9155FD',
primary: '#8D51F9',
},
dark: {
background: '#0E1116',
@@ -27,7 +27,7 @@ export const themeRootPalettes: Record<ResolvedThemeName, ThemeRootPalette> = {
},
purple: {
background: '#28243D',
primary: '#9155FD',
primary: '#8D51F9',
},
transparent: {
background: '#1C1C1C',