feat(glass): refine material response and rendering lifecycle (#596)

This commit is contained in:
InfinityPacer
2026-07-29 11:19:12 +08:00
committed by GitHub
parent 90d626e7c6
commit 287cbc76ae
29 changed files with 4145 additions and 632 deletions
+27
View File
@@ -0,0 +1,27 @@
/** 建立带 CORS 响应头的浏览器缓存,必要时修复旧的非 CORS 缓存条目。 */
export async function preloadCorsImage(url: string): Promise<boolean> {
const request = async (cache: RequestCache) => {
const source = new URL(url, window.location.href)
const response = await fetch(source, {
cache,
credentials: source.origin === window.location.origin ? 'same-origin' : 'omit',
mode: 'cors',
})
if (!response.ok) return false
await response.blob()
return true
}
try {
if (await request('force-cache')) return true
} catch {
// 缓存中的非 CORS 响应可能使首次读取失败,重新验证后再决定是否回退。
}
try {
return await request('reload')
} catch {
return false
}
}
+1 -28
View File
@@ -1,4 +1,5 @@
import ColorThief from 'colorthief' import ColorThief from 'colorthief'
export { preloadCorsImage } from './corsImage'
const DEFAULT_DOMINANT_COLOR = '#28A9E1' const DEFAULT_DOMINANT_COLOR = '#28A9E1'
const DOMINANT_COLOR_CACHE_LIMIT = 100 const DOMINANT_COLOR_CACHE_LIMIT = 100
@@ -96,31 +97,3 @@ export async function preloadImage(url: string): Promise<boolean> {
} }
}) })
} }
/** 在纹理加载前建立带 CORS 响应头的缓存,避免普通图片缓存污染 WebGL 读取。 */
export async function preloadCorsImage(url: string): Promise<boolean> {
const request = async (cache: RequestCache) => {
const source = new URL(url, window.location.href)
const response = await fetch(source, {
cache,
credentials: source.origin === window.location.origin ? 'same-origin' : 'omit',
mode: 'cors',
})
if (!response.ok) return false
await response.blob()
return true
}
try {
if (await request('force-cache')) return true
} catch {
// 缓存中的非 CORS 响应可能使首次读取失败,重新验证后再决定是否回退。
}
try {
return await request('reload')
} catch {
return false
}
}
+193 -94
View File
@@ -7,7 +7,7 @@ import { useAuthStore, useGlobalSettingsStore } from '@/stores'
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n' import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
import { SupportedLocale } from '@/types/i18n' import { SupportedLocale } from '@/types/i18n'
import { checkAndEmitUnreadMessages } from '@/utils/badge' import { checkAndEmitUnreadMessages } from '@/utils/badge'
import { preloadCorsImage, preloadImage } from './@core/utils/image' import { preloadImage } from './@core/utils/image'
import { globalLoadingStateManager } from '@/utils/loadingStateManager' import { globalLoadingStateManager } from '@/utils/loadingStateManager'
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager' import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue' import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
@@ -27,6 +27,7 @@ import { getDisplayImageUrl } from '@/utils/imageUtils'
import { configureApexChartsTheme } from '@/utils/apexCharts' import { configureApexChartsTheme } from '@/utils/apexCharts'
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus' import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle' import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
import { useGlassWallpaperTransaction } from '@/composables/useGlassWallpaperTransaction'
import { import {
BACKGROUND_ROTATION_GRACE_MS, BACKGROUND_ROTATION_GRACE_MS,
createBackgroundCandidateOrderResolver, createBackgroundCandidateOrderResolver,
@@ -45,7 +46,7 @@ import {
} from '@/utils/loginPresentation' } from '@/utils/loginPresentation'
import { import {
DEFAULT_GLASS_WALLPAPER_TONE_PROFILE, DEFAULT_GLASS_WALLPAPER_TONE_PROFILE,
loadGlassWallpaperToneProfile, loadGlassWallpaperTone,
type GlassWallpaperToneProfile, type GlassWallpaperToneProfile,
} from '@/utils/glassWallpaperTone' } from '@/utils/glassWallpaperTone'
@@ -97,6 +98,17 @@ function resolveInitialThemeName(themePreference: string) {
return resolveThemeName(themePreference) return resolveThemeName(themePreference)
} }
function recordGlassLaunchTiming(stage: string, detail?: string) {
const timingWindow = window as typeof window & {
__glassPerformanceProbeEnabled?: boolean
__glassLaunchTimings?: Array<{ detail?: string; stage: string; time: number }>
}
if (!import.meta.env.DEV || !timingWindow.__glassPerformanceProbeEnabled) return
timingWindow.__glassLaunchTimings ??= []
timingWindow.__glassLaunchTimings.push({ detail, stage, time: performance.now() })
}
// 生效主题 // 生效主题
const vuetifyTheme = useTheme() const vuetifyTheme = useTheme()
const { global: globalTheme } = vuetifyTheme const { global: globalTheme } = vuetifyTheme
@@ -135,12 +147,22 @@ const globalSettingsStore = useGlobalSettingsStore()
// 背景图片 // 背景图片
const backgroundImages = ref<string[]>([]) const backgroundImages = ref<string[]>([])
const backgroundLayers = ref(createLoginBackgroundLayers()) const backgroundLayers = ref(createLoginBackgroundLayers())
const backgroundDisplayImages = ref<Record<string, string>>({})
const backgroundToneProfiles = ref<Record<string, GlassWallpaperToneProfile>>({}) const backgroundToneProfiles = ref<Record<string, GlassWallpaperToneProfile>>({})
const activeImageIndex = ref(0) const activeImageIndex = ref(0)
const previousImageIndex = ref<number | null>(null) const previousImageIndex = ref<number | null>(null)
const isBackgroundCrossfading = ref(false) const isBackgroundCrossfading = ref(false)
const backgroundCrossfadeStartedAt = ref(0) const backgroundCrossfadeStartedAt = ref(0)
const pendingOpticalBackgroundImage = ref('') const {
acknowledgeActivated: acknowledgeOpticalWallpaperActivated,
acknowledgePrepared: acknowledgeOpticalWallpaperPrepared,
activationRevision: activateOpticalWallpaperRevision,
cancel: cancelOpticalWallpaperTransaction,
requestedRevision: pendingOpticalWallpaperRevision,
requestedUrl: pendingOpticalBackgroundImage,
requestActivation: requestOpticalWallpaperActivation,
requestPreparation: requestOpticalWallpaperPreparation,
} = useGlassWallpaperTransaction<number>()
const resolveBackgroundCandidateOrder = createBackgroundCandidateOrderResolver() const resolveBackgroundCandidateOrder = createBackgroundCandidateOrderResolver()
const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle() const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle()
const preferredMotion = usePreferredReducedMotion() const preferredMotion = usePreferredReducedMotion()
@@ -213,13 +235,16 @@ const shouldLoadBackgroundImages = computed(
) )
const activeBackgroundImage = computed(() => backgroundImages.value[activeImageIndex.value] ?? '') const activeBackgroundImage = computed(() => backgroundImages.value[activeImageIndex.value] ?? '')
const renderedBackgroundLayers = computed(() => backgroundLayers.value) const renderedBackgroundLayers = computed(() => backgroundLayers.value)
const getOpticalBackgroundImage = (imageUrl: string) => getDisplayImageUrl(imageUrl, Boolean(isLogin.value)) const getOpticalBackgroundImage = (imageUrl: string) => imageUrl
const activeOpticalBackgroundImage = computed(() => getOpticalBackgroundImage(activeBackgroundImage.value)) const getPreparedBackgroundImage = (imageUrl: string) => backgroundDisplayImages.value[imageUrl] ?? imageUrl
const getPreparedOpticalBackgroundImage = (imageUrl: string) =>
backgroundDisplayImages.value[imageUrl] ?? getOpticalBackgroundImage(imageUrl)
const activeOpticalBackgroundImage = computed(() => getPreparedOpticalBackgroundImage(activeBackgroundImage.value))
const previousOpticalBackgroundImage = computed(() => { const previousOpticalBackgroundImage = computed(() => {
const previousIndex = previousImageIndex.value const previousIndex = previousImageIndex.value
if (previousIndex === null) return '' if (previousIndex === null) return ''
return getOpticalBackgroundImage(backgroundImages.value[previousIndex] ?? '') return getPreparedOpticalBackgroundImage(backgroundImages.value[previousIndex] ?? '')
}) })
const shouldRenderGlassOpticalLayer = computed( const shouldRenderGlassOpticalLayer = computed(
() => () =>
@@ -242,12 +267,12 @@ const shouldRenderGlobalBlurLayer = computed(
let backgroundRetryTimer: number | null = null let backgroundRetryTimer: number | null = null
let backgroundRequestController: AbortController | null = null let backgroundRequestController: AbortController | null = null
let backgroundCrossfadeTimer: number | null = null let backgroundCrossfadeTimer: number | null = null
let pendingOpticalWallpaperTimer: number | null = null
let pendingOpticalWallpaperResolve: ((ready: boolean) => void) | null = null
let authenticatedStateTimer: number | null = null let authenticatedStateTimer: number | null = null
let backgroundLoadVersion = 0 let backgroundLoadVersion = 0
let backgroundRecoveryAttemptedVersion = -1 let backgroundRecoveryAttemptedVersion = -1
let backgroundRotationVersion = 0 let backgroundRotationVersion = 0
let backgroundPreloadIdleHandle: number | null = null
let backgroundPreloadTimer: number | null = null
// 读取并同步透明主题背景设置到根组件响应式状态。 // 读取并同步透明主题背景设置到根组件响应式状态。
function applyTransparentBackgroundSettings() { function applyTransparentBackgroundSettings() {
@@ -265,27 +290,15 @@ function handleTransparencySettingsChanged(event: Event) {
transparencyGlassQuality.value = glassQuality transparencyGlassQuality.value = glassQuality
} }
/** 在壁纸可见前准备稳健曝光;不可读跨域图片回落中性 profile,不阻断 CSS 背景。 */
async function ensureBackgroundToneProfile(imageUrl: string) {
if (!imageUrl || !isGlassTheme.value) return DEFAULT_GLASS_WALLPAPER_TONE_PROFILE
const profile = await loadGlassWallpaperToneProfile(getOpticalBackgroundImage(imageUrl))
backgroundToneProfiles.value = {
...backgroundToneProfiles.value,
[imageUrl]: profile,
}
return profile
}
/** 让稳定双槽位分别携带当前壁纸的曝光,交叉淡化期间不共享新图参数。 */ /** 让稳定双槽位分别携带当前壁纸的曝光,交叉淡化期间不共享新图参数。 */
function getBackgroundLayerStyle(layer: LoginBackgroundLayer) { function getBackgroundLayerStyle(layer: LoginBackgroundLayer) {
const profile = backgroundToneProfiles.value[layer.url] ?? DEFAULT_GLASS_WALLPAPER_TONE_PROFILE const profile = backgroundToneProfiles.value[layer.url] ?? DEFAULT_GLASS_WALLPAPER_TONE_PROFILE
const appearance = effectiveGlassSettings.value.glassAppearance const appearance = effectiveGlassSettings.value.glassAppearance
const materialExposure = appearance === 'frosted' ? 0.82 : appearance === 'tinted' ? 0.85 : 0.86 const materialExposure = appearance === 'frosted' ? 0.82 : appearance === 'tinted' ? 0.85 : 0.86
const displayUrl = isGlassTheme.value ? getPreparedBackgroundImage(layer.url) : layer.url
return { return {
'backgroundImage': layer.url ? `url(${layer.url})` : undefined, 'backgroundImage': displayUrl ? `url(${displayUrl})` : undefined,
'--glass-wallpaper-brightness': String(materialExposure * profile.exposure), '--glass-wallpaper-brightness': String(materialExposure * profile.exposure),
} }
} }
@@ -512,35 +525,39 @@ function clearBackgroundCrossfadeTimer() {
} }
} }
/** 结束当前 GPU 纹理预备等待,旧请求不得继续提交壁纸切换。 */
function settlePendingOpticalWallpaper(ready: boolean) {
if (pendingOpticalWallpaperTimer !== null) {
window.clearTimeout(pendingOpticalWallpaperTimer)
pendingOpticalWallpaperTimer = null
}
pendingOpticalBackgroundImage.value = ''
pendingOpticalWallpaperResolve?.(ready)
pendingOpticalWallpaperResolve = null
}
/** 等待两个 WebGL 呈现 context 完成下一张纹理上传。 */ /** 等待两个 WebGL 呈现 context 完成下一张纹理上传。 */
function prepareOpticalWallpaper(url: string) { async function prepareOpticalWallpaper(url: string) {
if (!shouldRenderGlassOpticalLayer.value || !url || url === activeOpticalBackgroundImage.value) { if (!shouldRenderGlassOpticalLayer.value || !url || url === activeOpticalBackgroundImage.value) {
return Promise.resolve(true) return { ready: true, revision: 0 }
} }
settlePendingOpticalWallpaper(false) const ready = requestOpticalWallpaperPreparation(url)
pendingOpticalBackgroundImage.value = url const revision = pendingOpticalWallpaperRevision.value
return new Promise<boolean>(resolve => { return { ready: await ready, revision }
pendingOpticalWallpaperResolve = resolve
pendingOpticalWallpaperTimer = window.setTimeout(() => settlePendingOpticalWallpaper(false), 10000)
})
} }
/** 只接受当前待切换 URL 的 renderer 就绪回执。 */ /** 只接受当前待切换 URL 的 renderer 就绪回执。 */
function handleOpticalWallpaperPrepared(url: string) { function handleOpticalWallpaperPrepared(url: string, revision: number) {
if (url === pendingOpticalBackgroundImage.value) settlePendingOpticalWallpaper(true) acknowledgeOpticalWallpaperPrepared(url, revision)
}
/** 任一 context 无法准备当前候选时取消 revision,禁止后续材质刷新重复加载失效 URL。 */
function handleOpticalWallpaperPreparationFailed(_url: string, revision: number) {
cancelOpticalWallpaperTransaction(revision)
}
/** 两个 context 均已原子消费 prepared 资源后,以同一时钟提交 DOM 壁纸。 */
function handleOpticalWallpaperActivated(url: string, revision: number, startedAt: number) {
const activation = acknowledgeOpticalWallpaperActivated(url, revision, startedAt)
if (!activation) return
activateBackgroundImage(activation.payload, activation.startedAt)
}
/** 任一 context 提交失败时取消整个 revision,禁止另一 context 继续持有半提交状态。 */
function handleOpticalWallpaperActivationFailed(_url: string, revision: number) {
cancelOpticalWallpaperTransaction(revision)
} }
// 重置背景图交叉淡入淡出状态。 // 重置背景图交叉淡入淡出状态。
@@ -553,22 +570,23 @@ function resetBackgroundCrossfade() {
} }
// 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。 // 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。
function activateBackgroundImage(nextIndex: number) { function activateBackgroundImage(nextIndex: number, startedAt = performance.now()) {
if (nextIndex === activeImageIndex.value) return if (nextIndex === activeImageIndex.value) return
clearBackgroundCrossfadeTimer() clearBackgroundCrossfadeTimer()
backgroundLayers.value = prepareLoginBackgroundLayer(backgroundLayers.value, backgroundImages.value[nextIndex] ?? '') backgroundLayers.value = prepareLoginBackgroundLayer(backgroundLayers.value, backgroundImages.value[nextIndex] ?? '')
previousImageIndex.value = activeImageIndex.value previousImageIndex.value = activeImageIndex.value
isBackgroundCrossfading.value = true isBackgroundCrossfading.value = true
backgroundCrossfadeStartedAt.value = performance.now() backgroundCrossfadeStartedAt.value = startedAt
activeImageIndex.value = nextIndex activeImageIndex.value = nextIndex
backgroundLayers.value = activateLoginBackgroundLayer(backgroundLayers.value) backgroundLayers.value = activateLoginBackgroundLayer(backgroundLayers.value)
const remainingDuration = Math.max(0, BACKGROUND_CROSSFADE_DURATION_MS - (performance.now() - startedAt))
backgroundCrossfadeTimer = window.setTimeout(() => { backgroundCrossfadeTimer = window.setTimeout(() => {
previousImageIndex.value = null previousImageIndex.value = null
isBackgroundCrossfading.value = false isBackgroundCrossfading.value = false
backgroundLayers.value = settleLoginBackgroundLayers(backgroundLayers.value) backgroundLayers.value = settleLoginBackgroundLayers(backgroundLayers.value)
backgroundCrossfadeTimer = null backgroundCrossfadeTimer = null
}, BACKGROUND_CROSSFADE_DURATION_MS) }, remainingDuration)
} }
// 获取背景图片列表;只有选出实际可用的首图后才提交到可见状态。 // 获取背景图片列表;只有选出实际可用的首图后才提交到可见状态。
@@ -592,31 +610,86 @@ function preloadNextBackgroundImage() {
void preloadBackgroundCandidate(backgroundImages.value[nextIndex]) void preloadBackgroundCandidate(backgroundImages.value[nextIndex])
} }
/** 实时玻璃先建立可供 WebGL 读取的缓存,失败时仍允许 CSS 材质显示该壁纸。 */ function cancelNextBackgroundPreload() {
const idleWindow = window as typeof window & {
cancelIdleCallback?: (handle: number) => void
}
if (backgroundPreloadIdleHandle !== null) {
idleWindow.cancelIdleCallback?.(backgroundPreloadIdleHandle)
backgroundPreloadIdleHandle = null
}
if (backgroundPreloadTimer !== null) {
window.clearTimeout(backgroundPreloadTimer)
backgroundPreloadTimer = null
}
}
/** 首屏稳定后才预备轮播候选,避免第二张完整壁纸与 Dashboard 和首张纹理争抢资源。 */
function scheduleNextBackgroundPreload() {
cancelNextBackgroundPreload()
if (!allowsBackgroundRotation.value || backgroundImages.value.length <= 1 || document.getElementById('loading-bg')) {
return
}
const idleWindow = window as typeof window & {
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number
}
if (typeof idleWindow.requestIdleCallback === 'function') {
backgroundPreloadIdleHandle = idleWindow.requestIdleCallback(
() => {
backgroundPreloadIdleHandle = null
preloadNextBackgroundImage()
},
{ timeout: 3000 },
)
return
}
backgroundPreloadTimer = window.setTimeout(() => {
backgroundPreloadTimer = null
preloadNextBackgroundImage()
}, 1200)
}
/** 玻璃壁纸以一次匿名解码同时完成可读性和 tone 分析,失败时仍允许 CSS 回退。 */
async function preloadBackgroundCandidate(imageUrl: string) { async function preloadBackgroundCandidate(imageUrl: string) {
const toneProfile = isGlassTheme.value recordGlassLaunchTiming('wallpaper-source-requested', imageUrl)
? ensureBackgroundToneProfile(imageUrl) if (!isGlassTheme.value) return preloadImage(imageUrl)
: Promise.resolve(DEFAULT_GLASS_WALLPAPER_TONE_PROFILE)
if (!shouldRenderGlassOpticalLayer.value) {
const [available] = await Promise.all([preloadImage(imageUrl), toneProfile])
return available let opticalUrl = getOpticalBackgroundImage(imageUrl)
let tone = await loadGlassWallpaperTone(opticalUrl)
if (!tone.corsReady && isLogin.value) {
const proxyUrl = getDisplayImageUrl(imageUrl, true)
if (proxyUrl !== opticalUrl) {
const proxyTone = await loadGlassWallpaperTone(proxyUrl)
if (proxyTone.corsReady) {
opticalUrl = proxyUrl
tone = proxyTone
}
}
}
backgroundToneProfiles.value = {
...backgroundToneProfiles.value,
[imageUrl]: tone.profile,
}
if (tone.corsReady) {
// DOM、tone 与 renderer 共享完全相同的像素源,避免首屏重复下载和解码。
backgroundDisplayImages.value = {
...backgroundDisplayImages.value,
[imageUrl]: opticalUrl,
}
recordGlassLaunchTiming('wallpaper-source-ready', imageUrl)
return true
} }
const opticalUrl = getOpticalBackgroundImage(imageUrl) backgroundDisplayImages.value = {
const opticalReady = await preloadCorsImage(opticalUrl) ...backgroundDisplayImages.value,
if (!opticalReady) { [imageUrl]: imageUrl,
const [displayReady] = await Promise.all([preloadImage(imageUrl), toneProfile])
return displayReady
} }
const [displayReady] = await Promise.all([ const ready = await preloadImage(imageUrl)
opticalUrl === imageUrl ? Promise.resolve(true) : preloadImage(imageUrl), recordGlassLaunchTiming(ready ? 'wallpaper-source-ready' : 'wallpaper-source-failed', imageUrl)
toneProfile, return ready
])
return displayReady
} }
// 背景图片轮换函数 // 背景图片轮换函数
@@ -630,20 +703,35 @@ async function rotateBackgroundImage() {
const nextIndex = (activeIndex + offset) % backgroundImages.value.length const nextIndex = (activeIndex + offset) % backgroundImages.value.length
const nextImage = backgroundImages.value[nextIndex] const nextImage = backgroundImages.value[nextIndex]
const opticalImage = shouldRenderGlassOpticalLayer.value ? getOpticalBackgroundImage(nextImage) : undefined let opticalRevision = 0
if (opticalImage && !(await prepareOpticalWallpaper(opticalImage))) continue try {
if (!(await preloadBackgroundCandidate(nextImage))) continue
const opticalImage = shouldRenderGlassOpticalLayer.value
? getPreparedOpticalBackgroundImage(nextImage)
: undefined
if (opticalImage) {
const preparation = await prepareOpticalWallpaper(opticalImage)
opticalRevision = preparation.revision
if (!preparation.ready) continue
}
const imagesReady = await preloadBackgroundRotationImages({ const imagesReady = await preloadBackgroundRotationImages({
displayUrl: nextImage, displayUrl: isGlassTheme.value ? getPreparedBackgroundImage(nextImage) : nextImage,
opticalUrl: opticalImage, opticalUrl: opticalImage,
preload: preloadImage, preload: preloadImage,
}) })
if (!imagesReady) continue if (!imagesReady) continue
await ensureBackgroundToneProfile(nextImage)
if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return
if (opticalImage) {
if (!(await requestOpticalWallpaperActivation(nextIndex, opticalRevision))) continue
} else {
activateBackgroundImage(nextIndex) activateBackgroundImage(nextIndex)
preloadNextBackgroundImage() }
scheduleNextBackgroundPreload()
return return
} finally {
if (opticalRevision > 0) cancelOpticalWallpaperTransaction(opticalRevision)
}
} }
if (requestVersion === backgroundRotationVersion && backgroundRecoveryAttemptedVersion !== backgroundLoadVersion) { if (requestVersion === backgroundRotationVersion && backgroundRecoveryAttemptedVersion !== backgroundLoadVersion) {
@@ -657,8 +745,9 @@ async function rotateBackgroundImage() {
// 停止轮询并使已经发起的下一图准备失效,避免非活动状态收到迟到提交。 // 停止轮询并使已经发起的下一图准备失效,避免非活动状态收到迟到提交。
function stopBackgroundRotation() { function stopBackgroundRotation() {
backgroundRotationVersion += 1 backgroundRotationVersion += 1
cancelNextBackgroundPreload()
removeBackgroundTimer('background-rotation') removeBackgroundTimer('background-rotation')
settlePendingOpticalWallpaper(false) cancelOpticalWallpaperTransaction()
} }
function clearBackgroundRotationGrace() { function clearBackgroundRotationGrace() {
@@ -684,7 +773,7 @@ function startBackgroundRotation() {
stopBackgroundRotation() stopBackgroundRotation()
if (allowsBackgroundRotation.value && backgroundImages.value.length > 1) { if (allowsBackgroundRotation.value && backgroundImages.value.length > 1) {
preloadNextBackgroundImage() scheduleNextBackgroundPreload()
// 隐藏页面也允许在有界宽限期内轮换,回调自身会再次核对生命周期。 // 隐藏页面也允许在有界宽限期内轮换,回调自身会再次核对生命周期。
addBackgroundTimer( addBackgroundTimer(
'background-rotation', 'background-rotation',
@@ -784,11 +873,15 @@ async function animateAndRemoveLoader() {
document.documentElement.style.removeProperty('overflow') document.documentElement.style.removeProperty('overflow')
document.body.style.removeProperty('overflow') document.body.style.removeProperty('overflow')
completeLaunchLoading() completeLaunchLoading()
recordGlassLaunchTiming('loader-removed')
scheduleNextBackgroundPreload()
resolve() resolve()
}, LAUNCH_EXIT_DURATION_MS) }, LAUNCH_EXIT_DURATION_MS)
}) })
} else { } else {
completeLaunchLoading() completeLaunchLoading()
recordGlassLaunchTiming('loader-removed')
scheduleNextBackgroundPreload()
} }
} }
@@ -860,6 +953,7 @@ async function loadBackgroundImages(loadVersion: number, retryCount = 0) {
} }
backgroundRecoveryAttemptedVersion = -1 backgroundRecoveryAttemptedVersion = -1
resetBackgroundCrossfade() resetBackgroundCrossfade()
recordGlassLaunchTiming('wallpaper-committed', activeBackgroundImage.value)
startBackgroundRotation() startBackgroundRotation()
} catch (error: any) { } catch (error: any) {
if (loadVersion !== backgroundLoadVersion) return if (loadVersion !== backgroundLoadVersion) return
@@ -877,6 +971,28 @@ async function loadBackgroundImages(loadVersion: number, retryCount = 0) {
} }
} }
// 登录前后复用同一壁纸列表和活动项;请求与主题样式并行,避免玻璃 CSS 阻塞首张壁纸准备。
watch(
shouldLoadBackgroundImages,
shouldLoad => {
stopBackgroundLoading()
if (shouldLoad) {
void loadBackgroundImages(backgroundLoadVersion)
} else if (!isBackdropTheme.value) {
backgroundImages.value = []
}
},
{ immediate: true },
)
watch(isGlassTheme, enabled => {
if (!enabled) return
void Promise.all(
renderedBackgroundLayers.value.filter(layer => layer.url).map(layer => preloadBackgroundCandidate(layer.url)),
)
})
onMounted(async () => { onMounted(async () => {
// 移除URL中的时间戳参数 // 移除URL中的时间戳参数
const url = new URL(window.location.href) const url = new URL(window.location.href)
@@ -915,28 +1031,6 @@ onMounted(async () => {
window.addEventListener('focus', handlePageShowThemeSync) window.addEventListener('focus', handlePageShowThemeSync)
window.addEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged) window.addEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged)
// 登录前后复用同一壁纸列表和活动项,主题变化只改变呈现方式。
watch(
shouldLoadBackgroundImages,
shouldLoad => {
stopBackgroundLoading()
if (shouldLoad) {
void loadBackgroundImages(backgroundLoadVersion)
} else if (!isBackdropTheme.value) {
backgroundImages.value = []
}
},
{ immediate: true },
)
watch(isGlassTheme, enabled => {
if (!enabled) return
void Promise.all(
renderedBackgroundLayers.value.filter(layer => layer.url).map(layer => ensureBackgroundToneProfile(layer.url)),
)
})
// 使用优化后的加载界面移除逻辑 // 使用优化后的加载界面移除逻辑
ensureRenderComplete(() => { ensureRenderComplete(() => {
nextTick(removeLoadingWithStateCheck) nextTick(removeLoadingWithStateCheck)
@@ -1031,6 +1125,11 @@ onUnmounted(() => {
:wallpaper-url="activeOpticalBackgroundImage" :wallpaper-url="activeOpticalBackgroundImage"
:previous-wallpaper-url="previousOpticalBackgroundImage" :previous-wallpaper-url="previousOpticalBackgroundImage"
:pending-wallpaper-url="pendingOpticalBackgroundImage" :pending-wallpaper-url="pendingOpticalBackgroundImage"
:pending-wallpaper-revision="pendingOpticalWallpaperRevision"
:activate-wallpaper-revision="activateOpticalWallpaperRevision"
@wallpaper-activation-failed="handleOpticalWallpaperActivationFailed"
@wallpaper-activated="handleOpticalWallpaperActivated"
@wallpaper-preparation-failed="handleOpticalWallpaperPreparationFailed"
@wallpaper-prepared="handleOpticalWallpaperPrepared" @wallpaper-prepared="handleOpticalWallpaperPrepared"
/> />
<!-- 页面内容 --> <!-- 页面内容 -->
+89 -21
View File
@@ -11,10 +11,15 @@ import {
GLASS_OPTICAL_STRENGTH_MAX, GLASS_OPTICAL_STRENGTH_MAX,
GLASS_OPTICAL_STRENGTH_MIN, GLASS_OPTICAL_STRENGTH_MIN,
getAvailableGlassOpticalPresets, getAvailableGlassOpticalPresets,
getGlassOpticalPresetKey,
getGlassOpticalPresetParameters, getGlassOpticalPresetParameters,
getGlassOpticalPresetParametersWithOverrides,
normalizeGlassOpticalStrength, normalizeGlassOpticalStrength,
type GlassOpticalParameters,
type GlassOpticalPreset, type GlassOpticalPreset,
type GlassOpticalPresetOverrides,
} from '@/utils/glassOptics' } from '@/utils/glassOptics'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const props = withDefaults( const props = withDefaults(
@@ -32,11 +37,13 @@ const emit = defineEmits<{
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const display = useDisplay()
const { settings } = useThemeCustomizer() const { settings } = useThemeCustomizer()
const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance) const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
const draftDeformationStrength = ref(settings.value.glassDeformationStrength) const draftDeformationStrength = ref(settings.value.glassDeformationStrength)
const draftFlowStrength = ref(settings.value.glassFlowStrength) const draftFlowStrength = ref(settings.value.glassFlowStrength)
const draftPreset = ref<GlassOpticalPreset>(settings.value.glassPreset) const draftPreset = ref<GlassOpticalPreset>(settings.value.glassPreset)
const draftPresetOverrides = ref<GlassOpticalPresetOverrides>({ ...settings.value.glassPresetOverrides })
const draftQuality = ref<ThemeCustomizerGlassQuality>(settings.value.glassQuality) const draftQuality = ref<ThemeCustomizerGlassQuality>(settings.value.glassQuality)
const draftReflectionStrength = ref(settings.value.glassReflectionStrength) const draftReflectionStrength = ref(settings.value.glassReflectionStrength)
const draftTransmissionStrength = ref(settings.value.glassTransmissionStrength) const draftTransmissionStrength = ref(settings.value.glassTransmissionStrength)
@@ -68,6 +75,7 @@ watch(
draftDeformationStrength.value = settings.value.glassDeformationStrength draftDeformationStrength.value = settings.value.glassDeformationStrength
draftFlowStrength.value = settings.value.glassFlowStrength draftFlowStrength.value = settings.value.glassFlowStrength
draftPreset.value = settings.value.glassPreset draftPreset.value = settings.value.glassPreset
draftPresetOverrides.value = { ...settings.value.glassPresetOverrides }
draftQuality.value = settings.value.glassQuality draftQuality.value = settings.value.glassQuality
draftReflectionStrength.value = settings.value.glassReflectionStrength draftReflectionStrength.value = settings.value.glassReflectionStrength
draftTransmissionStrength.value = settings.value.glassTransmissionStrength draftTransmissionStrength.value = settings.value.glassTransmissionStrength
@@ -112,7 +120,7 @@ function updateAppearance(value: unknown) {
if (value !== 'clear' && value !== 'tinted' && value !== 'frosted') return if (value !== 'clear' && value !== 'tinted' && value !== 'frosted') return
draftAppearance.value = value draftAppearance.value = value
previewGlassSettings({ glassAppearance: value }) applyPreset(activePreset.value)
} }
/** 仅允许面板声明的质量档位进入待保存设置。 */ /** 仅允许面板声明的质量档位进入待保存设置。 */
@@ -121,15 +129,18 @@ function updateQuality(value: unknown) {
if (!option) return if (!option) return
draftQuality.value = option.value draftQuality.value = option.value
previewGlassSettings({ glassQuality: option.value }) applyPreset(activePreset.value)
} }
/** 将六个具体参数作为一个预览事务同步,预置只负责生成这些值。 */ /** 将材质、质量、预设归属与六个具体参数作为一个预览事务同步。 */
function previewDraftParameters() { function previewDraftParameters() {
previewGlassSettings({ previewGlassSettings({
glassAppearance: draftAppearance.value,
glassDeformationStrength: draftDeformationStrength.value, glassDeformationStrength: draftDeformationStrength.value,
glassFlowStrength: draftFlowStrength.value, glassFlowStrength: draftFlowStrength.value,
glassPreset: draftPreset.value, glassPreset: draftPreset.value,
glassPresetOverrides: draftPresetOverrides.value,
glassQuality: draftQuality.value,
glassReflectionStrength: draftReflectionStrength.value, glassReflectionStrength: draftReflectionStrength.value,
glassTransmissionStrength: draftTransmissionStrength.value, glassTransmissionStrength: draftTransmissionStrength.value,
glassTranslationStrength: draftTranslationStrength.value, glassTranslationStrength: draftTranslationStrength.value,
@@ -137,13 +148,29 @@ function previewDraftParameters() {
}) })
} }
/** 应用当前材质与质量下的方案建议值,并将该方案作为后续重置目标。 */ /** 读取草稿中当前显示的六参数。 */
function getDraftParameters(): GlassOpticalParameters {
return {
deformation: draftDeformationStrength.value,
flow: draftFlowStrength.value,
reflection: draftReflectionStrength.value,
transmission: draftTransmissionStrength.value,
translation: draftTranslationStrength.value,
transparency: draftTransparencyStrength.value,
}
}
/** 切换方案时优先恢复该组合的草稿覆盖,没有覆盖才使用矩阵。 */
function applyPreset(value: unknown) { function applyPreset(value: unknown) {
if (value !== 'natural' && value !== 'glide' && value !== 'liquid') return if (value !== 'natural' && value !== 'glide' && value !== 'liquid') return
if (!availablePresets.value.includes(value)) return const effectivePreset = availablePresets.value.includes(value) ? value : 'natural'
const parameters = getGlassOpticalPresetParametersWithOverrides(
const parameters = getGlassOpticalPresetParameters(draftAppearance.value, draftQuality.value, value) draftAppearance.value,
draftPreset.value = value draftQuality.value,
effectivePreset,
draftPresetOverrides.value,
)
draftPreset.value = effectivePreset
draftDeformationStrength.value = parameters.deformation draftDeformationStrength.value = parameters.deformation
draftFlowStrength.value = parameters.flow draftFlowStrength.value = parameters.flow
draftReflectionStrength.value = parameters.reflection draftReflectionStrength.value = parameters.reflection
@@ -153,45 +180,66 @@ function applyPreset(value: unknown) {
previewDraftParameters() previewDraftParameters()
} }
/** 用调整后的当前六参数覆盖当前材质、质量与方案组合。 */
function updateDraftPresetOverride() {
const key = getGlassOpticalPresetKey(draftAppearance.value, draftQuality.value, draftPreset.value)
draftPresetOverrides.value = {
...draftPresetOverrides.value,
[key]: getDraftParameters(),
}
previewDraftParameters()
}
/** 将采样平移限制为 renderer 支持的稳定范围。 */ /** 将采样平移限制为 renderer 支持的稳定范围。 */
function updateTranslationStrength(value: unknown) { function updateTranslationStrength(value: unknown) {
draftTranslationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value) draftTranslationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassTranslationStrength: draftTranslationStrength.value }) updateDraftPresetOverride()
} }
/** 将局部形变限制为质量档软上限所消费的用户范围。 */ /** 将局部形变限制为质量档软上限所消费的用户范围。 */
function updateDeformationStrength(value: unknown) { function updateDeformationStrength(value: unknown) {
draftDeformationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value) draftDeformationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassDeformationStrength: draftDeformationStrength.value }) updateDraftPresetOverride()
} }
/** 将尾波、惯性与收敛输入限制为 renderer 的稳定范围。 */ /** 将尾波、惯性与收敛输入限制为 renderer 的稳定范围。 */
function updateFlowStrength(value: unknown) { function updateFlowStrength(value: unknown) {
draftFlowStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value) draftFlowStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassFlowStrength: draftFlowStrength.value }) updateDraftPresetOverride()
} }
/** 将滑杆输入限制为 renderer 的稳定范围并即时预览反射亮度。 */ /** 将滑杆输入限制为 renderer 的稳定范围并即时预览反射亮度。 */
function updateReflectionStrength(value: unknown) { function updateReflectionStrength(value: unknown) {
draftReflectionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value) draftReflectionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassReflectionStrength: draftReflectionStrength.value }) updateDraftPresetOverride()
} }
/** 将透射亮度限制为稳定范围并即时调整卡片内部壁纸的明暗。 */ /** 将透射亮度限制为稳定范围并即时调整卡片内部壁纸的明暗。 */
function updateTransmissionStrength(value: unknown) { function updateTransmissionStrength(value: unknown) {
draftTransmissionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value) draftTransmissionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassTransmissionStrength: draftTransmissionStrength.value }) updateDraftPresetOverride()
} }
/** 将通透度限制为稳定范围并即时调整材质与真实壁纸的占比。 */ /** 将通透度限制为稳定范围并即时调整材质与真实壁纸的占比。 */
function updateTransparencyStrength(value: unknown) { function updateTransparencyStrength(value: unknown) {
draftTransparencyStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value) draftTransparencyStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassTransparencyStrength: draftTransparencyStrength.value }) updateDraftPresetOverride()
} }
/** 保留当前材质与质量,将参数恢复为当前高亮方案的建议值。 */ /** 删除当前组合覆盖并恢复该方案矩阵,不影响其他组合。 */
function resetSettings() { function resetSettings() {
applyPreset(activePreset.value) const key = getGlassOpticalPresetKey(draftAppearance.value, draftQuality.value, draftPreset.value)
const nextOverrides = { ...draftPresetOverrides.value }
delete nextOverrides[key]
draftPresetOverrides.value = nextOverrides
const parameters = getGlassOpticalPresetParameters(draftAppearance.value, draftQuality.value, draftPreset.value)
draftDeformationStrength.value = parameters.deformation
draftFlowStrength.value = parameters.flow
draftReflectionStrength.value = parameters.reflection
draftTransmissionStrength.value = parameters.transmission
draftTranslationStrength.value = parameters.translation
draftTransparencyStrength.value = parameters.transparency
previewDraftParameters()
} }
/** 一次提交当前预览,持久化后关闭不会发生视觉回跳。 */ /** 一次提交当前预览,持久化后关闭不会发生视觉回跳。 */
@@ -206,6 +254,7 @@ async function saveSettings() {
glassDeformationStrength: draftDeformationStrength.value, glassDeformationStrength: draftDeformationStrength.value,
glassFlowStrength: draftFlowStrength.value, glassFlowStrength: draftFlowStrength.value,
glassPreset: draftPreset.value, glassPreset: draftPreset.value,
glassPresetOverrides: draftPresetOverrides.value,
glassQuality: draftQuality.value, glassQuality: draftQuality.value,
glassReflectionStrength: draftReflectionStrength.value, glassReflectionStrength: draftReflectionStrength.value,
glassTransmissionStrength: draftTransmissionStrength.value, glassTransmissionStrength: draftTransmissionStrength.value,
@@ -224,7 +273,14 @@ onScopeDispose(cancelGlassPreview)
</script> </script>
<template> <template>
<VDialog v-if="visible" v-model="visible" width="100%" max-width="30rem" scrollable> <VDialog
v-if="visible"
v-model="visible"
width="100%"
max-width="30rem"
scrollable
:fullscreen="display.smAndDown.value"
>
<VCard> <VCard>
<VCardItem> <VCardItem>
<VCardTitle> <VCardTitle>
@@ -415,14 +471,21 @@ onScopeDispose(cancelGlassPreview)
</VCardText> </VCardText>
<VDivider /> <VDivider />
<VCardText class="text-center"> <VCardActions class="glass-settings-dialog__actions justify-center">
<VBtn variant="outlined" prepend-icon="mdi-refresh" class="me-2" @click="resetSettings"> <VBtn :slim="false" variant="outlined" prepend-icon="mdi-refresh" class="me-2" @click="resetSettings">
{{ t('common.reset') }} {{ t('common.reset') }}
</VBtn> </VBtn>
<VBtn color="primary" prepend-icon="mdi-content-save" :loading="isSaving" @click="saveSettings"> <VBtn
:slim="false"
color="primary"
variant="elevated"
prepend-icon="mdi-content-save"
:loading="isSaving"
@click="saveSettings"
>
{{ t('common.save') }} {{ t('common.save') }}
</VBtn> </VBtn>
</VCardText> </VCardActions>
</VCard> </VCard>
</VDialog> </VDialog>
</template> </template>
@@ -436,6 +499,11 @@ onScopeDispose(cancelGlassPreview)
padding: 20px 24px 24px; padding: 20px 24px 24px;
} }
.glass-settings-dialog__actions {
flex: none;
padding: 16px 24px;
}
.glass-settings-dialog__label { .glass-settings-dialog__label {
margin: 0 0 10px; margin: 0 0 10px;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)); color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
+2 -7
View File
@@ -173,12 +173,7 @@ onBeforeMount(async () => {
<template> <template>
<VDialog scrollable :max-width="dialogMaxWidth" :fullscreen="!display.mdAndUp.value"> <VDialog scrollable :max-width="dialogMaxWidth" :fullscreen="!display.mdAndUp.value">
<!-- Vuetify 渲染模式 --> <!-- Vuetify 渲染模式 -->
<VCard <VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`">
v-if="renderMode === 'vuetify'"
:title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`"
data-glass-optical-surface
data-glass-optical-mode="static-material"
>
<VDialogCloseBtn @click="emit('close')" /> <VDialogCloseBtn @click="emit('close')" />
<VDivider /> <VDivider />
<LoadingBanner v-if="!isRefreshed" class="mt-5" /> <LoadingBanner v-if="!isRefreshed" class="mt-5" />
@@ -213,7 +208,7 @@ onBeforeMount(async () => {
</VCardActions> </VCardActions>
</VCard> </VCard>
<!-- Vue 渲染模式 --> <!-- Vue 渲染模式 -->
<VCard v-else-if="renderMode === 'vue'" data-glass-optical-surface data-glass-optical-mode="static-material"> <VCard v-else-if="renderMode === 'vue'">
<VCardText class="pa-0"> <VCardText class="pa-0">
<component <component
:is="dynamicComponent" :is="dynamicComponent"
+2 -7
View File
@@ -136,12 +136,7 @@ onMounted(() => {
<template> <template>
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value"> <VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<!-- Vuetify 渲染模式 --> <!-- Vuetify 渲染模式 -->
<VCard <VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name}`">
v-if="renderMode === 'vuetify'"
:title="`${props.plugin?.plugin_name}`"
data-glass-optical-surface
data-glass-optical-mode="static-material"
>
<VDialogCloseBtn @click="emit('close')" /> <VDialogCloseBtn @click="emit('close')" />
<LoadingBanner v-if="!isRefreshed" class="mt-5" /> <LoadingBanner v-if="!isRefreshed" class="mt-5" />
<VCardText v-else class="min-h-40"> <VCardText v-else class="min-h-40">
@@ -163,7 +158,7 @@ onMounted(() => {
/> />
</VCard> </VCard>
<!-- Vue 渲染模式 --> <!-- Vue 渲染模式 -->
<VCard v-else-if="renderMode === 'vue'" data-glass-optical-surface data-glass-optical-mode="static-material"> <VCard v-else-if="renderMode === 'vue'">
<VCardText class="pa-0"> <VCardText class="pa-0">
<component <component
:is="dynamicComponent" :is="dynamicComponent"
@@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import GlassSettingsDialog from '@/components/dialog/GlassSettingsDialog.vue' import GlassSettingsDialog from '@/components/dialog/GlassSettingsDialog.vue'
const slotStub = { template: '<div><slot /></div>' } const slotStub = { template: '<div><slot /></div>' }
const dialogStub = {
props: ['fullscreen'],
template: '<div class="dialog-stub" :data-fullscreen="String(fullscreen)"><slot /></div>',
}
const toggleStub = { const toggleStub = {
props: ['modelValue'], props: ['modelValue'],
template: '<div :data-model-value="modelValue"><slot /></div>', template: '<div :data-model-value="modelValue"><slot /></div>',
@@ -25,6 +29,7 @@ const mocks = vi.hoisted(() => ({
glassDeformationStrength: 50, glassDeformationStrength: 50,
glassFlowStrength: 50, glassFlowStrength: 50,
glassPreset: 'natural', glassPreset: 'natural',
glassPresetOverrides: {},
glassQuality: 'css', glassQuality: 'css',
glassReflectionStrength: 50, glassReflectionStrength: 50,
glassTransmissionStrength: 50, glassTransmissionStrength: 50,
@@ -47,6 +52,10 @@ vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }), useI18n: () => ({ t: (key: string) => key }),
})) }))
vi.mock('vuetify', () => ({
useDisplay: () => ({ smAndDown: { value: true } }),
}))
describe('GlassSettingsDialog', () => { describe('GlassSettingsDialog', () => {
beforeEach(() => { beforeEach(() => {
mocks.cancelGlassPreview.mockClear() mocks.cancelGlassPreview.mockClear()
@@ -56,6 +65,7 @@ describe('GlassSettingsDialog', () => {
mocks.settings.value.glassDeformationStrength = 50 mocks.settings.value.glassDeformationStrength = 50
mocks.settings.value.glassFlowStrength = 50 mocks.settings.value.glassFlowStrength = 50
mocks.settings.value.glassPreset = 'natural' mocks.settings.value.glassPreset = 'natural'
mocks.settings.value.glassPresetOverrides = {}
mocks.settings.value.glassQuality = 'css' mocks.settings.value.glassQuality = 'css'
mocks.settings.value.glassReflectionStrength = 50 mocks.settings.value.glassReflectionStrength = 50
mocks.settings.value.glassTransmissionStrength = 50 mocks.settings.value.glassTransmissionStrength = 50
@@ -68,8 +78,9 @@ describe('GlassSettingsDialog', () => {
global: { global: {
stubs: { stubs: {
VCard: slotStub, VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub, VCardText: slotStub,
VDialog: slotStub, VDialog: dialogStub,
VDialogCloseBtn: true, VDialogCloseBtn: true,
VSlider: sliderStub, VSlider: sliderStub,
}, },
@@ -81,6 +92,8 @@ describe('GlassSettingsDialog', () => {
expect(sliders).toHaveLength(3) expect(sliders).toHaveLength(3)
expect(sliders[0].attributes('data-disabled')).toBe('undefined') expect(sliders[0].attributes('data-disabled')).toBe('undefined')
expect(sliders[1].attributes('data-disabled')).toBe('undefined') expect(sliders[1].attributes('data-disabled')).toBe('undefined')
expect(wrapper.find('.dialog-stub').attributes('data-fullscreen')).toBe('true')
expect(wrapper.find('.glass-settings-dialog__actions').classes()).toContain('justify-center')
await wrapper.setProps({ modelValue: false }) await wrapper.setProps({ modelValue: false })
expect(mocks.cancelGlassPreview).toHaveBeenCalledOnce() expect(mocks.cancelGlassPreview).toHaveBeenCalledOnce()
@@ -96,17 +109,37 @@ describe('GlassSettingsDialog', () => {
mocks.settings.value.glassTransmissionStrength = 57 mocks.settings.value.glassTransmissionStrength = 57
mocks.settings.value.glassTranslationStrength = 57 mocks.settings.value.glassTranslationStrength = 57
mocks.settings.value.glassTransparencyStrength = 55 mocks.settings.value.glassTransparencyStrength = 55
mocks.settings.value.glassPresetOverrides = {
'clear:balanced:glide': {
deformation: 24,
flow: 35,
reflection: 46,
transmission: 57,
translation: 68,
transparency: 79,
},
'frosted:high:liquid': {
deformation: 65,
flow: 61,
reflection: 58,
transmission: 57,
translation: 57,
transparency: 55,
},
}
const wrapper = shallowMount(GlassSettingsDialog, { const wrapper = shallowMount(GlassSettingsDialog, {
global: { global: {
stubs: { stubs: {
VCard: slotStub, VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub, VCardText: slotStub,
VBtn: { VBtn: {
emits: ['click'], emits: ['click'],
props: ['prependIcon'], props: ['prependIcon', 'slim', 'variant'],
template: '<button :data-icon="prependIcon" @click="$emit(\'click\')"><slot /></button>', template:
'<button :data-icon="prependIcon" :data-slim="String(slim)" :data-variant="variant" @click="$emit(\'click\')"><slot /></button>',
}, },
VDialog: slotStub, VDialog: dialogStub,
VDialogCloseBtn: true, VDialogCloseBtn: true,
VDivider: true, VDivider: true,
VSlider: sliderStub, VSlider: sliderStub,
@@ -115,17 +148,33 @@ describe('GlassSettingsDialog', () => {
props: { modelValue: true }, props: { modelValue: true },
}) })
const resetButton = wrapper.find('[data-icon="mdi-refresh"]') const resetButton = wrapper.find('[data-icon="mdi-refresh"]')
const saveButton = wrapper.find('[data-icon="mdi-content-save"]')
expect(resetButton.attributes('data-slim')).toBe('false')
expect(saveButton.attributes('data-slim')).toBe('false')
expect(saveButton.attributes('data-variant')).toBe('elevated')
await resetButton.trigger('click') await resetButton.trigger('click')
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({ expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
glassDeformationStrength: 82, glassAppearance: 'frosted',
glassFlowStrength: 80, glassDeformationStrength: 66,
glassFlowStrength: 64,
glassPreset: 'liquid', glassPreset: 'liquid',
glassPresetOverrides: {
'clear:balanced:glide': {
deformation: 24,
flow: 35,
reflection: 46,
transmission: 57,
translation: 68,
transparency: 79,
},
},
glassQuality: 'high',
glassReflectionStrength: 31, glassReflectionStrength: 31,
glassTransmissionStrength: 58, glassTransmissionStrength: 47,
glassTranslationStrength: 54, glassTranslationStrength: 43,
glassTransparencyStrength: 46, glassTransparencyStrength: 32,
}) })
expect(mocks.commitGlassPreview).not.toHaveBeenCalled() expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
}) })
@@ -135,8 +184,9 @@ describe('GlassSettingsDialog', () => {
global: { global: {
stubs: { stubs: {
VCard: slotStub, VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub, VCardText: slotStub,
VDialog: slotStub, VDialog: dialogStub,
VDialogCloseBtn: true, VDialogCloseBtn: true,
VSlider: sliderStub, VSlider: sliderStub,
}, },
@@ -149,7 +199,27 @@ describe('GlassSettingsDialog', () => {
expect(sliders[2].attributes('data-disabled')).toBe('undefined') expect(sliders[2].attributes('data-disabled')).toBe('undefined')
await sliders[2].setValue('86') await sliders[2].setValue('86')
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({ glassReflectionStrength: 86 }) expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
glassAppearance: 'clear',
glassDeformationStrength: 50,
glassFlowStrength: 50,
glassPreset: 'natural',
glassPresetOverrides: {
'clear:css:natural': {
deformation: 50,
flow: 50,
reflection: 86,
transmission: 50,
translation: 50,
transparency: 50,
},
},
glassQuality: 'css',
glassReflectionStrength: 86,
glassTransmissionStrength: 50,
glassTranslationStrength: 50,
glassTransparencyStrength: 50,
})
}) })
it('hides preset choices in standard quality', () => { it('hides preset choices in standard quality', () => {
@@ -157,8 +227,9 @@ describe('GlassSettingsDialog', () => {
global: { global: {
stubs: { stubs: {
VCard: slotStub, VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub, VCardText: slotStub,
VDialog: slotStub, VDialog: dialogStub,
VDialogCloseBtn: true, VDialogCloseBtn: true,
VSlider: sliderStub, VSlider: sliderStub,
}, },
@@ -170,7 +241,7 @@ describe('GlassSettingsDialog', () => {
expect(wrapper.find('.glass-settings-dialog__preset-state').exists()).toBe(false) expect(wrapper.find('.glass-settings-dialog__preset-state').exists()).toBe(false)
}) })
it('keeps the selected preset highlighted after slider adjustments', async () => { it('keeps the selected preset highlighted and records its combination override', async () => {
mocks.settings.value.glassQuality = 'balanced' mocks.settings.value.glassQuality = 'balanced'
mocks.settings.value.glassPreset = 'glide' mocks.settings.value.glassPreset = 'glide'
mocks.settings.value.glassTransparencyStrength = 61 mocks.settings.value.glassTransparencyStrength = 61
@@ -178,10 +249,11 @@ describe('GlassSettingsDialog', () => {
global: { global: {
stubs: { stubs: {
VCard: slotStub, VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub, VCardText: slotStub,
VBtn: slotStub, VBtn: slotStub,
VBtnToggle: toggleStub, VBtnToggle: toggleStub,
VDialog: slotStub, VDialog: dialogStub,
VDialogCloseBtn: true, VDialogCloseBtn: true,
VSlider: sliderStub, VSlider: sliderStub,
}, },
@@ -199,6 +271,21 @@ describe('GlassSettingsDialog', () => {
await sliders[0].setValue('77') await sliders[0].setValue('77')
expect(preset.attributes('data-model-value')).toBe('glide') expect(preset.attributes('data-model-value')).toBe('glide')
expect(mocks.previewGlassSettings).toHaveBeenLastCalledWith(
expect.objectContaining({
glassPreset: 'glide',
glassPresetOverrides: {
'clear:balanced:glide': {
deformation: 50,
flow: 50,
reflection: 50,
transmission: 50,
translation: 50,
transparency: 77,
},
},
}),
)
}) })
it('normalizes and previews all six independent slider values', async () => { it('normalizes and previews all six independent slider values', async () => {
@@ -207,8 +294,9 @@ describe('GlassSettingsDialog', () => {
global: { global: {
stubs: { stubs: {
VCard: slotStub, VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub, VCardText: slotStub,
VDialog: slotStub, VDialog: dialogStub,
VDialogCloseBtn: true, VDialogCloseBtn: true,
VSlider: sliderStub, VSlider: sliderStub,
}, },
@@ -227,12 +315,28 @@ describe('GlassSettingsDialog', () => {
await sliders[4].setValue('68.7') await sliders[4].setValue('68.7')
await sliders[5].setValue('62.2') await sliders[5].setValue('62.2')
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(1, { glassTransparencyStrength: 91 }) expect(mocks.previewGlassSettings).toHaveBeenCalledTimes(6)
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(2, { glassTransmissionStrength: 78 }) expect(mocks.previewGlassSettings).toHaveBeenLastCalledWith({
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(3, { glassReflectionStrength: 74 }) glassAppearance: 'clear',
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(4, { glassTranslationStrength: 84 }) glassDeformationStrength: 69,
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(5, { glassDeformationStrength: 69 }) glassFlowStrength: 62,
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(6, { glassFlowStrength: 62 }) glassPreset: 'natural',
glassPresetOverrides: {
'clear:balanced:natural': {
deformation: 69,
flow: 62,
reflection: 74,
transmission: 78,
translation: 84,
transparency: 91,
},
},
glassQuality: 'balanced',
glassReflectionStrength: 74,
glassTransmissionStrength: 78,
glassTranslationStrength: 84,
glassTransparencyStrength: 91,
})
expect(mocks.commitGlassPreview).not.toHaveBeenCalled() expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
}) })
@@ -242,8 +346,9 @@ describe('GlassSettingsDialog', () => {
global: { global: {
stubs: { stubs: {
VCard: slotStub, VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub, VCardText: slotStub,
VDialog: slotStub, VDialog: dialogStub,
VDialogCloseBtn: true, VDialogCloseBtn: true,
VSlider: sliderStub, VSlider: sliderStub,
}, },
+172 -7
View File
@@ -2,6 +2,8 @@
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer' import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion' import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
import { import {
createGlassWallpaperSourceCache,
getGlassWallpaperPreparationKey,
setGlassRendererState, setGlassRendererState,
useGlassOpticalInteractionSource, useGlassOpticalInteractionSource,
useGlassOpticalRenderer, useGlassOpticalRenderer,
@@ -39,16 +41,40 @@ const props = defineProps<{
previousWallpaperUrl: string previousWallpaperUrl: string
/** 下一张同源壁纸;两个 context 均完成上传后才允许外层提交切换。 */ /** 下一张同源壁纸;两个 context 均完成上传后才允许外层提交切换。 */
pendingWallpaperUrl?: string pendingWallpaperUrl?: string
/** 单调递增的壁纸准备事务版本;相同 URL 的旧回执不得完成新事务。 */
pendingWallpaperRevision?: number
/** 父层已完成可见图片预载,允许两个 context 在同一绘制帧提交该 revision。 */
activateWallpaperRevision?: number
}>() }>()
const timingWindow = window as typeof window & {
__glassPerformanceProbeEnabled?: boolean
__glassLaunchTimings?: Array<{ detail?: string; stage: string; time: number }>
}
if (import.meta.env.DEV && timingWindow.__glassPerformanceProbeEnabled) {
timingWindow.__glassLaunchTimings ??= []
timingWindow.__glassLaunchTimings.push({
stage: 'optical-layer-setup',
time: performance.now(),
})
}
const emit = defineEmits<{ const emit = defineEmits<{
/** fixed 与 scroll renderer 均已准备同一张待切换纹理。 */ /** fixed 与 scroll renderer 均已准备同一张待切换纹理。 */
wallpaperPrepared: [url: string] wallpaperPrepared: [url: string, revision: number]
/** 任一 context 无法准备当前 revision;父层必须立即取消整笔事务。 */
wallpaperPreparationFailed: [url: string, revision: number]
/** fixed 与 scroll renderer 均已消费 prepared 纹理并切到活动槽。 */
wallpaperActivated: [url: string, revision: number, startedAt: number]
/** 任一 context 提交失败;父层必须取消该 revision。 */
wallpaperActivationFailed: [url: string, revision: number]
}>() }>()
const fixedCanvas = ref<HTMLCanvasElement | null>(null) const fixedCanvas = ref<HTMLCanvasElement | null>(null)
const scrollCanvas = ref<HTMLCanvasElement | null>(null) const scrollCanvas = ref<HTMLCanvasElement | null>(null)
const interactionSource = useGlassOpticalInteractionSource() const interactionSource = useGlassOpticalInteractionSource()
const pagePresentationMotion = usePagePresentationMotion() const pagePresentationMotion = usePagePresentationMotion()
const wallpaperSourceCache = createGlassWallpaperSourceCache()
const fixedRenderer = useGlassOpticalRenderer({ const fixedRenderer = useGlassOpticalRenderer({
active: true, active: true,
appearance: () => props.appearance, appearance: () => props.appearance,
@@ -66,8 +92,10 @@ const fixedRenderer = useGlassOpticalRenderer({
transitionDuration: () => props.transitionDuration, transitionDuration: () => props.transitionDuration,
transitionStartedAt: () => props.transitionStartedAt, transitionStartedAt: () => props.transitionStartedAt,
wallpaperUrl: () => props.wallpaperUrl, wallpaperUrl: () => props.wallpaperUrl,
wallpaperSourceCache,
previousWallpaperUrl: () => props.previousWallpaperUrl, previousWallpaperUrl: () => props.previousWallpaperUrl,
pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '', pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '',
pendingWallpaperRevision: () => props.pendingWallpaperRevision ?? 0,
surfaceSpace: 'fixed', surfaceSpace: 'fixed',
syncDocumentState: false, syncDocumentState: false,
}) })
@@ -89,8 +117,10 @@ const scrollRenderer = useGlassOpticalRenderer({
transitionDuration: () => props.transitionDuration, transitionDuration: () => props.transitionDuration,
transitionStartedAt: () => props.transitionStartedAt, transitionStartedAt: () => props.transitionStartedAt,
wallpaperUrl: () => props.wallpaperUrl, wallpaperUrl: () => props.wallpaperUrl,
wallpaperSourceCache,
previousWallpaperUrl: () => props.previousWallpaperUrl, previousWallpaperUrl: () => props.previousWallpaperUrl,
pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '', pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '',
pendingWallpaperRevision: () => props.pendingWallpaperRevision ?? 0,
surfaceSpace: 'scroll', surfaceSpace: 'scroll',
syncDocumentState: false, syncDocumentState: false,
}) })
@@ -107,16 +137,151 @@ watchEffect(() => {
: 'fallback' : 'fallback'
setGlassRendererState(rendererState, state) setGlassRendererState(rendererState, state)
}) if (import.meta.env.DEV && timingWindow.__glassPerformanceProbeEnabled) {
timingWindow.__glassLaunchTimings?.push({
watchEffect(() => { detail: state,
const url = props.pendingWallpaperUrl stage: 'optical-layer-state',
if (url && fixedRenderer.preparedWallpaperUrl.value === url && scrollRenderer.preparedWallpaperUrl.value === url) { time: performance.now(),
emit('wallpaperPrepared', url) })
} }
}) })
let lastPreparedAcknowledgement = ''
watchEffect(() => {
const url = props.pendingWallpaperUrl
const revision = props.pendingWallpaperRevision ?? 0
const preparationKey = getGlassWallpaperPreparationKey(props.appearance, props.quality, props.routeKey, url ?? '')
const acknowledgement = `${revision}:${preparationKey}:${url}`
const prepared =
Boolean(url) &&
revision > 0 &&
fixedRenderer.state.value === 'ready' &&
scrollRenderer.state.value === 'ready' &&
fixedRenderer.preparedWallpaperUrl.value === url &&
fixedRenderer.preparedWallpaperRevision.value === revision &&
fixedRenderer.preparedWallpaperPreparationKey.value === preparationKey &&
scrollRenderer.preparedWallpaperUrl.value === url &&
scrollRenderer.preparedWallpaperRevision.value === revision &&
scrollRenderer.preparedWallpaperPreparationKey.value === preparationKey
if (prepared && acknowledgement !== lastPreparedAcknowledgement) {
lastPreparedAcknowledgement = acknowledgement
emit('wallpaperPrepared', url, revision)
}
})
let lastPreparationFailedAcknowledgement = ''
watchEffect(() => {
const url = props.pendingWallpaperUrl ?? ''
const revision = props.pendingWallpaperRevision ?? 0
const preparationKey = getGlassWallpaperPreparationKey(props.appearance, props.quality, props.routeKey, url)
const acknowledgement = `${revision}:${preparationKey}:${url}`
const failed = [fixedRenderer, scrollRenderer].some(
renderer =>
renderer.failedWallpaperUrl.value === url &&
renderer.failedWallpaperRevision.value === revision &&
renderer.failedWallpaperPreparationKey.value === preparationKey,
)
if (url && revision > 0 && failed && acknowledgement !== lastPreparationFailedAcknowledgement) {
lastPreparationFailedAcknowledgement = acknowledgement
emit('wallpaperPreparationFailed', url, revision)
}
})
let activationFrame: number | null = null
let scheduledActivation = ''
let lastActivatedAcknowledgement = ''
let lastFailedAcknowledgement = ''
function rollbackWallpaperActivation(url: string, revision: number) {
for (const renderer of [fixedRenderer, scrollRenderer]) {
try {
renderer.rollbackPreparedWallpaperActivation(url, revision)
} catch {
// 两个 context 独立回滚;一个异常不得阻止另一个恢复并通知父层取消。
}
}
}
watchEffect(() => {
const url = props.pendingWallpaperUrl
const revision = props.pendingWallpaperRevision ?? 0
const activationRevision = props.activateWallpaperRevision ?? 0
const preparationKey = getGlassWallpaperPreparationKey(props.appearance, props.quality, props.routeKey, url ?? '')
const acknowledgement = `${revision}:${preparationKey}:${url}`
const canActivate =
Boolean(url) &&
revision > 0 &&
activationRevision === revision &&
fixedRenderer.state.value === 'ready' &&
scrollRenderer.state.value === 'ready' &&
fixedRenderer.preparedWallpaperUrl.value === url &&
fixedRenderer.preparedWallpaperRevision.value === revision &&
fixedRenderer.preparedWallpaperPreparationKey.value === preparationKey &&
scrollRenderer.preparedWallpaperUrl.value === url &&
scrollRenderer.preparedWallpaperRevision.value === revision &&
scrollRenderer.preparedWallpaperPreparationKey.value === preparationKey
if (
!canActivate ||
acknowledgement === lastActivatedAcknowledgement ||
acknowledgement === lastFailedAcknowledgement ||
acknowledgement === scheduledActivation
) {
return
}
if (activationFrame !== null) cancelAnimationFrame(activationFrame)
scheduledActivation = acknowledgement
activationFrame = requestAnimationFrame(startedAt => {
activationFrame = null
scheduledActivation = ''
const currentUrl = props.pendingWallpaperUrl ?? ''
const currentRevision = props.pendingWallpaperRevision ?? 0
const currentPreparationKey = getGlassWallpaperPreparationKey(
props.appearance,
props.quality,
props.routeKey,
currentUrl,
)
const currentAcknowledgement = `${currentRevision}:${currentPreparationKey}:${currentUrl}`
if (
currentAcknowledgement !== acknowledgement ||
props.activateWallpaperRevision !== currentRevision ||
!fixedRenderer.canActivatePreparedWallpaper(currentUrl, currentRevision, currentPreparationKey) ||
!scrollRenderer.canActivatePreparedWallpaper(currentUrl, currentRevision, currentPreparationKey)
) {
return
}
try {
const fixedActivated = fixedRenderer.activatePreparedWallpaper(
currentUrl,
currentRevision,
currentPreparationKey,
startedAt,
)
const scrollActivated =
fixedActivated &&
scrollRenderer.activatePreparedWallpaper(currentUrl, currentRevision, currentPreparationKey, startedAt)
if (!fixedActivated || !scrollActivated) {
rollbackWallpaperActivation(currentUrl, currentRevision)
lastFailedAcknowledgement = acknowledgement
emit('wallpaperActivationFailed', currentUrl, currentRevision)
return
}
} catch {
rollbackWallpaperActivation(currentUrl, currentRevision)
lastFailedAcknowledgement = acknowledgement
emit('wallpaperActivationFailed', currentUrl, currentRevision)
return
}
lastActivatedAcknowledgement = acknowledgement
emit('wallpaperActivated', currentUrl, currentRevision, startedAt)
})
})
onScopeDispose(() => { onScopeDispose(() => {
if (activationFrame !== null) cancelAnimationFrame(activationFrame)
setGlassRendererState(rendererState, 'fallback') setGlassRendererState(rendererState, 'fallback')
}) })
</script> </script>
@@ -1,10 +1,30 @@
import { shallowMount } from '@vue/test-utils' import { shallowMount } from '@vue/test-utils'
import { ref } from 'vue' import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import GlassOpticalLayer from '@/components/theme/GlassOpticalLayer.vue' import GlassOpticalLayer from '@/components/theme/GlassOpticalLayer.vue'
const rendererCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>) const rendererCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
const rendererResults = vi.hoisted(
() =>
[] as Array<{
activatePreparedWallpaper: ReturnType<typeof vi.fn>
activeWallpaperPreparationKey: { value: string }
activeWallpaperRevision: { value: number }
activeWallpaperUrl: { value: string }
canActivatePreparedWallpaper: ReturnType<typeof vi.fn>
failedWallpaperPreparationKey: { value: string }
failedWallpaperRevision: { value: number }
failedWallpaperUrl: { value: string }
preparedWallpaperPreparationKey: { value: string }
preparedWallpaperRevision: { value: number }
preparedWallpaperUrl: { value: string }
renderedFrames: { value: number }
rollbackPreparedWallpaperActivation: ReturnType<typeof vi.fn>
state: { value: string }
}>,
)
const interactionSource = vi.hoisted(() => ({ subscribe: vi.fn() })) const interactionSource = vi.hoisted(() => ({ subscribe: vi.fn() }))
const wallpaperSourceCache = vi.hoisted(() => ({ get: vi.fn() }))
const setRendererState = vi.hoisted(() => const setRendererState = vi.hoisted(() =>
vi.fn((state: { value: string }, value: string) => { vi.fn((state: { value: string }, value: string) => {
state.value = value state.value = value
@@ -12,21 +32,82 @@ const setRendererState = vi.hoisted(() =>
) )
vi.mock('@/composables/useGlassOpticalRenderer', () => ({ vi.mock('@/composables/useGlassOpticalRenderer', () => ({
createGlassWallpaperSourceCache: vi.fn(() => wallpaperSourceCache),
getGlassWallpaperPreparationKey: vi.fn(
(appearance: string, quality: string, routeKey: string, url: string) =>
`${appearance}:${quality}:${routeKey}:${url}`,
),
setGlassRendererState: setRendererState, setGlassRendererState: setRendererState,
useGlassOpticalInteractionSource: vi.fn(() => interactionSource), useGlassOpticalInteractionSource: vi.fn(() => interactionSource),
useGlassOpticalRenderer: vi.fn((options: Record<string, unknown>) => { useGlassOpticalRenderer: vi.fn((options: Record<string, unknown>) => {
rendererCalls.push(options) rendererCalls.push(options)
let rollbackState = {
preparationKey: '',
revision: 0,
url: '',
}
return { const result = {
activeWallpaperPreparationKey: ref(''),
activeWallpaperRevision: ref(0),
activeWallpaperUrl: ref(''),
failedWallpaperPreparationKey: ref(''),
failedWallpaperRevision: ref(0),
failedWallpaperUrl: ref(''),
preparedWallpaperPreparationKey: ref(''),
preparedWallpaperRevision: ref(0),
preparedWallpaperUrl: ref(''),
renderedFrames: ref(0), renderedFrames: ref(0),
state: ref('ready'), state: ref('ready'),
canActivatePreparedWallpaper: vi.fn((url: string, revision: number, preparationKey: string) => {
return (
result.state.value === 'ready' &&
result.preparedWallpaperUrl.value === url &&
result.preparedWallpaperRevision.value === revision &&
result.preparedWallpaperPreparationKey.value === preparationKey
)
}),
activatePreparedWallpaper: vi.fn((url: string, revision: number, preparationKey: string) => {
if (!result.canActivatePreparedWallpaper(url, revision, preparationKey)) return false
rollbackState = {
preparationKey: result.activeWallpaperPreparationKey.value,
revision: result.activeWallpaperRevision.value,
url: result.activeWallpaperUrl.value,
} }
result.preparedWallpaperUrl.value = ''
result.preparedWallpaperRevision.value = 0
result.preparedWallpaperPreparationKey.value = ''
result.activeWallpaperUrl.value = url
result.activeWallpaperRevision.value = revision
result.activeWallpaperPreparationKey.value = preparationKey
return true
}),
rollbackPreparedWallpaperActivation: vi.fn((url: string, revision: number) => {
if (result.activeWallpaperUrl.value !== url || result.activeWallpaperRevision.value !== revision) return false
result.activeWallpaperUrl.value = rollbackState.url
result.activeWallpaperRevision.value = rollbackState.revision
result.activeWallpaperPreparationKey.value = rollbackState.preparationKey
return true
}),
}
rendererResults.push(result)
return result
}), }),
})) }))
afterEach(() => {
vi.unstubAllGlobals()
})
describe('GlassOpticalLayer', () => { describe('GlassOpticalLayer', () => {
it('uses exactly two visible presentation contexts with one interaction source', () => { it('uses exactly two visible presentation contexts with one interaction source', () => {
rendererCalls.length = 0 rendererCalls.length = 0
rendererResults.length = 0
setRendererState.mockClear() setRendererState.mockClear()
const wrapper = shallowMount(GlassOpticalLayer, { const wrapper = shallowMount(GlassOpticalLayer, {
props: { props: {
@@ -52,6 +133,7 @@ describe('GlassOpticalLayer', () => {
expect(canvases.map(canvas => canvas.attributes('data-presentation-space'))).toEqual(['fixed', 'scroll']) expect(canvases.map(canvas => canvas.attributes('data-presentation-space'))).toEqual(['fixed', 'scroll'])
expect(rendererCalls.map(options => options.surfaceSpace)).toEqual(['fixed', 'scroll']) expect(rendererCalls.map(options => options.surfaceSpace)).toEqual(['fixed', 'scroll'])
expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true) expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true)
expect(rendererCalls.every(options => options.wallpaperSourceCache === wallpaperSourceCache)).toBe(true)
expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true) expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true)
expect(rendererCalls[0].pageMotion).toBeUndefined() expect(rendererCalls[0].pageMotion).toBeUndefined()
expect(rendererCalls[1].pageMotion).toEqual( expect(rendererCalls[1].pageMotion).toEqual(
@@ -64,4 +146,241 @@ describe('GlassOpticalLayer', () => {
wrapper.unmount() wrapper.unmount()
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback') expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')
}) })
it('activates matching resource bundles in one shared animation frame', async () => {
rendererCalls.length = 0
rendererResults.length = 0
let activationCallback: FrameRequestCallback | null = null
const requestFrame = vi.fn((callback: FrameRequestCallback) => {
activationCallback = callback
return 31
})
vi.stubGlobal('requestAnimationFrame', requestFrame)
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
appearance: 'frosted',
activateWallpaperRevision: 0,
deformationStrength: 50,
flowStrength: 50,
pendingWallpaperRevision: 7,
pendingWallpaperUrl: '/wallpaper-next.jpg',
previousWallpaperUrl: '',
quality: 'balanced',
reflectionStrength: 50,
routeKey: '/dashboard',
tintColor: '#8D51F9',
transitionDuration: 1500,
transitionStartedAt: 0,
transmissionStrength: 50,
translationStrength: 50,
transparencyStrength: 50,
wallpaperUrl: '/wallpaper-current.jpg',
},
})
const [fixedRenderer, scrollRenderer] = rendererResults
const preparationKey = 'frosted:balanced:/dashboard:/wallpaper-next.jpg'
fixedRenderer.preparedWallpaperUrl.value = '/wallpaper-next.jpg'
fixedRenderer.preparedWallpaperRevision.value = 7
fixedRenderer.preparedWallpaperPreparationKey.value = preparationKey
await nextTick()
expect(wrapper.emitted('wallpaperPrepared')).toBeUndefined()
scrollRenderer.preparedWallpaperUrl.value = '/wallpaper-next.jpg'
scrollRenderer.preparedWallpaperRevision.value = 7
scrollRenderer.preparedWallpaperPreparationKey.value = 'stale-key'
await nextTick()
expect(wrapper.emitted('wallpaperPrepared')).toBeUndefined()
scrollRenderer.preparedWallpaperPreparationKey.value = preparationKey
await nextTick()
expect(wrapper.emitted('wallpaperPrepared')).toEqual([['/wallpaper-next.jpg', 7]])
await wrapper.setProps({ activateWallpaperRevision: 7 })
await nextTick()
expect(wrapper.emitted('wallpaperActivated')).toBeUndefined()
expect(requestFrame).toHaveBeenCalledOnce()
;(activationCallback as FrameRequestCallback | null)?.(420)
await nextTick()
expect(fixedRenderer.activatePreparedWallpaper).toHaveBeenCalledWith('/wallpaper-next.jpg', 7, preparationKey, 420)
expect(scrollRenderer.activatePreparedWallpaper).toHaveBeenCalledWith('/wallpaper-next.jpg', 7, preparationKey, 420)
expect(wrapper.emitted('wallpaperActivated')).toEqual([['/wallpaper-next.jpg', 7, 420]])
expect(fixedRenderer.activeWallpaperRevision.value).toBe(7)
expect(scrollRenderer.activeWallpaperRevision.value).toBe(7)
wrapper.unmount()
})
it('reports one matching preparation failure so the parent can cancel the pending revision', async () => {
rendererCalls.length = 0
rendererResults.length = 0
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
appearance: 'frosted',
deformationStrength: 50,
flowStrength: 50,
pendingWallpaperRevision: 10,
pendingWallpaperUrl: '/wallpaper-next.jpg',
previousWallpaperUrl: '',
quality: 'balanced',
reflectionStrength: 50,
routeKey: '/dashboard',
tintColor: '#8D51F9',
transitionDuration: 1500,
transitionStartedAt: 0,
transmissionStrength: 50,
translationStrength: 50,
transparencyStrength: 50,
wallpaperUrl: '/wallpaper-current.jpg',
},
})
const [fixedRenderer, scrollRenderer] = rendererResults
const preparationKey = 'frosted:balanced:/dashboard:/wallpaper-next.jpg'
fixedRenderer.failedWallpaperUrl.value = '/wallpaper-next.jpg'
fixedRenderer.failedWallpaperRevision.value = 9
fixedRenderer.failedWallpaperPreparationKey.value = preparationKey
await nextTick()
expect(wrapper.emitted('wallpaperPreparationFailed')).toBeUndefined()
scrollRenderer.failedWallpaperUrl.value = '/wallpaper-next.jpg'
scrollRenderer.failedWallpaperRevision.value = 10
scrollRenderer.failedWallpaperPreparationKey.value = preparationKey
await nextTick()
expect(wrapper.emitted('wallpaperPreparationFailed')).toEqual([['/wallpaper-next.jpg', 10]])
fixedRenderer.failedWallpaperUrl.value = '/wallpaper-next.jpg'
fixedRenderer.failedWallpaperRevision.value = 10
fixedRenderer.failedWallpaperPreparationKey.value = preparationKey
await nextTick()
expect(wrapper.emitted('wallpaperPreparationFailed')).toEqual([['/wallpaper-next.jpg', 10]])
wrapper.unmount()
})
it('does not partially commit while either context is unavailable', async () => {
rendererCalls.length = 0
rendererResults.length = 0
let activationCallback: FrameRequestCallback | null = null
const requestFrame = vi.fn((callback: FrameRequestCallback) => {
activationCallback = callback
return 32
})
vi.stubGlobal('requestAnimationFrame', requestFrame)
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
appearance: 'frosted',
activateWallpaperRevision: 8,
deformationStrength: 50,
flowStrength: 50,
pendingWallpaperRevision: 8,
pendingWallpaperUrl: '/wallpaper-next.jpg',
previousWallpaperUrl: '',
quality: 'high',
reflectionStrength: 50,
routeKey: '/dashboard',
tintColor: '#8D51F9',
transitionDuration: 1500,
transitionStartedAt: 0,
transmissionStrength: 50,
translationStrength: 50,
transparencyStrength: 50,
wallpaperUrl: '/wallpaper-current.jpg',
},
})
const [fixedRenderer, scrollRenderer] = rendererResults
const preparationKey = 'frosted:high:/dashboard:/wallpaper-next.jpg'
for (const renderer of rendererResults) {
renderer.preparedWallpaperUrl.value = '/wallpaper-next.jpg'
renderer.preparedWallpaperRevision.value = 8
renderer.preparedWallpaperPreparationKey.value = preparationKey
}
scrollRenderer.state.value = 'fallback'
await nextTick()
expect(requestFrame).not.toHaveBeenCalled()
expect(fixedRenderer.activatePreparedWallpaper).not.toHaveBeenCalled()
expect(scrollRenderer.activatePreparedWallpaper).not.toHaveBeenCalled()
expect(wrapper.emitted('wallpaperActivated')).toBeUndefined()
scrollRenderer.state.value = 'ready'
await nextTick()
expect(requestFrame).toHaveBeenCalledOnce()
;(activationCallback as FrameRequestCallback | null)?.(640)
await nextTick()
expect(fixedRenderer.activatePreparedWallpaper).toHaveBeenCalledOnce()
expect(scrollRenderer.activatePreparedWallpaper).toHaveBeenCalledOnce()
expect(wrapper.emitted('wallpaperActivated')).toEqual([['/wallpaper-next.jpg', 8, 640]])
wrapper.unmount()
})
it.each([
[
'returns false',
(renderer: (typeof rendererResults)[number]) => renderer.activatePreparedWallpaper.mockReturnValueOnce(false),
],
[
'throws',
(renderer: (typeof rendererResults)[number]) =>
renderer.activatePreparedWallpaper.mockImplementationOnce(() => {
throw new Error('context commit failed')
}),
],
])('rolls both contexts back when the second activation %s', async (_, failScrollActivation) => {
rendererCalls.length = 0
rendererResults.length = 0
let activationCallback: FrameRequestCallback | null = null
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
activationCallback = callback
return 33
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
appearance: 'frosted',
activateWallpaperRevision: 9,
deformationStrength: 50,
flowStrength: 50,
pendingWallpaperRevision: 9,
pendingWallpaperUrl: '/wallpaper-next.jpg',
previousWallpaperUrl: '',
quality: 'balanced',
reflectionStrength: 50,
routeKey: '/dashboard',
tintColor: '#8D51F9',
transitionDuration: 1500,
transitionStartedAt: 0,
transmissionStrength: 50,
translationStrength: 50,
transparencyStrength: 50,
wallpaperUrl: '/wallpaper-current.jpg',
},
})
const [fixedRenderer, scrollRenderer] = rendererResults
const preparationKey = 'frosted:balanced:/dashboard:/wallpaper-next.jpg'
for (const renderer of rendererResults) {
renderer.activeWallpaperUrl.value = '/wallpaper-current.jpg'
renderer.preparedWallpaperUrl.value = '/wallpaper-next.jpg'
renderer.preparedWallpaperRevision.value = 9
renderer.preparedWallpaperPreparationKey.value = preparationKey
}
failScrollActivation(scrollRenderer)
await nextTick()
;(activationCallback as FrameRequestCallback | null)?.(720)
await nextTick()
expect(fixedRenderer.rollbackPreparedWallpaperActivation).toHaveBeenCalledWith('/wallpaper-next.jpg', 9)
expect(scrollRenderer.rollbackPreparedWallpaperActivation).toHaveBeenCalledWith('/wallpaper-next.jpg', 9)
expect(fixedRenderer.activeWallpaperUrl.value).toBe('/wallpaper-current.jpg')
expect(scrollRenderer.activeWallpaperUrl.value).toBe('/wallpaper-current.jpg')
expect(wrapper.emitted('wallpaperActivated')).toBeUndefined()
expect(wrapper.emitted('wallpaperActivationFailed')).toEqual([['/wallpaper-next.jpg', 9]])
wrapper.unmount()
})
}) })
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,134 @@
import { effectScope } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useGlassWallpaperTransaction } from '@/composables/useGlassWallpaperTransaction'
afterEach(() => {
vi.useRealTimers()
})
describe('useGlassWallpaperTransaction', () => {
it('retains the request after prepared and retires it only after active', async () => {
const scope = effectScope()
const transaction = scope.run(() => useGlassWallpaperTransaction<number>())!
const prepared = transaction.requestPreparation('/next.jpg')
const revision = transaction.requestedRevision.value
expect(transaction.acknowledgePrepared('/next.jpg', revision)).toBe(true)
await expect(prepared).resolves.toBe(true)
expect(transaction.requestedUrl.value).toBe('/next.jpg')
expect(transaction.requestedRevision.value).toBe(revision)
const activated = transaction.requestActivation(4)
const result = transaction.acknowledgeActivated('/next.jpg', revision, 420)
expect(result).toEqual({ payload: 4, startedAt: 420 })
await expect(activated).resolves.toBe(true)
expect(transaction.requestedUrl.value).toBe('')
expect(transaction.requestedRevision.value).toBe(0)
scope.stop()
})
it('ignores late prepared and active acknowledgements after a newer request', async () => {
const scope = effectScope()
const transaction = scope.run(() => useGlassWallpaperTransaction<number>())!
const firstPrepared = transaction.requestPreparation('/a.jpg')
const firstRevision = transaction.requestedRevision.value
const secondPrepared = transaction.requestPreparation('/b.jpg')
const secondRevision = transaction.requestedRevision.value
await expect(firstPrepared).resolves.toBe(false)
expect(transaction.acknowledgePrepared('/a.jpg', firstRevision)).toBe(false)
expect(transaction.acknowledgeActivated('/a.jpg', firstRevision, 100)).toBeNull()
expect(transaction.requestedUrl.value).toBe('/b.jpg')
expect(transaction.acknowledgePrepared('/b.jpg', secondRevision)).toBe(true)
await expect(secondPrepared).resolves.toBe(true)
scope.stop()
})
it('uses revision rather than URL identity for consecutive requests of the same wallpaper', async () => {
const scope = effectScope()
const transaction = scope.run(() => useGlassWallpaperTransaction<number>())!
const firstPrepared = transaction.requestPreparation('/same.jpg')
const firstRevision = transaction.requestedRevision.value
const secondPrepared = transaction.requestPreparation('/same.jpg')
const secondRevision = transaction.requestedRevision.value
await expect(firstPrepared).resolves.toBe(false)
expect(secondRevision).toBeGreaterThan(firstRevision)
expect(transaction.acknowledgePrepared('/same.jpg', firstRevision)).toBe(false)
expect(transaction.acknowledgePrepared('/same.jpg', secondRevision)).toBe(true)
await expect(secondPrepared).resolves.toBe(true)
scope.stop()
})
it('cancels both waits on timeout and rejects a same-tick late acknowledgement', async () => {
vi.useFakeTimers()
const scope = effectScope()
const transaction = scope.run(() => useGlassWallpaperTransaction<number>(50))!
const prepared = transaction.requestPreparation('/next.jpg')
const revision = transaction.requestedRevision.value
await vi.advanceTimersByTimeAsync(50)
await expect(prepared).resolves.toBe(false)
expect(transaction.acknowledgePrepared('/next.jpg', revision)).toBe(false)
expect(transaction.acknowledgeActivated('/next.jpg', revision, 50)).toBeNull()
expect(transaction.requestedUrl.value).toBe('')
scope.stop()
})
it('cancels an activation wait without treating prepared as active', async () => {
const scope = effectScope()
const transaction = scope.run(() => useGlassWallpaperTransaction<number>())!
const prepared = transaction.requestPreparation('/next.jpg')
const revision = transaction.requestedRevision.value
transaction.acknowledgePrepared('/next.jpg', revision)
await expect(prepared).resolves.toBe(true)
const activated = transaction.requestActivation(2)
expect(transaction.cancel(revision)).toBe(true)
await expect(activated).resolves.toBe(false)
expect(transaction.acknowledgeActivated('/next.jpg', revision, 100)).toBeNull()
scope.stop()
})
it('keeps one deadline across prepared and activation instead of retaining GPU bundles indefinitely', async () => {
vi.useFakeTimers()
const scope = effectScope()
const transaction = scope.run(() => useGlassWallpaperTransaction<number>(50))!
const prepared = transaction.requestPreparation('/next.jpg')
const revision = transaction.requestedRevision.value
await vi.advanceTimersByTimeAsync(20)
expect(transaction.acknowledgePrepared('/next.jpg', revision)).toBe(true)
await expect(prepared).resolves.toBe(true)
expect(transaction.requestedRevision.value).toBe(revision)
await vi.advanceTimersByTimeAsync(30)
expect(transaction.requestedUrl.value).toBe('')
expect(transaction.requestedRevision.value).toBe(0)
expect(await transaction.requestActivation(3, revision)).toBe(false)
scope.stop()
})
it('rejects activation for a stale revision without replacing the current transaction payload', async () => {
const scope = effectScope()
const transaction = scope.run(() => useGlassWallpaperTransaction<number>())!
const prepared = transaction.requestPreparation('/next.jpg')
const revision = transaction.requestedRevision.value
expect(await transaction.requestActivation(3, revision + 1)).toBe(false)
expect(transaction.acknowledgePrepared('/next.jpg', revision)).toBe(true)
await expect(prepared).resolves.toBe(true)
const activated = transaction.requestActivation(4, revision)
expect(transaction.acknowledgeActivated('/next.jpg', revision, 120)).toEqual({
payload: 4,
startedAt: 120,
})
await expect(activated).resolves.toBe(true)
scope.stop()
})
})
@@ -16,6 +16,7 @@ beforeEach(() => {
callbacks = new Map() callbacks = new Map()
frameId = 0 frameId = 0
document.documentElement.dataset.theme = 'glass' document.documentElement.dataset.theme = 'glass'
delete document.documentElement.dataset.launchLoading
vi.spyOn(performance, 'now').mockReturnValue(1000) vi.spyOn(performance, 'now').mockReturnValue(1000)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
frameId += 1 frameId += 1
@@ -30,13 +31,40 @@ beforeEach(() => {
afterEach(() => { afterEach(() => {
motion.cancel() motion.cancel()
document.getElementById('loading-bg')?.remove()
delete document.documentElement.dataset.theme delete document.documentElement.dataset.theme
delete document.documentElement.dataset.launchLoading
delete document.documentElement.dataset.pagePresentationMotion delete document.documentElement.dataset.pagePresentationMotion
document.documentElement.style.removeProperty('--mp-page-motion-opacity') document.documentElement.style.removeProperty('--mp-page-motion-opacity')
document.documentElement.style.removeProperty('--mp-page-motion-translate-y') document.documentElement.style.removeProperty('--mp-page-motion-translate-y')
}) })
describe('page presentation motion', () => { describe('page presentation motion', () => {
it('does not add a second reveal gate behind the initial launch screen', () => {
document.documentElement.dataset.launchLoading = 'true'
const launchScreen = document.createElement('div')
launchScreen.id = 'loading-bg'
document.body.append(launchScreen)
const initialRevision = motion.revision.value
expect(motion.start('/dashboard')).toBe(true)
expect(motion.active.value).toBe(false)
expect(motion.opacity.value).toBe(1)
expect(motion.revision.value).toBe(initialRevision + 1)
expect(callbacks.size).toBe(0)
expect(document.documentElement.dataset.pagePresentationMotion).toBeUndefined()
})
it('does not suppress normal route motion when only a stale launch attribute remains', () => {
document.documentElement.dataset.launchLoading = 'true'
expect(motion.start('/dashboard')).toBe(true)
expect(motion.active.value).toBe(true)
expect(motion.opacity.value).toBe(PAGE_PRESENTATION_MOTION_START_OPACITY)
expect(callbacks.size).toBe(1)
expect(document.documentElement.dataset.pagePresentationMotion).toBe('active')
})
it('holds a glass route until its shared layout geometry remains stable', () => { it('holds a glass route until its shared layout geometry remains stable', () => {
const routeRoot = document.createElement('div') const routeRoot = document.createElement('div')
let routeHeight = 2096 let routeHeight = 2096
@@ -6,11 +6,33 @@ import {
previewGlassSettings, previewGlassSettings,
readThemeCustomizerSettings, readThemeCustomizerSettings,
THEME_CUSTOMIZER_STORAGE_KEY, THEME_CUSTOMIZER_STORAGE_KEY,
useThemeCustomizer,
useEffectiveGlassSettings, useEffectiveGlassSettings,
} from '@/composables/useThemeCustomizer' } from '@/composables/useThemeCustomizer'
import vuetify from '@/plugins/vuetify'
import { mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it } from 'vitest'
describe('useThemeCustomizer glass settings', () => { describe('useThemeCustomizer glass settings', () => {
function mountThemeCustomizer() {
let customizer: ReturnType<typeof useThemeCustomizer> | undefined
const wrapper = mount(
defineComponent({
setup() {
customizer = useThemeCustomizer()
return () => h('div')
},
}),
{ global: { plugins: [vuetify] } },
)
if (!customizer) throw new Error('theme customizer setup failed')
return { customizer, wrapper }
}
beforeEach(() => { beforeEach(() => {
cancelGlassPreview() cancelGlassPreview()
localStorage.clear() localStorage.clear()
@@ -22,14 +44,15 @@ describe('useThemeCustomizer glass settings', () => {
const settings = readThemeCustomizerSettings() const settings = readThemeCustomizerSettings()
expect(settings.glassAppearance).toBe('clear') expect(settings.glassAppearance).toBe('clear')
expect(settings.glassDeformationStrength).toBe(50) expect(settings.glassDeformationStrength).toBe(40)
expect(settings.glassFlowStrength).toBe(50) expect(settings.glassFlowStrength).toBe(40)
expect(settings.glassPreset).toBe('natural') expect(settings.glassPreset).toBe('natural')
expect(settings.glassPresetOverrides).toEqual({})
expect(settings.glassQuality).toBe('balanced') expect(settings.glassQuality).toBe('balanced')
expect(settings.glassReflectionStrength).toBe(35) expect(settings.glassReflectionStrength).toBe(35)
expect(settings.glassTransmissionStrength).toBe(70) expect(settings.glassTransmissionStrength).toBe(54)
expect(settings.glassTranslationStrength).toBe(50) expect(settings.glassTranslationStrength).toBe(40)
expect(settings.glassTransparencyStrength).toBe(70) expect(settings.glassTransparencyStrength).toBe(46)
}) })
it.each(['balanced', 'high'] as const)('preserves the %s quality contract', quality => { it.each(['balanced', 'high'] as const)('preserves the %s quality contract', quality => {
@@ -54,6 +77,7 @@ describe('useThemeCustomizer glass settings', () => {
expect(settings.glassAppearance).toBe('clear') expect(settings.glassAppearance).toBe('clear')
expect(settings.glassPreset).toBe('natural') expect(settings.glassPreset).toBe('natural')
expect(settings.glassPresetOverrides).toHaveProperty('clear:balanced:natural')
expect(settings.glassQuality).toBe('balanced') expect(settings.glassQuality).toBe('balanced')
}) })
@@ -94,6 +118,19 @@ describe('useThemeCustomizer glass settings', () => {
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify({ glassAppearance: 'clear' })) localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify({ glassAppearance: 'clear' }))
expect(readThemeCustomizerSettings().glassTransmissionStrength).toBe(50) expect(readThemeCustomizerSettings().glassTransmissionStrength).toBe(50)
expect(readThemeCustomizerSettings()).toMatchObject({
glassPreset: 'natural',
glassPresetOverrides: {
'clear:balanced:natural': {
deformation: 40,
flow: 40,
reflection: 35,
transmission: 50,
translation: 40,
transparency: 46,
},
},
})
}) })
it('syncs glass settings to the document roots', () => { it('syncs glass settings to the document roots', () => {
@@ -111,10 +148,14 @@ describe('useThemeCustomizer glass settings', () => {
expect(document.body.dataset.glassQuality).toBe('high') expect(document.body.dataset.glassQuality).toBe('high')
expect(document.documentElement.style.getPropertyValue('--glass-reflection')).toBe('0.35') expect(document.documentElement.style.getPropertyValue('--glass-reflection')).toBe('0.35')
expect(document.body.style.getPropertyValue('--glass-reflection')).toBe('0.35') expect(document.body.style.getPropertyValue('--glass-reflection')).toBe('0.35')
expect(Number(document.documentElement.style.getPropertyValue('--glass-transmission'))).toBe(1) expect(Number(document.documentElement.style.getPropertyValue('--glass-transmission'))).toBeCloseTo(54 / 70)
expect(document.body.style.getPropertyValue('--glass-transmission-brightness')).not.toBe('') expect(document.body.style.getPropertyValue('--glass-transmission-brightness')).not.toBe('')
expect(Number(document.documentElement.style.getPropertyValue('--glass-transparency'))).toBeCloseTo(0.96, 2) expect(Number(document.documentElement.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(
expect(Number(document.body.style.getPropertyValue('--glass-transparency'))).toBeCloseTo(0.96, 2) 0.46639,
)
expect(Number(document.body.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(0.46639)
expect(Number(document.documentElement.style.getPropertyValue('--glass-surface-density'))).toBeCloseTo(0.72972)
expect(Number(document.body.style.getPropertyValue('--glass-tint-density'))).toBeCloseTo(0.66215)
}) })
it('previews glass settings without persisting them', () => { it('previews glass settings without persisting them', () => {
@@ -134,7 +175,17 @@ describe('useThemeCustomizer glass settings', () => {
glassAppearance: 'tinted', glassAppearance: 'tinted',
glassDeformationStrength: 74, glassDeformationStrength: 74,
glassFlowStrength: 63, glassFlowStrength: 63,
glassPreset: 'glide', glassPreset: 'natural',
glassPresetOverrides: {
'tinted:css:natural': {
deformation: 74,
flow: 63,
reflection: 81,
transmission: 76,
translation: 69,
transparency: 80,
},
},
glassQuality: 'css', glassQuality: 'css',
glassReflectionStrength: 81, glassReflectionStrength: 81,
glassTransmissionStrength: 76, glassTransmissionStrength: 76,
@@ -148,7 +199,17 @@ describe('useThemeCustomizer glass settings', () => {
glassAppearance: 'tinted', glassAppearance: 'tinted',
glassDeformationStrength: 74, glassDeformationStrength: 74,
glassFlowStrength: 63, glassFlowStrength: 63,
glassPreset: 'glide', glassPreset: 'natural',
glassPresetOverrides: {
'tinted:css:natural': {
deformation: 74,
flow: 63,
reflection: 81,
transmission: 76,
translation: 69,
transparency: 80,
},
},
glassQuality: 'css', glassQuality: 'css',
glassReflectionStrength: 81, glassReflectionStrength: 81,
glassTransmissionStrength: 76, glassTransmissionStrength: 76,
@@ -175,6 +236,16 @@ describe('useThemeCustomizer glass settings', () => {
glassAppearance: 'tinted', glassAppearance: 'tinted',
glassDeformationStrength: 42, glassDeformationStrength: 42,
glassFlowStrength: 44, glassFlowStrength: 44,
glassPresetOverrides: {
'tinted:balanced:natural': {
deformation: 42,
flow: 44,
reflection: 66,
transmission: 64,
translation: 46,
transparency: 72,
},
},
glassReflectionStrength: 66, glassReflectionStrength: 66,
glassTransmissionStrength: 64, glassTransmissionStrength: 64,
glassTranslationStrength: 46, glassTranslationStrength: 46,
@@ -184,6 +255,16 @@ describe('useThemeCustomizer glass settings', () => {
glassAppearance: 'clear', glassAppearance: 'clear',
glassDeformationStrength: 90, glassDeformationStrength: 90,
glassFlowStrength: 88, glassFlowStrength: 88,
glassPresetOverrides: {
'clear:balanced:natural': {
deformation: 90,
flow: 88,
reflection: 12,
transmission: 92,
translation: 86,
transparency: 94,
},
},
glassReflectionStrength: 12, glassReflectionStrength: 12,
glassTransmissionStrength: 92, glassTransmissionStrength: 92,
glassTranslationStrength: 86, glassTranslationStrength: 86,
@@ -206,10 +287,146 @@ describe('useThemeCustomizer glass settings', () => {
glassAppearance: 'tinted', glassAppearance: 'tinted',
glassDeformationStrength: 42, glassDeformationStrength: 42,
glassFlowStrength: 44, glassFlowStrength: 44,
glassPresetOverrides: {
'tinted:balanced:natural': {
deformation: 42,
flow: 44,
reflection: 66,
transmission: 64,
translation: 46,
transparency: 72,
},
},
glassReflectionStrength: 66, glassReflectionStrength: 66,
glassTransmissionStrength: 64, glassTransmissionStrength: 64,
glassTranslationStrength: 46, glassTranslationStrength: 46,
glassTransparencyStrength: 72, glassTransparencyStrength: 72,
}) })
}) })
it('applies the same preset for a new material and quality while preset-managed', async () => {
persistPartialThemeCustomizerSettings({
glassAppearance: 'clear',
glassPreset: 'natural',
glassPresetOverrides: {},
glassQuality: 'balanced',
})
const { customizer, wrapper } = mountThemeCustomizer()
const { setGlassAppearance, setGlassPreset, setGlassQuality } = customizer
await setGlassPreset('glide')
await setGlassAppearance('frosted')
await setGlassQuality('high')
expect(readThemeCustomizerSettings()).toMatchObject({
glassAppearance: 'frosted',
glassDeformationStrength: 32,
glassFlowStrength: 37,
glassPreset: 'glide',
glassQuality: 'high',
glassReflectionStrength: 25,
glassTransmissionStrength: 54,
glassTranslationStrength: 56,
glassTransparencyStrength: 38,
})
wrapper.unmount()
})
it('restores each combination override after material and quality changes', async () => {
persistPartialThemeCustomizerSettings({
glassAppearance: 'clear',
glassPreset: 'natural',
glassPresetOverrides: {},
glassQuality: 'balanced',
})
const { customizer, wrapper } = mountThemeCustomizer()
const { setGlassAppearance, setGlassDeformationStrength, setGlassQuality, setGlassTransparencyStrength } =
customizer
await setGlassDeformationStrength(73)
await setGlassTransparencyStrength(27)
await setGlassAppearance('tinted')
await setGlassQuality('high')
expect(readThemeCustomizerSettings()).toMatchObject({
glassAppearance: 'tinted',
glassDeformationStrength: 42,
glassPreset: 'natural',
glassPresetOverrides: {
'clear:balanced:natural': {
deformation: 73,
flow: 40,
reflection: 35,
transmission: 54,
translation: 40,
transparency: 27,
},
},
glassQuality: 'high',
glassTransparencyStrength: 30,
})
await setGlassQuality('balanced')
await setGlassAppearance('clear')
expect(readThemeCustomizerSettings()).toMatchObject({
glassDeformationStrength: 73,
glassTransparencyStrength: 27,
})
wrapper.unmount()
})
it('maps preset-managed standard quality to natural without overwriting custom values', async () => {
persistPartialThemeCustomizerSettings({
glassAppearance: 'clear',
glassPreset: 'natural',
glassPresetOverrides: {},
glassQuality: 'balanced',
})
const { customizer, wrapper } = mountThemeCustomizer()
const { setGlassPreset, setGlassQuality, setGlassTransparencyStrength } = customizer
await setGlassPreset('liquid')
await setGlassQuality('css')
expect(readThemeCustomizerSettings()).toMatchObject({
glassPreset: 'natural',
glassQuality: 'css',
glassTransparencyStrength: 48,
})
await setGlassTransparencyStrength(19)
await setGlassQuality('balanced')
expect(readThemeCustomizerSettings()).toMatchObject({
glassPreset: 'natural',
glassQuality: 'balanced',
glassTransparencyStrength: 46,
})
await setGlassQuality('css')
expect(readThemeCustomizerSettings().glassTransparencyStrength).toBe(19)
wrapper.unmount()
})
it('keeps overrides independent for two presets of the same material and quality', async () => {
persistPartialThemeCustomizerSettings({
glassAppearance: 'tinted',
glassPreset: 'natural',
glassPresetOverrides: {},
glassQuality: 'balanced',
})
const { customizer, wrapper } = mountThemeCustomizer()
const { setGlassPreset, setGlassTransparencyStrength } = customizer
await setGlassPreset('glide')
await setGlassTransparencyStrength(61)
await setGlassPreset('liquid')
await setGlassTransparencyStrength(37)
await setGlassPreset('glide')
expect(readThemeCustomizerSettings()).toMatchObject({
glassPreset: 'glide',
glassPresetOverrides: {
'tinted:balanced:glide': expect.objectContaining({ transparency: 61 }),
'tinted:balanced:liquid': expect.objectContaining({ transparency: 37 }),
},
glassTransparencyStrength: 61,
})
wrapper.unmount()
})
}) })
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,132 @@
import { onScopeDispose, ref } from 'vue'
interface GlassWallpaperActivation<TPayload> {
/** 由请求方保存、在原子激活回执时取回的业务载荷。 */
payload: TPayload
/** fixed 与 scroll context 共用的激活时间戳。 */
startedAt: number
}
/**
* 管理壁纸从请求、双 context 预备到原子激活的单调事务。
* prepared 只解除资源等待;请求必须保留到 active 回执或显式取消。
*/
export function useGlassWallpaperTransaction<TPayload>(timeoutMs = 10_000) {
const requestedUrl = ref('')
const requestedRevision = ref(0)
const activationRevision = ref(0)
let revisionSequence = 0
let revisionTimer: number | null = null
let preparationResolve: ((ready: boolean) => void) | null = null
let activationResolve: ((activated: boolean) => void) | null = null
let activationPayload: TPayload | null = null
function clearPreparationWait(ready: boolean) {
const resolve = preparationResolve
preparationResolve = null
resolve?.(ready)
}
function clearActivationWait(activated: boolean) {
const resolve = activationResolve
activationResolve = null
activationPayload = null
activationRevision.value = 0
resolve?.(activated)
}
function clearRevisionTimer() {
if (revisionTimer === null) return
window.clearTimeout(revisionTimer)
revisionTimer = null
}
/** 取消当前 revision;迟到的 prepared/active 回执不能影响后续事务。 */
function cancel(revision = requestedRevision.value) {
if (revision !== requestedRevision.value) return false
clearRevisionTimer()
clearPreparationWait(false)
clearActivationWait(false)
requestedUrl.value = ''
requestedRevision.value = 0
return true
}
/** 开始新的壁纸准备事务,并使上一事务的所有等待者立即失效。 */
function requestPreparation(url: string) {
cancel()
const revision = ++revisionSequence
requestedUrl.value = url
requestedRevision.value = revision
return new Promise<boolean>(resolve => {
preparationResolve = resolve
revisionTimer = window.setTimeout(() => cancel(revision), timeoutMs)
})
}
/** 只有当前 URL 与 revision 的 prepared 回执可以解除准备等待。 */
function acknowledgePrepared(url: string, revision: number) {
if (url !== requestedUrl.value || revision !== requestedRevision.value) return false
clearPreparationWait(true)
return true
}
/** 请求 Layer 在同一绘制帧内激活两个 context 已准备的资源。 */
function requestActivation(payload: TPayload, revision = requestedRevision.value) {
if (!requestedUrl.value || revision <= 0 || revision !== requestedRevision.value) {
return Promise.resolve(false)
}
clearActivationWait(false)
activationPayload = payload
activationRevision.value = revision
return new Promise<boolean>(resolve => {
activationResolve = resolve
})
}
/** 接受双 context 的原子 active 回执,并将请求载荷交还给父层提交 DOM。 */
function acknowledgeActivated(
url: string,
revision: number,
startedAt: number,
): GlassWallpaperActivation<TPayload> | null {
if (
url !== requestedUrl.value ||
revision !== requestedRevision.value ||
revision !== activationRevision.value ||
activationPayload === null
) {
return null
}
const activation = { payload: activationPayload, startedAt }
clearRevisionTimer()
clearPreparationWait(true)
clearActivationWait(true)
requestedUrl.value = ''
requestedRevision.value = 0
return activation
}
onScopeDispose(cancel)
return {
acknowledgeActivated,
acknowledgePrepared,
activationRevision,
cancel,
requestedRevision,
requestedUrl,
requestActivation,
requestPreparation,
}
}
+8 -3
View File
@@ -121,9 +121,7 @@ function sampleLayoutHold(timestamp: number, motionEpoch: number, root: HTMLElem
return return
} }
animationFrame = window.requestAnimationFrame(nextTimestamp => animationFrame = window.requestAnimationFrame(nextTimestamp => sampleLayoutHold(nextTimestamp, motionEpoch, root))
sampleLayoutHold(nextTimestamp, motionEpoch, root),
)
} }
function settleMotion() { function settleMotion() {
@@ -178,6 +176,13 @@ function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) {
const motionEpoch = epoch.value const motionEpoch = epoch.value
routeKey.value = nextRouteKey routeKey.value = nextRouteKey
// 启动屏已完整遮罩页面;在其背后再等待布局稳定会把一次启动拆成两次可见揭示。
if (document.documentElement.dataset.launchLoading === 'true' && document.getElementById('loading-bg')) {
settleMotion()
revision.value += 1
return true
}
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
settleMotion() settleMotion()
revision.value += 1 revision.value += 1
+189 -23
View File
@@ -6,12 +6,17 @@ import vuetify from '@/plugins/vuetify'
import { import {
GLASS_OPTICAL_STRENGTH_DEFAULT, GLASS_OPTICAL_STRENGTH_DEFAULT,
GLASS_OPTICAL_STRENGTH_MAX, GLASS_OPTICAL_STRENGTH_MAX,
getGlassCssFrostBlur,
getGlassMaterialResponse,
getGlassOpticalCssTransmissionBrightness, getGlassOpticalCssTransmissionBrightness,
getGlassOpticalPresetKey,
getGlassOpticalPresetParameters, getGlassOpticalPresetParameters,
getGlassOpticalPresetParametersWithOverrides,
getGlassOpticalTransmissionStrength, getGlassOpticalTransmissionStrength,
getGlassOpticalTransparency,
normalizeGlassOpticalStrength, normalizeGlassOpticalStrength,
type GlassOpticalParameters,
type GlassOpticalPreset, type GlassOpticalPreset,
type GlassOpticalPresetOverrides,
} from '@/utils/glassOptics' } from '@/utils/glassOptics'
import { themeManager } from '@/utils/themeManager' import { themeManager } from '@/utils/themeManager'
import { syncThemeFavicon } from '@/utils/themePalette' import { syncThemeFavicon } from '@/utils/themePalette'
@@ -80,6 +85,8 @@ export interface ThemeCustomizerSettings {
glassFlowStrength: number glassFlowStrength: number
/** 当前玻璃方案;具体参数独立保存,滑杆调整不会丢失方案归属。 */ /** 当前玻璃方案;具体参数独立保存,滑杆调整不会丢失方案归属。 */
glassPreset: GlassOpticalPreset glassPreset: GlassOpticalPreset
/** 按材质、质量与方案保存的六参数覆盖;缺失组合使用预设矩阵。 */
glassPresetOverrides: GlassOpticalPresetOverrides
/** 玻璃主题的渲染质量,决定使用标准 CSS 或共享光学渲染器。 */ /** 玻璃主题的渲染质量,决定使用标准 CSS 或共享光学渲染器。 */
glassQuality: ThemeCustomizerGlassQuality glassQuality: ThemeCustomizerGlassQuality
/** 玻璃亮边、镜面高光与焦散光照强度,范围 0 到 100。 */ /** 玻璃亮边、镜面高光与焦散光照强度,范围 0 到 100。 */
@@ -155,6 +162,7 @@ function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
glassDeformationStrength: glassParameters.deformation, glassDeformationStrength: glassParameters.deformation,
glassFlowStrength: glassParameters.flow, glassFlowStrength: glassParameters.flow,
glassPreset: 'natural', glassPreset: 'natural',
glassPresetOverrides: {},
glassQuality: defaultGlassQuality, glassQuality: defaultGlassQuality,
glassReflectionStrength: glassParameters.reflection, glassReflectionStrength: glassParameters.reflection,
glassTransmissionStrength: glassParameters.transmission, glassTransmissionStrength: glassParameters.transmission,
@@ -183,6 +191,49 @@ function normalizeMigratedGlassStrength(value: unknown, legacyValue: unknown, fa
return fallback return fallback
} }
/** 规范化一组六参数,非法结构不进入持久化覆盖。 */
function normalizeGlassParameters(value: unknown): GlassOpticalParameters | null {
if (!value || typeof value !== 'object') return null
const parameters = value as Partial<GlassOpticalParameters>
const fields: Array<keyof GlassOpticalParameters> = [
'deformation',
'flow',
'reflection',
'transmission',
'translation',
'transparency',
]
if (fields.some(field => typeof parameters[field] !== 'number' || !Number.isFinite(parameters[field]))) return null
return {
deformation: normalizeGlassOpticalStrength(parameters.deformation),
flow: normalizeGlassOpticalStrength(parameters.flow),
reflection: normalizeGlassOpticalStrength(parameters.reflection),
transmission: normalizeGlassOpticalStrength(parameters.transmission),
translation: normalizeGlassOpticalStrength(parameters.translation),
transparency: normalizeGlassOpticalStrength(parameters.transparency),
}
}
/** 只保留 21 个有效组合的完整六参数覆盖。 */
function normalizeGlassPresetOverrides(value: unknown): GlassOpticalPresetOverrides {
if (!value || typeof value !== 'object') return {}
const normalized: GlassOpticalPresetOverrides = {}
for (const appearance of validGlassAppearances) {
for (const quality of validGlassQualities) {
for (const preset of quality === 'css' ? (['natural'] as const) : validGlassPresets) {
const key = getGlassOpticalPresetKey(appearance, quality, preset)
const parameters = normalizeGlassParameters((value as Record<string, unknown>)[key])
if (parameters) normalized[key] = parameters
}
}
}
return normalized
}
/** 将旧版语义阴影档位迁移到 Vuetify elevation 数值档位。 */ /** 将旧版语义阴影档位迁移到 Vuetify elevation 数值档位。 */
function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow { function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow {
if (validShadows.includes(shadow as ThemeCustomizerShadow)) return shadow as ThemeCustomizerShadow if (validShadows.includes(shadow as ThemeCustomizerShadow)) return shadow as ThemeCustomizerShadow
@@ -200,8 +251,11 @@ function normalizeThemeCustomizerSettings(
const storedRadius = settings.radius as string | undefined const storedRadius = settings.radius as string | undefined
const radius = storedRadius === 'huge' ? 'extra' : storedRadius const radius = storedRadius === 'huge' ? 'extra' : storedRadius
const primaryColor = isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor const primaryColor = isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor
const storedPreset = validGlassPresets.includes(settings.glassPreset as GlassOpticalPreset)
? (settings.glassPreset as GlassOpticalPreset)
: fallback.glassPreset
return { const normalized: ThemeCustomizerSettings = {
glassAppearance: validGlassAppearances.includes(settings.glassAppearance as ThemeCustomizerGlassAppearance) glassAppearance: validGlassAppearances.includes(settings.glassAppearance as ThemeCustomizerGlassAppearance)
? (settings.glassAppearance as ThemeCustomizerGlassAppearance) ? (settings.glassAppearance as ThemeCustomizerGlassAppearance)
: fallback.glassAppearance, : fallback.glassAppearance,
@@ -215,9 +269,8 @@ function normalizeThemeCustomizerSettings(
settings.glassMotionStrength, settings.glassMotionStrength,
fallback.glassFlowStrength, fallback.glassFlowStrength,
), ),
glassPreset: validGlassPresets.includes(settings.glassPreset as GlassOpticalPreset) glassPreset: storedPreset,
? (settings.glassPreset as GlassOpticalPreset) glassPresetOverrides: normalizeGlassPresetOverrides(settings.glassPresetOverrides),
: fallback.glassPreset,
glassQuality: validGlassQualities.includes(settings.glassQuality as ThemeCustomizerGlassQuality) glassQuality: validGlassQualities.includes(settings.glassQuality as ThemeCustomizerGlassQuality)
? (settings.glassQuality as ThemeCustomizerGlassQuality) ? (settings.glassQuality as ThemeCustomizerGlassQuality)
: fallback.glassQuality, : fallback.glassQuality,
@@ -254,6 +307,19 @@ function normalizeThemeCustomizerSettings(
? (settings.theme as ThemeCustomizerTheme) ? (settings.theme as ThemeCustomizerTheme)
: fallback.theme, : fallback.theme,
} }
if (preserveLegacyTransmission && settings.glassPresetOverrides === undefined) {
const key = getGlassOpticalPresetKey(normalized.glassAppearance, normalized.glassQuality, normalized.glassPreset)
normalized.glassPresetOverrides[key] = {
deformation: normalized.glassDeformationStrength,
flow: normalized.glassFlowStrength,
reflection: normalized.glassReflectionStrength,
transmission: normalized.glassTransmissionStrength,
translation: normalized.glassTranslationStrength,
transparency: normalized.glassTransparencyStrength,
}
}
return normalized
} }
/** 从本地存储读取主题定制器设置,异常数据会自动回落到默认值。 */ /** 从本地存储读取主题定制器设置,异常数据会自动回落到默认值。 */
@@ -281,6 +347,7 @@ type ThemeCustomizerGlassSettings = Pick<
| 'glassDeformationStrength' | 'glassDeformationStrength'
| 'glassFlowStrength' | 'glassFlowStrength'
| 'glassPreset' | 'glassPreset'
| 'glassPresetOverrides'
| 'glassQuality' | 'glassQuality'
| 'glassReflectionStrength' | 'glassReflectionStrength'
| 'glassTransmissionStrength' | 'glassTransmissionStrength'
@@ -294,6 +361,7 @@ const effectiveGlassSettings = computed(() => ({
glassPreviewState.value?.glassDeformationStrength ?? settingsState.value.glassDeformationStrength, glassPreviewState.value?.glassDeformationStrength ?? settingsState.value.glassDeformationStrength,
glassFlowStrength: glassPreviewState.value?.glassFlowStrength ?? settingsState.value.glassFlowStrength, glassFlowStrength: glassPreviewState.value?.glassFlowStrength ?? settingsState.value.glassFlowStrength,
glassPreset: glassPreviewState.value?.glassPreset ?? settingsState.value.glassPreset, glassPreset: glassPreviewState.value?.glassPreset ?? settingsState.value.glassPreset,
glassPresetOverrides: glassPreviewState.value?.glassPresetOverrides ?? settingsState.value.glassPresetOverrides,
glassQuality: glassPreviewState.value?.glassQuality ?? settingsState.value.glassQuality, glassQuality: glassPreviewState.value?.glassQuality ?? settingsState.value.glassQuality,
glassReflectionStrength: glassReflectionStrength:
glassPreviewState.value?.glassReflectionStrength ?? settingsState.value.glassReflectionStrength, glassPreviewState.value?.glassReflectionStrength ?? settingsState.value.glassReflectionStrength,
@@ -375,6 +443,18 @@ export function applyThemeCustomizerRootSettings(
) { ) {
if (!isBrowser()) return if (!isBrowser()) return
const materialResponse = getGlassMaterialResponse(settings.glassAppearance, settings.glassTransparencyStrength)
const frostBlur = getGlassCssFrostBlur(settings.glassTransparencyStrength)
const applyGlassResponse = (element: HTMLElement) => {
element.style.setProperty('--glass-background-visibility', String(materialResponse.backgroundVisibility))
element.style.setProperty('--glass-frost-blur-scale', String(materialResponse.frostBlurScale))
element.style.setProperty('--glass-frost-detail-level', String(materialResponse.frostDetailLevel))
element.style.setProperty('--glass-surface-density', String(materialResponse.surfaceDensity))
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`)
}
document.documentElement.setAttribute('data-glass-appearance', settings.glassAppearance) document.documentElement.setAttribute('data-glass-appearance', settings.glassAppearance)
document.documentElement.setAttribute('data-glass-quality', settings.glassQuality) document.documentElement.setAttribute('data-glass-quality', settings.glassQuality)
document.documentElement.style.setProperty( document.documentElement.style.setProperty(
@@ -389,10 +469,7 @@ export function applyThemeCustomizerRootSettings(
'--glass-transmission-brightness', '--glass-transmission-brightness',
String(getGlassOpticalCssTransmissionBrightness(settings.glassTransmissionStrength)), String(getGlassOpticalCssTransmissionBrightness(settings.glassTransmissionStrength)),
) )
document.documentElement.style.setProperty( applyGlassResponse(document.documentElement)
'--glass-transparency',
String(getGlassOpticalTransparency(settings.glassTransparencyStrength)),
)
document.documentElement.setAttribute('data-theme-layout', settings.layout) document.documentElement.setAttribute('data-theme-layout', settings.layout)
document.documentElement.setAttribute('data-theme-radius', settings.radius) document.documentElement.setAttribute('data-theme-radius', settings.radius)
document.documentElement.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu)) document.documentElement.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
@@ -412,10 +489,7 @@ export function applyThemeCustomizerRootSettings(
'--glass-transmission-brightness', '--glass-transmission-brightness',
String(getGlassOpticalCssTransmissionBrightness(settings.glassTransmissionStrength)), String(getGlassOpticalCssTransmissionBrightness(settings.glassTransmissionStrength)),
) )
document.body.style.setProperty( applyGlassResponse(document.body)
'--glass-transparency',
String(getGlassOpticalTransparency(settings.glassTransparencyStrength)),
)
document.body.setAttribute('data-theme-layout', settings.layout) document.body.setAttribute('data-theme-layout', settings.layout)
document.body.setAttribute('data-theme-radius', settings.radius) document.body.setAttribute('data-theme-radius', settings.radius)
document.body.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu)) document.body.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
@@ -496,6 +570,7 @@ export function previewGlassSettings(patch: Partial<ThemeCustomizerGlassSettings
glassDeformationStrength: previewSettings.glassDeformationStrength, glassDeformationStrength: previewSettings.glassDeformationStrength,
glassFlowStrength: previewSettings.glassFlowStrength, glassFlowStrength: previewSettings.glassFlowStrength,
glassPreset: previewSettings.glassPreset, glassPreset: previewSettings.glassPreset,
glassPresetOverrides: previewSettings.glassPresetOverrides,
glassQuality: previewSettings.glassQuality, glassQuality: previewSettings.glassQuality,
glassReflectionStrength: previewSettings.glassReflectionStrength, glassReflectionStrength: previewSettings.glassReflectionStrength,
glassTransmissionStrength: previewSettings.glassTransmissionStrength, glassTransmissionStrength: previewSettings.glassTransmissionStrength,
@@ -539,6 +614,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
glassDeformationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT, glassDeformationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassFlowStrength: GLASS_OPTICAL_STRENGTH_DEFAULT, glassFlowStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassPreset: 'natural', glassPreset: 'natural',
glassPresetOverrides: {},
glassQuality: defaultGlassQuality, glassQuality: defaultGlassQuality,
glassReflectionStrength: glassParameters.reflection, glassReflectionStrength: glassParameters.reflection,
glassTransmissionStrength: glassParameters.transmission, glassTransmissionStrength: glassParameters.transmission,
@@ -558,6 +634,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
settings.glassDeformationStrength === defaults.glassDeformationStrength && settings.glassDeformationStrength === defaults.glassDeformationStrength &&
settings.glassFlowStrength === defaults.glassFlowStrength && settings.glassFlowStrength === defaults.glassFlowStrength &&
settings.glassPreset === defaults.glassPreset && settings.glassPreset === defaults.glassPreset &&
JSON.stringify(settings.glassPresetOverrides) === JSON.stringify(defaults.glassPresetOverrides) &&
settings.glassQuality === defaults.glassQuality && settings.glassQuality === defaults.glassQuality &&
settings.glassReflectionStrength === defaults.glassReflectionStrength && settings.glassReflectionStrength === defaults.glassReflectionStrength &&
settings.glassTransmissionStrength === defaults.glassTransmissionStrength && settings.glassTransmissionStrength === defaults.glassTransmissionStrength &&
@@ -607,49 +684,137 @@ export function useThemeCustomizer() {
return updateSettings({ primaryColor: color }) return updateSettings({ primaryColor: color })
} }
/** 读取当前组合的六个具体参数。 */
function getCurrentGlassParameters(): GlassOpticalParameters {
return {
deformation: settings.value.glassDeformationStrength,
flow: settings.value.glassFlowStrength,
reflection: settings.value.glassReflectionStrength,
transmission: settings.value.glassTransmissionStrength,
translation: settings.value.glassTranslationStrength,
transparency: settings.value.glassTransparencyStrength,
}
}
/** 用调整后的完整六参数覆盖当前材质、质量与方案组合。 */
function updateGlassPresetOverride(patch: Partial<GlassOpticalParameters>) {
const parameters = {
...getCurrentGlassParameters(),
...patch,
}
const key = getGlassOpticalPresetKey(
settings.value.glassAppearance,
settings.value.glassQuality,
settings.value.glassPreset,
)
return updateSettings({
glassDeformationStrength: parameters.deformation,
glassFlowStrength: parameters.flow,
glassPresetOverrides: {
...settings.value.glassPresetOverrides,
[key]: parameters,
},
glassReflectionStrength: parameters.reflection,
glassTransmissionStrength: parameters.transmission,
glassTranslationStrength: parameters.translation,
glassTransparencyStrength: parameters.transparency,
})
}
/** 更新玻璃主题材质,不隐式改变渲染质量。 */ /** 更新玻璃主题材质,不隐式改变渲染质量。 */
function setGlassAppearance(glassAppearance: ThemeCustomizerGlassAppearance) { function setGlassAppearance(glassAppearance: ThemeCustomizerGlassAppearance) {
return updateSettings({ glassAppearance }) const glassPreset = settings.value.glassQuality === 'css' ? 'natural' : settings.value.glassPreset
const parameters = getGlassOpticalPresetParametersWithOverrides(
glassAppearance,
settings.value.glassQuality,
glassPreset,
settings.value.glassPresetOverrides,
)
return updateSettings({
glassAppearance,
glassDeformationStrength: parameters.deformation,
glassFlowStrength: parameters.flow,
glassPreset,
glassReflectionStrength: parameters.reflection,
glassTransmissionStrength: parameters.transmission,
glassTranslationStrength: parameters.translation,
glassTransparencyStrength: parameters.transparency,
})
} }
/** 更新玻璃局部非均匀形变强度。 */ /** 更新玻璃局部非均匀形变强度。 */
function setGlassDeformationStrength(glassDeformationStrength: number) { function setGlassDeformationStrength(glassDeformationStrength: number) {
return updateSettings({ glassDeformationStrength }) return updateGlassPresetOverride({ deformation: normalizeGlassOpticalStrength(glassDeformationStrength) })
} }
/** 更新玻璃轨迹、尾波与惯性强度。 */ /** 更新玻璃轨迹、尾波与惯性强度。 */
function setGlassFlowStrength(glassFlowStrength: number) { function setGlassFlowStrength(glassFlowStrength: number) {
return updateSettings({ glassFlowStrength }) return updateGlassPresetOverride({ flow: normalizeGlassOpticalStrength(glassFlowStrength) })
} }
/** 更新当前玻璃方案,不隐式覆盖用户已保存的具体参数。 */ /** 切换方案时优先恢复该组合已保存的覆盖。 */
function setGlassPreset(glassPreset: GlassOpticalPreset) { function setGlassPreset(glassPreset: GlassOpticalPreset) {
return updateSettings({ glassPreset }) const effectivePreset = settings.value.glassQuality === 'css' ? 'natural' : glassPreset
const parameters = getGlassOpticalPresetParametersWithOverrides(
settings.value.glassAppearance,
settings.value.glassQuality,
effectivePreset,
settings.value.glassPresetOverrides,
)
return updateSettings({
glassDeformationStrength: parameters.deformation,
glassFlowStrength: parameters.flow,
glassPreset: effectivePreset,
glassReflectionStrength: parameters.reflection,
glassTransmissionStrength: parameters.transmission,
glassTranslationStrength: parameters.translation,
glassTransparencyStrength: parameters.transparency,
})
} }
/** 更新玻璃主题渲染质量档位。 */ /** 更新玻璃主题渲染质量档位。 */
function setGlassQuality(glassQuality: ThemeCustomizerGlassQuality) { function setGlassQuality(glassQuality: ThemeCustomizerGlassQuality) {
return updateSettings({ glassQuality }) const glassPreset: GlassOpticalPreset = glassQuality === 'css' ? 'natural' : settings.value.glassPreset
const parameters = getGlassOpticalPresetParametersWithOverrides(
settings.value.glassAppearance,
glassQuality,
glassPreset,
settings.value.glassPresetOverrides,
)
return updateSettings({
glassDeformationStrength: parameters.deformation,
glassFlowStrength: parameters.flow,
glassPreset,
glassQuality,
glassReflectionStrength: parameters.reflection,
glassTransmissionStrength: parameters.transmission,
glassTranslationStrength: parameters.translation,
glassTransparencyStrength: parameters.transparency,
})
} }
/** 更新玻璃表面反射亮度。 */ /** 更新玻璃表面反射亮度。 */
function setGlassReflectionStrength(glassReflectionStrength: number) { function setGlassReflectionStrength(glassReflectionStrength: number) {
return updateSettings({ glassReflectionStrength }) return updateGlassPresetOverride({ reflection: normalizeGlassOpticalStrength(glassReflectionStrength) })
} }
/** 更新玻璃内部壁纸采样的透射亮度。 */ /** 更新玻璃内部壁纸采样的透射亮度。 */
function setGlassTransmissionStrength(glassTransmissionStrength: number) { function setGlassTransmissionStrength(glassTransmissionStrength: number) {
return updateSettings({ glassTransmissionStrength }) return updateGlassPresetOverride({ transmission: normalizeGlassOpticalStrength(glassTransmissionStrength) })
} }
/** 更新玻璃统一采样平移强度。 */ /** 更新玻璃统一采样平移强度。 */
function setGlassTranslationStrength(glassTranslationStrength: number) { function setGlassTranslationStrength(glassTranslationStrength: number) {
return updateSettings({ glassTranslationStrength }) return updateGlassPresetOverride({ translation: normalizeGlassOpticalStrength(glassTranslationStrength) })
} }
/** 更新玻璃材质的真实壁纸可见度。 */ /** 更新玻璃材质的真实壁纸可见度。 */
function setGlassTransparencyStrength(glassTransparencyStrength: number) { function setGlassTransparencyStrength(glassTransparencyStrength: number) {
return updateSettings({ glassTransparencyStrength }) return updateGlassPresetOverride({ transparency: normalizeGlassOpticalStrength(glassTransparencyStrength) })
} }
/** 更新全局圆角档位。 */ /** 更新全局圆角档位。 */
@@ -691,6 +856,7 @@ export function useThemeCustomizer() {
glassDeformationStrength: glassParameters.deformation, glassDeformationStrength: glassParameters.deformation,
glassFlowStrength: glassParameters.flow, glassFlowStrength: glassParameters.flow,
glassPreset: 'natural', glassPreset: 'natural',
glassPresetOverrides: {},
glassQuality: defaultGlassQuality, glassQuality: defaultGlassQuality,
glassReflectionStrength: glassParameters.reflection, glassReflectionStrength: glassParameters.reflection,
glassTransmissionStrength: glassParameters.transmission, glassTransmissionStrength: glassParameters.transmission,
@@ -631,7 +631,7 @@ onUnmounted(() => {
</VListItem> </VListItem>
<!-- 👉 UI模式设置 - 使用嵌套菜单 --> <!-- 👉 UI模式设置 - 使用嵌套菜单 -->
<VMenu location="end" offset-x min-width="200" v-model="showUIModeMenu" :close-on-content-click="true"> <VMenu location="end" offset-x width="15rem" v-model="showUIModeMenu" :close-on-content-click="true">
<template v-slot:activator="{ props: menuProps }"> <template v-slot:activator="{ props: menuProps }">
<VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover> <VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover>
<template #prepend> <template #prepend>
@@ -666,7 +666,7 @@ onUnmounted(() => {
</VMenu> </VMenu>
<!-- 👉 主题设置 - 使用嵌套菜单 --> <!-- 👉 主题设置 - 使用嵌套菜单 -->
<VMenu location="end" offset-x min-width="200" v-model="showThemeMenu" :close-on-content-click="true"> <VMenu location="end" offset-x width="15rem" v-model="showThemeMenu" :close-on-content-click="true">
<template v-slot:activator="{ props: menuProps }"> <template v-slot:activator="{ props: menuProps }">
<VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover> <VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover>
<template #prepend> <template #prepend>
@@ -730,7 +730,7 @@ onUnmounted(() => {
</VMenu> </VMenu>
<!-- 👉 语言设置 - 使用嵌套菜单 --> <!-- 👉 语言设置 - 使用嵌套菜单 -->
<VMenu location="end" offset-x min-width="200" v-model="showLanguageMenu" :close-on-content-click="true"> <VMenu location="end" offset-x width="15rem" v-model="showLanguageMenu" :close-on-content-click="true">
<template v-slot:activator="{ props: menuProps }"> <template v-slot:activator="{ props: menuProps }">
<VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover> <VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover>
<template #prepend> <template #prepend>
+2 -3
View File
@@ -175,7 +175,7 @@ export default {
glassOpticalStrengthHint: glassOpticalStrengthHint:
'Sample Translation moves the shared wallpaper. Deformation controls local bending. Flow Strength controls trail range and inertia.', 'Sample Translation moves the shared wallpaper. Deformation controls local bending. Flow Strength controls trail range and inertia.',
glassOpticalStrengthUnavailableHint: glassOpticalStrengthUnavailableHint:
'Standard quality keeps all three material controls. Switch to Balanced or High to adjust sample translation, deformation, and flow.', 'Standard quality keeps all three material controls; switch to Balanced or High to adjust sample translation, deformation, and flow.',
purple: 'Purple', purple: 'Purple',
custom: 'Custom Style', custom: 'Custom Style',
transparency: 'Transparency', transparency: 'Transparency',
@@ -3810,8 +3810,7 @@ export default {
password: 'Password', password: 'Password',
passwordHint: 'Login password', passwordHint: 'Login password',
accessCode: 'Access Code', accessCode: 'Access Code',
accessCodeHint: accessCodeHint: 'Access code enabled in fnOS Settings -> Security -> Access Settings. Leave empty if not enabled',
'Access code enabled in fnOS Settings -> Security -> Access Settings. Leave empty if not enabled',
syncLibraries: 'Sync Libraries', syncLibraries: 'Sync Libraries',
syncLibrariesHint: 'Only selected libraries will be synchronized', syncLibrariesHint: 'Only selected libraries will be synchronized',
scanMode: 'Scan Mode', scanMode: 'Scan Mode',
+1 -1
View File
@@ -170,7 +170,7 @@ export default {
glassTransmissionStrength: '透射亮度', glassTransmissionStrength: '透射亮度',
glassTransparencyStrength: '通透度', glassTransparencyStrength: '通透度',
glassOpticalStrengthHint: '采样平移控制整体滑移,形变强度控制局部弯曲,流动强度控制轨迹范围与惯性。', glassOpticalStrengthHint: '采样平移控制整体滑移,形变强度控制局部弯曲,流动强度控制轨迹范围与惯性。',
glassOpticalStrengthUnavailableHint: '标准质量保留三项材质参数切换到均衡或高质量后,可调整采样平移、形变和流动。', glassOpticalStrengthUnavailableHint: '标准质量保留三项材质参数切换到均衡或高质量后,可调整采样平移、形变和流动。',
purple: '幻紫', purple: '幻紫',
custom: '附加样式', custom: '附加样式',
transparency: '透明度', transparency: '透明度',
+1 -1
View File
@@ -170,7 +170,7 @@ export default {
glassTransmissionStrength: '透射亮度', glassTransmissionStrength: '透射亮度',
glassTransparencyStrength: '通透度', glassTransparencyStrength: '通透度',
glassOpticalStrengthHint: '採樣平移控制整體滑移,形變強度控制局部彎曲,流動強度控制軌跡範圍與慣性。', glassOpticalStrengthHint: '採樣平移控制整體滑移,形變強度控制局部彎曲,流動強度控制軌跡範圍與慣性。',
glassOpticalStrengthUnavailableHint: '標準品質保留三項材質參數切換到均衡或高品質後,可調整採樣平移、形變和流動。', glassOpticalStrengthUnavailableHint: '標準品質保留三項材質參數切換到均衡或高品質後,可調整採樣平移、形變和流動。',
purple: '幻紫', purple: '幻紫',
custom: '附加樣式', custom: '附加樣式',
transparency: '透明度', transparency: '透明度',
@@ -0,0 +1,67 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { cwd } from 'node:process'
import { describe, expect, it } from 'vitest'
describe('glass overlay material styles', () => {
it('keeps overlays translucent enough for CSS backdrop compositing in every material', () => {
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).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%')
expect(styles).toContain('--glass-overlay-scrim: rgba(3, 7, 18, 30%)')
expect(styles).toContain('--glass-overlay-scrim: rgba(3, 7, 18, 32%)')
expect(styles).toContain('--glass-overlay-scrim: rgba(3, 7, 18, 36%)')
expect(styles).toContain('calc(0.24 + var(--glass-surface-density, 0.86) * 0.12)')
expect(styles).not.toContain('calc(0.64 + var(--glass-surface-density, 0.86) * 0.16)')
expect(styles).not.toContain('background: rgba(3, 7, 18, 62%)')
})
it('composites glass dialogs at their final geometry instead of resampling a scaled backdrop', () => {
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
expect(styles).toMatch(
/\.v-overlay__content\.mp-dialog-transition-enter-active[\s\S]*?transition:\s*opacity 120ms var\(--mp-motion-ease-standard\);/,
)
expect(styles).toMatch(
/\.v-overlay__content\.mp-dialog-transition-enter-from[\s\S]*?filter:\s*none;[\s\S]*?transform:\s*none;/,
)
})
it('keeps the fixed navigation backdrop isolated from route content and its scrollbar', () => {
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
expect(styles).toContain('--glass-fixed-shell-backdrop-filter: blur(min(var(--glass-blur-raised), 60px))')
expect(styles).toMatch(
/\.layout-vertical-nav\s*\{[\s\S]*?isolation:\s*isolate;[\s\S]*?backdrop-filter:\s*none;[\s\S]*?&::before\s*\{[\s\S]*?backdrop-filter:\s*var\(--glass-fixed-shell-backdrop-filter\);/,
)
expect(styles).toMatch(/\.layout-vertical-nav \.ps__rail-y\s*\{[\s\S]*?inset-inline-end:\s*0\.5rem !important;/)
})
it('uses the shared hover-card contract instead of a Dashboard-specific shadow rule', () => {
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
expect(styles).toContain('.app-hover-lift-card:is(:hover, .app-hover-lift-card--hovering)')
expect(styles).not.toContain(
'.dashboard-grid-item-content .app-hover-lift-card:is(:hover, .app-hover-lift-card--hovering)',
)
})
it('hands the CSS fallback to an already rendered canvas without a second opacity transition', () => {
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
const layerRuleStart = styles.indexOf('.glass-optical-layer {')
const layerRuleEnd = styles.indexOf('.glass-optical-layer--fixed', layerRuleStart)
const layerRule = styles.slice(layerRuleStart, layerRuleEnd)
expect(layerRuleStart).toBeGreaterThanOrEqual(0)
expect(layerRuleEnd).toBeGreaterThan(layerRuleStart)
expect(layerRule).toContain('opacity: 0')
expect(layerRule).not.toMatch(/transition\s*:/)
expect(styles).toMatch(/\[data-glass-renderer-state='ready'\]\s*\.glass-optical-layer\s*\{\s*opacity:\s*1;/)
})
})
@@ -0,0 +1,18 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
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', () => {
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)
const bannerRule = commonStyles.slice(ruleStart, ruleEnd)
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)')
})
})
+2 -3
View File
@@ -1346,11 +1346,10 @@ html[data-theme='transparent'].transparent-glass-realtime .v-theme--transparent
background-image: var(--plugin-card-banner-scrim), var(--plugin-card-banner-tint); background-image: var(--plugin-card-banner-scrim), var(--plugin-card-banner-tint);
transition: background-image 0.2s ease; transition: background-image 0.2s ease;
--plugin-card-accent-rgb: 40, 169, 225;
--plugin-card-banner-scrim: linear-gradient(rgba(0, 0, 0, 60%) 0%, rgba(0, 0, 0, 50%) 100%); --plugin-card-banner-scrim: linear-gradient(rgba(0, 0, 0, 60%) 0%, rgba(0, 0, 0, 50%) 100%);
--plugin-card-banner-tint: linear-gradient( --plugin-card-banner-tint: linear-gradient(
rgb(var(--plugin-card-accent-rgb)) 0%, rgb(var(--plugin-card-accent-rgb, 40, 169, 225)) 0%,
rgb(var(--plugin-card-accent-rgb)) 100% rgb(var(--plugin-card-accent-rgb, 40, 169, 225)) 100%
); );
} }
+85 -46
View File
@@ -3,9 +3,9 @@
// 材质只覆盖统一表面 token质量档只替换光学层不改变业务组件契约 // 材质只覆盖统一表面 token质量档只替换光学层不改变业务组件契约
html[data-theme='glass'] { html[data-theme='glass'] {
--glass-surface: rgba(11, 19, 34, calc(0.3 - var(--glass-transparency, 0.5) * 0.24)); --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.36 - var(--glass-transparency, 0.5) * 0.24)); --glass-surface-soft: rgba(11, 19, 34, calc(0.04 + var(--glass-surface-density, 0.62) * 0.32));
--glass-surface-raised: rgba(11, 19, 34, calc(0.44 - var(--glass-transparency, 0.5) * 0.24)); --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: rgba(11, 19, 34, 52%);
--glass-control-prominent: rgba(255, 255, 255, 7%); --glass-control-prominent: rgba(255, 255, 255, 7%);
--glass-control-prominent-focus: color-mix(in srgb, rgba(255, 255, 255, 10%) 84%, rgba(var(--v-theme-primary), 24%)); --glass-control-prominent-focus: color-mix(in srgb, rgba(255, 255, 255, 10%) 84%, rgba(var(--v-theme-primary), 24%));
@@ -44,12 +44,14 @@ html[data-theme='glass'] {
inset 0 -1px 0 rgba(2, 6, 16, 16%); inset 0 -1px 0 rgba(2, 6, 16, 16%);
--glass-surface-backdrop-filter: brightness(var(--glass-transmission-brightness)); --glass-surface-backdrop-filter: brightness(var(--glass-transmission-brightness));
--glass-raised-backdrop-filter: brightness(var(--glass-transmission-brightness)); --glass-raised-backdrop-filter: brightness(var(--glass-transmission-brightness));
--glass-fixed-shell-backdrop-filter: var(--glass-raised-backdrop-filter);
--glass-control-backdrop-filter: none; --glass-control-backdrop-filter: none;
--glass-control-prominent-backdrop-filter: none; --glass-control-prominent-backdrop-filter: none;
--glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter); --glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter);
--glass-overlay-surface: var(--glass-surface-raised); --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: 3px;
--glass-overlay-saturate: 135%; --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)); --glass-overlay-backdrop-filter: blur(var(--glass-overlay-blur)) saturate(var(--glass-overlay-saturate));
--glass-control-icon-color: rgba(242, 245, 250, 70%); --glass-control-icon-color: rgba(242, 245, 250, 70%);
--glass-control-placeholder-color: rgba(242, 245, 250, 54%); --glass-control-placeholder-color: rgba(242, 245, 250, 54%);
@@ -112,18 +114,18 @@ html[data-theme='glass'] {
&[data-glass-appearance='tinted'] { &[data-glass-appearance='tinted'] {
--glass-surface: color-mix( --glass-surface: color-mix(
in srgb, in srgb,
rgba(11, 19, 34, calc(0.3 - var(--glass-transparency, 0.5) * 0.24)) 88%, rgba(11, 19, 34, calc(0.02 + var(--glass-surface-density, 0.72) * 0.3)) 88%,
rgba(var(--v-theme-primary), 28%) rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.28))
); );
--glass-surface-soft: color-mix( --glass-surface-soft: color-mix(
in srgb, in srgb,
rgba(11, 19, 34, calc(0.36 - var(--glass-transparency, 0.5) * 0.24)) 89%, rgba(11, 19, 34, calc(0.04 + var(--glass-surface-density, 0.72) * 0.32)) 89%,
rgba(var(--v-theme-primary), 26%) rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.26))
); );
--glass-surface-raised: color-mix( --glass-surface-raised: color-mix(
in srgb, in srgb,
rgba(11, 19, 34, calc(0.44 - var(--glass-transparency, 0.5) * 0.24)) 84%, rgba(11, 19, 34, calc(0.07 + var(--glass-surface-density, 0.72) * 0.36)) 84%,
rgba(var(--v-theme-primary), 32%) rgba(var(--v-theme-primary), 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: 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%)); --glass-control-prominent: color-mix(in srgb, rgba(255, 255, 255, 7%) 82%, rgba(var(--v-theme-primary), 24%));
@@ -164,13 +166,21 @@ html[data-theme='glass'] {
rgba(var(--v-theme-primary), 6%) 72%, rgba(var(--v-theme-primary), 6%) 72%,
transparent transparent
); );
--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))
);
--glass-overlay-blur: 12px;
--glass-overlay-saturate: 120%;
--glass-overlay-scrim: rgba(3, 7, 18, 32%);
} }
// 磨砂材质使用更亮的散射表面但仍只让高价值层执行实时背景采样 // 磨砂材质使用更亮的散射表面但仍只让高价值层执行实时背景采样
&[data-glass-appearance='frosted'] { &[data-glass-appearance='frosted'] {
--glass-surface: rgba(255, 255, 255, calc(0.14 - var(--glass-transparency, 0.5) * 0.1)); --glass-surface: rgba(255, 255, 255, calc(0.035 + var(--glass-surface-density, 0.86) * 0.075));
--glass-surface-soft: rgba(255, 255, 255, calc(0.13 - var(--glass-transparency, 0.5) * 0.08)); --glass-surface-soft: rgba(255, 255, 255, calc(0.03 + var(--glass-surface-density, 0.86) * 0.07));
--glass-surface-raised: rgba(255, 255, 255, calc(0.18 - var(--glass-transparency, 0.5) * 0.11)); --glass-surface-raised: rgba(255, 255, 255, calc(0.045 + var(--glass-surface-density, 0.86) * 0.09));
--glass-control: rgba(255, 255, 255, 9%); --glass-control: rgba(255, 255, 255, 9%);
--glass-control-prominent: rgba(255, 255, 255, 10%); --glass-control-prominent: rgba(255, 255, 255, 10%);
--glass-control-prominent-focus: rgba(255, 255, 255, 13%); --glass-control-prominent-focus: rgba(255, 255, 255, 13%);
@@ -194,13 +204,14 @@ html[data-theme='glass'] {
brightness(var(--glass-transmission-brightness)); brightness(var(--glass-transmission-brightness));
--glass-raised-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate)) --glass-raised-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate))
brightness(var(--glass-transmission-brightness)); brightness(var(--glass-transmission-brightness));
// 固定全高导航限制采样半径避免页面重绘扩大其 backdrop 栅格化区域
--glass-fixed-shell-backdrop-filter: blur(min(var(--glass-blur-raised), 60px)) saturate(var(--glass-saturate))
brightness(var(--glass-transmission-brightness));
--glass-control-prominent-backdrop-filter: blur(24px) saturate(150%); --glass-control-prominent-backdrop-filter: blur(24px) saturate(150%);
--glass-overlay-surface: rgba( --glass-overlay-surface: rgba(var(--v-theme-background), calc(0.24 + var(--glass-surface-density, 0.86) * 0.12));
var(--v-theme-background), --glass-overlay-blur: min(var(--glass-blur-raised), 36px);
calc(0.76 - var(--glass-transparency, 0.5) * 0.12) --glass-overlay-saturate: 135%;
); --glass-overlay-scrim: rgba(3, 7, 18, 36%);
--glass-overlay-blur: var(--glass-blur-raised);
--glass-overlay-saturate: var(--glass-saturate);
--glass-blur-surface: 40px; --glass-blur-surface: 40px;
--glass-blur: 40px; --glass-blur: 40px;
--glass-blur-raised: 60px; --glass-blur-raised: 60px;
@@ -215,10 +226,6 @@ html[data-theme='glass'] {
--glass-dashboard-backdrop-filter: blur(var(--glass-blur-surface)) saturate(var(--glass-saturate)) --glass-dashboard-backdrop-filter: blur(var(--glass-blur-surface)) saturate(var(--glass-saturate))
brightness(var(--glass-transmission-brightness)); brightness(var(--glass-transmission-brightness));
.v-overlay__scrim {
background: rgba(3, 7, 18, 62%);
}
// 磨砂弹窗只由最外层表面采样背景内部卡片与自定义分组表面保持扁平 // 磨砂弹窗只由最外层表面采样背景内部卡片与自定义分组表面保持扁平
.v-overlay__content > :where(.v-card, .v-sheet), .v-overlay__content > :where(.v-card, .v-sheet),
.v-overlay__content > form > :where(.v-card, .v-sheet) { .v-overlay__content > form > :where(.v-card, .v-sheet) {
@@ -438,8 +445,8 @@ html[data-theme='glass'] {
position: absolute; position: absolute;
z-index: -1; z-index: -1;
border-inline-end: 1px solid var(--glass-border-raised); border-inline-end: 1px solid var(--glass-border-raised);
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter); -webkit-backdrop-filter: var(--glass-fixed-shell-backdrop-filter);
backdrop-filter: var(--glass-raised-backdrop-filter); backdrop-filter: var(--glass-fixed-shell-backdrop-filter);
background-color: var(--glass-surface-raised); background-color: var(--glass-surface-raised);
background-image: var(--glass-sheen); background-image: var(--glass-sheen);
box-shadow: var(--glass-shadow-raised); box-shadow: var(--glass-shadow-raised);
@@ -575,7 +582,7 @@ html[data-theme='glass'] {
.v-overlay__scrim { .v-overlay__scrim {
-webkit-backdrop-filter: none; -webkit-backdrop-filter: none;
backdrop-filter: none; backdrop-filter: none;
background: rgba(3, 7, 18, 48%); background: var(--glass-overlay-scrim);
} }
.v-dialog--fullscreen { .v-dialog--fullscreen {
@@ -595,6 +602,25 @@ html[data-theme='glass'] {
} }
} }
// 弹层材质按最终几何一次合成避免 transform/filter 动画反复重采样背景
.v-overlay__content.mp-dialog-transition-enter-active,
.v-overlay__content.mp-dialog-transition-leave-active,
.v-overlay__content.dialog-transition-enter-active,
.v-overlay__content.dialog-transition-leave-active {
filter: none;
transform: none;
transition: opacity 120ms var(--mp-motion-ease-standard);
will-change: opacity;
}
.v-overlay__content.mp-dialog-transition-enter-from,
.v-overlay__content.mp-dialog-transition-leave-to,
.v-overlay__content.dialog-transition-enter-from,
.v-overlay__content.dialog-transition-leave-to {
filter: none;
transform: none;
}
// 透明材质在内容上滚后复用移动底栏材质顶部时则保持原有透光感 // 透明材质在内容上滚后复用移动底栏材质顶部时则保持原有透光感
&[data-glass-appearance='clear'] { &[data-glass-appearance='clear'] {
.layout-wrapper.window-scrolled.layout-navbar-fixed .layout-navbar, .layout-wrapper.window-scrolled.layout-navbar-fixed .layout-navbar,
@@ -814,14 +840,14 @@ html[data-theme='glass'] {
border-block-end: 1px solid var(--glass-border); border-block-end: 1px solid var(--glass-border);
--plugin-card-banner-scrim: linear-gradient( --plugin-card-banner-scrim: linear-gradient(
rgba(11, 19, 34, calc(0.34 - var(--glass-transparency, 0.5) * 0.14)) 0%, rgba(11, 19, 34, calc(0.2 + var(--glass-surface-density, 0.62) * 0.115)) 0%,
rgba(11, 19, 34, calc(0.46 - var(--glass-transparency, 0.5) * 0.16)) 100% rgba(11, 19, 34, calc(0.28 + var(--glass-surface-density, 0.62) * 0.16)) 100%
); );
--plugin-card-banner-tint: linear-gradient( --plugin-card-banner-tint: linear-gradient(
135deg, 135deg,
rgba(var(--plugin-card-accent-rgb), 0.42) 0%, 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), 0.24) 58%, 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), 0.12) 100% rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.055 + var(--glass-tint-density, 0.65) * 0.1)) 100%
); );
} }
@@ -842,14 +868,14 @@ html[data-theme='glass'] {
// 磨砂材质透光更强染色需要同步减弱以免头部压过卡片本体 // 磨砂材质透光更强染色需要同步减弱以免头部压过卡片本体
&[data-glass-appearance='frosted'] .plugin-card__banner { &[data-glass-appearance='frosted'] .plugin-card__banner {
--plugin-card-banner-scrim: linear-gradient( --plugin-card-banner-scrim: linear-gradient(
rgba(11, 19, 34, calc(0.24 - var(--glass-transparency, 0.5) * 0.12)) 0%, rgba(11, 19, 34, calc(0.1 + var(--glass-surface-density, 0.86) * 0.09)) 0%,
rgba(11, 19, 34, calc(0.34 - var(--glass-transparency, 0.5) * 0.14)) 100% rgba(11, 19, 34, calc(0.14 + var(--glass-surface-density, 0.86) * 0.145)) 100%
); );
--plugin-card-banner-tint: linear-gradient( --plugin-card-banner-tint: linear-gradient(
135deg, 135deg,
rgba(var(--plugin-card-accent-rgb), 0.32) 0%, 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), 0.18) 58%, 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), 0.08) 100% rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.03 + var(--glass-tint-density, 0.65) * 0.06)) 100%
); );
} }
@@ -857,15 +883,25 @@ html[data-theme='glass'] {
&[data-glass-appearance='tinted'] .plugin-card__banner { &[data-glass-appearance='tinted'] .plugin-card__banner {
--plugin-card-banner-tint: linear-gradient( --plugin-card-banner-tint: linear-gradient(
135deg, 135deg,
color-mix(in srgb, rgba(var(--plugin-card-accent-rgb), 0.42) 74%, rgba(var(--v-theme-primary), 0.42)) 0%, color-mix(
color-mix(in srgb, rgba(var(--plugin-card-accent-rgb), 0.24) 74%, rgba(var(--v-theme-primary), 0.24)) 58%, in srgb,
rgba(var(--plugin-card-accent-rgb), 0.12) 100% 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))
)
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))
)
58%,
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.04 + var(--glass-tint-density, 0.65) * 0.095)) 100%
); );
} }
// 文件夹卡片保留用户自选渐变作为色相只降低不透明度让卡片本体的玻璃透出来 // 文件夹卡片保留用户自选渐变作为色相只降低不透明度让卡片本体的玻璃透出来
.plugin-folder-card__bg { .plugin-folder-card__bg {
opacity: calc(0.5 - var(--glass-transparency, 0.5) * 0.14); opacity: calc(0.34 + var(--glass-surface-density, 0.62) * 0.145);
} }
.plugin-folder-card__bg::after { .plugin-folder-card__bg::after {
@@ -879,8 +915,8 @@ html[data-theme='glass'] {
.plugin-folder-card__overlay { .plugin-folder-card__overlay {
background: linear-gradient( background: linear-gradient(
rgba(11, 19, 34, calc(0.34 - var(--glass-transparency, 0.5) * 0.14)) 0%, rgba(11, 19, 34, calc(0.2 + var(--glass-surface-density, 0.62) * 0.115)) 0%,
rgba(11, 19, 34, calc(0.46 - var(--glass-transparency, 0.5) * 0.16)) 100% rgba(11, 19, 34, calc(0.28 + var(--glass-surface-density, 0.62) * 0.16)) 100%
); );
} }
@@ -1039,6 +1075,11 @@ html[data-theme='glass'] {
border-color: var(--glass-border-hover) !important; border-color: var(--glass-border-hover) !important;
box-shadow: var(--glass-shadow-hover) !important; box-shadow: var(--glass-shadow-hover) !important;
} }
// 玻璃主题的上浮反馈只增强边缘受光避免外投影与相邻材质叠成宽暗带
.app-hover-lift-card:is(:hover, .app-hover-lift-card--hovering) {
box-shadow: var(--glass-dashboard-shadow-hover) !important;
}
} }
.v-btn:not(.v-btn--variant-text, .v-btn--variant-plain):active { .v-btn:not(.v-btn--variant-text, .v-btn--variant-plain):active {
@@ -1123,7 +1164,6 @@ html[data-theme='glass'] {
inset: 0; inset: 0;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition: opacity 180ms ease-out;
} }
.glass-optical-layer--fixed { .glass-optical-layer--fixed {
@@ -1152,6 +1192,7 @@ html[data-theme='glass']:is(
)[data-glass-renderer-state='ready'] { )[data-glass-renderer-state='ready'] {
--glass-surface-backdrop-filter: none; --glass-surface-backdrop-filter: none;
--glass-raised-backdrop-filter: none; --glass-raised-backdrop-filter: none;
--glass-fixed-shell-backdrop-filter: none;
--glass-navbar-backdrop-filter: var(--glass-surface-backdrop-filter); --glass-navbar-backdrop-filter: var(--glass-surface-backdrop-filter);
} }
@@ -1160,8 +1201,6 @@ html[data-theme='glass'][data-glass-appearance='frosted']:is(
[data-glass-quality='high'] [data-glass-quality='high']
)[data-glass-renderer-state='ready'] { )[data-glass-renderer-state='ready'] {
--glass-navbar-backdrop-filter: none; --glass-navbar-backdrop-filter: none;
--glass-overlay-blur: 0px;
--glass-overlay-saturate: 100%;
} }
html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass-renderer-state='ready'] html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass-renderer-state='ready']
+170 -9
View File
@@ -3,7 +3,9 @@ import {
GLASS_OPTICAL_MOTION_MAX_SCALE, GLASS_OPTICAL_MOTION_MAX_SCALE,
GLASS_OPTICAL_REFLECTION_MAX_SCALE, GLASS_OPTICAL_REFLECTION_MAX_SCALE,
getAvailableGlassOpticalPresets, getAvailableGlassOpticalPresets,
getGlassCssFrostBlur,
getGlassCoverScale, getGlassCoverScale,
getGlassMaterialResponse,
getGlassOpticalCssTransmissionBrightness, getGlassOpticalCssTransmissionBrightness,
getGlassOpticalDecay, getGlassOpticalDecay,
getGlassOpticalBufferSize, getGlassOpticalBufferSize,
@@ -12,6 +14,8 @@ import {
getGlassOpticalMotionExpansion, getGlassOpticalMotionExpansion,
getGlassOpticalMotionStrengthScale, getGlassOpticalMotionStrengthScale,
getGlassOpticalPresetParameters, getGlassOpticalPresetParameters,
getGlassOpticalPresetParametersWithOverrides,
getGlassOpticalPresetKey,
getGlassOpticalReflectionStrengthScale, getGlassOpticalReflectionStrengthScale,
getGlassOpticalRenderProfile, getGlassOpticalRenderProfile,
getGlassOpticalTransparency, getGlassOpticalTransparency,
@@ -57,7 +61,9 @@ describe('glass optics geometry', () => {
expect(getGlassOpticalMotionStrengthScale(75) - getGlassOpticalMotionStrengthScale(50)).toBeGreaterThan( expect(getGlassOpticalMotionStrengthScale(75) - getGlassOpticalMotionStrengthScale(50)).toBeGreaterThan(
getGlassOpticalMotionStrengthScale(50) - getGlassOpticalMotionStrengthScale(25), getGlassOpticalMotionStrengthScale(50) - getGlassOpticalMotionStrengthScale(25),
) )
expect(getGlassOpticalMotionExpansion(50)).toBe(0) expect(getGlassOpticalMotionExpansion(0)).toBe(0)
expect(getGlassOpticalMotionExpansion(50)).toBeGreaterThan(0.3)
expect(getGlassOpticalMotionExpansion(50)).toBeLessThan(0.4)
expect(getGlassOpticalMotionExpansion(80)).toBeGreaterThan(0.4) expect(getGlassOpticalMotionExpansion(80)).toBeGreaterThan(0.4)
expect(getGlassOpticalMotionExpansion(100)).toBe(1) expect(getGlassOpticalMotionExpansion(100)).toBe(1)
expect(getGlassOpticalMaxRefractionPixels(9, 50)).toBe(9) expect(getGlassOpticalMaxRefractionPixels(9, 50)).toBe(9)
@@ -87,12 +93,12 @@ describe('glass optics geometry', () => {
const liquid = getGlassOpticalPresetParameters('frosted', 'high', 'liquid') const liquid = getGlassOpticalPresetParameters('frosted', 'high', 'liquid')
expect(natural).toEqual({ expect(natural).toEqual({
deformation: 50, deformation: 40,
flow: 50, flow: 40,
reflection: 35, reflection: 35,
transmission: 70, transmission: 54,
translation: 50, translation: 40,
transparency: 70, transparency: 46,
}) })
expect(glide.translation).toBeGreaterThan(glide.deformation) expect(glide.translation).toBeGreaterThan(glide.deformation)
expect(liquid.deformation).toBeGreaterThan(glide.deformation) expect(liquid.deformation).toBeGreaterThan(glide.deformation)
@@ -101,11 +107,166 @@ describe('glass optics geometry', () => {
expect(getAvailableGlassOpticalPresets('balanced')).toEqual(['natural', 'glide', 'liquid']) expect(getAvailableGlassOpticalPresets('balanced')).toEqual(['natural', 'glide', 'liquid'])
}) })
it('keeps every preset dynamic parameter at the approved material calibration', () => {
const expected = {
clear: {
css: { natural: { deformation: 40, flow: 40, translation: 40 } },
balanced: {
natural: { deformation: 40, flow: 40, translation: 40 },
glide: { deformation: 24, flow: 35, translation: 58 },
liquid: { deformation: 56, flow: 61, translation: 45 },
},
high: {
natural: { deformation: 40, flow: 40, translation: 40 },
glide: { deformation: 26, flow: 37, translation: 59 },
liquid: { deformation: 59, flow: 64, translation: 46 },
},
},
tinted: {
css: { natural: { deformation: 40, flow: 40, translation: 40 } },
balanced: {
natural: { deformation: 42, flow: 40, translation: 40 },
glide: { deformation: 26, flow: 35, translation: 56 },
liquid: { deformation: 58, flow: 61, translation: 45 },
},
high: {
natural: { deformation: 42, flow: 40, translation: 40 },
glide: { deformation: 27, flow: 37, translation: 58 },
liquid: { deformation: 61, flow: 64, translation: 46 },
},
},
frosted: {
css: { natural: { deformation: 40, flow: 40, translation: 40 } },
balanced: {
natural: { deformation: 46, flow: 42, translation: 38 },
glide: { deformation: 30, flow: 35, translation: 54 },
liquid: { deformation: 62, flow: 61, translation: 42 },
},
high: {
natural: { deformation: 48, flow: 42, translation: 38 },
glide: { deformation: 32, flow: 37, translation: 56 },
liquid: { deformation: 66, flow: 64, translation: 43 },
},
},
} as const
for (const [appearance, qualities] of Object.entries(expected)) {
for (const [quality, presets] of Object.entries(qualities)) {
for (const [preset, parameters] of Object.entries(presets)) {
expect(
getGlassOpticalPresetParameters(
appearance as 'clear' | 'frosted' | 'tinted',
quality as 'balanced' | 'css' | 'high',
preset as 'glide' | 'liquid' | 'natural',
),
).toMatchObject(parameters as object)
}
}
}
})
it('restores per-combination overrides and keeps standard quality on natural', () => {
const key = getGlassOpticalPresetKey('tinted', 'high', 'glide')
const override = {
deformation: 11,
flow: 22,
reflection: 33,
transmission: 44,
translation: 55,
transparency: 66,
}
expect(key).toBe('tinted:high:glide')
expect(getGlassOpticalPresetKey('frosted', 'css', 'liquid')).toBe('frosted:css:natural')
expect(getGlassOpticalPresetParametersWithOverrides('tinted', 'high', 'glide', { [key]: override })).toEqual(
override,
)
expect(getGlassOpticalPresetParametersWithOverrides('clear', 'balanced', 'natural', {})).toEqual(
getGlassOpticalPresetParameters('clear', 'balanced', 'natural'),
)
})
it('uses the approved transparency and transmission matrix for all effective presets', () => {
const expected = {
clear: {
css: { natural: [48, 56] },
balanced: { natural: [46, 54], glide: [56, 58], liquid: [51, 51] },
high: { natural: [45, 53], glide: [54, 56], liquid: [50, 50] },
},
tinted: {
css: { natural: [34, 54] },
balanced: { natural: [32, 56], glide: [40, 61], liquid: [36, 53] },
high: { natural: [30, 54], glide: [38, 59], liquid: [34, 51] },
},
frosted: {
css: { natural: [31, 50] },
balanced: { natural: [29, 52], glide: [40, 56], liquid: [34, 49] },
high: { natural: [27, 50], glide: [38, 54], liquid: [32, 47] },
},
} as const
for (const [appearance, qualities] of Object.entries(expected)) {
for (const [quality, presets] of Object.entries(qualities)) {
for (const [preset, values] of Object.entries(presets)) {
const [transparency, transmission] = values as readonly [number, number]
expect(
getGlassOpticalPresetParameters(
appearance as 'clear' | 'frosted' | 'tinted',
quality as 'balanced' | 'css' | 'high',
preset as 'glide' | 'liquid' | 'natural',
),
).toMatchObject({ transmission, transparency })
}
}
}
})
it('derives independent material responses from piecewise smooth transparency anchors', () => {
expect(getGlassMaterialResponse('clear', 0)).toMatchObject({
backgroundVisibility: 0.18,
surfaceDensity: 1,
})
expect(getGlassMaterialResponse('tinted', 50)).toMatchObject({
backgroundVisibility: 0.48,
tintDensity: 0.65,
})
const frostedLow = getGlassMaterialResponse('frosted', 20)
expect(frostedLow).toMatchObject({
backgroundVisibility: 0.14,
frostBlurScale: 1.48,
surfaceDensity: 0.96,
})
expect(frostedLow.frostDetailLevel).toBeCloseTo(0.1)
expect(getGlassMaterialResponse('frosted', 100)).toMatchObject({
backgroundVisibility: 0.88,
frostBlurScale: 0.52,
frostDetailLevel: 0.9,
surfaceDensity: 0.4,
})
expect(getGlassCssFrostBlur(0)).toEqual({ raised: 84, surface: 64 })
expect(getGlassCssFrostBlur(50)).toEqual({ raised: 62, surface: 44 })
expect(getGlassCssFrostBlur(100)).toEqual({ raised: 26, surface: 16 })
const samples = [0, 10, 20, 35, 50, 60, 70, 78, 85, 92, 100].map(value =>
getGlassMaterialResponse('frosted', value),
)
expect(
samples.every(
(sample, index) =>
index === 0 ||
(sample.backgroundVisibility > samples[index - 1].backgroundVisibility &&
sample.frostDetailLevel > samples[index - 1].frostDetailLevel &&
sample.surfaceDensity < samples[index - 1].surfaceDensity),
),
).toBe(true)
})
it('returns preset copies so previews cannot mutate the shared matrix', () => { it('returns preset copies so previews cannot mutate the shared matrix', () => {
const first = getGlassOpticalPresetParameters('tinted', 'high', 'glide') const first = getGlassOpticalPresetParameters('tinted', 'high', 'glide')
first.translation = 0 first.translation = 0
expect(getGlassOpticalPresetParameters('tinted', 'high', 'glide').translation).toBe(72) expect(getGlassOpticalPresetParameters('tinted', 'high', 'glide').translation).toBe(58)
}) })
it('matches the monotonic CSS ease timeline used by wallpaper crossfades', () => { it('matches the monotonic CSS ease timeline used by wallpaper crossfades', () => {
@@ -346,8 +507,8 @@ describe('glass optics geometry', () => {
}) })
it('crossfades active surface weights monotonically', () => { it('crossfades active surface weights monotonically', () => {
expect(getGlassOpticalSurfaceTransitionWeights(0, 96)).toEqual({ incoming: 0.35, outgoing: 1 }) expect(getGlassOpticalSurfaceTransitionWeights(0, 96)).toEqual({ incoming: 1, outgoing: 1 })
expect(getGlassOpticalSurfaceTransitionWeights(48, 96)).toEqual({ incoming: 0.675, outgoing: 0.5 }) expect(getGlassOpticalSurfaceTransitionWeights(48, 96)).toEqual({ incoming: 1, outgoing: 0.5 })
expect(getGlassOpticalSurfaceTransitionWeights(96, 96)).toEqual({ incoming: 1, outgoing: 0 }) expect(getGlassOpticalSurfaceTransitionWeights(96, 96)).toEqual({ incoming: 1, outgoing: 0 })
}) })
+135 -2
View File
@@ -1,7 +1,17 @@
import { DEFAULT_GLASS_WALLPAPER_TONE_PROFILE, getGlassWallpaperToneProfile } from '@/utils/glassWallpaperTone' import {
import { describe, expect, it } from 'vitest' DEFAULT_GLASS_WALLPAPER_TONE_PROFILE,
getGlassWallpaperToneProfile,
loadGlassWallpaperTone,
takeGlassWallpaperDecodedSource,
} from '@/utils/glassWallpaperTone'
import { afterEach, describe, expect, it, vi } from 'vitest'
describe('glass wallpaper tone profile', () => { describe('glass wallpaper tone profile', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('keeps a representative mid-tone wallpaper near neutral exposure', () => { it('keeps a representative mid-tone wallpaper near neutral exposure', () => {
const profile = getGlassWallpaperToneProfile([0.18, 0.3, 0.36, 0.4, 0.52, 0.72, 0.82]) const profile = getGlassWallpaperToneProfile([0.18, 0.3, 0.36, 0.4, 0.52, 0.72, 0.82])
@@ -32,4 +42,127 @@ describe('glass wallpaper tone profile', () => {
it('falls back to the neutral profile when no valid samples exist', () => { it('falls back to the neutral profile when no valid samples exist', () => {
expect(getGlassWallpaperToneProfile([Number.NaN])).toEqual(DEFAULT_GLASS_WALLPAPER_TONE_PROFILE) expect(getGlassWallpaperToneProfile([Number.NaN])).toEqual(DEFAULT_GLASS_WALLPAPER_TONE_PROFILE)
}) })
it('reuses the successful CORS image decode for readiness and tone analysis', async () => {
const pixels = new Uint8ClampedArray(64 * 64 * 4)
for (let offset = 0; offset < pixels.length; offset += 4) {
pixels[offset] = 128
pixels[offset + 1] = 128
pixels[offset + 2] = 128
pixels[offset + 3] = 255
}
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
drawImage: vi.fn(),
getImageData: vi.fn(() => ({ data: pixels })),
} as unknown as CanvasRenderingContext2D)
const fetchMock = vi.fn()
let imageCount = 0
class SuccessfulImage {
crossOrigin = ''
decoding = ''
height = 64
naturalHeight = 64
naturalWidth = 64
onerror: (() => void) | null = null
onload: (() => void) | null = null
width = 64
set src(_value: string) {
imageCount += 1
queueMicrotask(() => this.onload?.())
}
}
vi.stubGlobal('fetch', fetchMock)
vi.stubGlobal('Image', SuccessfulImage)
const result = await loadGlassWallpaperTone('https://image.example/success.jpg')
expect(result.corsReady).toBe(true)
expect(result.profile.medianLuminance).toBeCloseTo(128 / 255)
expect(imageCount).toBe(1)
expect(fetchMock).not.toHaveBeenCalled()
expect(takeGlassWallpaperDecodedSource('https://image.example/success.jpg')?.profile).toEqual(result.profile)
expect(takeGlassWallpaperDecodedSource('https://image.example/success.jpg')).toBeUndefined()
})
it('repairs a polluted browser cache before retrying the CORS image decode', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
drawImage: vi.fn(),
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray([128, 128, 128, 255]) })),
} as unknown as CanvasRenderingContext2D)
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
.mockResolvedValueOnce({
blob: vi.fn().mockResolvedValue(new Blob(['image'])),
ok: true,
})
let imageCount = 0
class RecoverableImage {
crossOrigin = ''
decoding = ''
height = 64
naturalHeight = 64
naturalWidth = 64
onerror: (() => void) | null = null
onload: (() => void) | null = null
width = 64
set src(_value: string) {
imageCount += 1
const succeeds = imageCount > 1
queueMicrotask(() => (succeeds ? this.onload?.() : this.onerror?.()))
}
}
vi.stubGlobal('fetch', fetchMock)
vi.stubGlobal('Image', RecoverableImage)
const result = await loadGlassWallpaperTone('https://image.example/recovered.jpg')
expect(result.corsReady).toBe(true)
expect(imageCount).toBe(2)
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock).toHaveBeenLastCalledWith(
new URL('https://image.example/recovered.jpg'),
expect.objectContaining({ cache: 'reload', credentials: 'omit', mode: 'cors' }),
)
})
it('retries a transiently failed CORS decode instead of caching the fallback', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
drawImage: vi.fn(),
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray([128, 128, 128, 255]) })),
} as unknown as CanvasRenderingContext2D)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
blob: vi.fn().mockResolvedValue(new Blob()),
ok: false,
}),
)
let imageCount = 0
class TransientImage {
crossOrigin = ''
decoding = ''
height = 64
naturalHeight = 64
naturalWidth = 64
onerror: (() => void) | null = null
onload: (() => void) | null = null
width = 64
set src(_value: string) {
imageCount += 1
queueMicrotask(() => (imageCount === 1 ? this.onerror?.() : this.onload?.()))
}
}
vi.stubGlobal('Image', TransientImage)
const failed = await loadGlassWallpaperTone('https://image.example/transient.jpg')
const recovered = await loadGlassWallpaperTone('https://image.example/transient.jpg')
expect(failed.corsReady).toBe(false)
expect(recovered.corsReady).toBe(true)
expect(imageCount).toBe(2)
})
}) })
+125 -40
View File
@@ -13,6 +13,8 @@ export const GLASS_OPTICAL_REFERENCE_STRENGTH = 70
export type GlassAppearance = 'clear' | 'frosted' | 'tinted' export type GlassAppearance = 'clear' | 'frosted' | 'tinted'
export type GlassOpticalCapability = 'balanced' | 'css' | 'high' export type GlassOpticalCapability = 'balanced' | 'css' | 'high'
export type GlassOpticalPreset = 'glide' | 'liquid' | 'natural' export type GlassOpticalPreset = 'glide' | 'liquid' | 'natural'
export type GlassOpticalPresetKey = `${GlassAppearance}:${GlassOpticalCapability}:${GlassOpticalPreset}`
export type GlassOpticalPresetOverrides = Partial<Record<GlassOpticalPresetKey, GlassOpticalParameters>>
export type GlassOpticalQuality = 'balanced' | 'high' export type GlassOpticalQuality = 'balanced' | 'high'
export type GlassCornerRadii = [number, number, number, number] export type GlassCornerRadii = [number, number, number, number]
@@ -31,6 +33,19 @@ export interface GlassOpticalParameters {
transparency: number transparency: number
} }
export interface GlassMaterialResponse {
/** 真实壁纸在表面材质后的感知可见程度。 */
backgroundVisibility: number
/** 磨砂预滤纹理允许保留的高频细节比例。 */
frostDetailLevel: number
/** 标准档 CSS 磨砂半径相对既有 40px 基线的缩放。 */
frostBlurScale: number
/** 表面遮罩对真实壁纸的覆盖密度。 */
surfaceDensity: number
/** 色调材质的主体染色密度。 */
tintDensity: number
}
export interface GlassInteractionPoint { export interface GlassInteractionPoint {
/** 指针或触点相对视口的横坐标。 */ /** 指针或触点相对视口的横坐标。 */
x: number x: number
@@ -128,59 +143,57 @@ export function normalizeGlassOpticalStrength(value: unknown) {
return Math.min(GLASS_OPTICAL_STRENGTH_MAX, Math.max(GLASS_OPTICAL_STRENGTH_MIN, Math.round(value))) return Math.min(GLASS_OPTICAL_STRENGTH_MAX, Math.max(GLASS_OPTICAL_STRENGTH_MIN, Math.round(value)))
} }
const GLASS_OPTICAL_PRESET_MATRIX: Record< type GlassOpticalPresetSet = Record<GlassOpticalPreset, GlassOpticalParameters>
GlassAppearance, type GlassOpticalCapabilityPresets = {
Record<GlassOpticalCapability, Record<GlassOpticalPreset, GlassOpticalParameters>> balanced: GlassOpticalPresetSet
> = { css: Pick<GlassOpticalPresetSet, 'natural'>
high: GlassOpticalPresetSet
}
const GLASS_OPTICAL_PRESET_MATRIX: Record<GlassAppearance, GlassOpticalCapabilityPresets> = {
clear: { clear: {
css: { css: {
natural: { deformation: 50, flow: 50, reflection: 35, transmission: 70, translation: 50, transparency: 70 }, natural: { deformation: 40, flow: 40, reflection: 35, transmission: 56, translation: 40, transparency: 48 },
glide: { deformation: 28, flow: 42, reflection: 29, transmission: 72, translation: 72, transparency: 78 },
liquid: { deformation: 72, flow: 78, reflection: 38, transmission: 66, translation: 56, transparency: 74 },
}, },
balanced: { balanced: {
natural: { deformation: 50, flow: 50, reflection: 35, transmission: 70, translation: 50, transparency: 70 }, natural: { deformation: 40, flow: 40, reflection: 35, transmission: 54, translation: 40, transparency: 46 },
glide: { deformation: 30, flow: 44, reflection: 29, transmission: 72, translation: 72, transparency: 78 }, glide: { deformation: 24, flow: 35, reflection: 29, transmission: 58, translation: 58, transparency: 56 },
liquid: { deformation: 70, flow: 76, reflection: 36, transmission: 66, translation: 56, transparency: 74 }, liquid: { deformation: 56, flow: 61, reflection: 36, transmission: 51, translation: 45, transparency: 51 },
}, },
high: { high: {
natural: { deformation: 50, flow: 50, reflection: 32, transmission: 70, translation: 50, transparency: 70 }, natural: { deformation: 40, flow: 40, reflection: 32, transmission: 53, translation: 40, transparency: 45 },
glide: { deformation: 32, flow: 46, reflection: 28, transmission: 74, translation: 74, transparency: 80 }, glide: { deformation: 26, flow: 37, reflection: 28, transmission: 56, translation: 59, transparency: 54 },
liquid: { deformation: 74, flow: 80, reflection: 35, transmission: 68, translation: 58, transparency: 76 }, liquid: { deformation: 59, flow: 64, reflection: 35, transmission: 50, translation: 46, transparency: 50 },
}, },
}, },
tinted: { tinted: {
css: { css: {
natural: { deformation: 50, flow: 50, reflection: 38, transmission: 68, translation: 50, transparency: 46 }, natural: { deformation: 40, flow: 40, reflection: 38, transmission: 54, translation: 40, transparency: 34 },
glide: { deformation: 30, flow: 42, reflection: 34, transmission: 74, translation: 70, transparency: 52 },
liquid: { deformation: 70, flow: 76, reflection: 41, transmission: 64, translation: 54, transparency: 48 },
}, },
balanced: { balanced: {
natural: { deformation: 52, flow: 50, reflection: 38, transmission: 72, translation: 50, transparency: 46 }, natural: { deformation: 42, flow: 40, reflection: 38, transmission: 56, translation: 40, transparency: 32 },
glide: { deformation: 32, flow: 44, reflection: 34, transmission: 78, translation: 70, transparency: 52 }, glide: { deformation: 26, flow: 35, reflection: 34, transmission: 61, translation: 56, transparency: 40 },
liquid: { deformation: 72, flow: 76, reflection: 39, transmission: 68, translation: 56, transparency: 48 }, liquid: { deformation: 58, flow: 61, reflection: 39, transmission: 53, translation: 45, transparency: 36 },
}, },
high: { high: {
natural: { deformation: 52, flow: 50, reflection: 35, transmission: 76, translation: 50, transparency: 48 }, natural: { deformation: 42, flow: 40, reflection: 35, transmission: 54, translation: 40, transparency: 30 },
glide: { deformation: 34, flow: 46, reflection: 32, transmission: 82, translation: 72, transparency: 54 }, glide: { deformation: 27, flow: 37, reflection: 32, transmission: 59, translation: 58, transparency: 38 },
liquid: { deformation: 76, flow: 80, reflection: 38, transmission: 72, translation: 58, transparency: 50 }, liquid: { deformation: 61, flow: 64, reflection: 38, transmission: 51, translation: 46, transparency: 34 },
}, },
}, },
frosted: { frosted: {
css: { css: {
natural: { deformation: 50, flow: 50, reflection: 31, transmission: 54, translation: 50, transparency: 42 }, natural: { deformation: 40, flow: 40, reflection: 31, transmission: 50, translation: 40, transparency: 31 },
glide: { deformation: 34, flow: 42, reflection: 27, transmission: 58, translation: 66, transparency: 46 },
liquid: { deformation: 76, flow: 74, reflection: 34, transmission: 50, translation: 50, transparency: 44 },
}, },
balanced: { balanced: {
natural: { deformation: 58, flow: 52, reflection: 31, transmission: 58, translation: 48, transparency: 42 }, natural: { deformation: 46, flow: 42, reflection: 31, transmission: 52, translation: 38, transparency: 29 },
glide: { deformation: 38, flow: 44, reflection: 27, transmission: 62, translation: 68, transparency: 46 }, glide: { deformation: 30, flow: 35, reflection: 27, transmission: 56, translation: 54, transparency: 40 },
liquid: { deformation: 78, flow: 76, reflection: 32, transmission: 54, translation: 52, transparency: 44 }, liquid: { deformation: 62, flow: 61, reflection: 32, transmission: 49, translation: 42, transparency: 34 },
}, },
high: { high: {
natural: { deformation: 60, flow: 52, reflection: 29, transmission: 62, translation: 48, transparency: 44 }, natural: { deformation: 48, flow: 42, reflection: 29, transmission: 50, translation: 38, transparency: 27 },
glide: { deformation: 40, flow: 46, reflection: 25, transmission: 66, translation: 70, transparency: 48 }, glide: { deformation: 32, flow: 37, reflection: 25, transmission: 54, translation: 56, transparency: 38 },
liquid: { deformation: 82, flow: 80, reflection: 31, transmission: 58, translation: 54, transparency: 46 }, liquid: { deformation: 66, flow: 64, reflection: 31, transmission: 47, translation: 43, transparency: 32 },
}, },
}, },
} }
@@ -191,7 +204,10 @@ export function getGlassOpticalPresetParameters(
quality: GlassOpticalCapability, quality: GlassOpticalCapability,
preset: GlassOpticalPreset, preset: GlassOpticalPreset,
): GlassOpticalParameters { ): GlassOpticalParameters {
return { ...GLASS_OPTICAL_PRESET_MATRIX[appearance][quality][preset] } const presets = GLASS_OPTICAL_PRESET_MATRIX[appearance][quality]
const parameters = 'natural' === preset ? presets.natural : (presets as Partial<GlassOpticalPresetSet>)[preset]
return { ...(parameters ?? presets.natural) }
} }
/** 标准档只保留自然基线;实时档同时开放滑移与液态方案。 */ /** 标准档只保留自然基线;实时档同时开放滑移与液态方案。 */
@@ -199,6 +215,79 @@ export function getAvailableGlassOpticalPresets(quality: GlassOpticalCapability)
return quality === 'css' ? ['natural'] : ['natural', 'glide', 'liquid'] return quality === 'css' ? ['natural'] : ['natural', 'glide', 'liquid']
} }
/** 生成持久化覆盖使用的稳定组合键;标准档始终归入自然方案。 */
export function getGlassOpticalPresetKey(
appearance: GlassAppearance,
quality: GlassOpticalCapability,
preset: GlassOpticalPreset,
): GlassOpticalPresetKey {
return `${appearance}:${quality}:${quality === 'css' ? 'natural' : preset}`
}
/** 切换组合时优先恢复用户覆盖,没有覆盖才返回预设矩阵副本。 */
export function getGlassOpticalPresetParametersWithOverrides(
appearance: GlassAppearance,
quality: GlassOpticalCapability,
preset: GlassOpticalPreset,
overrides: GlassOpticalPresetOverrides,
) {
const key = getGlassOpticalPresetKey(appearance, quality, preset)
return { ...(overrides[key] ?? getGlassOpticalPresetParameters(appearance, quality, preset)) }
}
const GLASS_RESPONSE_STOPS = [0, 20, 50, 70, 85, 100] as const
const GLASS_BACKGROUND_VISIBILITY: Record<GlassAppearance, readonly number[]> = {
clear: [0.18, 0.3, 0.58, 0.77, 0.9, 0.96],
tinted: [0.08, 0.2, 0.48, 0.7, 0.84, 0.92],
frosted: [0.04, 0.14, 0.35, 0.6, 0.78, 0.88],
}
const GLASS_SURFACE_DENSITY: Record<GlassAppearance, readonly number[]> = {
clear: [1, 0.88, 0.62, 0.42, 0.26, 0.18],
tinted: [1, 0.92, 0.72, 0.52, 0.39, 0.3],
frosted: [1, 0.96, 0.86, 0.68, 0.5, 0.4],
}
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.4, 0.18, 0.1] as const
/** 在相邻业务锚点之间使用零斜率边界插值,避免滑杆经过锚点时出现视觉折线。 */
function interpolateGlassResponse(value: unknown, anchors: readonly number[]) {
const normalized = normalizeGlassOpticalStrength(value)
const upperIndex = GLASS_RESPONSE_STOPS.findIndex(stop => normalized <= stop)
if (upperIndex <= 0) return anchors[0]
const lowerIndex = upperIndex - 1
const lowerStop = GLASS_RESPONSE_STOPS[lowerIndex]
const upperStop = GLASS_RESPONSE_STOPS[upperIndex]
const linearProgress = (normalized - lowerStop) / (upperStop - lowerStop)
const smoothProgress = linearProgress * linearProgress * (3 - 2 * linearProgress)
return anchors[lowerIndex] + (anchors[upperIndex] - anchors[lowerIndex]) * smoothProgress
}
/**
* tone
*/
export function getGlassMaterialResponse(appearance: GlassAppearance, value: unknown): GlassMaterialResponse {
const frostDensity = interpolateGlassResponse(value, GLASS_FROST_DENSITY)
return {
backgroundVisibility: interpolateGlassResponse(value, GLASS_BACKGROUND_VISIBILITY[appearance]),
frostBlurScale: 0.4 + frostDensity * 1.2,
frostDetailLevel: 1 - frostDensity,
surfaceDensity: interpolateGlassResponse(value, GLASS_SURFACE_DENSITY[appearance]),
tintDensity: interpolateGlassResponse(value, GLASS_TINT_DENSITY),
}
}
/** 标准档磨砂使用独立的 surface/raised 半径锚点,不借用背景亮度制造厚度。 */
export function getGlassCssFrostBlur(value: unknown) {
return {
raised: interpolateGlassResponse(value, [84, 76, 62, 46, 34, 26]),
surface: interpolateGlassResponse(value, [64, 58, 44, 30, 22, 16]),
}
}
/** 计算与 CSS `ease` 相同的交叉淡化进度,使 DOM 壁纸与 shader 双纹理保持同一时钟。 */ /** 计算与 CSS `ease` 相同的交叉淡化进度,使 DOM 壁纸与 shader 双纹理保持同一时钟。 */
export function getGlassWallpaperTransitionProgress(elapsed: number, duration: number) { export function getGlassWallpaperTransitionProgress(elapsed: number, duration: number) {
if (duration <= 0 || elapsed >= duration) return 1 if (duration <= 0 || elapsed >= duration) return 1
@@ -258,15 +347,11 @@ export function getGlassOpticalFlowStrengthScale(value: unknown) {
return normalizedRatio ** Math.log2(GLASS_OPTICAL_FLOW_MAX_SCALE) return normalizedRatio ** Math.log2(GLASS_OPTICAL_FLOW_MAX_SCALE)
} }
/** 高于默认值的流动强度逐步扩大作用范围,低区间不会意外改变既有空间尺度。 */ /** 流动强度在完整滑杆区间连续控制轨迹范围,低值也能明显收紧空间足迹。 */
export function getGlassOpticalMotionExpansion(value: unknown) { export function getGlassOpticalMotionExpansion(value: unknown) {
const normalized = normalizeGlassOpticalStrength(value) const normalized = normalizeGlassOpticalStrength(value)
const highRangeProgress = Math.max(
0,
(normalized - GLASS_OPTICAL_STRENGTH_DEFAULT) / (GLASS_OPTICAL_STRENGTH_MAX - GLASS_OPTICAL_STRENGTH_DEFAULT),
)
return highRangeProgress ** 1.55 return (normalized / GLASS_OPTICAL_STRENGTH_MAX) ** 1.4
} }
/** 最大几何形变只由质量档约束;流动滑杆改变覆盖与连续性,不继续拉伸背景内容。 */ /** 最大几何形变只由质量档约束;流动滑杆改变覆盖与连续性,不继续拉伸背景内容。 */
@@ -442,13 +527,13 @@ export function getGlassOpticalWakeDirection(
return directionDot < Math.cos((55 * Math.PI) / 180) ? nextDirection : currentDirection return directionDot < Math.cos((55 * Math.PI) / 180) ? nextDirection : currentDirection
} }
/** 活动表面立即可感知,随后与离场表面完成短时单调交叉过渡。 */ /** 活动表面立即接管输入,离场表面只做短时单调淡出。 */
export function getGlassOpticalSurfaceTransitionWeights(elapsed: number, duration: number) { export function getGlassOpticalSurfaceTransitionWeights(elapsed: number, duration: number) {
const progress = Math.min(1, Math.max(0, elapsed / Math.max(1, duration))) const progress = Math.min(1, Math.max(0, elapsed / Math.max(1, duration)))
const eased = progress * progress * (3 - 2 * progress) const eased = progress * progress * (3 - 2 * progress)
return { return {
incoming: 0.35 + eased * 0.65, incoming: 1,
outgoing: 1 - eased, outgoing: 1 - eased,
} }
} }
+103 -15
View File
@@ -1,3 +1,5 @@
import { preloadCorsImage } from '@/@core/utils/corsImage'
export interface GlassWallpaperToneProfile { export interface GlassWallpaperToneProfile {
/** 进入材质曲线前的有限整体曝光,避免壁纸明暗差异直接放大到操作表面。 */ /** 进入材质曲线前的有限整体曝光,避免壁纸明暗差异直接放大到操作表面。 */
exposure: number exposure: number
@@ -7,7 +9,24 @@ export interface GlassWallpaperToneProfile {
medianLuminance: number medianLuminance: number
} }
/** 一次图片解码同时给出 WebGL 可读性与壁纸曝光分析结果。 */
export interface GlassWallpaperToneLoadResult {
/** 当前 URL 已按匿名 CORS 模式完成解码,可安全交给 WebGL。 */
corsReady: boolean
/** 与 DOM 背景和 renderer 共用的有限曝光 profile。 */
profile: GlassWallpaperToneProfile
}
/** 交给 renderer 消费的一次性已解码壁纸源。 */
export interface GlassWallpaperDecodedSource {
/** 已完成匿名 CORS 解码的图片。 */
image: HTMLImageElement
/** 与该图片同一次解码得到的曝光 profile。 */
profile: GlassWallpaperToneProfile
}
const ANALYSIS_MAX_EDGE = 64 const ANALYSIS_MAX_EDGE = 64
const DECODED_SOURCE_CACHE_LIMIT = 3
const PROFILE_CACHE_LIMIT = 32 const PROFILE_CACHE_LIMIT = 32
const PROFILE_LOAD_TIMEOUT_MS = 3000 const PROFILE_LOAD_TIMEOUT_MS = 3000
const EXPOSURE_MIN = 0.88 const EXPOSURE_MIN = 0.88
@@ -21,7 +40,8 @@ export const DEFAULT_GLASS_WALLPAPER_TONE_PROFILE: GlassWallpaperToneProfile = {
medianLuminance: MEDIAN_TARGET, medianLuminance: MEDIAN_TARGET,
} }
const profileCache = new Map<string, Promise<GlassWallpaperToneProfile>>() const profileCache = new Map<string, Promise<GlassWallpaperToneLoadResult>>()
const decodedSourceCache = new Map<string, GlassWallpaperDecodedSource>()
function clamp(value: number, min: number, max: number) { function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value)) return Math.min(max, Math.max(min, value))
@@ -86,7 +106,7 @@ export function analyzeGlassWallpaperTone(image: CanvasImageSource, width: numbe
} }
} }
function rememberProfile(url: string, profile: Promise<GlassWallpaperToneProfile>) { function rememberProfile(url: string, profile: Promise<GlassWallpaperToneLoadResult>) {
if (profileCache.size >= PROFILE_CACHE_LIMIT) { if (profileCache.size >= PROFILE_CACHE_LIMIT) {
const oldestKey = profileCache.keys().next().value const oldestKey = profileCache.keys().next().value
if (oldestKey) profileCache.delete(oldestKey) if (oldestKey) profileCache.delete(oldestKey)
@@ -96,37 +116,105 @@ function rememberProfile(url: string, profile: Promise<GlassWallpaperToneProfile
return profile return profile
} }
/** 只保留 current/previous/prepared 三个候选,避免完整解码图片变成长期内存缓存。 */
function rememberDecodedSource(url: string, source: GlassWallpaperDecodedSource) {
decodedSourceCache.delete(url)
decodedSourceCache.set(url, source)
while (decodedSourceCache.size > DECODED_SOURCE_CACHE_LIMIT) {
const oldestKey = decodedSourceCache.keys().next().value
if (oldestKey) decodedSourceCache.delete(oldestKey)
}
}
/** /**
* DOM CORS profile * renderer tone
* CSS * fixed scroll context renderer CPU source cache
*/ */
export function loadGlassWallpaperToneProfile(url: string): Promise<GlassWallpaperToneProfile> { export function takeGlassWallpaperDecodedSource(url: string): GlassWallpaperDecodedSource | undefined {
if (!url || typeof Image === 'undefined') return Promise.resolve({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }) const source = decodedSourceCache.get(url)
if (source) decodedSourceCache.delete(url)
const cached = profileCache.get(url) return source
if (cached) return cached }
const profile = new Promise<GlassWallpaperToneProfile>(resolve => { function loadCorsReadableToneProfile(url: string): Promise<GlassWallpaperToneLoadResult> {
return new Promise(resolve => {
const image = new Image() const image = new Image()
let settled = false let settled = false
const finish = (result: GlassWallpaperToneProfile) => { const finish = (result: GlassWallpaperToneLoadResult) => {
if (settled) return if (settled) return
settled = true settled = true
window.clearTimeout(timeout) window.clearTimeout(timeout)
resolve(result) resolve(result)
} }
const timeout = window.setTimeout( const timeout = window.setTimeout(
() => finish({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }), () => finish({ corsReady: false, profile: { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE } }),
PROFILE_LOAD_TIMEOUT_MS, PROFILE_LOAD_TIMEOUT_MS,
) )
image.crossOrigin = 'anonymous' image.crossOrigin = 'anonymous'
image.decoding = 'async' image.decoding = 'async'
image.onload = () => image.onload = () => {
finish(analyzeGlassWallpaperTone(image, image.naturalWidth || image.width, image.naturalHeight || image.height)) const profile = analyzeGlassWallpaperTone(
image.onerror = () => finish({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }) image,
image.naturalWidth || image.width,
image.naturalHeight || image.height,
)
rememberDecodedSource(url, { image, profile })
finish({
corsReady: true,
profile,
})
}
image.onerror = () =>
finish({
corsReady: false,
profile: { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE },
})
image.src = url image.src = url
}) })
}
return rememberProfile(url, profile) /**
* WebGL
* CORS
*/
export function loadGlassWallpaperTone(url: string): Promise<GlassWallpaperToneLoadResult> {
if (!url || typeof Image === 'undefined') {
return Promise.resolve({
corsReady: false,
profile: { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE },
})
}
const cached = profileCache.get(url)
if (cached) return cached
const profile = (async () => {
const initial = await loadCorsReadableToneProfile(url)
if (initial.corsReady || !(await preloadCorsImage(url))) return initial
return loadCorsReadableToneProfile(url)
})()
rememberProfile(url, profile)
void profile.then(
result => {
if (!result.corsReady && profileCache.get(url) === profile) profileCache.delete(url)
},
() => {
if (profileCache.get(url) === profile) profileCache.delete(url)
},
)
return profile
}
/**
* DOM CORS profile
* CSS
*/
export async function loadGlassWallpaperToneProfile(url: string): Promise<GlassWallpaperToneProfile> {
return (await loadGlassWallpaperTone(url)).profile
} }