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
+179 -65
View File
@@ -4,7 +4,7 @@
--safe-area-inset-bottom: env(safe-area-inset-bottom); --safe-area-inset-bottom: env(safe-area-inset-bottom);
--safe-area-inset-top: env(safe-area-inset-top); --safe-area-inset-top: env(safe-area-inset-top);
--initial-loader-bg: #0E1116; --initial-loader-bg: #0E1116;
--initial-loader-color: #9155FD; --initial-loader-color: #8D51F9;
--initial-loader-height: 100svh; --initial-loader-height: 100svh;
--initial-loader-width: 100vw; --initial-loader-width: 100vw;
--initial-color-scheme: dark; --initial-color-scheme: dark;
@@ -171,30 +171,11 @@
display: block; display: block;
block-size: auto; block-size: auto;
inline-size: 100%; inline-size: 100%;
opacity: 0;
} }
.loading-logo__mark { .loading-logo img[data-theme-ready='true'] {
display: none; opacity: 1;
}
@supports ((-webkit-mask-image: url('/logo.svg')) or (mask-image: url('/logo.svg'))) {
.loading-logo img {
display: none;
}
.loading-logo__mark {
display: block;
background: linear-gradient(
145deg,
color-mix(in srgb, var(--initial-loader-color) 48%, white) 0%,
var(--initial-loader-color) 48%,
color-mix(in srgb, var(--initial-loader-color) 72%, black) 100%
);
block-size: min(160px, 36vw);
inline-size: min(160px, 36vw);
mask: url('/logo.svg') center / contain no-repeat;
-webkit-mask: url('/logo.svg') center / contain no-repeat;
}
} }
.loading-footer { .loading-footer {
@@ -297,11 +278,11 @@
} }
#timeout-btn { #timeout-btn {
color: var(--initial-loader-color, #9155FD); color: var(--initial-loader-color, #8D51F9);
text-decoration: none; text-decoration: none;
font-weight: bold; font-weight: bold;
margin-inline-start: 8px; margin-inline-start: 8px;
border-bottom: 1px solid var(--initial-loader-color, #9155FD); border-bottom: 1px solid var(--initial-loader-color, #8D51F9);
} }
</style> </style>
@@ -327,7 +308,7 @@
const launchThemePalettes = { const launchThemePalettes = {
light: { light: {
background: '#F4F5FA', background: '#F4F5FA',
primary: '#9155FD', primary: '#8D51F9',
}, },
dark: { dark: {
background: '#0E1116', background: '#0E1116',
@@ -335,7 +316,7 @@
}, },
purple: { purple: {
background: '#28243D', background: '#28243D',
primary: '#9155FD', primary: '#8D51F9',
}, },
transparent: { transparent: {
background: '#1C1C1C', background: '#1C1C1C',
@@ -386,54 +367,188 @@
document.head.appendChild(meta) document.head.appendChild(meta)
} }
let faviconSourceImage let logoSvgSourcePromise
let pendingFaviconColor = '#9155FD' let pendingFaviconColor = '#8D51F9'
const themeLogoCacheKey = 'moviepilot-themed-logo-cache'
function mixFaviconColor(hexColor, target, amount) { const sourceLogoPalette = {
const normalized = hexColor.replace('#', '') 'rgb(141,81,249)': 'primary',
const source = [0, 2, 4].map(offset => Number.parseInt(normalized.slice(offset, offset + 2), 16)) 'rgb(165,118,255)': 'light',
'rgb(211,187,255)': 'highlight',
return `rgb(${source.map((channel, index) => Math.round(channel + (target[index] - channel) * amount)).join(', ')})` 'rgb(116,50,223)': 'dark',
'rgb(110,38,217)': 'darker',
'rgb(104,0,197)': 'deep',
'rgb(91,0,197)': 'deepest',
} }
// Tab 图标使用小尺寸位图承载主题色,避免 favicon 内部 SVG 无法继承页面 CSS 变量。 const sourceLogoRgb = {
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 clampLogoChannel(value, min = 0, max = 1) {
return Math.min(max, Math.max(min, value))
}
function logoRgbToHsl([red, green, blue]) {
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 logoHslToRgb({ h, l, s }) {
const chroma = (1 - Math.abs(2 * l - 1)) * s
const segment = h / 60
const secondary = chroma * (1 - Math.abs((segment % 2) - 1))
let channels
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, hueOffset, lightnessOffset, saturationScale = 1) {
return logoHslToRgb({
h: (color.h + hueOffset + 360) % 360,
l: clampLogoChannel(color.l + lightnessOffset, 0.08, 0.92),
s: clampLogoChannel(color.s * saturationScale),
})
}
function createLaunchLogoPalette(primaryColor) {
const normalized = primaryColor.slice(1)
const rgb = [0, 2, 4].map(offset => Number.parseInt(normalized.slice(offset, offset + 2), 16))
const hsl = logoRgbToHsl(rgb)
const sourcePrimaryHsl = logoRgbToHsl(sourceLogoRgb.primary)
const lightDirection = hsl.l >= 0.78 ? -1 : 1
const darkDirection = hsl.l <= 0.22 ? 1 : -1
return Object.fromEntries(
Object.entries(sourceLogoRgb).map(([key, sourceRgb]) => {
if (key === 'primary') return [key, `rgb(${rgb.join(',')})`]
const sourceHsl = logoRgbToHsl(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 [key, shiftLogoTone(hsl, hueOffset, lightnessDelta, saturationScale)]
}),
)
}
function createThemedLogoDataUrl(svgSource, primaryColor) {
const palette = createLaunchLogoPalette(primaryColor)
const themedSvg = Object.entries(sourceLogoPalette).reduce(
(svg, [sourceColor, paletteKey]) => svg.replaceAll(sourceColor, palette[paletteKey]),
svgSource,
)
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(themedSvg)}`
}
function withLoadingLogo(callback) {
const loadingLogo = document.querySelector('.loading-logo img')
if (loadingLogo) {
callback(loadingLogo)
return
}
if (document.readyState !== 'loading') return
const observer = new MutationObserver(() => {
const nextLoadingLogo = document.querySelector('.loading-logo img')
if (!nextLoadingLogo) return
observer.disconnect()
callback(nextLoadingLogo)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
}
function applyThemedLogoUrl(themedLogoUrl) {
const faviconLink = document.querySelector('#theme-favicon')
faviconLink?.setAttribute('type', 'image/svg+xml')
faviconLink?.setAttribute('href', themedLogoUrl)
withLoadingLogo(loadingLogo => {
const revealLogo = () => loadingLogo.setAttribute('data-theme-ready', 'true')
loadingLogo.addEventListener('load', revealLogo, { once: true })
loadingLogo.setAttribute('src', themedLogoUrl)
if (loadingLogo.complete) revealLogo()
})
}
function revealOriginalLoadingLogo() {
withLoadingLogo(loadingLogo => loadingLogo.setAttribute('data-theme-ready', 'true'))
}
// 启动层和 Tab 图标共享原始矢量结构,主题切换只替换色阶,不破坏分面和透明高光。
function syncThemeFavicon(primaryColor) { function syncThemeFavicon(primaryColor) {
if (!/^#[0-9a-f]{6}$/i.test(primaryColor)) return if (!/^#[0-9a-f]{6}$/i.test(primaryColor)) return
pendingFaviconColor = primaryColor pendingFaviconColor = primaryColor
const render = () => { try {
const faviconLink = document.querySelector('#theme-favicon') const cachedLogo = JSON.parse(localStorage.getItem(themeLogoCacheKey) || 'null')
if (!faviconLink || !faviconSourceImage?.naturalWidth) return if (cachedLogo?.color === primaryColor && typeof cachedLogo.url === 'string') applyThemedLogoUrl(cachedLogo.url)
} catch {
const canvas = document.createElement('canvas') // 缓存异常不影响根据品牌源文件重新生成主题标识。
const context = canvas.getContext('2d')
if (!context) return
canvas.width = 64
canvas.height = 64
context.drawImage(faviconSourceImage, 0, 0, 64, 64)
context.globalCompositeOperation = 'source-in'
const gradient = context.createLinearGradient(10, 6, 54, 58)
gradient.addColorStop(0, mixFaviconColor(pendingFaviconColor, [255, 255, 255], 0.38))
gradient.addColorStop(0.48, pendingFaviconColor)
gradient.addColorStop(1, mixFaviconColor(pendingFaviconColor, [0, 0, 0], 0.28))
context.fillStyle = gradient
context.fillRect(0, 0, 64, 64)
faviconLink.setAttribute('type', 'image/png')
faviconLink.setAttribute('href', canvas.toDataURL('image/png'))
} }
if (!faviconSourceImage) { logoSvgSourcePromise ||= fetch('/logo.svg').then(response => {
faviconSourceImage = new Image() if (!response.ok) throw new Error(`Logo SVG request failed: ${response.status}`)
faviconSourceImage.onload = render return response.text()
faviconSourceImage.src = '/logo.svg' })
return
}
if (faviconSourceImage.complete) render() logoSvgSourcePromise
.then(svgSource => {
if (primaryColor !== pendingFaviconColor) return
const themedLogoUrl = createThemedLogoDataUrl(svgSource, primaryColor)
applyThemedLogoUrl(themedLogoUrl)
try {
localStorage.setItem(themeLogoCacheKey, JSON.stringify({ color: primaryColor, url: themedLogoUrl }))
} catch {
// 存储空间不可用时仍保留当前页面内的主题标识。
}
})
.catch(() => {
// 原始 SVG 始终保留为无网络或解析异常时的可见回退。
revealOriginalLoadingLogo()
})
} }
window.addEventListener('moviepilot-theme-primary-color-change', event => { window.addEventListener('moviepilot-theme-primary-color-change', event => {
@@ -630,7 +745,6 @@
<div class="loading-logo"> <div class="loading-logo">
<!-- Logo --> <!-- Logo -->
<img src="/logo.svg" alt="MoviePilot" width="160" height="160" /> <img src="/logo.svg" alt="MoviePilot" width="160" height="160" />
<span class="loading-logo__mark" role="img" aria-label="MoviePilot"></span>
</div> </div>
</div> </div>
<div class="loading-footer"> <div class="loading-footer">
+3 -3
View File
@@ -7,7 +7,7 @@
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<style> <style>
:root { :root {
--primary-color: #9155FD; --primary-color: #8D51F9;
--surface-color: #FFFFFF; --surface-color: #FFFFFF;
--text-color: #333333; --text-color: #333333;
--border-color: rgba(0, 0, 0, 0.12); --border-color: rgba(0, 0, 0, 0.12);
@@ -52,7 +52,7 @@
width: 120px; width: 120px;
height: 120px; height: 120px;
margin: 0 auto 32px; margin: 0 auto 32px;
background: rgba(145, 85, 253, 0.1); background: rgba(141, 81, 249, 0.1);
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -100,7 +100,7 @@
gap: 8px; gap: 8px;
margin-top: 24px; margin-top: 24px;
padding: 8px 16px; padding: 8px 16px;
background: rgba(145, 85, 253, 0.1); background: rgba(141, 81, 249, 0.1);
border-radius: 20px; border-radius: 20px;
font-size: 0.875rem; font-size: 0.875rem;
} }
+1 -1
View File
@@ -5,7 +5,7 @@ import { nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { storageRemoteDict } from '@/api/constants' import { storageRemoteDict } from '@/api/constants'
const DEFAULT_DIRECTORY_ACCENT_RGB = '145, 85, 253' const DEFAULT_DIRECTORY_ACCENT_RGB = '141, 81, 249'
const STORAGE_ACCENT_COLOR_MAP = { const STORAGE_ACCENT_COLOR_MAP = {
local: '#FFB400', local: '#FFB400',
alipan: '#00A7F2', alipan: '#00A7F2',
+1 -1
View File
@@ -964,7 +964,7 @@ function registerTexture<T extends ThreeTexture>(texture: T) {
function getThemeColors() { function getThemeColors() {
const T = requireThree() const T = requireThree()
const colors = vuetifyTheme.global.current.value.colors const colors = vuetifyTheme.global.current.value.colors
const primary = new T.Color(colors.primary || '#9155FD') const primary = new T.Color(colors.primary || '#8D51F9')
const surface = new T.Color(colors.surface || colors.background || '#14161F') const surface = new T.Color(colors.surface || colors.background || '#14161F')
const onSurface = new T.Color(colors['on-surface'] || '#FFFFFF') const onSurface = new T.Color(colors['on-surface'] || '#FFFFFF')
const hsl = { h: 0, l: 0, s: 0 } const hsl = { h: 0, l: 0, s: 0 }
+9 -9
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue' import { computed, onBeforeUnmount, ref } from 'vue'
import logoUrl from '@images/logo.svg' import logoUrl from '@images/logo.svg'
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -71,7 +72,7 @@ onBeforeUnmount(() => {
@pointermove="handlePointerMove" @pointermove="handlePointerMove"
@pointerleave="resetPointerResponse" @pointerleave="resetPointerResponse"
> >
<span class="prismatic-logo__base" aria-hidden="true" /> <ThemeLogoMark class="prismatic-logo__base" decorative />
<span class="prismatic-logo__spectrum" aria-hidden="true" /> <span class="prismatic-logo__spectrum" aria-hidden="true" />
<span class="prismatic-logo__specular" aria-hidden="true" /> <span class="prismatic-logo__specular" aria-hidden="true" />
<span class="prismatic-logo__reveal" aria-hidden="true" /> <span class="prismatic-logo__reveal" aria-hidden="true" />
@@ -117,7 +118,6 @@ onBeforeUnmount(() => {
transform: translate3d(0, 8px, -16px) scaleX(1.18); transform: translate3d(0, 8px, -16px) scaleX(1.18);
} }
.prismatic-logo__base,
.prismatic-logo__spectrum, .prismatic-logo__spectrum,
.prismatic-logo__specular, .prismatic-logo__specular,
.prismatic-logo__reveal { .prismatic-logo__reveal {
@@ -131,19 +131,19 @@ onBeforeUnmount(() => {
} }
.prismatic-logo__base { .prismatic-logo__base {
background: linear-gradient( position: absolute;
145deg, display: block;
color-mix(in srgb, rgb(var(--v-theme-primary)) 46%, white) 0%, block-size: calc(100% - 14px);
rgb(var(--v-theme-primary)) 48%,
color-mix(in srgb, rgb(var(--v-theme-primary)) 74%, black) 100%
);
filter: filter:
drop-shadow(0 8px 12px rgba(24, 8, 52, 0.3)) drop-shadow(0 8px 12px rgba(24, 8, 52, 0.3))
drop-shadow(0 0 10px rgba(var(--v-theme-primary), 0.22)); drop-shadow(0 0 10px rgba(var(--v-theme-primary), 0.22));
inline-size: calc(100% - 14px);
inset: 7px;
pointer-events: none;
transform: translateZ(10px); transform: translateZ(10px);
user-select: none;
} }
.prismatic-logo__base,
.prismatic-logo__spectrum, .prismatic-logo__spectrum,
.prismatic-logo__specular, .prismatic-logo__specular,
.prismatic-logo__reveal { .prismatic-logo__reveal {
+35 -14
View File
@@ -1,29 +1,50 @@
<script setup lang="ts"> <script setup lang="ts">
import logoUrl from '@images/logo.svg' import logoSvg from '@images/logo.svg?raw'
import { applyThemeLogoPalette, createThemeLogoPalette } from '@/utils/themeLogo'
import { computed } from 'vue'
import { useTheme } from 'vuetify'
/** 使用当前 Vuetify 主题主色渲染非 WebGL 场景中的 MoviePilot 标识。 */ const props = withDefaults(
const logoMaskStyle = { defineProps<{
'--theme-logo-mask': `url("${logoUrl}")`, /** 装饰模式不重复暴露图像语义,适用于已有外层可访问名称的复合标识。 */
} decorative?: boolean
}>(),
{
decorative: false,
},
)
const theme = useTheme()
/** 保留品牌 SVG 的分面与高光结构,只将原始紫色色阶映射到当前主题色家族。 */
const themedLogoSvg = computed(() =>
applyThemeLogoPalette(logoSvg, createThemeLogoPalette(theme.current.value.colors.primary)),
)
</script> </script>
<template> <template>
<span class="theme-logo-mark" :style="logoMaskStyle" role="img" aria-label="MoviePilot" /> <span
class="theme-logo-mark"
:role="props.decorative ? undefined : 'img'"
:aria-label="props.decorative ? undefined : 'MoviePilot'"
:aria-hidden="props.decorative || undefined"
>
<span class="theme-logo-mark__svg" v-html="themedLogoSvg" />
</span>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.theme-logo-mark { .theme-logo-mark {
display: inline-block; display: inline-block;
flex: none; flex: none;
background: linear-gradient(
145deg,
color-mix(in srgb, rgb(var(--v-theme-primary)) 48%, white) 0%,
rgb(var(--v-theme-primary)) 48%,
color-mix(in srgb, rgb(var(--v-theme-primary)) 72%, black) 100%
);
block-size: 3em; block-size: 3em;
inline-size: 3em; inline-size: 3em;
mask: var(--theme-logo-mask) center / contain no-repeat; }
-webkit-mask: var(--theme-logo-mask) center / contain no-repeat;
.theme-logo-mark__svg,
.theme-logo-mark__svg :deep(svg) {
display: block;
block-size: 100%;
inline-size: 100%;
} }
</style> </style>
+3 -3
View File
@@ -1,6 +1,6 @@
import { getDominantColor } from '@/@core/utils/image' import { getDominantColor } from '@/@core/utils/image'
const DEFAULT_ACCENT_RGB = '145, 85, 253' const DEFAULT_ACCENT_RGB = '141, 81, 249'
/** 将图标主色转换为卡片 CSS 变量可直接使用的 RGB 字符串。 */ /** 将图标主色转换为卡片 CSS 变量可直接使用的 RGB 字符串。 */
function hexToRgbString(hexColor: string) { function hexToRgbString(hexColor: string) {
@@ -13,14 +13,14 @@ function hexToRgbString(hexColor: string) {
} }
/** 从指定图片中提取卡片强调色,返回 CSS 变量可直接使用的 RGB 字符串。 */ /** 从指定图片中提取卡片强调色,返回 CSS 变量可直接使用的 RGB 字符串。 */
export async function getCardAccentRgbFromImage(image: HTMLImageElement | undefined | null, fallback = '#9155FD') { export async function getCardAccentRgbFromImage(image: HTMLImageElement | undefined | null, fallback = '#8D51F9') {
const dominantColor = await getDominantColor(image, { fallback }) const dominantColor = await getDominantColor(image, { fallback })
return hexToRgbString(dominantColor) return hexToRgbString(dominantColor)
} }
/** 从卡片图标中提取强调色,保证设置页卡片颜色跟随各自图标。 */ /** 从卡片图标中提取强调色,保证设置页卡片颜色跟随各自图标。 */
export function useCardAccentColor(fallback = '#9155FD') { export function useCardAccentColor(fallback = '#8D51F9') {
const accentRgb = ref(DEFAULT_ACCENT_RGB) const accentRgb = ref(DEFAULT_ACCENT_RGB)
const imageRef = ref<any>() const imageRef = ref<any>()
+3 -3
View File
@@ -11,7 +11,7 @@ export const THEME_CUSTOMIZER_CHANGE_EVENT = 'moviepilot-theme-customizer-change
export const THEME_CUSTOMIZER_OPEN_EVENT = 'moviepilot-theme-customizer-open' export const THEME_CUSTOMIZER_OPEN_EVENT = 'moviepilot-theme-customizer-open'
export const themeCustomizerPrimaryColors = [ export const themeCustomizerPrimaryColors = [
{ name: 'Purple', value: '#9155FD' }, { name: 'Purple', value: '#8D51F9' },
{ name: 'Indigo', value: '#3F51B5' }, { name: 'Indigo', value: '#3F51B5' },
{ name: 'Blue', value: '#1976D2' }, { name: 'Blue', value: '#1976D2' },
{ name: 'Cyan', value: '#00BCD4' }, { name: 'Cyan', value: '#00BCD4' },
@@ -126,12 +126,13 @@ function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSetti
const fallback = getDefaultThemeCustomizerSettings() const fallback = getDefaultThemeCustomizerSettings()
const storedRadius = settings.radius as string | undefined const storedRadius = settings.radius as string | undefined
const radius = storedRadius === 'huge' ? 'extra' : storedRadius const radius = storedRadius === 'huge' ? 'extra' : storedRadius
const primaryColor = isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor
return { return {
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout) layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
? (settings.layout as ThemeCustomizerLayout) ? (settings.layout as ThemeCustomizerLayout)
: fallback.layout, : fallback.layout,
primaryColor: isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor, primaryColor,
radius: validRadii.includes(radius as ThemeCustomizerRadius) radius: validRadii.includes(radius as ThemeCustomizerRadius)
? (radius as ThemeCustomizerRadius) ? (radius as ThemeCustomizerRadius)
: fallback.radius, : fallback.radius,
@@ -155,7 +156,6 @@ export function readThemeCustomizerSettings(): ThemeCustomizerSettings {
try { try {
const stored = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY) const stored = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)
const parsed = stored ? JSON.parse(stored) : {} const parsed = stored ? JSON.parse(stored) : {}
return normalizeThemeCustomizerSettings({ return normalizeThemeCustomizerSettings({
...fallback, ...fallback,
...parsed, ...parsed,
+2 -2
View File
@@ -6,7 +6,7 @@ const theme: VuetifyOptions['theme'] = {
light: { light: {
dark: false, dark: false,
colors: { colors: {
'primary': '#9155FD', 'primary': '#8D51F9',
'secondary': '#8A8D93', 'secondary': '#8A8D93',
'on-secondary': '#FFFFFF', 'on-secondary': '#FFFFFF',
'success': '#56CA00', 'success': '#56CA00',
@@ -107,7 +107,7 @@ const theme: VuetifyOptions['theme'] = {
purple: { purple: {
dark: true, dark: true,
colors: { colors: {
'primary': '#9155FD', 'primary': '#8D51F9',
'secondary': '#8A8D93', 'secondary': '#8A8D93',
'on-secondary': '#FFFFFF', 'on-secondary': '#FFFFFF',
'success': '#56CA00', 'success': '#56CA00',
+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> = { export const themeRootPalettes: Record<ResolvedThemeName, ThemeRootPalette> = {
light: { light: {
background: '#F4F5FA', background: '#F4F5FA',
primary: '#9155FD', primary: '#8D51F9',
}, },
dark: { dark: {
background: '#0E1116', background: '#0E1116',
@@ -27,7 +27,7 @@ export const themeRootPalettes: Record<ResolvedThemeName, ThemeRootPalette> = {
}, },
purple: { purple: {
background: '#28243D', background: '#28243D',
primary: '#9155FD', primary: '#8D51F9',
}, },
transparent: { transparent: {
background: '#1C1C1C', background: '#1C1C1C',