Compare commits

...

7 Commits
v2.15.5 ... v2

Author SHA1 Message Date
jxxghp
19cead06f7 Update package.json 2026-08-10 13:43:12 +08:00
InfinityPacer
288d46c32a fix(glass): normalize tinted material tones (#659) 2026-08-10 13:07:17 +08:00
InfinityPacer
1d0ca1d163 fix(glass): preserve plugin brand accents (#658) 2026-08-10 12:57:39 +08:00
InfinityPacer
f41fcbff6d fix(glass): preserve content clarity across transparency (#656) 2026-08-10 12:46:21 +08:00
InfinityPacer
eb3b7b63b6 fix(glass): unify content surfaces and renderer eligibility (#653) 2026-08-10 06:54:29 +08:00
InfinityPacer
309ce3c92e fix(a11y): label dialog close buttons (#654) 2026-08-10 06:53:56 +08:00
千石
e6b26cc831 feat: 添加 AList 存储选项 (#645) 2026-08-09 14:40:54 +08:00
41 changed files with 1805 additions and 195 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.15.5",
"version": "2.15.6",
"private": true,
"type": "module",
"bin": "dist/service.js",

View File

@@ -1,12 +1,13 @@
<script lang="ts" setup>
// 定义输入参数
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({
// 是否显示
/** 覆盖关闭按钮默认的右上角定位 class。 */
innerClass: String,
})
// 定义触发的自定义事件
const emit = defineEmits(['click', 'update:modelValue'])
// 按钮点击
function onClick() {
emit('update:modelValue', false)
emit('click')
@@ -15,6 +16,7 @@ function onClick() {
<template>
<IconBtn
:aria-label="t('common.close')"
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3 z-10'"
@click.stop="onClick"
>

View File

@@ -0,0 +1,40 @@
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import i18n from '@/plugins/i18n'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { afterEach, describe, expect, it } from 'vitest'
afterEach(() => {
i18n.global.locale.value = 'zh-CN'
})
describe('DialogCloseBtn', () => {
it('keeps its visual and event contracts while exposing a localized name', async () => {
const user = userEvent.setup()
const { container, emitted, rerender } = await renderWithProviders(DialogCloseBtn)
const button = screen.getByRole('button', { name: '关闭' })
expect(button).toHaveClass('absolute', 'right-3', 'top-3', 'z-10')
const icon = container.querySelector('svg.v-icon')
expect(icon).not.toBeNull()
expect(icon).toHaveAttribute('aria-hidden', 'true')
await waitFor(() =>
expect(icon?.querySelector('path')).toHaveAttribute(
'd',
'M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z',
),
)
i18n.global.locale.value = 'en-US'
await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toBe(button))
await user.click(button)
expect(emitted()['update:modelValue']).toEqual([[false]])
expect(emitted().click).toEqual([[]])
await rerender({ innerClass: 'dialog-close-custom' })
expect(button).toHaveClass('dialog-close-custom')
expect(button).not.toHaveClass('absolute', 'right-3', 'top-3', 'z-10')
})
})

View File

@@ -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(() => {

View File

@@ -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<string, Promise<string>>()
const dominantColorCache = new Map<string, string>()
const pendingDominantColorRequests = new Map<string, Promise<string | undefined>>()
interface DominantColorOptions {
fallback?: string
@@ -29,42 +30,60 @@ function getImageCacheKey(image: HTMLImageElement) {
return image.currentSrc || image.src || ''
}
function rememberDominantColor(key: string, colorPromise: Promise<string>) {
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<DominantColorOptions, 'quality'> = {},
): Promise<string | undefined> {
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<string> {
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
}
// 预加载图片

View File

@@ -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,

View File

@@ -26,6 +26,11 @@ export const storageAttributes = [
icon: 'mdi-server-network-outline',
remote: true,
},
{
type: 'alistgo',
icon: 'mdi-server-network-outline',
remote: true,
},
{
type: 'smb',
icon: 'mdi-folder-network-outline',

View File

@@ -12,6 +12,7 @@ const STORAGE_ACCENT_COLOR_MAP = {
u115: '#17B26A',
rclone: '#6675FF',
alist: '#12B8D7',
alistgo: '#1BA0D8',
smb: '#3B82F6',
}

View File

@@ -62,6 +62,34 @@ const isImageLoaded = ref(false)
// 图片加载失败
const imageLoadError = ref(false)
// 图片请求代际隔离复用卡片的迟到事件,避免旧海报改变新媒体的 renderer 资格。
const imageRequestRevision = ref(0)
// renderer 只能在真实海报完成可见淡入后退出,资源 load 本身不代表像素已完全覆盖卡片。
const hasCompletedPosterReveal = ref(false)
const POSTER_REVEAL_FALLBACK_MS = 400
let posterRevealFallbackTimer: number | null = null
/** 清理当前海报的视觉覆盖状态与兜底提交。 */
function resetPosterRevealState() {
hasCompletedPosterReveal.value = false
if (posterRevealFallbackTimer === null) return
window.clearTimeout(posterRevealFallbackTimer)
posterRevealFallbackTimer = null
}
/** 仅允许当前真实海报请求完成 renderer 排除提交。 */
function completePosterReveal(revision: number, usesFallback: boolean) {
if (revision !== imageRequestRevision.value || usesFallback) return
if (posterRevealFallbackTimer !== null) {
window.clearTimeout(posterRevealFallbackTimer)
posterRevealFallbackTimer = null
}
hasCompletedPosterReveal.value = true
}
// 当前订阅状态
const isSubscribed = ref(false)
@@ -399,6 +427,54 @@ const getImgUrl: Ref<string> = computed(() => {
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
})
const hasLoadedRealPoster = computed(
() =>
Boolean(props.media?.poster_path) && isImageLoaded.value && hasCompletedPosterReveal.value && !imageLoadError.value,
)
/** 为当前图片实例绑定不可跨媒体复用的成功与失败回调。 */
const imageRequest = computed(() => {
const revision = imageRequestRevision.value
const src = getImgUrl.value
const usesFallback = imageLoadError.value || !props.media?.poster_path
return {
handleError: () => {
if (revision !== imageRequestRevision.value || usesFallback) return
resetPosterRevealState()
isImageLoaded.value = false
imageLoadError.value = true
imageRequestRevision.value += 1
},
handleLoad: () => {
if (revision !== imageRequestRevision.value) return
resetPosterRevealState()
isImageLoaded.value = true
if (!usesFallback) {
posterRevealFallbackTimer = window.setTimeout(
() => completePosterReveal(revision, usesFallback),
POSTER_REVEAL_FALLBACK_MS,
)
}
},
handleReveal: (event: TransitionEvent) => {
if (
event.propertyName !== 'opacity' ||
!(event.target instanceof Element) ||
!event.target.matches('.v-img__img')
) {
return
}
completePosterReveal(revision, usesFallback)
},
key: revision,
src,
}
})
// 获取媒体类型文本
function getMediaTypeText(type: string | undefined) {
if (!type) return ''
@@ -425,13 +501,21 @@ watch(isSubscribed, subscribed => {
})
watch(
() => props.media,
() => {
[() => props.media, () => props.media?.poster_path],
([media], [previousMedia]) => {
imageRequestRevision.value += 1
resetPosterRevealState()
isImageLoaded.value = false
imageLoadError.value = false
// 海报补全只重置图片代际;详情和订阅状态绑定媒体对象身份。
if (media === previousMedia) return
resetMediaCardDetailState()
subscribedSeasons.value = []
subscribedSeasonModes.value = {}
subscribedSeasonsLoaded.value = false
},
{ flush: 'sync' },
)
onMounted(() => {
@@ -445,6 +529,7 @@ onActivated(resetMediaCardDetailState)
onDeactivated(resetMediaCardDetailState)
onBeforeUnmount(() => {
resetPosterRevealState()
resetMediaCardDetailState()
document.removeEventListener('pointerdown', handleDocumentPointerDown)
observer.value?.disconnect()
@@ -461,6 +546,7 @@ onBeforeUnmount(() => {
:height="props.height"
:width="props.width"
:ripple="false"
:data-glass-optical-mode="hasLoadedRealPoster ? 'excluded' : undefined"
class="app-hover-lift-card outline-none ring-gray-500 media-card"
:class="{
'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
@@ -470,12 +556,14 @@ onBeforeUnmount(() => {
@click.stop="handleMediaCardClick(hover.isHovering)"
>
<VImg
:key="imageRequest.key"
aspect-ratio="2/3"
:src="getImgUrl"
:src="imageRequest.src"
class="object-cover aspect-w-2 aspect-h-3"
cover
@load="isImageLoaded = true"
@error="imageLoadError = true"
@load="imageRequest.handleLoad"
@error="imageRequest.handleError"
@transitionend="imageRequest.handleReveal"
>
<template #placeholder>
<div class="w-full h-full">

View File

@@ -2,7 +2,7 @@
import api from '@/api'
import type { ApiResponse, Plugin } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils'
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'
@@ -35,11 +35,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(() => {
@@ -71,9 +67,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()
}
// 计算图标路径
@@ -237,7 +236,7 @@ onUnmounted(() => {
:class="{
'app-hover-lift-card--hovering': hover.isHovering,
}"
:style="{ '--plugin-card-accent-rgb': accentRgb }"
:style="accentStyle"
>
<div class="plugin-card__banner flex-grow">
<VCardText class="px-2 pt-2 pb-0">
@@ -280,7 +279,7 @@ onUnmounted(() => {
aspect-ratio="4/3"
cover
@load="imageLoaded"
@error="imageLoadError = true"
@error="imageFailed"
/>
</VAvatar>
</div>

View File

@@ -4,7 +4,7 @@ import { useConfirm } from '@/composables/useConfirm'
import api from '@/api'
import type { ApiResponse, Plugin, PluginRating } from '@/api/types'
import { getLogoUrl } 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'
@@ -52,11 +52,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()
@@ -108,9 +104,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()
}
// 显示更新日志
@@ -646,7 +645,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"
>
<div class="plugin-card__banner flex-grow">
@@ -677,7 +676,7 @@ watch(
aspect-ratio="4/3"
cover
@load="imageLoaded"
@error="imageLoadError = true"
@error="imageFailed"
/>
</VAvatar>
</div>

View File

@@ -6,6 +6,7 @@ import alipan_png from '@images/misc/alipan.webp'
import u115_png from '@images/misc/u115.png'
import rclone_png from '@images/misc/rclone.png'
import alist_png from '@images/misc/openlist.svg'
import alistgo_png from '@images/misc/alist.svg'
import custom_png from '@images/misc/database.png'
import smb_png from '@images/misc/smb.png'
import api from '@/api'
@@ -58,6 +59,7 @@ function openStorageDialog() {
u115: U115AuthDialog,
rclone: RcloneConfigDialog,
alist: AlistConfigDialog,
alistgo: AlistConfigDialog,
smb: SmbConfigDialog,
}
@@ -69,7 +71,9 @@ function openStorageDialog() {
const dialog = dialogMap[props.storage.type] || StorageCustomConfigDialog
const dialogProps = dialog === StorageCustomConfigDialog
? { storage: props.storage }
: { conf: props.storage.config || {} }
: dialog === AlistConfigDialog
? { conf: props.storage.config || {}, type: props.storage.type }
: { conf: props.storage.config || {} }
openSharedDialog(
dialog,
@@ -94,6 +98,8 @@ const getIcon = computed(() => {
return rclone_png
case 'alist':
return alist_png
case 'alistgo':
return alistgo_png
case 'smb':
return smb_png
default:

View File

@@ -8,7 +8,7 @@ import { querySubscribeByMediaHandler, subscribeListHandler } from '@tests/suppo
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { defineComponent, h } from 'vue'
import { defineComponent, h, reactive, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
@@ -89,6 +89,50 @@ interface RenderCardOptions {
superUser?: boolean
}
interface ControlledImageRequest {
/** 模拟当前 VImg 请求失败。 */
fail: () => void
/** 模拟当前 VImg 请求成功。 */
load: () => void
/** 模拟当前 VImg 的 opacity 淡入完成。 */
reveal: () => void
/** 当前 VImg 实例发起的图片地址。 */
src: string
}
/** 创建可保留旧实例回调的图片替身,用于验证媒体复用时的迟到事件隔离。 */
function createControlledImageStub(requests: ControlledImageRequest[]) {
return defineComponent({
name: 'VImg',
emits: ['error', 'load'],
props: { src: String },
setup(props, { emit, slots }) {
const src = props.src ?? ''
const imageElement = ref<HTMLImageElement | null>(null)
const request = {
fail: () => emit('error', src),
load: () => emit('load', src),
reveal: () => {
const event = new Event('transitionend', { bubbles: true }) as TransitionEvent
Object.defineProperty(event, 'propertyName', { value: 'opacity' })
imageElement.value?.dispatchEvent(event)
},
src,
}
requests.push(request)
return () =>
h('div', { 'data-src': src }, [
h('img', {
ref: imageElement,
class: 'v-img__img',
}),
slots.default?.(),
])
},
})
}
/** 使用指定媒体信息和用户权限渲染媒体卡片。 */
async function renderCard(media: MediaInfo, options: RenderCardOptions = {}) {
return renderWithProviders(MediaCard, {
@@ -414,17 +458,22 @@ describe('MediaCard', () => {
expect(dialogProps.selected).toEqual([])
})
it('loads matching TV seasons before opening the subscription dialog', async () => {
const media = createMediaInfo({ season: 2, title: '多季剧集', tmdb_id: 9551, type: '电视剧' })
it('preserves loaded TV seasons when the same media receives a new poster', async () => {
const media = reactive(createMediaInfo({ season: 2, title: '多季剧集', tmdb_id: 9551, type: '电视剧' }))
const subscribeListRequest = vi.fn<(url: URL) => void>()
server.use(
querySubscribeByMediaHandler('tmdb:9551', { id: 81, season: 2 }),
mediaExistsHandler({ data: { item: {} }, success: false }),
subscribeListHandler([
{ best_version: 0, id: 81, season: 3, tmdbid: 9551, type: '电视剧' },
{ best_version: 1, best_version_full: 1, id: 82, season: 1, tmdbid: 9551, type: '电视剧' },
{ id: 83, season: 4, tmdbid: 9999, type: '电视剧' },
{ id: 84, tmdbid: 9551, type: '电' },
]),
subscribeListHandler(
[
{ best_version: 0, id: 81, season: 3, tmdbid: 9551, type: '电视剧' },
{ best_version: 1, best_version_full: 1, id: 82, season: 1, tmdbid: 9551, type: '电视剧' },
{ id: 83, season: 4, tmdbid: 9999, type: '电视剧' },
{ id: 84, tmdbid: 9551, type: '电影' },
],
200,
subscribeListRequest,
),
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
HttpResponse.json({ data: { value: { best_version: 0 } }, success: true }),
),
@@ -443,6 +492,19 @@ describe('MediaCard', () => {
subscribedSeasonModes: { 1: 'best_version_full', 3: 'normal' },
subscribedSeasons: [1, 3],
})
expect(subscribeListRequest).toHaveBeenCalledOnce()
media.poster_path = '/original/updated.jpg'
mocks.openSharedDialog.mockClear()
await fireEvent.click(getActionButtons(container).at(-1) as HTMLButtonElement)
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
expect(subscribeListRequest).toHaveBeenCalledOnce()
const [, updatedDialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, Record<string, unknown>]
expect(updatedDialogProps).toMatchObject({
subscribedSeasonModes: { 1: 'best_version_full', 3: 'normal' },
subscribedSeasons: [1, 3],
})
})
it('matches custom media IDs when collecting subscribed TV seasons', async () => {
@@ -533,35 +595,84 @@ describe('MediaCard', () => {
type: '电视剧',
vote_average: 8.6,
})
const VImgStub = defineComponent({
name: 'VImg',
emits: ['error', 'load'],
props: { src: String },
/** 渲染可主动触发图片成功和失败事件的测试替身。 */
setup(props, { emit, slots }) {
return () =>
h('div', { 'data-src': props.src }, [
h('button', { 'aria-label': '图片加载成功', onClick: () => emit('load') }),
h('button', { 'aria-label': '图片加载失败', onClick: () => emit('error') }),
slots.default?.(),
])
},
})
const requests: ControlledImageRequest[] = []
const VImgStub = createControlledImageStub(requests)
const { container } = await renderWithProviders(MediaCard, {
props: { media, width: '9rem' },
initialState: { user: { superUser: true } },
global: { stubs: { VImg: VImgStub } },
})
await fireEvent.click(container.querySelector('[aria-label="图片加载成功"]') as HTMLElement)
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
requests[0].load()
await waitFor(() => expect(container.querySelector('.media-card')).toHaveClass('ring-1'))
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
requests[0].reveal()
await waitFor(() => expect(getCard(container)).toHaveAttribute('data-glass-optical-mode', 'excluded'))
expect(container).toHaveTextContent('TV')
expect(container).toHaveTextContent('8.6')
await fireEvent.click(container.querySelector('[aria-label="图片加载失败"]') as HTMLElement)
requests[0].fail()
await waitFor(() =>
expect(container.querySelector('.media-card-title')?.parentElement).not.toHaveStyle({ display: 'none' }),
)
await waitFor(() => expect(requests.some(request => request.src.includes('no-image'))).toBe(true))
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
const fallbackRequest = requests.find(request => request.src.includes('no-image'))
fallbackRequest?.load()
await waitFor(() => expect(getCard(container)).toHaveClass('ring-1'))
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
fallbackRequest?.reveal()
await Promise.resolve()
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
})
it('keeps placeholder-only cards inside the renderer after the image loads', async () => {
const requests: ControlledImageRequest[] = []
const { container } = await renderWithProviders(MediaCard, {
props: { media: createMediaInfo({ poster_path: undefined, tmdb_id: 9553 }), width: '9rem' },
initialState: { user: { superUser: true } },
global: { stubs: { VImg: createControlledImageStub(requests) } },
})
requests[0].load()
await waitFor(() => expect(getCard(container)).toHaveClass('ring-1'))
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
})
it('ignores a previous poster load after the card is reused for another media item', async () => {
const requests: ControlledImageRequest[] = []
const mediaA = createMediaInfo({ poster_path: '/original/a.jpg', title: '媒体 A', tmdb_id: 9554 })
const mediaB = createMediaInfo({ poster_path: '/original/b.jpg', title: '媒体 B', tmdb_id: 9555 })
const { container, rerender } = await renderWithProviders(MediaCard, {
props: { media: mediaA, width: '9rem' },
initialState: { user: { superUser: true } },
global: { stubs: { VImg: createControlledImageStub(requests) } },
})
requests[0].load()
await waitFor(() => expect(getCard(container)).toHaveClass('media-card--image-loaded'))
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
await rerender({ media: mediaB, width: '9rem' })
await waitFor(() => expect(requests.some(request => request.src.includes('/w500/b.jpg'))).toBe(true))
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
requests[0].reveal()
await Promise.resolve()
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
const currentRequest = requests.find(request => request.src.includes('/w500/b.jpg'))
currentRequest?.load()
await waitFor(() => expect(getCard(container)).toHaveClass('media-card--image-loaded'))
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
currentRequest?.reveal()
await waitFor(() => expect(getCard(container)).toHaveAttribute('data-glass-optical-mode', 'excluded'))
})
it('renders the AniList source badge after the poster loads', async () => {

View File

@@ -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: '<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')" />',
template:
'<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')"><img /></button>',
})
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<HTMLElement>('.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<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
).toBe(normalizePluginAccentColor('#654321')?.rgb)
await fireEvent.contextMenu(image)
expect(
container.querySelector<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
).toBe('')
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
await fireEvent.click(await screen.findByText('项目主页'))

View File

@@ -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: '<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')" />',
template:
'<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')"><img /></button>',
})
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<HTMLElement>('.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<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
).toBe(normalizePluginAccentColor('#123456')?.rgb)
await fireEvent.contextMenu(image)
expect(
container.querySelector<HTMLElement>('.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()

View File

@@ -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', () => ({

View File

@@ -15,6 +15,10 @@ const props = defineProps({
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
type: {
type: String,
default: 'alist',
},
})
// 定义事件
@@ -29,7 +33,7 @@ async function handleDone() {
// 重置配置
async function handleReset() {
try {
const result: { [key: string]: any } = await api.get('/storage/reset/alist')
const result: { [key: string]: any } = await api.get(`/storage/reset/${props.type}`)
if (result.success) {
// 重置成功
handleDone()
@@ -62,7 +66,7 @@ const sourceItems = [
// 保存alist设置
async function savaAlistConfig() {
try {
await api.post(`storage/save/alist`, props.conf)
await api.post(`storage/save/${props.type}`, props.conf)
} catch (e) {
console.error(e)
}
@@ -78,7 +82,7 @@ async function savaAlistConfig() {
<VIcon icon="mdi-cog-outline" class="me-2" />
</template>
<VCardTitle>
{{ t('dialog.alistConfig.title') }}
{{ t(`dialog.${props.type}Config.title`) }}
</VCardTitle>
</VCardItem>
<VDivider />
@@ -87,8 +91,8 @@ async function savaAlistConfig() {
<VCol cols="12">
<VTextField
v-model="props.conf.url"
:hint="t('dialog.alistConfig.serverUrl')"
:label="t('dialog.alistConfig.serverUrl')"
:hint="t(`dialog.${props.type}Config.serverUrl`)"
:label="t(`dialog.${props.type}Config.serverUrl`)"
persistent-hint
prepend-inner-icon="mdi-server"
/>

View File

@@ -220,7 +220,9 @@ describe('SiteImportDialog', () => {
await fireEvent.click(await screen.findByRole('button', { name: '开始导入' }))
expect(await screen.findByText('导入过程中出现 1 个错误')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '关闭' }))
const resultCloseButton = screen.getByText('关闭').closest('button')
expect(resultCloseButton).not.toBeNull()
await fireEvent.click(resultCloseButton!)
expect(events.update).toHaveBeenCalledWith(false)
expect(events.importSuccess).not.toHaveBeenCalled()
})

View File

@@ -37,7 +37,7 @@ const props = defineProps<{
translationStrength: number
/** 路由变化标识,用于在页面内容稳定后重新发现高价值表面。 */
routeKey: string
/** 当前主题主色,用于同步色调材质的光学高光。 */
/** 由用户主色派生的大面积玻璃材料色,用于同步色调材质的光学高光。 */
tintColor: string
/** 外层壁纸交叉淡化的时长shader 使用同一时钟混合双纹理。 */
transitionDuration: number

View File

@@ -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')
})

View File

@@ -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' })
})
})

View File

@@ -148,6 +148,28 @@ function createTouchList(points: Array<{ clientX: number; clientY: number; ident
}) as unknown as TouchList
}
/** 构造浏览器在 detached 子树交付前保留的 child-list 移除记录。 */
function createRemovalRecord(target: Element, removedNodes: Element[]): MutationRecord {
const toNodeList = (nodes: Node[]) =>
Object.assign(nodes, {
item(index: number) {
return nodes[index] ?? null
},
}) as unknown as NodeList
return {
addedNodes: toNodeList([]),
attributeName: null,
attributeNamespace: null,
nextSibling: null,
oldValue: null,
previousSibling: null,
removedNodes: toNodeList(removedNodes),
target,
type: 'childList',
}
}
function dispatchTouchEvent(
type: 'touchcancel' | 'touchend' | 'touchmove' | 'touchstart',
touches: Array<{ clientX: number; clientY: number; identifier: number }>,
@@ -454,6 +476,24 @@ describe('glass optical surface discovery', () => {
expect(resolveGlassOpticalSurfaceMode(overridden)).toBe('dynamic')
})
it('excludes direct surfaces and descendants even when a child requests dynamic mode', () => {
const direct = appendOpticalSurface('app-hover-lift-card', { height: 220, width: 150, x: 24, y: 96 })
direct.dataset.glassOpticalMode = 'excluded'
const excludedContainer = document.createElement('section')
excludedContainer.dataset.glassOpticalMode = 'excluded'
const overridden = document.createElement('article')
overridden.className = 'app-hover-lift-card'
overridden.dataset.glassOpticalMode = 'dynamic'
setOpticalSurfaceBounds(overridden, { height: 220, width: 150, x: 200, y: 96 })
excludedContainer.append(overridden)
document.body.append(excludedContainer)
expect(containsGlassOpticalSurface(direct)).toBe(false)
expect(containsGlassOpticalSurface(excludedContainer)).toBe(false)
expect(resolveGlassOpticalSurfaceMode(overridden)).toBe('dynamic')
expect(collectGlassOpticalRects(390, 844, 'clear')).toEqual([])
})
it('discovers the shared interactive card contract used across routes', () => {
const surface = appendOpticalSurface('app-hover-lift-card', { height: 220, width: 150, x: 24, y: 96 })
surface.style.borderTopLeftRadius = '20px'
@@ -1998,6 +2038,458 @@ describe('glass optical surface discovery', () => {
scope.stop()
})
it('keeps a parent material static when all nested interaction clips are excluded', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
const three = await import('three')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const pageContent = document.createElement('main')
pageContent.className = 'app-wrapper layout-page-content'
const outerSurface = document.createElement('section')
outerSurface.className = 'v-card'
setOpticalSurfaceBounds(outerSurface, { height: 420, width: 900, x: 40, y: 80 })
const nestedCards = [80, 400].map(x => {
const nestedCard = document.createElement('article')
nestedCard.className = 'app-hover-lift-card'
nestedCard.dataset.glassOpticalMode = 'excluded'
setOpticalSurfaceBounds(nestedCard, { height: 160, width: 280, x, y: 140 })
outerSurface.append(nestedCard)
return nestedCard
})
pageContent.append(outerSurface)
document.body.append(pageContent)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
quality: ref('balanced'),
routeKey: ref('/search'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
await vi.waitFor(() => expect(render).toHaveBeenCalled())
const getUniforms = () => {
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
uniforms: {
uInteractionRectCount: { value: number }
uRectCount: { value: number }
uSurfaceDynamics: { value: number[] }
}
}
}>
}
return scene.children[0].material.uniforms
}
expect(getUniforms().uRectCount.value).toBe(1)
expect(getUniforms().uInteractionRectCount.value).toBe(0)
expect(getUniforms().uSurfaceDynamics.value[0]).toBe(0)
nestedCards[0].removeAttribute('data-glass-optical-mode')
await vi.waitFor(() => expect(getUniforms().uInteractionRectCount.value).toBe(1))
expect(getUniforms().uSurfaceDynamics.value[0]).toBe(1)
scope.stop()
})
it('refreshes excluded interaction clip membership across direct and nested mutations', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
const three = await import('three')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const pageContent = document.createElement('main')
pageContent.className = 'app-wrapper layout-page-content'
const surfaces = [40, 520].map(x => {
const surface = document.createElement('section')
surface.className = 'v-card'
setOpticalSurfaceBounds(surface, { height: 420, width: 400, x, y: 80 })
pageContent.append(surface)
return surface
})
document.body.append(pageContent)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
quality: ref('balanced'),
routeKey: ref('/search'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
await vi.waitFor(() => expect(render).toHaveBeenCalled())
const getInteractionState = () => {
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
uniforms: {
uInteractionRectCount: { value: number }
uInteractionRects: { value: Array<{ toArray: () => number[] }> }
uRectCount: { value: number }
uSurfaceDynamics: { value: number[] }
}
}
}>
}
const uniforms = scene.children[0].material.uniforms
return {
interactionCount: uniforms.uInteractionRectCount.value,
interactionXs: uniforms.uInteractionRects.value
.slice(0, uniforms.uInteractionRectCount.value)
.map(rect => rect.toArray()[0])
.sort((left, right) => left - right),
surfaceCount: uniforms.uRectCount.value,
surfaceDynamics: uniforms.uSurfaceDynamics.value.slice(0, 2).sort((left, right) => left - right),
}
}
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 }))
await vi.waitFor(() =>
expect(getInteractionState()).toEqual({
interactionCount: 2,
interactionXs: [40 / 1200, 520 / 1200],
surfaceCount: 2,
surfaceDynamics: [1, 1],
}),
)
const excludedClip = document.createElement('article')
excludedClip.className = 'app-hover-lift-card'
excludedClip.dataset.glassOpticalMode = 'excluded'
surfaces[0].append(excludedClip)
await vi.waitFor(() =>
expect(getInteractionState()).toEqual({
interactionCount: 1,
interactionXs: [520 / 1200],
surfaceCount: 2,
surfaceDynamics: [0, 1],
}),
)
surfaces[1].append(excludedClip)
await vi.waitFor(() =>
expect(getInteractionState()).toEqual({
interactionCount: 1,
interactionXs: [40 / 1200],
surfaceCount: 2,
surfaceDynamics: [0, 1],
}),
)
excludedClip.remove()
await vi.waitFor(() =>
expect(getInteractionState()).toEqual({
interactionCount: 2,
interactionXs: [40 / 1200, 520 / 1200],
surfaceCount: 2,
surfaceDynamics: [1, 1],
}),
)
const excludedContainer = document.createElement('div')
excludedContainer.dataset.glassOpticalMode = 'excluded'
surfaces[0].append(excludedContainer)
const nestedExcludedClip = document.createElement('article')
nestedExcludedClip.className = 'app-hover-lift-card'
excludedContainer.append(nestedExcludedClip)
await vi.waitFor(() =>
expect(getInteractionState()).toEqual({
interactionCount: 1,
interactionXs: [520 / 1200],
surfaceCount: 2,
surfaceDynamics: [0, 1],
}),
)
nestedExcludedClip.remove()
await vi.waitFor(() =>
expect(getInteractionState()).toEqual({
interactionCount: 2,
interactionXs: [40 / 1200, 520 / 1200],
surfaceCount: 2,
surfaceDynamics: [1, 1],
}),
)
excludedContainer.append(nestedExcludedClip)
await vi.waitFor(() =>
expect(getInteractionState()).toEqual({
interactionCount: 1,
interactionXs: [520 / 1200],
surfaceCount: 2,
surfaceDynamics: [0, 1],
}),
)
nestedExcludedClip.remove()
excludedContainer.remove()
await vi.waitFor(() =>
expect(getInteractionState()).toEqual({
interactionCount: 2,
interactionXs: [40 / 1200, 520 / 1200],
surfaceCount: 2,
surfaceDynamics: [1, 1],
}),
)
scope.stop()
})
it('propagates a managed owner through deeply nested removals in one observer batch', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
const mutationObservers: Array<MutationObserver & { trigger: (records: MutationRecord[]) => void }> = []
class MutationObserverMock implements MutationObserver {
constructor(private readonly callback: MutationCallback) {
mutationObservers.push(this)
}
disconnect() {}
observe() {}
takeRecords() {
return []
}
trigger(records: MutationRecord[]) {
this.callback(records, this)
}
}
vi.stubGlobal('MutationObserver', MutationObserverMock)
const three = await import('three')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const pageContent = document.createElement('main')
pageContent.className = 'app-wrapper layout-page-content'
const surface = document.createElement('section')
surface.className = 'v-card'
setOpticalSurfaceBounds(surface, { height: 420, width: 900, x: 40, y: 80 })
const outerExcludedContainer = document.createElement('div')
outerExcludedContainer.dataset.glassOpticalMode = 'excluded'
const innerExcludedContainer = document.createElement('div')
innerExcludedContainer.dataset.glassOpticalMode = 'excluded'
const deeplyNestedClip = document.createElement('article')
deeplyNestedClip.className = 'app-hover-lift-card'
innerExcludedContainer.append(deeplyNestedClip)
outerExcludedContainer.append(innerExcludedContainer)
surface.append(outerExcludedContainer)
pageContent.append(surface)
document.body.append(pageContent)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
quality: ref('balanced'),
routeKey: ref('/search'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
await vi.waitFor(() => expect(render).toHaveBeenCalled())
const getInteractionState = () => {
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
uniforms: {
uInteractionRectCount: { value: number }
uSurfaceDynamics: { value: number[] }
}
}
}>
}
const uniforms = scene.children[0].material.uniforms
return {
interactionCount: uniforms.uInteractionRectCount.value,
surfaceDynamics: uniforms.uSurfaceDynamics.value[0],
}
}
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 }))
await vi.waitFor(() => expect(getInteractionState()).toEqual({ interactionCount: 0, surfaceDynamics: 0 }))
outerExcludedContainer.remove()
innerExcludedContainer.remove()
deeplyNestedClip.remove()
mutationObservers[0].trigger([
createRemovalRecord(surface, [outerExcludedContainer]),
createRemovalRecord(outerExcludedContainer, [innerExcludedContainer]),
createRemovalRecord(innerExcludedContainer, [deeplyNestedClip]),
])
await vi.waitFor(() => expect(getInteractionState()).toEqual({ interactionCount: 1, surfaceDynamics: 1 }))
scope.stop()
})
it('removes and restores an active surface and interaction clip when exclusion changes', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
const three = await import('three')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const appWrapper = document.createElement('main')
appWrapper.className = 'app-wrapper'
const surface = document.createElement('article')
surface.className = 'app-hover-lift-card'
setOpticalSurfaceBounds(surface, { height: 180, width: 360, x: 100, y: 140 })
appWrapper.append(surface)
document.body.append(appWrapper)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
quality: ref('balanced'),
routeKey: ref('/search'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 }))
await vi.waitFor(() => expect(render).toHaveBeenCalled())
const getCounts = () => {
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
uniforms: {
uInteractionRectCount: { value: number }
uRectCount: { value: number }
}
}
}>
}
const uniforms = scene.children[0].material.uniforms
return [uniforms.uRectCount.value, uniforms.uInteractionRectCount.value]
}
expect(getCounts()).toEqual([1, 1])
surface.dataset.glassOpticalMode = 'excluded'
await vi.waitFor(() => expect(getCounts()).toEqual([0, 0]))
surface.removeAttribute('data-glass-optical-mode')
await vi.waitFor(() => expect(getCounts()).toEqual([1, 1]))
scope.stop()
})
it('ignores child-list churn inside an excluded surface after removed nodes lose their ancestor', async () => {
const appWrapper = document.createElement('main')
appWrapper.className = 'app-wrapper'
const surface = document.createElement('article')
surface.className = 'app-hover-lift-card'
surface.dataset.glassOpticalMode = 'excluded'
const imageContent = document.createElement('div')
surface.append(imageContent)
appWrapper.append(surface)
document.body.append(appWrapper)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
dynamicsActive: ref(false),
quality: ref('balanced'),
routeKey: ref('/search'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
const querySelectorAll = vi.spyOn(document, 'querySelectorAll')
imageContent.remove()
await nextTick()
await new Promise(resolve => requestAnimationFrame(resolve))
expect(querySelectorAll).not.toHaveBeenCalled()
scope.stop()
})
it('removes and restores a managed surface when it moves across an excluded boundary', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
const three = await import('three')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const appWrapper = document.createElement('main')
appWrapper.className = 'app-wrapper'
const eligibleParent = document.createElement('section')
const excludedParent = document.createElement('section')
excludedParent.dataset.glassOpticalMode = 'excluded'
const surface = document.createElement('article')
surface.className = 'app-hover-lift-card'
setOpticalSurfaceBounds(surface, { height: 180, width: 360, x: 100, y: 140 })
eligibleParent.append(surface)
appWrapper.append(eligibleParent, excludedParent)
document.body.append(appWrapper)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
quality: ref('balanced'),
routeKey: ref('/search'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
const getCounts = () => {
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
uniforms: {
uInteractionRectCount: { value: number }
uRectCount: { value: number }
}
}
}>
}
const uniforms = scene.children[0].material.uniforms
return [uniforms.uRectCount.value, uniforms.uInteractionRectCount.value]
}
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 }))
await vi.waitFor(() => expect(getCounts()).toEqual([1, 1]))
const querySelectorAll = vi.spyOn(document, 'querySelectorAll')
excludedParent.append(surface)
expect(surface.closest('[data-glass-optical-mode="excluded"]')).toBe(excludedParent)
await vi.waitFor(() => expect(querySelectorAll).toHaveBeenCalled())
await vi.waitFor(() => expect(getCounts()).toEqual([0, 0]))
eligibleParent.append(surface)
await vi.waitFor(() => expect(getCounts()).toEqual([1, 1]))
scope.stop()
})
it('keeps an explicit optical boundary as the interaction clip without allocating nested slots', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)

View File

@@ -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<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(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<string | undefined>()
const current = deferred<string | undefined>()
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<string | undefined>()
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()
})
})

View File

@@ -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'
@@ -210,6 +211,49 @@ describe('useThemeCustomizer glass settings', () => {
expect(Number(document.body.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(0.48)
expect(Number(document.documentElement.style.getPropertyValue('--glass-surface-density'))).toBeCloseTo(0.72)
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', () => {
persistPartialThemeCustomizerSettings({ glassTransparencyStrength: 50 })
expect(document.documentElement.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('6.7px')
previewGlassSettings({ glassTransparencyStrength: 100 })
expect(document.documentElement.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('5px')
expect(document.body.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('5px')
cancelGlassPreview()
expect(document.documentElement.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('6.7px')
expect(document.body.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('6.7px')
previewGlassSettings({ glassTransparencyStrength: 20 })
commitGlassPreview()
expect(readThemeCustomizerSettings().glassTransparencyStrength).toBe(20)
expect(document.documentElement.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('7.8px')
expect(document.body.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('7.8px')
})
it('previews glass settings without persisting them', () => {

View File

@@ -442,8 +442,19 @@ const SURFACE_SELECTORS = [
const SURFACE_SELECTOR_QUERY = SURFACE_SELECTORS.map(({ selector }) => selector).join(',')
const INTERACTION_CLIP_SELECTOR = '.app-hover-lift-card'
const OPTICAL_BOUNDARY_SELECTOR = '[data-glass-optical-boundary]'
const OPTICAL_EXCLUSION_SELECTOR = '[data-glass-optical-mode="excluded"]'
const INTERACTION_CLIP_OVERSCAN_PX = 96
/** 排除合同覆盖整个子树;后代不能用 dynamic 声明重新加入 renderer。 */
function isGlassOpticalElementExcluded(element: Element) {
return Boolean(element.closest(OPTICAL_EXCLUSION_SELECTOR))
}
/** overlay 与显式排除子树都不参与壁纸光学表面或交互裁剪。 */
function isGlassOpticalElementEligible(element: Element) {
return !element.closest('.v-overlay') && !isGlassOpticalElementExcluded(element)
}
/** 登录卡片随文档弹性合成,其余固定表面继续使用 viewport 坐标。 */
function getSurfacePresentationSpace(
selector: (typeof SURFACE_SELECTORS)[number]['selector'],
@@ -455,9 +466,16 @@ function getSurfacePresentationSpace(
/** 判断新增或移除的 DOM 子树是否会改变光学表面集合。 */
export function containsGlassOpticalSurface(node: Node) {
if (!(node instanceof Element)) return false
if (node.matches(SURFACE_SELECTOR_QUERY) && !node.closest('.v-overlay')) return true
if (node.matches(SURFACE_SELECTOR_QUERY) && isGlassOpticalElementEligible(node)) return true
return Array.from(node.querySelectorAll(SURFACE_SELECTOR_QUERY)).some(element => !element.closest('.v-overlay'))
return Array.from(node.querySelectorAll(SURFACE_SELECTOR_QUERY)).some(isGlassOpticalElementEligible)
}
/** 判断 DOM 子树是否包含会约束父表面动态输出的交互裁剪。 */
function containsGlassInteractionClip(node: Node) {
if (!(node instanceof Element)) return false
return node.matches(INTERACTION_CLIP_SELECTOR) || Boolean(node.querySelector(INTERACTION_CLIP_SELECTOR))
}
const VERTEX_SHADER = `
@@ -1100,7 +1118,7 @@ function collectGlassOpticalSurfaceDescriptors(
for (const element of document.querySelectorAll<HTMLElement>(selector)) {
if (seen.has(element)) continue
seen.add(element)
if (element.closest('.v-overlay')) continue
if (!isGlassOpticalElementEligible(element)) continue
collectedElements?.push(element)
const bounds = element.getBoundingClientRect()
@@ -1291,8 +1309,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
let surfaceRegistry: GlassOpticalSurfaceDescriptor[] = []
let availableSurfaces: GlassOpticalSurfaceDescriptor[] = []
let surfaceSlots: GlassOpticalSurfaceSlot<HTMLElement>[] = []
let interactionClips: GlassOpticalSurfaceDescriptor[] = []
let interactionClips: GlassInteractionClipDescriptor[] = []
let interactionClipRegistry: GlassInteractionClipDescriptor[] = []
let interactionClipConstrainedOwners = new Set<HTMLElement>()
let interactionClipMembershipDirty = true
let activeSurface: HTMLElement | null = null
let activeInteractionClip: HTMLElement | null = null
@@ -1869,6 +1888,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
const uniformRadii = resources.uniforms.uRadii.value
const uniformWeights = resources.uniforms.uSurfaceWeights.value
const uniformDynamics = resources.uniforms.uSurfaceDynamics.value
const ownersWithVisibleInteractionClips = new Set(interactionClips.map(clip => clip.owner))
const transitionWeights = outgoingSurface
? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS)
: { incoming: 1, outgoing: 0 }
@@ -1894,7 +1914,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
? 1
: 0
uniformWeights[index] = surfaceWeight * pagePresentationWeight
uniformDynamics[index] = slot?.mode === 'static-material' ? 0 : 1
const nestedInteractionAvailable =
!slot || !interactionClipConstrainedOwners.has(slot.key) || ownersWithVisibleInteractionClips.has(slot.key)
uniformDynamics[index] = slot?.mode === 'static-material' || !nestedInteractionAvailable ? 0 : 1
}
resources.uniforms.uRectCount.value = normalized.length
@@ -1907,13 +1929,21 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
function refreshInteractionClipRegistry() {
const seen = new Set<HTMLElement>()
const candidates: GlassInteractionClipDescriptor[] = []
const constrainedOwners = new Set<HTMLElement>()
const append = (
element: HTMLElement,
owner: HTMLElement,
mode: GlassOpticalSurfaceMode,
committedRect?: GlassOpticalRect,
) => {
if (seen.has(element) || !element.isConnected || resolveGlassOpticalSurfaceMode(element) !== mode) return
if (
seen.has(element) ||
!element.isConnected ||
!isGlassOpticalElementEligible(element) ||
resolveGlassOpticalSurfaceMode(element) !== mode
) {
return
}
const rect = committedRect ?? getElementPresentationRect(element)
@@ -1935,6 +1965,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
if (surfaceIsClip) append(surface.key, surface.key, mode, surface.rect)
const nestedClips = [...surface.key.querySelectorAll<HTMLElement>(INTERACTION_CLIP_SELECTOR)]
if (nestedClips.length > 0) {
constrainedOwners.add(surface.key)
nestedClips.forEach(clip => append(clip, surface.key, mode))
} else if (!surfaceIsClip) {
append(surface.key, surface.key, mode, surface.rect)
@@ -1942,6 +1973,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
}
interactionClipRegistry = candidates
interactionClipConstrainedOwners = constrainedOwners
interactionClipMembershipDirty = false
}
@@ -1952,6 +1984,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
if (
!clip.key.isConnected ||
!clip.owner.isConnected ||
!isGlassOpticalElementEligible(clip.key) ||
!isGlassOpticalElementEligible(clip.owner) ||
resolveGlassOpticalSurfaceMode(clip.key) !== clip.mode ||
!committedSurfaces.has(clip.owner)
) {
@@ -2030,6 +2064,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
activeInteractionClip = null
}
if (outgoingSurface && !availableKeys.has(outgoingSurface)) outgoingSurface = null
const interactionClipKeys = new Set(interactionClipRegistry.map(clip => clip.key))
if (activeInteractionClip && !interactionClipKeys.has(activeInteractionClip)) activeInteractionClip = null
const maxCount = viewportWidth <= 600 ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP
surfaceSlots = reconcileGlassOpticalSurfaceSlots(
surfaceSlots,
@@ -2242,7 +2278,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
const surface = target.matches(SURFACE_SELECTOR_QUERY)
? target
: target.closest<HTMLElement>(SURFACE_SELECTOR_QUERY)
if (!surface) return null
if (!surface || !isGlassOpticalElementEligible(surface)) return null
return SURFACE_SELECTORS.some(
({ selector, space }) =>
@@ -2482,13 +2518,16 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
function findInteractionTarget(x: number, y: number) {
if (availableSurfaces.length === 0) updateSurfaceUniforms()
const surface = availableSurfaces.find(candidate => rectContainsPoint(candidate.rect, x, y))
const surface = availableSurfaces.find(
candidate => isGlassOpticalElementEligible(candidate.key) && rectContainsPoint(candidate.rect, x, y),
)
if (!surface) return null
const matchingClips = interactionClipRegistry
.filter(
candidate =>
candidate.owner === surface.key &&
isGlassOpticalElementEligible(candidate.key) &&
isInteractionClipRenderable(candidate.rect) &&
rectContainsPoint(candidate.rect, x, y),
)
@@ -2689,6 +2728,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
clientY: number,
timestamp: number,
velocityOverride?: { x: number; y: number },
target?: EventTarget | null,
) {
if (
!hasDynamicCapability() ||
@@ -2696,6 +2736,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
) {
return
}
if (target instanceof Element && isGlassOpticalElementExcluded(target)) return
const viewportWidth = Math.max(window.innerWidth, 1)
const viewportHeight = Math.max(window.innerHeight, 1)
@@ -2803,7 +2844,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
function handlePointerMove(event: PointerEvent) {
if (event.pointerType === 'touch') return
applyInteraction(event.clientX, event.clientY, event.timeStamp || performance.now())
applyInteraction(event.clientX, event.clientY, event.timeStamp || performance.now(), undefined, event.target)
}
function findTouch(touches: TouchList, identifier: number | null) {
@@ -2818,6 +2859,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
/** 被动跟踪真实触点;不阻止页面滚动,也不恢复按压放大语义。 */
function handleTouchStart(event: TouchEvent) {
if (activeTouchIdentifier !== null) return
if (event.target instanceof Element && isGlassOpticalElementExcluded(event.target)) return
const touch = findTouch(event.changedTouches, null)
if (!touch) return
@@ -2854,7 +2896,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
if (!touch) return
const timestamp = event.timeStamp || performance.now()
applyInteraction(touch.clientX, touch.clientY, timestamp)
applyInteraction(touch.clientX, touch.clientY, timestamp, undefined, event.target)
}
function handleTouchEnd(event: TouchEvent) {
@@ -3105,11 +3147,72 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
/** 只让会改变目标表面集合或圆角几何的 DOM 变更触发重扫。 */
function mutationTouchesOpticalSurface(mutations: MutationRecord[]) {
return mutations.some(
mutation =>
mutation.type === 'attributes' ||
[...mutation.addedNodes, ...mutation.removedNodes].some(containsGlassOpticalSurface),
)
const removalRecords = mutations.flatMap(mutation => {
if (mutation.type !== 'childList' || mutation.removedNodes.length === 0 || !(mutation.target instanceof Element))
return []
return [
{
removedElements: [...mutation.removedNodes].filter((node): node is Element => node instanceof Element),
target: mutation.target,
},
]
})
const managedRemovalTargets = new Set<Element>()
const removedElementsFromManagedSurfaces = new Set<Element>()
let removalGraphExpanded = removalRecords.length > 0
// MutationRecord 保留节点身份,但回调时的最终 DOM 已丢失中间祖先关系,需要沿同批次移除边传递 owner。
while (removalGraphExpanded) {
removalGraphExpanded = false
for (const record of removalRecords) {
const belongsToManagedSurface =
managedRemovalTargets.has(record.target) ||
surfaceRegistry.some(surface => surface.key === record.target || surface.key.contains(record.target)) ||
[...removedElementsFromManagedSurfaces].some(
element => element === record.target || element.contains(record.target),
)
if (!belongsToManagedSurface) continue
if (!managedRemovalTargets.has(record.target)) {
managedRemovalTargets.add(record.target)
removalGraphExpanded = true
}
for (const element of record.removedElements) {
if (removedElementsFromManagedSurfaces.has(element)) continue
removedElementsFromManagedSurfaces.add(element)
removalGraphExpanded = true
}
}
}
return mutations.some(mutation => {
if (mutation.type === 'attributes') return true
const removedManagedSurface = [...mutation.removedNodes].some(
node =>
node instanceof Element &&
(surfaceRegistry.some(surface => surface.key === node || node.contains(surface.key)) ||
interactionClipRegistry.some(clip => clip.key === node || node.contains(clip.key))),
)
if (removedManagedSurface) return true
const changedNodes = [...mutation.addedNodes, ...mutation.removedNodes]
// 新增节点按提交后的祖先资格判断;排除子树内部的图片 DOM 变化不需要重扫。
if (mutation.target instanceof Element && !isGlassOpticalElementEligible(mutation.target)) {
const belongsToManagedSurface = surfaceRegistry.some(
surface => surface.key === mutation.target || surface.key.contains(mutation.target),
)
const removedFromManagedSurface = managedRemovalTargets.has(mutation.target)
return (belongsToManagedSurface || removedFromManagedSurface) && changedNodes.some(containsGlassInteractionClip)
}
return changedNodes.some(node => containsGlassOpticalSurface(node) || containsGlassInteractionClip(node))
})
}
function setupObservers() {
@@ -3262,6 +3365,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
surfaceSlots = []
interactionClips = []
interactionClipRegistry = []
interactionClipConstrainedOwners = new Set<HTMLElement>()
interactionClipMembershipDirty = true
activeSurface = null
activeInteractionClip = null

View File

@@ -0,0 +1,32 @@
import { extractDominantColor } from '@/@core/utils/image'
import { normalizePluginAccentColor } from '@/utils/glassColor'
/** 管理插件卡 Logo 的可选品牌强调色;无有效提色时由 CSS 环境色接管。 */
export function usePluginCardAccent() {
const accentRgb = ref<string>()
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,
}
}

View File

@@ -8,6 +8,7 @@ import {
GLASS_OPTICAL_STRENGTH_MAX,
getGlassCssFrostBlur,
getGlassMaterialResponse,
getGlassOverlayClarityBlur,
getGlassOpticalCssTransmissionBrightness,
getGlassOpticalPresetKey,
getGlassOpticalPresetParameters,
@@ -18,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'
@@ -470,6 +472,7 @@ export function applyThemeCustomizerRootSettings(
| 'glassTransmissionStrength'
| 'glassTransparencyStrength'
| 'layout'
| 'primaryColor'
| 'radius'
| 'semiDarkMenu'
| 'shadow'
@@ -480,6 +483,9 @@ 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))
@@ -488,6 +494,8 @@ export function applyThemeCustomizerRootSettings(
element.style.setProperty('--glass-tint-density', String(materialResponse.tintDensity))
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)

View File

@@ -1401,6 +1401,7 @@ export default {
u115: '115 Cloud',
rclone: 'RClone',
alist: 'OpenList',
alistgo: 'AList',
smb: 'SMB Network Share',
custom: 'Custom',
},
@@ -2916,6 +2917,10 @@ export default {
complete: 'Complete',
reset: 'Reset',
},
alistgoConfig: {
title: 'AList Configuration',
serverUrl: 'AList server address',
},
smbConfig: {
title: 'SMB Network Share Configuration',
host: 'SMB Server Address',

View File

@@ -1391,6 +1391,7 @@ export default {
u115: '115网盘',
rclone: 'RClone',
alist: 'OpenList',
alistgo: 'AList',
smb: 'SMB网络共享',
custom: '自定义',
},
@@ -2861,6 +2862,10 @@ export default {
complete: '完成',
reset: '重置',
},
alistgoConfig: {
title: 'AList配置',
serverUrl: 'AList服务地址',
},
smbConfig: {
title: 'SMB网络共享配置',
host: 'SMB服务器地址',

View File

@@ -1389,6 +1389,7 @@ export default {
u115: '115網盤',
rclone: 'RClone',
alist: 'OpenList',
alistgo: 'AList',
smb: 'SMB網路共享',
custom: '自定義',
},
@@ -2860,6 +2861,10 @@ export default {
complete: '完成',
reset: '重置',
},
alistgoConfig: {
title: 'AList配置',
serverUrl: 'AList服務地址',
},
smbConfig: {
title: 'SMB網路共享配置',
host: 'SMB伺服器地址',

View File

@@ -126,9 +126,9 @@ onMounted(async () => {
.settings-section-card {
overflow: hidden;
border: var(--app-surface-border);
backdrop-filter: blur(10px);
background-color: rgb(var(--v-theme-surface));
border: var(--app-grouped-list-border);
backdrop-filter: var(--app-grouped-list-backdrop-filter);
background-color: var(--app-grouped-list-background);
box-shadow: var(--app-surface-shadow);
transition:
border-color 0.2s ease,

View File

@@ -1686,8 +1686,10 @@ onUnmounted(() => {
.search-progress-card {
padding: 16px;
backdrop-filter: blur(10px);
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.08), transparent 42%), rgb(var(--v-theme-surface));
border: var(--app-grouped-list-border);
backdrop-filter: var(--app-grouped-list-backdrop-filter);
background:
linear-gradient(135deg, rgba(var(--v-theme-primary), 0.08), transparent 42%), var(--app-grouped-list-background);
inline-size: 100%;
}

View File

@@ -8,9 +8,8 @@ describe('glass overlay material styles', () => {
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
expect(styles).toContain('calc(0.1 + var(--glass-surface-density, 0.62) * 0.22)')
expect(styles).toContain('--glass-overlay-blur: 3px')
expect(styles.match(/--glass-overlay-blur:\s*var\(--glass-overlay-clarity-blur, 6px\)/g)).toHaveLength(2)
expect(styles).toContain('--glass-overlay-saturate: 115%')
expect(styles).toContain('--glass-overlay-blur: 12px')
expect(styles).toContain('--glass-overlay-saturate: 120%')
expect(styles).toContain('--glass-overlay-blur: min(var(--glass-blur-raised), 36px)')
expect(styles).toContain('--glass-overlay-saturate: 135%')
@@ -22,6 +21,70 @@ describe('glass overlay material styles', () => {
expect(styles).not.toContain('background: rgba(3, 7, 18, 62%)')
})
it('protects ordinary clear and tinted content without changing raised or frosted materials', () => {
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
expect(styles).toContain('rgba(11, 19, 34, calc(0.12 + var(--glass-surface-density, 0.62) * 0.2))')
expect(styles).toContain('rgba(11, 19, 34, calc(0.13 + var(--glass-surface-density, 0.62) * 0.23))')
expect(styles).toContain('rgba(11, 19, 34, calc(0.12 + var(--glass-surface-density, 0.72) * 0.2)) 88%')
expect(styles).toContain('rgba(11, 19, 34, calc(0.13 + var(--glass-surface-density, 0.72) * 0.23)) 89%')
expect(styles).toContain('rgba(11, 19, 34, calc(0.07 + var(--glass-surface-density, 0.62) * 0.36))')
expect(styles).toContain('rgba(11, 19, 34, calc(0.07 + var(--glass-surface-density, 0.72) * 0.36)) 84%')
expect(styles).toContain('rgba(255, 255, 255, calc(0.035 + var(--glass-surface-density, 0.86) * 0.075))')
expect(styles).toContain('rgba(255, 255, 255, calc(0.03 + var(--glass-surface-density, 0.86) * 0.07))')
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*\{(?<declarations>[\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*\{(?<declarations>[\s\S]*?)\n\}/u,
)?.groups?.declarations
const workflowRule = styles.match(
/&\[data-glass-appearance='tinted'\] \.workflow-task-card\s*\{(?<declarations>[\s\S]*?)\n {2}\}/u,
)?.groups?.declarations
const getPropertyValue = (rule: string | undefined, token: string) =>
rule?.match(new RegExp(`${token}:\\s*(?<value>[\\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')
@@ -216,14 +279,12 @@ describe('glass overlay material styles', () => {
/\[data-glass-scroll-presentation='native'\][\s\S]*?\.glass-optical-layer--scroll\s*\{\s*opacity:\s*0\s*!important;/,
)
expect(styles).toMatch(
/\[data-glass-renderer-state='ready'\][\s\S]*?\.app-hover-lift-card:not\(\.media-card--image-loaded\)[\s\S]*?backdrop-filter:\s*var\(--glass-native-surface-backdrop-filter\)\s*!important;/,
/\[data-glass-renderer-state='ready'\][\s\S]*?:is\([\s\S]*?\.app-hover-lift-card[\s\S]*?\):not\(\[data-glass-optical-mode='excluded'\]\):not\(\[data-glass-optical-mode='excluded'\] \*\)[\s\S]*?backdrop-filter:\s*var\(--glass-native-surface-backdrop-filter\)\s*!important;/,
)
expect(styles).toMatch(
/\.layout-wrapper:not\(\.layout-fixed-shell-backplate-active\) \.layout-vertical-nav::before,[\s\S]*?backdrop-filter:\s*var\(--glass-native-surface-backdrop-filter\)\s*!important;/,
)
expect(styles).toMatch(
/\.settings-section-card\.app-grouped-list\s*\{[\s\S]*?backdrop-filter:\s*var\(--glass-native-surface-backdrop-filter\)\s*!important;/,
)
expect(styles).not.toContain('.settings-section-card.app-grouped-list')
expect(styles).toMatch(
/\.file-browser-toolbar\.v-toolbar\s*\{[\s\S]*?backdrop-filter:\s*var\(--glass-surface-backdrop-filter\)\s*!important;/,
)
@@ -232,6 +293,26 @@ describe('glass overlay material styles', () => {
)
})
it('keeps the audited content surfaces on the shared grouped-list material contract', () => {
const commonStyles = readFileSync(resolve(cwd(), 'src/styles/common.scss'), 'utf8')
const transparentStyles = readFileSync(resolve(cwd(), 'src/styles/themes/transparent.scss'), 'utf8')
const glassStyles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
const resourcePage = readFileSync(resolve(cwd(), 'src/pages/resource.vue'), 'utf8')
const appCenterPage = readFileSync(resolve(cwd(), 'src/pages/appcenter.vue'), 'utf8')
expect(commonStyles).toContain('--app-grouped-list-backdrop-filter: none')
expect(transparentStyles).toContain('--app-grouped-list-backdrop-filter: blur(var(--transparent-blur))')
expect(transparentStyles.match(/--app-grouped-list-backdrop-filter:\s*none/g)).toHaveLength(3)
expect(glassStyles).toContain('--app-grouped-list-backdrop-filter: var(--glass-surface-backdrop-filter)')
for (const page of [resourcePage, appCenterPage]) {
expect(page).toContain('border: var(--app-grouped-list-border)')
expect(page).toContain('backdrop-filter: var(--app-grouped-list-backdrop-filter)')
expect(page).toContain('var(--app-grouped-list-background)')
expect(page).not.toContain('backdrop-filter: blur(10px)')
}
})
it('keeps frosted route opacity static while preserving its short movement', () => {
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')

View File

@@ -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*\{(?<declarations>[\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)')
})
})

View File

@@ -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%
);
}

View File

@@ -5,8 +5,8 @@
// 材质只覆盖统一表面 token质量档只替换光学层不改变业务组件契约。
html[data-theme='glass'] {
--glass-surface: rgba(11, 19, 34, calc(0.02 + var(--glass-surface-density, 0.62) * 0.3));
--glass-surface-soft: rgba(11, 19, 34, calc(0.04 + var(--glass-surface-density, 0.62) * 0.32));
--glass-surface: rgba(11, 19, 34, calc(0.12 + var(--glass-surface-density, 0.62) * 0.2));
--glass-surface-soft: rgba(11, 19, 34, calc(0.13 + var(--glass-surface-density, 0.62) * 0.23));
--glass-surface-raised: rgba(11, 19, 34, calc(0.07 + var(--glass-surface-density, 0.62) * 0.36));
--glass-control: rgba(11, 19, 34, 52%);
--glass-control-prominent: rgba(255, 255, 255, 7%);
@@ -54,7 +54,7 @@ html[data-theme='glass'] {
--glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter);
--glass-navbar-scrolled-backdrop-filter: blur(3px) saturate(115%);
--glass-overlay-surface: rgba(11, 19, 34, calc(0.1 + var(--glass-surface-density, 0.62) * 0.22));
--glass-overlay-blur: 3px;
--glass-overlay-blur: var(--glass-overlay-clarity-blur, 6px);
--glass-overlay-saturate: 115%;
--glass-overlay-scrim: rgba(3, 7, 18, 30%);
--glass-overlay-backdrop-filter: blur(var(--glass-overlay-blur)) saturate(var(--glass-overlay-saturate));
@@ -130,18 +130,18 @@ html[data-theme='glass'] {
&[data-glass-appearance='tinted'] {
--glass-surface: color-mix(
in srgb,
rgba(11, 19, 34, calc(0.02 + var(--glass-surface-density, 0.72) * 0.3)) 88%,
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.28))
rgba(11, 19, 34, calc(0.12 + var(--glass-surface-density, 0.72) * 0.2)) 88%,
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.04 + var(--glass-surface-density, 0.72) * 0.32)) 89%,
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.26))
rgba(11, 19, 34, calc(0.13 + var(--glass-surface-density, 0.72) * 0.23)) 89%,
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,9 +185,9 @@ 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: 12px;
--glass-overlay-blur: var(--glass-overlay-clarity-blur, 6px);
--glass-overlay-saturate: 120%;
--glass-overlay-scrim: rgba(3, 7, 18, 32%);
--glass-chip-backdrop-filter: blur(10px) saturate(165%) brightness(var(--glass-transmission-brightness));
@@ -386,8 +386,8 @@ html[data-theme='glass'] {
background-color: var(--glass-surface) !important;
}
// 海报完成绘制后已完全遮住卡片底面,释放不可见的实时背景采样
.media-card.media-card--image-loaded {
// 只有成功覆盖的真实海报才释放底面采样;占位图与失败态仍保留完整材料
.media-card[data-glass-optical-mode='excluded'] {
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
}
@@ -1012,23 +1012,17 @@ html[data-theme='glass'] {
.workflow-share-card {
--workflow-share-glass-start-opacity: calc(0.18 + var(--glass-tint-density, 0.65) * 0.38);
--workflow-share-glass-end-opacity: calc(0.24 + var(--glass-tint-density, 0.65) * 0.42);
--workflow-share-glass-scrim:
linear-gradient(
rgba(11, 19, 34, calc(0.1 + var(--glass-surface-density, 0.62) * 0.1)),
rgba(11, 19, 34, calc(0.2 + var(--glass-surface-density, 0.62) * 0.14))
);
--workflow-share-glass-scrim: linear-gradient(
rgba(11, 19, 34, calc(0.1 + var(--glass-surface-density, 0.62) * 0.1)),
rgba(11, 19, 34, calc(0.2 + var(--glass-surface-density, 0.62) * 0.14))
);
background-color: var(--glass-surface) !important;
background-image:
var(--glass-sheen),
var(--workflow-share-glass-scrim),
var(--glass-sheen), var(--workflow-share-glass-scrim),
linear-gradient(
135deg,
rgba(
var(--workflow-share-gradient-start-rgb, 74, 85, 104),
var(--workflow-share-glass-start-opacity)
)
0%,
rgba(var(--workflow-share-gradient-start-rgb, 74, 85, 104), var(--workflow-share-glass-start-opacity)) 0%,
rgba(var(--workflow-share-gradient-end-rgb, 45, 55, 72), var(--workflow-share-glass-end-opacity)) 100%
) !important;
}
@@ -1043,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%
);
}
@@ -1071,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%
);
}
@@ -1095,11 +1089,10 @@ html[data-theme='glass'] {
&[data-glass-appearance='frosted'] .workflow-share-card {
--workflow-share-glass-start-opacity: calc(0.12 + var(--glass-tint-density, 0.65) * 0.28);
--workflow-share-glass-end-opacity: calc(0.16 + var(--glass-tint-density, 0.65) * 0.34);
--workflow-share-glass-scrim:
linear-gradient(
rgba(11, 19, 34, calc(0.06 + var(--glass-surface-density, 0.86) * 0.07)),
rgba(11, 19, 34, calc(0.13 + var(--glass-surface-density, 0.86) * 0.1))
);
--workflow-share-glass-scrim: linear-gradient(
rgba(11, 19, 34, calc(0.06 + var(--glass-surface-density, 0.86) * 0.07)),
rgba(11, 19, 34, calc(0.13 + var(--glass-surface-density, 0.86) * 0.1))
);
}
// 色调材质本身带主色语义,头部染色向主色收敛保持整页同一色温。
@@ -1108,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%
);
}
@@ -1150,11 +1143,10 @@ html[data-theme='glass'] {
&[data-glass-appearance='tinted'] .workflow-share-card {
--workflow-share-glass-start-opacity: calc(0.16 + var(--glass-tint-density, 0.65) * 0.32);
--workflow-share-glass-end-opacity: calc(0.21 + var(--glass-tint-density, 0.65) * 0.38);
--workflow-share-glass-scrim:
linear-gradient(
rgba(11, 19, 34, calc(0.09 + var(--glass-surface-density, 0.72) * 0.09)),
rgba(11, 19, 34, calc(0.18 + var(--glass-surface-density, 0.72) * 0.13))
);
--workflow-share-glass-scrim: linear-gradient(
rgba(11, 19, 34, calc(0.09 + var(--glass-surface-density, 0.72) * 0.09)),
rgba(11, 19, 34, calc(0.18 + var(--glass-surface-density, 0.72) * 0.13))
);
}
// 文件夹卡片保留用户自选渐变作为色相,只降低不透明度让卡片本体的玻璃透出来。
@@ -1195,12 +1187,6 @@ html[data-theme='glass'] {
}
}
// “更多”应用列表的分组卡片存在局部固定模糊,需要在完整小屏区间跟随玻璃材质。
.settings-section-card.app-grouped-list {
-webkit-backdrop-filter: var(--glass-native-surface-backdrop-filter) !important;
backdrop-filter: var(--glass-native-surface-backdrop-filter) !important;
}
// 文件地址栏与下方内容卡属于同一工作表面,不使用更强的 raised 材质。
.file-browser-toolbar.v-toolbar {
-webkit-backdrop-filter: var(--glass-surface-backdrop-filter) !important;
@@ -1514,13 +1500,19 @@ html[data-glass-appearance='frosted']:is(
}
}
// 滚动表面始终由原生 backdrop 持有壁纸基底;GPU 层只叠加局部动态折射
// 滚动表面由原生 backdrop 持有壁纸基底;完整内容覆盖的排除子树不再采样
html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass-renderer-state='ready']
body[data-theme='glass'] {
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card,
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > :first-child > .v-card,
[data-glass-optical-surface],
.app-hover-lift-card:not(.media-card--image-loaded) {
:is(
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card,
.dashboard-grid-item-content
> .dashboard-grid-auto-size
> .dashboard-grid-content-measure
> :first-child
> .v-card,
[data-glass-optical-surface],
.app-hover-lift-card
):not([data-glass-optical-mode='excluded']):not([data-glass-optical-mode='excluded'] *) {
-webkit-backdrop-filter: var(--glass-native-surface-backdrop-filter) !important;
backdrop-filter: var(--glass-native-surface-backdrop-filter) !important;
}
@@ -1581,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;
}

View File

@@ -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()
})
})

View File

@@ -6,6 +6,7 @@ import {
getGlassCssFrostBlur,
getGlassCoverScale,
getGlassMaterialResponse,
getGlassOverlayClarityBlur,
getGlassOpticalCssTransmissionBrightness,
getGlassOpticalDecay,
getGlassOpticalBufferSize,
@@ -269,6 +270,34 @@ describe('glass optics geometry', () => {
).toBe(true)
})
it('derives a continuous overlay clarity floor from transparency anchors', () => {
const anchors = [
[0, 8.9],
[20, 7.8],
[50, 6.7],
[70, 5.8],
[85, 5.4],
[100, 5],
] as const
for (const [transparency, blur] of anchors) {
expect(getGlassOverlayClarityBlur(transparency)).toBe(blur)
}
expect(getGlassOverlayClarityBlur(-1)).toBe(8.9)
expect(getGlassOverlayClarityBlur(101)).toBe(5)
expect(getGlassOverlayClarityBlur(Number.NaN)).toBe(6.7)
const samples = Array.from({ length: 101 }, (_, value) => getGlassOverlayClarityBlur(value))
expect(samples.every((sample, index) => index === 0 || sample <= samples[index - 1])).toBe(true)
for (const anchor of [20, 50, 70, 85]) {
const blur = getGlassOverlayClarityBlur(anchor)
expect(Math.abs(getGlassOverlayClarityBlur(anchor - 1) - blur)).toBeLessThan(0.02)
expect(Math.abs(getGlassOverlayClarityBlur(anchor + 1) - blur)).toBeLessThan(0.03)
}
})
it('returns preset copies so previews cannot mutate the shared matrix', () => {
const first = getGlassOpticalPresetParameters('tinted', 'high', 'glide')
first.translation = 0

151
src/utils/glassColor.ts Normal file
View File

@@ -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,
)
}

View File

@@ -250,6 +250,7 @@ const GLASS_SURFACE_DENSITY: Record<GlassAppearance, readonly number[]> = {
const GLASS_TINT_DENSITY = [1, 0.9, 0.65, 0.48, 0.36, 0.28] as const
const GLASS_FROST_DENSITY = [1, 0.9, 0.7, 0.34, 0.12, 0.04] as const
const GLASS_FROSTED_DENSITY = [1, 0.82, 0.55, 0.28, 0.1, 0.025] as const
const GLASS_OVERLAY_CLARITY_BLUR = [8.9, 7.8, 6.7, 5.8, 5.4, 5] as const
/** 在相邻业务锚点之间使用零斜率边界插值,避免滑杆经过锚点时出现视觉折线。 */
function interpolateGlassResponse(value: unknown, anchors: readonly number[]) {
@@ -292,6 +293,11 @@ export function getGlassCssFrostBlur(value: unknown) {
}
}
/** 透明与色调浮层保留独立模糊下限,避免高通透度使临时内容直接暴露在复杂壁纸上。 */
export function getGlassOverlayClarityBlur(value: unknown) {
return interpolateGlassResponse(value, GLASS_OVERLAY_CLARITY_BLUR)
}
/** 计算与 CSS `ease` 相同的交叉淡化进度,使 DOM 壁纸与 shader 双纹理保持同一时钟。 */
export function getGlassWallpaperTransitionProgress(elapsed: number, duration: number) {
if (duration <= 0 || elapsed >= duration) return 1

View File

@@ -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', () => ({