From 1a303b43234fadb693cecbcd9fbf7a4374b8fe5b Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:01:08 +0800 Subject: [PATCH] fix(glass): port material accents to v3 (#660) --- src/@core/utils/__tests__/image.spec.ts | 70 +++++++- src/@core/utils/image.ts | 63 +++++--- src/App.vue | 16 +- src/components/cards/PluginAppCard.vue | 21 ++- src/components/cards/PluginCard.vue | 21 ++- .../__tests__/PluginAppCardRating.spec.ts | 23 ++- .../cards/__tests__/PluginCard.spec.ts | 23 ++- .../cards/__tests__/PluginCardAbout.spec.ts | 4 +- src/components/theme/GlassOpticalLayer.vue | 2 +- .../theme/__tests__/GlassOpticalLayer.spec.ts | 6 +- .../__tests__/useCardAccentColor.spec.ts | 37 +++++ .../__tests__/usePluginCardAccent.spec.ts | 93 +++++++++++ .../__tests__/useThemeCustomizer.spec.ts | 23 +++ src/composables/usePluginCardAccent.ts | 32 ++++ src/composables/useThemeCustomizer.ts | 5 + .../__tests__/glassOverlayMaterial.spec.ts | 50 ++++++ src/styles/__tests__/pluginCardAccent.spec.ts | 20 ++- src/styles/common.scss | 8 +- src/styles/themes/glass.scss | 32 ++-- src/utils/__tests__/glassColor.spec.ts | 124 ++++++++++++++ src/utils/glassColor.ts | 151 ++++++++++++++++++ .../__tests__/PluginCardListView.spec.ts | 4 +- 22 files changed, 742 insertions(+), 86 deletions(-) create mode 100644 src/composables/__tests__/useCardAccentColor.spec.ts create mode 100644 src/composables/__tests__/usePluginCardAccent.spec.ts create mode 100644 src/composables/usePluginCardAccent.ts create mode 100644 src/utils/__tests__/glassColor.spec.ts create mode 100644 src/utils/glassColor.ts diff --git a/src/@core/utils/__tests__/image.spec.ts b/src/@core/utils/__tests__/image.spec.ts index 5ad84b44..393596d6 100644 --- a/src/@core/utils/__tests__/image.spec.ts +++ b/src/@core/utils/__tests__/image.spec.ts @@ -1,11 +1,73 @@ import { vi } from 'vitest' -vi.mock('colorthief', () => ({ - default: class ColorThief {}, +const mocks = vi.hoisted(() => ({ + getColor: vi.fn(), })) -import { preloadCorsImage } from '@/@core/utils/image' -import { afterEach, describe, expect, it } from 'vitest' +vi.mock('colorthief', () => ({ + default: class ColorThief { + getColor(image: HTMLImageElement, quality?: number) { + return mocks.getColor(image, quality) + } + }, +})) + +import { extractDominantColor, getDominantColor, preloadCorsImage } from '@/@core/utils/image' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +function createImage(cacheKey: string) { + const image = document.createElement('img') + Object.defineProperty(image, 'currentSrc', { configurable: true, value: `https://image.example/${cacheKey}.png` }) + + return image +} + +describe('dominant color extraction', () => { + beforeEach(() => { + mocks.getColor.mockReset() + }) + + it('shares a pending extraction and reuses only the successful result', async () => { + const image = createImage('shared-success') + mocks.getColor.mockReturnValue([18, 52, 86]) + + await expect(Promise.all([extractDominantColor(image), extractDominantColor(image)])).resolves.toEqual([ + '#123456', + '#123456', + ]) + await expect(extractDominantColor(image)).resolves.toBe('#123456') + expect(mocks.getColor).toHaveBeenCalledOnce() + }) + + it('does not cache failures or let one caller fallback pollute another', async () => { + const image = createImage('retry-after-failure') + mocks.getColor.mockImplementation(() => { + throw new Error('tainted canvas') + }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(getDominantColor(image, { fallback: '#111111' })).resolves.toBe('#111111') + await expect(getDominantColor(image, { fallback: '#222222' })).resolves.toBe('#222222') + expect(mocks.getColor).toHaveBeenCalledTimes(2) + }) + + it('keeps the existing default fallback contract for callers such as QuickAccess', async () => { + await expect(getDominantColor(null)).resolves.toBe('#28A9E1') + expect(mocks.getColor).not.toHaveBeenCalled() + }) + + it('retains the bounded FIFO success cache', async () => { + mocks.getColor.mockReturnValue([1, 2, 3]) + + for (let index = 0; index <= 100; index += 1) await extractDominantColor(createImage(`fifo-${index}`)) + + expect(mocks.getColor).toHaveBeenCalledTimes(101) + await extractDominantColor(createImage('fifo-50')) + expect(mocks.getColor).toHaveBeenCalledTimes(101) + await extractDominantColor(createImage('fifo-0')) + expect(mocks.getColor).toHaveBeenCalledTimes(102) + }) +}) describe('preloadCorsImage', () => { afterEach(() => { diff --git a/src/@core/utils/image.ts b/src/@core/utils/image.ts index d6a82600..0b0820d5 100644 --- a/src/@core/utils/image.ts +++ b/src/@core/utils/image.ts @@ -4,7 +4,8 @@ export { preloadCorsImage } from './corsImage' const DEFAULT_DOMINANT_COLOR = '#28A9E1' const DOMINANT_COLOR_CACHE_LIMIT = 100 const colorThief = new ColorThief() -const dominantColorCache = new Map>() +const dominantColorCache = new Map() +const pendingDominantColorRequests = new Map>() interface DominantColorOptions { fallback?: string @@ -29,42 +30,60 @@ function getImageCacheKey(image: HTMLImageElement) { return image.currentSrc || image.src || '' } -function rememberDominantColor(key: string, colorPromise: Promise) { - if (!key) return colorPromise +function rememberDominantColor(key: string, color: string) { + if (!key) return if (dominantColorCache.size >= DOMINANT_COLOR_CACHE_LIMIT) { const firstKey = dominantColorCache.keys().next().value if (firstKey) dominantColorCache.delete(firstKey) } - dominantColorCache.set(key, colorPromise) + dominantColorCache.set(key, color) +} + +/** 提取真实主色;失败不写入成功缓存,允许后续请求重试。 */ +export async function extractDominantColor( + image: HTMLImageElement | undefined | null, + options: Pick = {}, +): Promise { + if (!image) return undefined + + const cacheKey = getImageCacheKey(image) + const cachedColor = cacheKey ? dominantColorCache.get(cacheKey) : undefined + if (cachedColor) return cachedColor + + const pendingRequest = cacheKey ? pendingDominantColorRequests.get(cacheKey) : undefined + if (pendingRequest) return pendingRequest + + const colorPromise = Promise.resolve() + .then(() => { + const dominantColor = colorThief.getColor(image, options.quality ?? 20) + const color = rgbStringToHex(dominantColor) + rememberDominantColor(cacheKey, color) + + return color + }) + .catch(error => { + console.warn('Failed to extract dominant color:', error) + return undefined + }) + .finally(() => { + if (cacheKey) pendingDominantColorRequests.delete(cacheKey) + }) + + if (cacheKey) pendingDominantColorRequests.set(cacheKey, colorPromise) + return colorPromise } -// 提取主要颜色 +/** 提取主色并在失败时解析调用方 fallback,保持既有调用合同。 */ export async function getDominantColor( image: HTMLImageElement | undefined | null, options: DominantColorOptions = {}, ): Promise { const fallback = options.fallback ?? DEFAULT_DOMINANT_COLOR - if (!image) return fallback - - const cacheKey = getImageCacheKey(image) - const cachedColor = cacheKey ? dominantColorCache.get(cacheKey) : undefined - if (cachedColor) return cachedColor - - const colorPromise = Promise.resolve() - .then(() => { - const dominantColor = colorThief.getColor(image, options.quality ?? 20) - return rgbStringToHex(dominantColor) - }) - .catch(error => { - console.warn('Failed to extract dominant color:', error) - return fallback - }) - - return rememberDominantColor(cacheKey, colorPromise) + return (await extractDominantColor(image, options)) ?? fallback } // 预加载图片 diff --git a/src/App.vue b/src/App.vue index 2b9d2489..bae25403 100644 --- a/src/App.vue +++ b/src/App.vue @@ -12,7 +12,11 @@ import { globalLoadingStateManager } from '@/utils/loadingStateManager' import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager' import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue' import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue' -import { applyStoredThemeCustomizerAppearance, useEffectiveGlassSettings } from '@/composables/useThemeCustomizer' +import { + applyStoredThemeCustomizerAppearance, + themeCustomizerPrimaryColors, + useEffectiveGlassSettings, +} from '@/composables/useThemeCustomizer' import { applyStoredTransparencySettings, TRANSPARENCY_SETTINGS_CHANGED_EVENT, @@ -24,6 +28,7 @@ import { usePWA } from '@/composables/usePWA' import { themeManager } from '@/utils/themeManager' import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette' import { getDisplayImageUrl } from '@/utils/imageUtils' +import { normalizeThemeMaterialAccent } from '@/utils/glassColor' import { configureApexChartsTheme } from '@/utils/apexCharts' import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus' import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle' @@ -118,6 +123,11 @@ function recordGlassLaunchTiming(stage: string, detail?: string) { // 生效主题 const vuetifyTheme = useTheme() const { global: globalTheme } = vuetifyTheme +const glassMaterialTintColor = computed( + () => + normalizeThemeMaterialAccent(globalTheme.current.value.colors.primary)?.hex ?? + normalizeThemeMaterialAccent(themeCustomizerPrimaryColors[0].value)!.hex, +) let themeValue = localStorage.getItem('theme') || 'auto' let resumeThemeSyncTimer: number | null = null globalTheme.name.value = resolveInitialThemeName(themeValue) @@ -1191,7 +1201,7 @@ onUnmounted(() => { :transmission-strength="opticalTransmissionStrength" :translation-strength="opticalTranslationStrength" :route-key="route.fullPath" - :tint-color="globalTheme.current.value.colors.primary" + :tint-color="glassMaterialTintColor" :transition-duration="BACKGROUND_CROSSFADE_DURATION_MS" :transition-started-at="backgroundCrossfadeStartedAt" :wallpaper-url="activeOpticalBackgroundImage" @@ -1314,7 +1324,7 @@ html[data-glass-appearance='tinted'] .background-container.is-glass-theme .backg html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.previous::after { background: radial-gradient(circle at 50% 18%, transparent 22%, rgba(6, 10, 19, 14%) 100%), - linear-gradient(rgba(6, 10, 19, 10%) 0%, rgba(6, 10, 19, 32%) 100%), rgba(var(--v-theme-primary), 3%); + linear-gradient(rgba(6, 10, 19, 10%) 0%, rgba(6, 10, 19, 32%) 100%), rgba(var(--glass-material-accent-rgb), 3%); } html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active, diff --git a/src/components/cards/PluginAppCard.vue b/src/components/cards/PluginAppCard.vue index e916a252..dd4adb56 100644 --- a/src/components/cards/PluginAppCard.vue +++ b/src/components/cards/PluginAppCard.vue @@ -3,7 +3,7 @@ import api from '@/api' import type { ApiResponse, Plugin } from '@/api/types' import { getLogoUrl, getProxyImageUrl } from '@/utils/imageUtils' import { useGlobalSettingsStore } from '@/stores' -import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor' +import { usePluginCardAccent } from '@/composables/usePluginCardAccent' import { isNullOrEmptyObject } from '@/@core/utils' import { formatDownloadCount } from '@/@core/utils/formatters' import { useToast } from 'vue-toastification' @@ -37,11 +37,7 @@ const $toast = useToast() const createConfirm = useConfirm() -// 卡片头部染色所用的图标主色(CSS 变量可直接消费的 RGB 通道值) -const accentRgb = ref('40, 169, 225') - -// 图片对象 -const imageRef = ref<{ $el: HTMLElement } | null>(null) +const { accentStyle, imageRef, resetAccentColor, updateAccentColor } = usePluginCardAccent() // 获取当前插件的标签 const pluginLabels = computed(() => { @@ -73,9 +69,12 @@ function closeInstallProgress() { // 图片加载完成 async function imageLoaded() { - const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement - // 从图标中提取主色,作为卡片头部染色玻璃的色相来源 - accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1') + await updateAccentColor() +} + +function imageFailed() { + imageLoadError.value = true + resetAccentColor() } // 计算图标路径 @@ -240,7 +239,7 @@ onUnmounted(() => { :class="{ 'app-hover-lift-card--hovering': hover.isHovering, }" - :style="{ '--plugin-card-accent-rgb': accentRgb }" + :style="accentStyle" >
@@ -283,7 +282,7 @@ onUnmounted(() => { aspect-ratio="4/3" cover @load="imageLoaded" - @error="imageLoadError = true" + @error="imageFailed" />
diff --git a/src/components/cards/PluginCard.vue b/src/components/cards/PluginCard.vue index 65f66f2b..f03ec98e 100644 --- a/src/components/cards/PluginCard.vue +++ b/src/components/cards/PluginCard.vue @@ -4,7 +4,7 @@ import { useConfirm } from '@/composables/useConfirm' import api from '@/api' import type { ApiResponse, Plugin, PluginRating } from '@/api/types' import { getLogoUrl, getProxyImageUrl } from '@/utils/imageUtils' -import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor' +import { usePluginCardAccent } from '@/composables/usePluginCardAccent' import { formatDownloadCount } from '@/@core/utils/formatters' import { useDisplay } from 'vuetify' import { useI18n } from 'vue-i18n' @@ -54,11 +54,7 @@ const cardRatingSummary = computed(() => // 显示器宽度 const display = useDisplay() -// 卡片头部染色所用的图标主色(CSS 变量可直接消费的 RGB 通道值) -const accentRgb = ref('40, 169, 225') - -// 图片对象 -const imageRef = ref<{ $el: HTMLElement } | null>(null) +const { accentStyle, imageRef, resetAccentColor, updateAccentColor } = usePluginCardAccent() // 提示框 const $toast = useToast() @@ -110,9 +106,12 @@ watch( // 图片加载完成 async function imageLoaded() { - const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement - // 从图标中提取主色,作为卡片头部染色玻璃的色相来源 - accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1') + await updateAccentColor() +} + +function imageFailed() { + imageLoadError.value = true + resetAccentColor() } // 显示更新日志 @@ -651,7 +650,7 @@ watch( 'app-hover-lift-card--hovering': hover.isHovering && !props.sortable, 'cursor-move': props.sortable, }" - :style="{ '--plugin-card-accent-rgb': accentRgb }" + :style="accentStyle" :ripple="!props.sortable" >
@@ -682,7 +681,7 @@ watch( aspect-ratio="4/3" cover @load="imageLoaded" - @error="imageLoadError = true" + @error="imageFailed" />
diff --git a/src/components/cards/__tests__/PluginAppCardRating.spec.ts b/src/components/cards/__tests__/PluginAppCardRating.spec.ts index a9b4e5de..92764bca 100644 --- a/src/components/cards/__tests__/PluginAppCardRating.spec.ts +++ b/src/components/cards/__tests__/PluginAppCardRating.spec.ts @@ -1,5 +1,6 @@ import type { Plugin } from '@/api/types' import PluginAppCard from '@/components/cards/PluginAppCard.vue' +import { normalizePluginAccentColor } from '@/utils/glassColor' import { renderWithProviders } from '@tests/support/render' import { fireEvent, screen, waitFor } from '@testing-library/vue' import { defineComponent } from 'vue' @@ -31,8 +32,8 @@ vi.mock('vue-toastification', () => ({ useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }), })) -vi.mock('@/composables/useCardAccentColor', () => ({ - getCardAccentRgbFromImage: mocks.accentFromImage, +vi.mock('@/@core/utils/image', () => ({ + extractDominantColor: mocks.accentFromImage, })) const plugin: Plugin = { @@ -47,12 +48,13 @@ const plugin: Plugin = { const ImageStub = defineComponent({ name: 'VImg', emits: ['error', 'load'], - template: '', }) describe('PluginAppCard rating badge', () => { beforeEach(() => { - mocks.accentFromImage.mockReset().mockResolvedValue('12, 34, 56') + mocks.accentFromImage.mockReset().mockResolvedValue('#123456') mocks.apiGet.mockReset() mocks.confirm.mockReset().mockResolvedValue(true) mocks.dialogCloses.length = 0 @@ -232,7 +234,20 @@ describe('PluginAppCard rating badge', () => { const image = screen.getByTestId('plugin-image') await fireEvent.click(image) await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalled()) + expect( + container.querySelector('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'), + ).toBe(normalizePluginAccentColor('#123456')?.rgb) await fireEvent.contextMenu(image) + mocks.accentFromImage.mockResolvedValueOnce('#654321') + await fireEvent.click(image) + await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalledTimes(2)) + expect( + container.querySelector('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'), + ).toBe(normalizePluginAccentColor('#654321')?.rgb) + await fireEvent.contextMenu(image) + expect( + container.querySelector('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'), + ).toBe('') await fireEvent.click(container.querySelector('.v-card .v-btn')!) await fireEvent.click(await screen.findByText('项目主页')) diff --git a/src/components/cards/__tests__/PluginCard.spec.ts b/src/components/cards/__tests__/PluginCard.spec.ts index 3d1918bf..1245148f 100644 --- a/src/components/cards/__tests__/PluginCard.spec.ts +++ b/src/components/cards/__tests__/PluginCard.spec.ts @@ -1,6 +1,7 @@ import type { Plugin } from '@/api/types' import PluginCard from '@/components/cards/PluginCard.vue' import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav' +import { normalizePluginAccentColor } from '@/utils/glassColor' import { renderWithProviders } from '@tests/support/render' import { fireEvent, screen, waitFor } from '@testing-library/vue' import { defineComponent } from 'vue' @@ -34,8 +35,8 @@ vi.mock('@/composables/useSharedDialog', () => ({ openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), })) -vi.mock('@/composables/useCardAccentColor', () => ({ - getCardAccentRgbFromImage: mocks.accentFromImage, +vi.mock('@/@core/utils/image', () => ({ + extractDominantColor: mocks.accentFromImage, })) vi.mock('vue-toastification', () => ({ @@ -55,12 +56,13 @@ const plugin: Plugin = { const ImageStub = defineComponent({ name: 'VImg', emits: ['error', 'load'], - template: '', }) describe('PluginCard lifecycle actions', () => { beforeEach(() => { - mocks.accentFromImage.mockReset().mockResolvedValue('12, 34, 56') + mocks.accentFromImage.mockReset().mockResolvedValue('#123456') mocks.apiDelete.mockReset() mocks.apiGet.mockReset() mocks.apiPost.mockReset() @@ -362,6 +364,7 @@ describe('PluginCard lifecycle actions', () => { }) it('handles image lifecycle and ignores card clicks while sorting', async () => { + mocks.accentFromImage.mockResolvedValueOnce(undefined).mockResolvedValueOnce('#123456') const { container } = await renderWithProviders(PluginCard, { props: { plugin: { ...plugin, plugin_icon: 'https://example.com/plugin.png' }, @@ -372,7 +375,19 @@ describe('PluginCard lifecycle actions', () => { const [image, authorImage] = screen.getAllByTestId('plugin-image') await fireEvent.click(image) await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalled()) + expect( + container.querySelector('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'), + ).toBe('') await fireEvent.contextMenu(image) + await fireEvent.click(image) + await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalledTimes(2)) + expect( + container.querySelector('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'), + ).toBe(normalizePluginAccentColor('#123456')?.rgb) + await fireEvent.contextMenu(image) + expect( + container.querySelector('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'), + ).toBe('') await fireEvent.click(authorImage) await fireEvent.click(container.querySelector('.v-card')!) expect(mocks.openSharedDialog).not.toHaveBeenCalled() diff --git a/src/components/cards/__tests__/PluginCardAbout.spec.ts b/src/components/cards/__tests__/PluginCardAbout.spec.ts index 8497a336..db3157c5 100644 --- a/src/components/cards/__tests__/PluginCardAbout.spec.ts +++ b/src/components/cards/__tests__/PluginCardAbout.spec.ts @@ -25,8 +25,8 @@ vi.mock('@/composables/useSharedDialog', () => ({ openSharedDialog: mocks.openSharedDialog, })) -vi.mock('@/composables/useCardAccentColor', () => ({ - getCardAccentRgbFromImage: vi.fn().mockResolvedValue('40, 169, 225'), +vi.mock('@/@core/utils/image', () => ({ + extractDominantColor: vi.fn().mockResolvedValue(undefined), })) vi.mock('vue-toastification', () => ({ diff --git a/src/components/theme/GlassOpticalLayer.vue b/src/components/theme/GlassOpticalLayer.vue index 8a5b1737..90ba6025 100644 --- a/src/components/theme/GlassOpticalLayer.vue +++ b/src/components/theme/GlassOpticalLayer.vue @@ -37,7 +37,7 @@ const props = defineProps<{ translationStrength: number /** 路由变化标识,用于在页面内容稳定后重新发现高价值表面。 */ routeKey: string - /** 当前主题主色,用于同步色调材质的光学高光。 */ + /** 由用户主色派生的大面积玻璃材料色,用于同步色调材质的光学高光。 */ tintColor: string /** 外层壁纸交叉淡化的时长,shader 使用同一时钟混合双纹理。 */ transitionDuration: number diff --git a/src/components/theme/__tests__/GlassOpticalLayer.spec.ts b/src/components/theme/__tests__/GlassOpticalLayer.spec.ts index 70a4ca17..a73330f9 100644 --- a/src/components/theme/__tests__/GlassOpticalLayer.spec.ts +++ b/src/components/theme/__tests__/GlassOpticalLayer.spec.ts @@ -126,7 +126,7 @@ afterEach(() => { }) describe('GlassOpticalLayer', () => { - it('uses exactly two visible presentation contexts with one interaction source', () => { + it('uses exactly two visible presentation contexts with one interaction source', async () => { rendererCalls.length = 0 rendererResults.length = 0 setRendererState.mockClear() @@ -159,6 +159,7 @@ describe('GlassOpticalLayer', () => { expect(rendererCalls.every(options => options.wallpaperSourceCache === wallpaperSourceCache)).toBe(true) expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true) expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true) + expect(rendererCalls.map(options => (options.tintColor as () => string)())).toEqual(['#8D51F9', '#8D51F9']) expect(rendererCalls[0].pageMotion).toBeUndefined() expect(rendererCalls[1].pageMotion).toEqual( expect.objectContaining({ @@ -167,6 +168,9 @@ describe('GlassOpticalLayer', () => { }), ) + await wrapper.setProps({ tintColor: '#00A6B8' }) + expect(rendererCalls.map(options => (options.tintColor as () => string)())).toEqual(['#00A6B8', '#00A6B8']) + wrapper.unmount() expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback') }) diff --git a/src/composables/__tests__/useCardAccentColor.spec.ts b/src/composables/__tests__/useCardAccentColor.spec.ts new file mode 100644 index 00000000..89967b6b --- /dev/null +++ b/src/composables/__tests__/useCardAccentColor.spec.ts @@ -0,0 +1,37 @@ +import { getCardAccentRgbFromImage, useCardAccentColor } from '@/composables/useCardAccentColor' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getDominantColor: vi.fn(), +})) + +vi.mock('@/@core/utils/image', () => ({ + getDominantColor: mocks.getDominantColor, +})) + +describe('useCardAccentColor compatibility', () => { + beforeEach(() => { + mocks.getDominantColor.mockReset() + }) + + it.each([ + ['#FFB400', '255, 180, 0'], + ['#56CA00', '86, 202, 0'], + ])('preserves the caller fallback %s', async (fallback, expectedRgb) => { + mocks.getDominantColor.mockResolvedValue(fallback) + + await expect(getCardAccentRgbFromImage(null, fallback)).resolves.toBe(expectedRgb) + expect(mocks.getDominantColor).toHaveBeenCalledWith(null, { fallback }) + }) + + it('keeps the composable fallback scoped to its caller', async () => { + mocks.getDominantColor.mockResolvedValue('#8D51F9') + const accent = useCardAccentColor('#8D51F9') + accent.imageRef.value = { $el: document.createElement('div') } + + await accent.updateAccentColor() + + expect(accent.accentRgb.value).toBe('141, 81, 249') + expect(mocks.getDominantColor).toHaveBeenCalledWith(null, { fallback: '#8D51F9' }) + }) +}) diff --git a/src/composables/__tests__/usePluginCardAccent.spec.ts b/src/composables/__tests__/usePluginCardAccent.spec.ts new file mode 100644 index 00000000..d4f6d61d --- /dev/null +++ b/src/composables/__tests__/usePluginCardAccent.spec.ts @@ -0,0 +1,93 @@ +import { usePluginCardAccent } from '@/composables/usePluginCardAccent' +import { normalizePluginAccentColor } from '@/utils/glassColor' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + extractDominantColor: vi.fn(), +})) + +vi.mock('@/@core/utils/image', () => ({ + extractDominantColor: mocks.extractDominantColor, +})) + +function createImageHost() { + const host = document.createElement('div') + host.append(document.createElement('img')) + + return host +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(resolvePromise => { + resolve = resolvePromise + }) + + return { promise, resolve } +} + +describe('usePluginCardAccent', () => { + beforeEach(() => { + mocks.extractDominantColor.mockReset() + }) + + it('publishes a normalized nullable accent for the plugin card CSS variable', async () => { + mocks.extractDominantColor.mockResolvedValue('#ff0000') + const accent = usePluginCardAccent() + accent.imageRef.value = { $el: createImageHost() } + + await accent.updateAccentColor() + + expect(accent.accentRgb.value).toBe(normalizePluginAccentColor('#ff0000')?.rgb) + expect(accent.accentStyle.value).toEqual({ '--plugin-card-accent-rgb': accent.accentRgb.value }) + }) + + it('leaves the accent unset when extraction fails and can clear a previous value', async () => { + const accent = usePluginCardAccent() + accent.imageRef.value = { $el: createImageHost() } + mocks.extractDominantColor.mockResolvedValueOnce('#00ff00') + await accent.updateAccentColor() + expect(accent.accentRgb.value).toBeDefined() + + accent.resetAccentColor() + expect(accent.accentRgb.value).toBeUndefined() + expect(accent.accentStyle.value).toBeUndefined() + + mocks.extractDominantColor.mockResolvedValueOnce(undefined) + await accent.updateAccentColor() + expect(accent.accentRgb.value).toBeUndefined() + }) + + it('ignores an older extraction that resolves after the current logo', async () => { + const older = deferred() + const current = deferred() + mocks.extractDominantColor.mockReturnValueOnce(older.promise).mockReturnValueOnce(current.promise) + const accent = usePluginCardAccent() + accent.imageRef.value = { $el: createImageHost() } + + const olderUpdate = accent.updateAccentColor() + const currentUpdate = accent.updateAccentColor() + current.resolve('#00ff00') + await currentUpdate + expect(accent.accentRgb.value).toBe(normalizePluginAccentColor('#00ff00')?.rgb) + + older.resolve('#ff0000') + await olderUpdate + expect(accent.accentRgb.value).toBe(normalizePluginAccentColor('#00ff00')?.rgb) + }) + + it('keeps the CSS fallback after reset when an extraction resolves late', async () => { + const pending = deferred() + mocks.extractDominantColor.mockReturnValueOnce(pending.promise) + const accent = usePluginCardAccent() + accent.imageRef.value = { $el: createImageHost() } + + const update = accent.updateAccentColor() + accent.resetAccentColor() + pending.resolve('#ff0000') + await update + + expect(accent.accentRgb.value).toBeUndefined() + expect(accent.accentStyle.value).toBeUndefined() + }) +}) diff --git a/src/composables/__tests__/useThemeCustomizer.spec.ts b/src/composables/__tests__/useThemeCustomizer.spec.ts index dc1df586..abbf5865 100644 --- a/src/composables/__tests__/useThemeCustomizer.spec.ts +++ b/src/composables/__tests__/useThemeCustomizer.spec.ts @@ -12,6 +12,7 @@ import { useEffectiveGlassSettings, } from '@/composables/useThemeCustomizer' import vuetify from '@/plugins/vuetify' +import { normalizeThemeMaterialAccent } from '@/utils/glassColor' import { mount } from '@vue/test-utils' import { defineComponent, h } from 'vue' import { beforeEach, describe, expect, it } from 'vitest' @@ -212,6 +213,28 @@ describe('useThemeCustomizer glass settings', () => { expect(Number(document.body.style.getPropertyValue('--glass-tint-density'))).toBeCloseTo(0.65) expect(document.documentElement.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('6.7px') expect(document.body.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('6.7px') + expect(document.documentElement.style.getPropertyValue('--glass-material-accent-rgb')).toBe( + normalizeThemeMaterialAccent(settings.primaryColor)?.rgb, + ) + expect(document.body.style.getPropertyValue('--glass-material-accent-rgb')).toBe( + normalizeThemeMaterialAccent(settings.primaryColor)?.rgb, + ) + }) + + it('keeps the user primary color while publishing its material tone', async () => { + const { customizer, wrapper } = mountThemeCustomizer() + + await customizer.setPrimaryColor('#00BCD4') + + expect(customizer.settings.value.primaryColor).toBe('#00BCD4') + expect(vuetify.theme.current.value.colors.primary).toBe('#00BCD4') + expect(document.documentElement.style.getPropertyValue('--glass-material-accent-rgb')).toBe( + normalizeThemeMaterialAccent('#00BCD4')?.rgb, + ) + expect(document.body.style.getPropertyValue('--glass-material-accent-rgb')).toBe( + normalizeThemeMaterialAccent('#00BCD4')?.rgb, + ) + wrapper.unmount() }) it('keeps overlay clarity synchronized across preview, cancel, and commit', () => { diff --git a/src/composables/usePluginCardAccent.ts b/src/composables/usePluginCardAccent.ts new file mode 100644 index 00000000..2d0c742d --- /dev/null +++ b/src/composables/usePluginCardAccent.ts @@ -0,0 +1,32 @@ +import { extractDominantColor } from '@/@core/utils/image' +import { normalizePluginAccentColor } from '@/utils/glassColor' + +/** 管理插件卡 Logo 的可选品牌强调色;无有效提色时由 CSS 环境色接管。 */ +export function usePluginCardAccent() { + const accentRgb = ref() + const imageRef = ref<{ $el: HTMLElement } | null>(null) + const accentStyle = computed(() => (accentRgb.value ? { '--plugin-card-accent-rgb': accentRgb.value } : undefined)) + let requestGeneration = 0 + + async function updateAccentColor() { + const generation = ++requestGeneration + const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement | undefined + const dominantColor = await extractDominantColor(imageElement) + if (generation !== requestGeneration) return + + accentRgb.value = dominantColor ? normalizePluginAccentColor(dominantColor)?.rgb : undefined + } + + function resetAccentColor() { + requestGeneration += 1 + accentRgb.value = undefined + } + + return { + accentRgb, + accentStyle, + imageRef, + resetAccentColor, + updateAccentColor, + } +} diff --git a/src/composables/useThemeCustomizer.ts b/src/composables/useThemeCustomizer.ts index 0ffb2b6c..7a36b26c 100644 --- a/src/composables/useThemeCustomizer.ts +++ b/src/composables/useThemeCustomizer.ts @@ -19,6 +19,7 @@ import { type GlassOpticalPreset, type GlassOpticalPresetOverrides, } from '@/utils/glassOptics' +import { normalizeThemeMaterialAccent } from '@/utils/glassColor' import { themeManager } from '@/utils/themeManager' import { syncThemeFavicon } from '@/utils/themePalette' @@ -471,6 +472,7 @@ export function applyThemeCustomizerRootSettings( | 'glassTransmissionStrength' | 'glassTransparencyStrength' | 'layout' + | 'primaryColor' | 'radius' | 'semiDarkMenu' | 'shadow' @@ -482,6 +484,8 @@ export function applyThemeCustomizerRootSettings( const materialResponse = getGlassMaterialResponse(settings.glassAppearance, settings.glassTransparencyStrength) const frostBlur = getGlassCssFrostBlur(settings.glassTransparencyStrength) const overlayClarityBlur = getGlassOverlayClarityBlur(settings.glassTransparencyStrength) + const materialAccent = + normalizeThemeMaterialAccent(settings.primaryColor) ?? normalizeThemeMaterialAccent(defaultPrimaryColor)! const applyGlassResponse = (element: HTMLElement) => { element.style.setProperty('--glass-background-visibility', String(materialResponse.backgroundVisibility)) element.style.setProperty('--glass-frost-blur-scale', String(materialResponse.frostBlurScale)) @@ -491,6 +495,7 @@ export function applyThemeCustomizerRootSettings( element.style.setProperty('--glass-blur-surface', `${frostBlur.surface}px`) element.style.setProperty('--glass-blur-raised', `${frostBlur.raised}px`) element.style.setProperty('--glass-overlay-clarity-blur', `${overlayClarityBlur}px`) + element.style.setProperty('--glass-material-accent-rgb', materialAccent.rgb) } document.documentElement.setAttribute('data-glass-appearance', settings.glassAppearance) diff --git a/src/styles/__tests__/glassOverlayMaterial.spec.ts b/src/styles/__tests__/glassOverlayMaterial.spec.ts index 9d799856..a9d05cea 100644 --- a/src/styles/__tests__/glassOverlayMaterial.spec.ts +++ b/src/styles/__tests__/glassOverlayMaterial.spec.ts @@ -35,6 +35,56 @@ describe('glass overlay material styles', () => { expect(styles).toContain('rgba(255, 255, 255, calc(0.045 + var(--glass-surface-density, 0.86) * 0.09))') }) + it('uses the derived theme tone only for tinted material surfaces', () => { + const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8') + const appStyles = readFileSync(resolve(cwd(), 'src/App.vue'), 'utf8') + const tintedRule = styles.match(/&\[data-glass-appearance='tinted'\]\s*\{(?[\s\S]*?)\n {2}\}/u) + ?.groups?.declarations + const wallpaperTintStart = appStyles.indexOf( + "html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active::after,", + ) + const wallpaperTintEnd = appStyles.indexOf("html[data-glass-appearance='frosted']", wallpaperTintStart) + const wallpaperTintRule = appStyles.slice(wallpaperTintStart, wallpaperTintEnd) + const loginRule = styles.match( + /html\[data-theme='glass'\]\[data-glass-appearance='tinted'\] body\[data-theme='glass'\]\s*\{(?[\s\S]*?)\n\}/u, + )?.groups?.declarations + const workflowRule = styles.match( + /&\[data-glass-appearance='tinted'\] \.workflow-task-card\s*\{(?[\s\S]*?)\n {2}\}/u, + )?.groups?.declarations + const getPropertyValue = (rule: string | undefined, token: string) => + rule?.match(new RegExp(`${token}:\\s*(?[\\s\\S]*?);`))?.groups?.value + + expect(tintedRule).toBeDefined() + for (const token of [ + '--glass-surface', + '--glass-surface-soft', + '--glass-surface-raised', + '--glass-overlay-surface', + ]) { + expect(getPropertyValue(tintedRule, token)).toContain('var(--glass-material-accent-rgb)') + } + for (const token of [ + '--glass-control', + '--glass-control-prominent', + '--glass-control-prominent-focus', + '--glass-border', + '--glass-border-raised', + '--glass-border-hover', + '--glass-highlight', + '--glass-sheen', + ]) { + expect(getPropertyValue(tintedRule, token)).toContain('var(--v-theme-primary)') + } + expect(wallpaperTintStart).toBeGreaterThanOrEqual(0) + expect(wallpaperTintEnd).toBeGreaterThan(wallpaperTintStart) + expect(wallpaperTintRule).toContain('rgba(var(--glass-material-accent-rgb), 3%)') + expect(wallpaperTintRule).not.toContain('var(--v-theme-primary)') + expect(loginRule).toMatch(/\.login-card__surface[\s\S]*?var\(--glass-material-accent-rgb\)/) + expect(loginRule).toMatch(/\.native-login-field[\s\S]*?var\(--v-theme-primary\)/) + expect(workflowRule).toContain('var(--workflow-status-rgb)') + expect(workflowRule).toContain('var(--v-theme-primary)') + }) + it('renders colored chips as shadowless glass without flattening their variants', () => { const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8') diff --git a/src/styles/__tests__/pluginCardAccent.spec.ts b/src/styles/__tests__/pluginCardAccent.spec.ts index bc992959..141daf7c 100644 --- a/src/styles/__tests__/pluginCardAccent.spec.ts +++ b/src/styles/__tests__/pluginCardAccent.spec.ts @@ -4,7 +4,7 @@ import { cwd } from 'node:process' import { describe, expect, it } from 'vitest' describe('plugin card accent styles', () => { - it('inherits the icon-derived accent instead of shadowing it on the banner', () => { + it('uses a nullable icon accent before the dynamic material and theme fallbacks', () => { const commonStyles = readFileSync(resolve(cwd(), 'src/styles/common.scss'), 'utf8') const ruleStart = commonStyles.indexOf('.plugin-card__banner') const ruleEnd = commonStyles.indexOf('.grid-downloading-card', ruleStart) @@ -12,7 +12,21 @@ describe('plugin card accent styles', () => { expect(ruleStart).toBeGreaterThanOrEqual(0) expect(ruleEnd).toBeGreaterThan(ruleStart) - expect(bannerRule).not.toMatch(/--plugin-card-accent-rgb\s*:/) - expect(bannerRule).toContain('var(--plugin-card-accent-rgb, 40, 169, 225)') + expect(bannerRule).toContain('--plugin-card-effective-accent-rgb: var(') + expect(bannerRule).toContain('--plugin-card-accent-rgb,') + expect(bannerRule).toContain('var(--glass-material-accent-rgb, var(--v-theme-primary))') + expect(bannerRule).not.toContain('40, 169, 225') + }) + + it('limits tinted theme mixing to six percent', () => { + const glassStyles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8') + const tintedBannerRule = glassStyles.match( + /&\[data-glass-appearance='tinted'\] \.plugin-card__banner\s*\{(?[\s\S]*?)\n {2}\}/u, + )?.groups?.declarations + + expect(glassStyles.match(/rgba\(var\(--plugin-card-effective-accent-rgb\)/g)).toHaveLength(9) + expect(glassStyles.match(/rgba\(var\(--plugin-card-effective-accent-rgb\)[\s\S]*?\) 94%/g)).toHaveLength(2) + expect(tintedBannerRule?.match(/rgba\(var\(--glass-material-accent-rgb\)[\s\S]*?\)\s*\)/g)).toHaveLength(2) + expect(glassStyles).not.toContain('var(--plugin-card-accent-rgb, 40, 169, 225)') }) }) diff --git a/src/styles/common.scss b/src/styles/common.scss index db191ee3..0eaaae4c 100644 --- a/src/styles/common.scss +++ b/src/styles/common.scss @@ -1350,10 +1350,14 @@ html[data-theme='transparent'].transparent-glass-realtime .v-theme--transparent background-image: var(--plugin-card-banner-scrim), var(--plugin-card-banner-tint); transition: background-image 0.2s ease; + --plugin-card-effective-accent-rgb: var( + --plugin-card-accent-rgb, + var(--glass-material-accent-rgb, var(--v-theme-primary)) + ); --plugin-card-banner-scrim: linear-gradient(rgba(0, 0, 0, 60%) 0%, rgba(0, 0, 0, 50%) 100%); --plugin-card-banner-tint: linear-gradient( - rgb(var(--plugin-card-accent-rgb, 40, 169, 225)) 0%, - rgb(var(--plugin-card-accent-rgb, 40, 169, 225)) 100% + rgb(var(--plugin-card-effective-accent-rgb)) 0%, + rgb(var(--plugin-card-effective-accent-rgb)) 100% ); } diff --git a/src/styles/themes/glass.scss b/src/styles/themes/glass.scss index 1951fa27..e6952229 100644 --- a/src/styles/themes/glass.scss +++ b/src/styles/themes/glass.scss @@ -131,17 +131,17 @@ html[data-theme='glass'] { --glass-surface: color-mix( in srgb, rgba(11, 19, 34, calc(0.12 + var(--glass-surface-density, 0.72) * 0.2)) 88%, - rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.28)) + rgba(var(--glass-material-accent-rgb), calc(var(--glass-tint-density, 0.65) * 0.28)) ); --glass-surface-soft: color-mix( in srgb, rgba(11, 19, 34, calc(0.13 + var(--glass-surface-density, 0.72) * 0.23)) 89%, - rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.26)) + rgba(var(--glass-material-accent-rgb), calc(var(--glass-tint-density, 0.65) * 0.26)) ); --glass-surface-raised: color-mix( in srgb, rgba(11, 19, 34, calc(0.07 + var(--glass-surface-density, 0.72) * 0.36)) 84%, - rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.32)) + rgba(var(--glass-material-accent-rgb), calc(var(--glass-tint-density, 0.65) * 0.32)) ); --glass-control: color-mix(in srgb, rgba(11, 19, 34, 52%) 88%, rgba(var(--v-theme-primary), 28%)); --glass-control-prominent: color-mix(in srgb, rgba(255, 255, 255, 7%) 82%, rgba(var(--v-theme-primary), 24%)); @@ -185,7 +185,7 @@ html[data-theme='glass'] { --glass-overlay-surface: color-mix( in srgb, rgba(11, 19, 34, calc(0.09 + var(--glass-surface-density, 0.72) * 0.2)) 88%, - rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.22)) + rgba(var(--glass-material-accent-rgb), calc(var(--glass-tint-density, 0.65) * 0.22)) ); --glass-overlay-blur: var(--glass-overlay-clarity-blur, 6px); --glass-overlay-saturate: 120%; @@ -1037,9 +1037,9 @@ html[data-theme='glass'] { ); --plugin-card-banner-tint: linear-gradient( 135deg, - rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.2 + var(--glass-tint-density, 0.65) * 0.34)) 0%, - rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.11 + var(--glass-tint-density, 0.65) * 0.2)) 58%, - rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.055 + var(--glass-tint-density, 0.65) * 0.1)) 100% + rgba(var(--plugin-card-effective-accent-rgb), calc(0.2 + var(--glass-tint-density, 0.65) * 0.34)) 0%, + rgba(var(--plugin-card-effective-accent-rgb), calc(0.11 + var(--glass-tint-density, 0.65) * 0.2)) 58%, + rgba(var(--plugin-card-effective-accent-rgb), calc(0.055 + var(--glass-tint-density, 0.65) * 0.1)) 100% ); } @@ -1065,9 +1065,9 @@ html[data-theme='glass'] { ); --plugin-card-banner-tint: linear-gradient( 135deg, - rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.15 + var(--glass-tint-density, 0.65) * 0.2)) 0%, - rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.07 + var(--glass-tint-density, 0.65) * 0.13)) 58%, - rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.03 + var(--glass-tint-density, 0.65) * 0.06)) 100% + rgba(var(--plugin-card-effective-accent-rgb), calc(0.15 + var(--glass-tint-density, 0.65) * 0.2)) 0%, + rgba(var(--plugin-card-effective-accent-rgb), calc(0.07 + var(--glass-tint-density, 0.65) * 0.13)) 58%, + rgba(var(--plugin-card-effective-accent-rgb), calc(0.03 + var(--glass-tint-density, 0.65) * 0.06)) 100% ); } @@ -1101,17 +1101,17 @@ html[data-theme='glass'] { 135deg, color-mix( in srgb, - rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.14 + var(--glass-tint-density, 0.65) * 0.33)) 74%, - rgba(var(--v-theme-primary), calc(0.14 + var(--glass-tint-density, 0.65) * 0.33)) + rgba(var(--plugin-card-effective-accent-rgb), calc(0.14 + var(--glass-tint-density, 0.65) * 0.33)) 94%, + rgba(var(--glass-material-accent-rgb), calc(0.14 + var(--glass-tint-density, 0.65) * 0.33)) ) 0%, color-mix( in srgb, - rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.07 + var(--glass-tint-density, 0.65) * 0.2)) 74%, - rgba(var(--v-theme-primary), calc(0.07 + var(--glass-tint-density, 0.65) * 0.2)) + rgba(var(--plugin-card-effective-accent-rgb), calc(0.07 + var(--glass-tint-density, 0.65) * 0.2)) 94%, + rgba(var(--glass-material-accent-rgb), calc(0.07 + var(--glass-tint-density, 0.65) * 0.2)) ) 58%, - rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.04 + var(--glass-tint-density, 0.65) * 0.095)) 100% + rgba(var(--plugin-card-effective-accent-rgb), calc(0.04 + var(--glass-tint-density, 0.65) * 0.095)) 100% ); } @@ -1573,7 +1573,7 @@ html[data-theme='glass'][data-glass-appearance='tinted'] body[data-theme='glass' .login-card__surface { background: linear-gradient(145deg, rgba(255, 255, 255, 0.1), transparent 36%), - linear-gradient(rgba(var(--v-theme-primary), 0.08), rgba(var(--v-theme-primary), 0.03)), + linear-gradient(rgba(var(--glass-material-accent-rgb), 0.08), rgba(var(--glass-material-accent-rgb), 0.03)), linear-gradient(rgba(7, 14, 25, 0.16), rgba(7, 14, 25, 0.3)) !important; } diff --git a/src/utils/__tests__/glassColor.spec.ts b/src/utils/__tests__/glassColor.spec.ts new file mode 100644 index 00000000..a56a451b --- /dev/null +++ b/src/utils/__tests__/glassColor.spec.ts @@ -0,0 +1,124 @@ +import { normalizePluginAccentColor, normalizeThemeMaterialAccent } from '@/utils/glassColor' +import { describe, expect, it } from 'vitest' + +interface OklchColor { + lightness: number + chroma: number + hue: number +} + +function srgbToLinear(channel: number) { + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4 +} + +function hexToOklch(color: string): OklchColor { + const channels = [color.slice(1, 3), color.slice(3, 5), color.slice(5, 7)].map(channel => + srgbToLinear(Number.parseInt(channel, 16) / 255), + ) + const [red, green, blue] = channels + const l = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue) + const m = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue) + const s = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue) + const lightness = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s + const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s + const b = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s + + return { lightness, chroma: Math.hypot(a, b), hue: Math.atan2(b, a) } +} + +function hueDistanceDegrees(first: number, second: number) { + const radians = Math.abs(Math.atan2(Math.sin(first - second), Math.cos(first - second))) + + return (radians * 180) / Math.PI +} + +describe('normalizePluginAccentColor', () => { + it.each(['#ff0000', '#00ff00', '#0000ff', '#00ffff', '#ff00ff', '#f5c400'])( + 'keeps chromatic %s within the plugin accent contract', + sourceHex => { + const normalized = normalizePluginAccentColor(sourceHex) + + expect(normalized).toBeDefined() + expect(normalized?.hex).toMatch(/^#[0-9a-f]{6}$/) + expect(normalized?.rgb).toMatch(/^\d{1,3}, \d{1,3}, \d{1,3}$/) + + const source = hexToOklch(sourceHex) + const output = hexToOklch(normalized!.hex) + expect(output.lightness).toBeGreaterThanOrEqual(0.475) + expect(output.lightness).toBeLessThanOrEqual(0.765) + expect(output.chroma).toBeLessThanOrEqual(Math.min(source.chroma, 0.18) + 0.005) + if (source.chroma >= 0.03 && output.chroma >= 0.03) + expect(hueDistanceDegrees(source.hue, output.hue)).toBeLessThanOrEqual(2) + }, + ) + + it.each(['#000000', '#ffffff', '#7d7d7d'])('keeps neutral %s neutral without inventing chroma', sourceHex => { + const normalized = normalizePluginAccentColor(sourceHex) + const source = hexToOklch(sourceHex) + const output = hexToOklch(normalized!.hex) + + expect(output.lightness).toBeGreaterThanOrEqual(0.475) + expect(output.lightness).toBeLessThanOrEqual(0.765) + expect(output.chroma).toBeLessThanOrEqual(source.chroma + 0.003) + }) + + it('is deterministic, case-insensitive, and rejects non-contract inputs', () => { + expect(normalizePluginAccentColor('#12ABef')).toEqual(normalizePluginAccentColor('#12abef')) + expect(normalizePluginAccentColor('#fff')).toBeUndefined() + expect(normalizePluginAccentColor('12abef')).toBeUndefined() + expect(normalizePluginAccentColor('#gg0000')).toBeUndefined() + }) +}) + +describe('normalizeThemeMaterialAccent', () => { + it.each([ + '#8D51F9', + '#3F51B5', + '#1976D2', + '#00BCD4', + '#009688', + '#4CAF50', + '#FFB400', + '#FF9800', + '#FF4C51', + '#E91E63', + '#16B1FF', + '#607D8B', + ])('keeps preset %s within the material tone contract', sourceHex => { + const normalized = normalizeThemeMaterialAccent(sourceHex) + + expect(normalized).toBeDefined() + expect(normalized?.hex).toMatch(/^#[0-9a-f]{6}$/) + expect(normalized?.rgb).toMatch(/^\d{1,3}, \d{1,3}, \d{1,3}$/) + + const source = hexToOklch(sourceHex) + const output = hexToOklch(normalized!.hex) + expect(output.lightness).toBeGreaterThanOrEqual(0.555) + expect(output.lightness).toBeLessThanOrEqual(0.725) + expect(output.chroma).toBeLessThanOrEqual(Math.min(source.chroma, 0.14) + 0.005) + if (source.chroma >= 0.03 && output.chroma >= 0.03) + expect(hueDistanceDegrees(source.hue, output.hue)).toBeLessThanOrEqual(2) + }) + + it.each(['#000000', '#ffffff', '#7d7d7d', '#ffff00', '#00ffff', '#ff0000'])( + 'keeps extreme %s in gamut without inventing chroma', + sourceHex => { + const normalized = normalizeThemeMaterialAccent(sourceHex) + const source = hexToOklch(sourceHex) + const output = hexToOklch(normalized!.hex) + + expect(output.lightness).toBeGreaterThanOrEqual(0.555) + expect(output.lightness).toBeLessThanOrEqual(0.725) + expect(output.chroma).toBeLessThanOrEqual(Math.min(source.chroma, 0.14) + 0.005) + if (source.chroma >= 0.03 && output.chroma >= 0.03) + expect(hueDistanceDegrees(source.hue, output.hue)).toBeLessThanOrEqual(2) + }, + ) + + it('is deterministic, case-insensitive, and rejects non-contract inputs', () => { + expect(normalizeThemeMaterialAccent('#12ABef')).toEqual(normalizeThemeMaterialAccent('#12abef')) + expect(normalizeThemeMaterialAccent('#fff')).toBeUndefined() + expect(normalizeThemeMaterialAccent('12abef')).toBeUndefined() + expect(normalizeThemeMaterialAccent('#gg0000')).toBeUndefined() + }) +}) diff --git a/src/utils/glassColor.ts b/src/utils/glassColor.ts new file mode 100644 index 00000000..e95c1c94 --- /dev/null +++ b/src/utils/glassColor.ts @@ -0,0 +1,151 @@ +interface OklabColor { + lightness: number + a: number + b: number +} + +interface OklchColor { + lightness: number + chroma: number + hue: number +} + +export interface GlassAccentColor { + /** 可传给颜色输入或 WebGL 的六位十六进制色值。 */ + hex: string + /** 可直接用于 `rgb()` / `rgba()` CSS 变量的通道值。 */ + rgb: string +} + +const PLUGIN_ACCENT_MIN_LIGHTNESS = 0.48 +const PLUGIN_ACCENT_MAX_LIGHTNESS = 0.76 +const PLUGIN_ACCENT_MAX_CHROMA = 0.18 +const THEME_MATERIAL_MIN_LIGHTNESS = 0.56 +const THEME_MATERIAL_MAX_LIGHTNESS = 0.72 +const THEME_MATERIAL_MAX_CHROMA = 0.14 +const NEUTRAL_CHROMA_THRESHOLD = 0.02 +const GAMUT_SEARCH_ITERATIONS = 24 + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function parseHexColor(color: string) { + if (!/^#[0-9a-f]{6}$/i.test(color)) return undefined + + return [ + Number.parseInt(color.slice(1, 3), 16) / 255, + Number.parseInt(color.slice(3, 5), 16) / 255, + Number.parseInt(color.slice(5, 7), 16) / 255, + ] as const +} + +function srgbToLinear(channel: number) { + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4 +} + +function linearToSrgb(channel: number) { + return channel <= 0.0031308 ? 12.92 * channel : 1.055 * channel ** (1 / 2.4) - 0.055 +} + +function srgbToOklab([red, green, blue]: readonly [number, number, number]): OklabColor { + const linearRed = srgbToLinear(red) + const linearGreen = srgbToLinear(green) + const linearBlue = srgbToLinear(blue) + const l = Math.cbrt(0.4122214708 * linearRed + 0.5363325363 * linearGreen + 0.0514459929 * linearBlue) + const m = Math.cbrt(0.2119034982 * linearRed + 0.6806995451 * linearGreen + 0.1073969566 * linearBlue) + const s = Math.cbrt(0.0883024619 * linearRed + 0.2817188376 * linearGreen + 0.6299787005 * linearBlue) + + return { + lightness: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, + } +} + +function oklabToLinearSrgb({ lightness, a, b }: OklabColor) { + const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3 + const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3 + const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3 + + return [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, + ] as const +} + +function oklabToOklch({ lightness, a, b }: OklabColor): OklchColor { + return { + lightness, + chroma: Math.hypot(a, b), + hue: Math.atan2(b, a), + } +} + +function oklchToOklab({ lightness, chroma, hue }: OklchColor): OklabColor { + return { + lightness, + a: chroma * Math.cos(hue), + b: chroma * Math.sin(hue), + } +} + +function isInSrgbGamut(color: OklchColor) { + return oklabToLinearSrgb(oklchToOklab(color)).every(channel => channel >= 0 && channel <= 1) +} + +function mapChromaToSrgb(color: OklchColor): OklchColor { + if (isInSrgbGamut(color)) return color + + let lowerChroma = 0 + let upperChroma = color.chroma + for (let iteration = 0; iteration < GAMUT_SEARCH_ITERATIONS; iteration += 1) { + const candidateChroma = (lowerChroma + upperChroma) / 2 + if (isInSrgbGamut({ ...color, chroma: candidateChroma })) lowerChroma = candidateChroma + else upperChroma = candidateChroma + } + + return { ...color, chroma: lowerChroma } +} + +function formatAccentColor(color: OklchColor): GlassAccentColor { + const channels = oklabToLinearSrgb(oklchToOklab(color)).map(channel => + Math.round(clamp(linearToSrgb(channel), 0, 1) * 255), + ) as [number, number, number] + + return { + hex: `#${channels.map(channel => channel.toString(16).padStart(2, '0')).join('')}`, + rgb: channels.join(', '), + } +} + +function normalizeAccentColor(color: string, minLightness: number, maxLightness: number, maxChroma: number) { + const srgb = parseHexColor(color) + if (!srgb) return undefined + + const source = oklabToOklch(srgbToOklab(srgb)) + const chroma = source.chroma < NEUTRAL_CHROMA_THRESHOLD ? source.chroma : Math.min(source.chroma, maxChroma) + const normalized = mapChromaToSrgb({ + lightness: clamp(source.lightness, minLightness, maxLightness), + chroma, + hue: source.hue, + }) + + return formatAccentColor(normalized) +} + +/** 将插件 Logo 主色限制在可读范围内,同时保持品牌色相与中性色属性。 */ +export function normalizePluginAccentColor(color: string): GlassAccentColor | undefined { + return normalizeAccentColor(color, PLUGIN_ACCENT_MIN_LIGHTNESS, PLUGIN_ACCENT_MAX_LIGHTNESS, PLUGIN_ACCENT_MAX_CHROMA) +} + +/** 派生大面积色调玻璃使用的材料色,不改变用户选择的真实主色。 */ +export function normalizeThemeMaterialAccent(color: string): GlassAccentColor | undefined { + return normalizeAccentColor( + color, + THEME_MATERIAL_MIN_LIGHTNESS, + THEME_MATERIAL_MAX_LIGHTNESS, + THEME_MATERIAL_MAX_CHROMA, + ) +} diff --git a/src/views/plugin/__tests__/PluginCardListView.spec.ts b/src/views/plugin/__tests__/PluginCardListView.spec.ts index bbc42a63..e9cf1024 100644 --- a/src/views/plugin/__tests__/PluginCardListView.spec.ts +++ b/src/views/plugin/__tests__/PluginCardListView.spec.ts @@ -53,8 +53,8 @@ vi.mock('@/composables/useKeepAliveRefresh', () => ({ }, })) -vi.mock('@/composables/useCardAccentColor', () => ({ - getCardAccentRgbFromImage: vi.fn().mockResolvedValue('40, 169, 225'), +vi.mock('@/@core/utils/image', () => ({ + extractDominantColor: vi.fn().mockResolvedValue(undefined), })) vi.mock('@/composables/usePWA', () => ({