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 4146 additions and 633 deletions

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
}
}

View File

@@ -1,4 +1,5 @@
import ColorThief from 'colorthief'
export { preloadCorsImage } from './corsImage'
const DEFAULT_DOMINANT_COLOR = '#28A9E1'
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
}
}

View File

@@ -7,7 +7,7 @@ import { useAuthStore, useGlobalSettingsStore } from '@/stores'
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
import { SupportedLocale } from '@/types/i18n'
import { checkAndEmitUnreadMessages } from '@/utils/badge'
import { preloadCorsImage, preloadImage } from './@core/utils/image'
import { preloadImage } from './@core/utils/image'
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
@@ -27,6 +27,7 @@ import { getDisplayImageUrl } from '@/utils/imageUtils'
import { configureApexChartsTheme } from '@/utils/apexCharts'
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
import { useGlassWallpaperTransaction } from '@/composables/useGlassWallpaperTransaction'
import {
BACKGROUND_ROTATION_GRACE_MS,
createBackgroundCandidateOrderResolver,
@@ -45,7 +46,7 @@ import {
} from '@/utils/loginPresentation'
import {
DEFAULT_GLASS_WALLPAPER_TONE_PROFILE,
loadGlassWallpaperToneProfile,
loadGlassWallpaperTone,
type GlassWallpaperToneProfile,
} from '@/utils/glassWallpaperTone'
@@ -97,6 +98,17 @@ function resolveInitialThemeName(themePreference: string) {
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 { global: globalTheme } = vuetifyTheme
@@ -135,12 +147,22 @@ const globalSettingsStore = useGlobalSettingsStore()
// 背景图片
const backgroundImages = ref<string[]>([])
const backgroundLayers = ref(createLoginBackgroundLayers())
const backgroundDisplayImages = ref<Record<string, string>>({})
const backgroundToneProfiles = ref<Record<string, GlassWallpaperToneProfile>>({})
const activeImageIndex = ref(0)
const previousImageIndex = ref<number | null>(null)
const isBackgroundCrossfading = ref(false)
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 { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle()
const preferredMotion = usePreferredReducedMotion()
@@ -213,13 +235,16 @@ const shouldLoadBackgroundImages = computed(
)
const activeBackgroundImage = computed(() => backgroundImages.value[activeImageIndex.value] ?? '')
const renderedBackgroundLayers = computed(() => backgroundLayers.value)
const getOpticalBackgroundImage = (imageUrl: string) => getDisplayImageUrl(imageUrl, Boolean(isLogin.value))
const activeOpticalBackgroundImage = computed(() => getOpticalBackgroundImage(activeBackgroundImage.value))
const getOpticalBackgroundImage = (imageUrl: string) => imageUrl
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 previousIndex = previousImageIndex.value
if (previousIndex === null) return ''
return getOpticalBackgroundImage(backgroundImages.value[previousIndex] ?? '')
return getPreparedOpticalBackgroundImage(backgroundImages.value[previousIndex] ?? '')
})
const shouldRenderGlassOpticalLayer = computed(
() =>
@@ -242,12 +267,12 @@ const shouldRenderGlobalBlurLayer = computed(
let backgroundRetryTimer: number | null = null
let backgroundRequestController: AbortController | 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 backgroundLoadVersion = 0
let backgroundRecoveryAttemptedVersion = -1
let backgroundRotationVersion = 0
let backgroundPreloadIdleHandle: number | null = null
let backgroundPreloadTimer: number | null = null
// 读取并同步透明主题背景设置到根组件响应式状态。
function applyTransparentBackgroundSettings() {
@@ -265,27 +290,15 @@ function handleTransparencySettingsChanged(event: Event) {
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) {
const profile = backgroundToneProfiles.value[layer.url] ?? DEFAULT_GLASS_WALLPAPER_TONE_PROFILE
const appearance = effectiveGlassSettings.value.glassAppearance
const materialExposure = appearance === 'frosted' ? 0.82 : appearance === 'tinted' ? 0.85 : 0.86
const displayUrl = isGlassTheme.value ? getPreparedBackgroundImage(layer.url) : layer.url
return {
'backgroundImage': layer.url ? `url(${layer.url})` : undefined,
'backgroundImage': displayUrl ? `url(${displayUrl})` : undefined,
'--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 完成下一张纹理上传。 */
function prepareOpticalWallpaper(url: string) {
async function prepareOpticalWallpaper(url: string) {
if (!shouldRenderGlassOpticalLayer.value || !url || url === activeOpticalBackgroundImage.value) {
return Promise.resolve(true)
return { ready: true, revision: 0 }
}
settlePendingOpticalWallpaper(false)
pendingOpticalBackgroundImage.value = url
const ready = requestOpticalWallpaperPreparation(url)
const revision = pendingOpticalWallpaperRevision.value
return new Promise<boolean>(resolve => {
pendingOpticalWallpaperResolve = resolve
pendingOpticalWallpaperTimer = window.setTimeout(() => settlePendingOpticalWallpaper(false), 10000)
})
return { ready: await ready, revision }
}
/** 只接受当前待切换 URL 的 renderer 就绪回执。 */
function handleOpticalWallpaperPrepared(url: string) {
if (url === pendingOpticalBackgroundImage.value) settlePendingOpticalWallpaper(true)
function handleOpticalWallpaperPrepared(url: string, revision: number) {
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
clearBackgroundCrossfadeTimer()
backgroundLayers.value = prepareLoginBackgroundLayer(backgroundLayers.value, backgroundImages.value[nextIndex] ?? '')
previousImageIndex.value = activeImageIndex.value
isBackgroundCrossfading.value = true
backgroundCrossfadeStartedAt.value = performance.now()
backgroundCrossfadeStartedAt.value = startedAt
activeImageIndex.value = nextIndex
backgroundLayers.value = activateLoginBackgroundLayer(backgroundLayers.value)
const remainingDuration = Math.max(0, BACKGROUND_CROSSFADE_DURATION_MS - (performance.now() - startedAt))
backgroundCrossfadeTimer = window.setTimeout(() => {
previousImageIndex.value = null
isBackgroundCrossfading.value = false
backgroundLayers.value = settleLoginBackgroundLayers(backgroundLayers.value)
backgroundCrossfadeTimer = null
}, BACKGROUND_CROSSFADE_DURATION_MS)
}, remainingDuration)
}
// 获取背景图片列表;只有选出实际可用的首图后才提交到可见状态。
@@ -592,31 +610,86 @@ function preloadNextBackgroundImage() {
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) {
const toneProfile = isGlassTheme.value
? ensureBackgroundToneProfile(imageUrl)
: Promise.resolve(DEFAULT_GLASS_WALLPAPER_TONE_PROFILE)
if (!shouldRenderGlassOpticalLayer.value) {
const [available] = await Promise.all([preloadImage(imageUrl), toneProfile])
recordGlassLaunchTiming('wallpaper-source-requested', imageUrl)
if (!isGlassTheme.value) return preloadImage(imageUrl)
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)
const opticalReady = await preloadCorsImage(opticalUrl)
if (!opticalReady) {
const [displayReady] = await Promise.all([preloadImage(imageUrl), toneProfile])
return displayReady
backgroundDisplayImages.value = {
...backgroundDisplayImages.value,
[imageUrl]: imageUrl,
}
const [displayReady] = await Promise.all([
opticalUrl === imageUrl ? Promise.resolve(true) : preloadImage(imageUrl),
toneProfile,
])
return displayReady
const ready = await preloadImage(imageUrl)
recordGlassLaunchTiming(ready ? 'wallpaper-source-ready' : 'wallpaper-source-failed', imageUrl)
return ready
}
// 背景图片轮换函数
@@ -630,20 +703,35 @@ async function rotateBackgroundImage() {
const nextIndex = (activeIndex + offset) % backgroundImages.value.length
const nextImage = backgroundImages.value[nextIndex]
const opticalImage = shouldRenderGlassOpticalLayer.value ? getOpticalBackgroundImage(nextImage) : undefined
if (opticalImage && !(await prepareOpticalWallpaper(opticalImage))) continue
const imagesReady = await preloadBackgroundRotationImages({
displayUrl: nextImage,
opticalUrl: opticalImage,
preload: preloadImage,
})
if (!imagesReady) continue
await ensureBackgroundToneProfile(nextImage)
if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return
let opticalRevision = 0
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({
displayUrl: isGlassTheme.value ? getPreparedBackgroundImage(nextImage) : nextImage,
opticalUrl: opticalImage,
preload: preloadImage,
})
if (!imagesReady) continue
if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return
activateBackgroundImage(nextIndex)
preloadNextBackgroundImage()
return
if (opticalImage) {
if (!(await requestOpticalWallpaperActivation(nextIndex, opticalRevision))) continue
} else {
activateBackgroundImage(nextIndex)
}
scheduleNextBackgroundPreload()
return
} finally {
if (opticalRevision > 0) cancelOpticalWallpaperTransaction(opticalRevision)
}
}
if (requestVersion === backgroundRotationVersion && backgroundRecoveryAttemptedVersion !== backgroundLoadVersion) {
@@ -657,8 +745,9 @@ async function rotateBackgroundImage() {
// 停止轮询并使已经发起的下一图准备失效,避免非活动状态收到迟到提交。
function stopBackgroundRotation() {
backgroundRotationVersion += 1
cancelNextBackgroundPreload()
removeBackgroundTimer('background-rotation')
settlePendingOpticalWallpaper(false)
cancelOpticalWallpaperTransaction()
}
function clearBackgroundRotationGrace() {
@@ -684,7 +773,7 @@ function startBackgroundRotation() {
stopBackgroundRotation()
if (allowsBackgroundRotation.value && backgroundImages.value.length > 1) {
preloadNextBackgroundImage()
scheduleNextBackgroundPreload()
// 隐藏页面也允许在有界宽限期内轮换,回调自身会再次核对生命周期。
addBackgroundTimer(
'background-rotation',
@@ -784,11 +873,15 @@ async function animateAndRemoveLoader() {
document.documentElement.style.removeProperty('overflow')
document.body.style.removeProperty('overflow')
completeLaunchLoading()
recordGlassLaunchTiming('loader-removed')
scheduleNextBackgroundPreload()
resolve()
}, LAUNCH_EXIT_DURATION_MS)
})
} else {
completeLaunchLoading()
recordGlassLaunchTiming('loader-removed')
scheduleNextBackgroundPreload()
}
}
@@ -860,6 +953,7 @@ async function loadBackgroundImages(loadVersion: number, retryCount = 0) {
}
backgroundRecoveryAttemptedVersion = -1
resetBackgroundCrossfade()
recordGlassLaunchTiming('wallpaper-committed', activeBackgroundImage.value)
startBackgroundRotation()
} catch (error: any) {
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 () => {
// 移除URL中的时间戳参数
const url = new URL(window.location.href)
@@ -915,28 +1031,6 @@ onMounted(async () => {
window.addEventListener('focus', handlePageShowThemeSync)
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(() => {
nextTick(removeLoadingWithStateCheck)
@@ -1031,6 +1125,11 @@ onUnmounted(() => {
:wallpaper-url="activeOpticalBackgroundImage"
:previous-wallpaper-url="previousOpticalBackgroundImage"
: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"
/>
<!-- 页面内容 -->

View File

@@ -11,10 +11,15 @@ import {
GLASS_OPTICAL_STRENGTH_MAX,
GLASS_OPTICAL_STRENGTH_MIN,
getAvailableGlassOpticalPresets,
getGlassOpticalPresetKey,
getGlassOpticalPresetParameters,
getGlassOpticalPresetParametersWithOverrides,
normalizeGlassOpticalStrength,
type GlassOpticalParameters,
type GlassOpticalPreset,
type GlassOpticalPresetOverrides,
} from '@/utils/glassOptics'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
const props = withDefaults(
@@ -32,11 +37,13 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const display = useDisplay()
const { settings } = useThemeCustomizer()
const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
const draftDeformationStrength = ref(settings.value.glassDeformationStrength)
const draftFlowStrength = ref(settings.value.glassFlowStrength)
const draftPreset = ref<GlassOpticalPreset>(settings.value.glassPreset)
const draftPresetOverrides = ref<GlassOpticalPresetOverrides>({ ...settings.value.glassPresetOverrides })
const draftQuality = ref<ThemeCustomizerGlassQuality>(settings.value.glassQuality)
const draftReflectionStrength = ref(settings.value.glassReflectionStrength)
const draftTransmissionStrength = ref(settings.value.glassTransmissionStrength)
@@ -68,6 +75,7 @@ watch(
draftDeformationStrength.value = settings.value.glassDeformationStrength
draftFlowStrength.value = settings.value.glassFlowStrength
draftPreset.value = settings.value.glassPreset
draftPresetOverrides.value = { ...settings.value.glassPresetOverrides }
draftQuality.value = settings.value.glassQuality
draftReflectionStrength.value = settings.value.glassReflectionStrength
draftTransmissionStrength.value = settings.value.glassTransmissionStrength
@@ -112,7 +120,7 @@ function updateAppearance(value: unknown) {
if (value !== 'clear' && value !== 'tinted' && value !== 'frosted') return
draftAppearance.value = value
previewGlassSettings({ glassAppearance: value })
applyPreset(activePreset.value)
}
/** 仅允许面板声明的质量档位进入待保存设置。 */
@@ -121,15 +129,18 @@ function updateQuality(value: unknown) {
if (!option) return
draftQuality.value = option.value
previewGlassSettings({ glassQuality: option.value })
applyPreset(activePreset.value)
}
/** 将六个具体参数作为一个预览事务同步,预置只负责生成这些值。 */
/** 将材质、质量、预设归属与六个具体参数作为一个预览事务同步。 */
function previewDraftParameters() {
previewGlassSettings({
glassAppearance: draftAppearance.value,
glassDeformationStrength: draftDeformationStrength.value,
glassFlowStrength: draftFlowStrength.value,
glassPreset: draftPreset.value,
glassPresetOverrides: draftPresetOverrides.value,
glassQuality: draftQuality.value,
glassReflectionStrength: draftReflectionStrength.value,
glassTransmissionStrength: draftTransmissionStrength.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) {
if (value !== 'natural' && value !== 'glide' && value !== 'liquid') return
if (!availablePresets.value.includes(value)) return
const parameters = getGlassOpticalPresetParameters(draftAppearance.value, draftQuality.value, value)
draftPreset.value = value
const effectivePreset = availablePresets.value.includes(value) ? value : 'natural'
const parameters = getGlassOpticalPresetParametersWithOverrides(
draftAppearance.value,
draftQuality.value,
effectivePreset,
draftPresetOverrides.value,
)
draftPreset.value = effectivePreset
draftDeformationStrength.value = parameters.deformation
draftFlowStrength.value = parameters.flow
draftReflectionStrength.value = parameters.reflection
@@ -153,45 +180,66 @@ function applyPreset(value: unknown) {
previewDraftParameters()
}
/** 用调整后的当前六参数覆盖当前材质、质量与方案组合。 */
function updateDraftPresetOverride() {
const key = getGlassOpticalPresetKey(draftAppearance.value, draftQuality.value, draftPreset.value)
draftPresetOverrides.value = {
...draftPresetOverrides.value,
[key]: getDraftParameters(),
}
previewDraftParameters()
}
/** 将采样平移限制为 renderer 支持的稳定范围。 */
function updateTranslationStrength(value: unknown) {
draftTranslationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassTranslationStrength: draftTranslationStrength.value })
updateDraftPresetOverride()
}
/** 将局部形变限制为质量档软上限所消费的用户范围。 */
function updateDeformationStrength(value: unknown) {
draftDeformationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassDeformationStrength: draftDeformationStrength.value })
updateDraftPresetOverride()
}
/** 将尾波、惯性与收敛输入限制为 renderer 的稳定范围。 */
function updateFlowStrength(value: unknown) {
draftFlowStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassFlowStrength: draftFlowStrength.value })
updateDraftPresetOverride()
}
/** 将滑杆输入限制为 renderer 的稳定范围并即时预览反射亮度。 */
function updateReflectionStrength(value: unknown) {
draftReflectionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassReflectionStrength: draftReflectionStrength.value })
updateDraftPresetOverride()
}
/** 将透射亮度限制为稳定范围并即时调整卡片内部壁纸的明暗。 */
function updateTransmissionStrength(value: unknown) {
draftTransmissionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassTransmissionStrength: draftTransmissionStrength.value })
updateDraftPresetOverride()
}
/** 将通透度限制为稳定范围并即时调整材质与真实壁纸的占比。 */
function updateTransparencyStrength(value: unknown) {
draftTransparencyStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassTransparencyStrength: draftTransparencyStrength.value })
updateDraftPresetOverride()
}
/** 保留当前材质与质量,将参数恢复为当前高亮方案的建议值。 */
/** 删除当前组合覆盖并恢复该方案矩阵,不影响其他组合。 */
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,
glassFlowStrength: draftFlowStrength.value,
glassPreset: draftPreset.value,
glassPresetOverrides: draftPresetOverrides.value,
glassQuality: draftQuality.value,
glassReflectionStrength: draftReflectionStrength.value,
glassTransmissionStrength: draftTransmissionStrength.value,
@@ -224,7 +273,14 @@ onScopeDispose(cancelGlassPreview)
</script>
<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>
<VCardItem>
<VCardTitle>
@@ -415,14 +471,21 @@ onScopeDispose(cancelGlassPreview)
</VCardText>
<VDivider />
<VCardText class="text-center">
<VBtn variant="outlined" prepend-icon="mdi-refresh" class="me-2" @click="resetSettings">
<VCardActions class="glass-settings-dialog__actions justify-center">
<VBtn :slim="false" variant="outlined" prepend-icon="mdi-refresh" class="me-2" @click="resetSettings">
{{ t('common.reset') }}
</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') }}
</VBtn>
</VCardText>
</VCardActions>
</VCard>
</VDialog>
</template>
@@ -436,6 +499,11 @@ onScopeDispose(cancelGlassPreview)
padding: 20px 24px 24px;
}
.glass-settings-dialog__actions {
flex: none;
padding: 16px 24px;
}
.glass-settings-dialog__label {
margin: 0 0 10px;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));

View File

@@ -173,12 +173,7 @@ onBeforeMount(async () => {
<template>
<VDialog scrollable :max-width="dialogMaxWidth" :fullscreen="!display.mdAndUp.value">
<!-- Vuetify 渲染模式 -->
<VCard
v-if="renderMode === 'vuetify'"
:title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`"
data-glass-optical-surface
data-glass-optical-mode="static-material"
>
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`">
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
@@ -213,7 +208,7 @@ onBeforeMount(async () => {
</VCardActions>
</VCard>
<!-- 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">
<component
:is="dynamicComponent"

View File

@@ -136,12 +136,7 @@ onMounted(() => {
<template>
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<!-- Vuetify 渲染模式 -->
<VCard
v-if="renderMode === 'vuetify'"
:title="`${props.plugin?.plugin_name}`"
data-glass-optical-surface
data-glass-optical-mode="static-material"
>
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name}`">
<VDialogCloseBtn @click="emit('close')" />
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
<VCardText v-else class="min-h-40">
@@ -163,7 +158,7 @@ onMounted(() => {
/>
</VCard>
<!-- 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">
<component
:is="dynamicComponent"

View File

@@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import GlassSettingsDialog from '@/components/dialog/GlassSettingsDialog.vue'
const slotStub = { template: '<div><slot /></div>' }
const dialogStub = {
props: ['fullscreen'],
template: '<div class="dialog-stub" :data-fullscreen="String(fullscreen)"><slot /></div>',
}
const toggleStub = {
props: ['modelValue'],
template: '<div :data-model-value="modelValue"><slot /></div>',
@@ -25,6 +29,7 @@ const mocks = vi.hoisted(() => ({
glassDeformationStrength: 50,
glassFlowStrength: 50,
glassPreset: 'natural',
glassPresetOverrides: {},
glassQuality: 'css',
glassReflectionStrength: 50,
glassTransmissionStrength: 50,
@@ -47,6 +52,10 @@ vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('vuetify', () => ({
useDisplay: () => ({ smAndDown: { value: true } }),
}))
describe('GlassSettingsDialog', () => {
beforeEach(() => {
mocks.cancelGlassPreview.mockClear()
@@ -56,6 +65,7 @@ describe('GlassSettingsDialog', () => {
mocks.settings.value.glassDeformationStrength = 50
mocks.settings.value.glassFlowStrength = 50
mocks.settings.value.glassPreset = 'natural'
mocks.settings.value.glassPresetOverrides = {}
mocks.settings.value.glassQuality = 'css'
mocks.settings.value.glassReflectionStrength = 50
mocks.settings.value.glassTransmissionStrength = 50
@@ -68,8 +78,9 @@ describe('GlassSettingsDialog', () => {
global: {
stubs: {
VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub,
VDialog: slotStub,
VDialog: dialogStub,
VDialogCloseBtn: true,
VSlider: sliderStub,
},
@@ -81,6 +92,8 @@ describe('GlassSettingsDialog', () => {
expect(sliders).toHaveLength(3)
expect(sliders[0].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 })
expect(mocks.cancelGlassPreview).toHaveBeenCalledOnce()
@@ -96,17 +109,37 @@ describe('GlassSettingsDialog', () => {
mocks.settings.value.glassTransmissionStrength = 57
mocks.settings.value.glassTranslationStrength = 57
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, {
global: {
stubs: {
VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub,
VBtn: {
emits: ['click'],
props: ['prependIcon'],
template: '<button :data-icon="prependIcon" @click="$emit(\'click\')"><slot /></button>',
props: ['prependIcon', 'slim', 'variant'],
template:
'<button :data-icon="prependIcon" :data-slim="String(slim)" :data-variant="variant" @click="$emit(\'click\')"><slot /></button>',
},
VDialog: slotStub,
VDialog: dialogStub,
VDialogCloseBtn: true,
VDivider: true,
VSlider: sliderStub,
@@ -115,17 +148,33 @@ describe('GlassSettingsDialog', () => {
props: { modelValue: true },
})
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')
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
glassDeformationStrength: 82,
glassFlowStrength: 80,
glassAppearance: 'frosted',
glassDeformationStrength: 66,
glassFlowStrength: 64,
glassPreset: 'liquid',
glassPresetOverrides: {
'clear:balanced:glide': {
deformation: 24,
flow: 35,
reflection: 46,
transmission: 57,
translation: 68,
transparency: 79,
},
},
glassQuality: 'high',
glassReflectionStrength: 31,
glassTransmissionStrength: 58,
glassTranslationStrength: 54,
glassTransparencyStrength: 46,
glassTransmissionStrength: 47,
glassTranslationStrength: 43,
glassTransparencyStrength: 32,
})
expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
})
@@ -135,8 +184,9 @@ describe('GlassSettingsDialog', () => {
global: {
stubs: {
VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub,
VDialog: slotStub,
VDialog: dialogStub,
VDialogCloseBtn: true,
VSlider: sliderStub,
},
@@ -149,7 +199,27 @@ describe('GlassSettingsDialog', () => {
expect(sliders[2].attributes('data-disabled')).toBe('undefined')
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', () => {
@@ -157,8 +227,9 @@ describe('GlassSettingsDialog', () => {
global: {
stubs: {
VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub,
VDialog: slotStub,
VDialog: dialogStub,
VDialogCloseBtn: true,
VSlider: sliderStub,
},
@@ -170,7 +241,7 @@ describe('GlassSettingsDialog', () => {
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.glassPreset = 'glide'
mocks.settings.value.glassTransparencyStrength = 61
@@ -178,10 +249,11 @@ describe('GlassSettingsDialog', () => {
global: {
stubs: {
VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub,
VBtn: slotStub,
VBtnToggle: toggleStub,
VDialog: slotStub,
VDialog: dialogStub,
VDialogCloseBtn: true,
VSlider: sliderStub,
},
@@ -199,6 +271,21 @@ describe('GlassSettingsDialog', () => {
await sliders[0].setValue('77')
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 () => {
@@ -207,8 +294,9 @@ describe('GlassSettingsDialog', () => {
global: {
stubs: {
VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub,
VDialog: slotStub,
VDialog: dialogStub,
VDialogCloseBtn: true,
VSlider: sliderStub,
},
@@ -227,12 +315,28 @@ describe('GlassSettingsDialog', () => {
await sliders[4].setValue('68.7')
await sliders[5].setValue('62.2')
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(1, { glassTransparencyStrength: 91 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(2, { glassTransmissionStrength: 78 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(3, { glassReflectionStrength: 74 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(4, { glassTranslationStrength: 84 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(5, { glassDeformationStrength: 69 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(6, { glassFlowStrength: 62 })
expect(mocks.previewGlassSettings).toHaveBeenCalledTimes(6)
expect(mocks.previewGlassSettings).toHaveBeenLastCalledWith({
glassAppearance: 'clear',
glassDeformationStrength: 69,
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()
})
@@ -242,8 +346,9 @@ describe('GlassSettingsDialog', () => {
global: {
stubs: {
VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub,
VDialog: slotStub,
VDialog: dialogStub,
VDialogCloseBtn: true,
VSlider: sliderStub,
},

View File

@@ -2,6 +2,8 @@
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
import {
createGlassWallpaperSourceCache,
getGlassWallpaperPreparationKey,
setGlassRendererState,
useGlassOpticalInteractionSource,
useGlassOpticalRenderer,
@@ -39,16 +41,40 @@ const props = defineProps<{
previousWallpaperUrl: string
/** 下一张同源壁纸;两个 context 均完成上传后才允许外层提交切换。 */
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<{
/** 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 scrollCanvas = ref<HTMLCanvasElement | null>(null)
const interactionSource = useGlassOpticalInteractionSource()
const pagePresentationMotion = usePagePresentationMotion()
const wallpaperSourceCache = createGlassWallpaperSourceCache()
const fixedRenderer = useGlassOpticalRenderer({
active: true,
appearance: () => props.appearance,
@@ -66,8 +92,10 @@ const fixedRenderer = useGlassOpticalRenderer({
transitionDuration: () => props.transitionDuration,
transitionStartedAt: () => props.transitionStartedAt,
wallpaperUrl: () => props.wallpaperUrl,
wallpaperSourceCache,
previousWallpaperUrl: () => props.previousWallpaperUrl,
pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '',
pendingWallpaperRevision: () => props.pendingWallpaperRevision ?? 0,
surfaceSpace: 'fixed',
syncDocumentState: false,
})
@@ -89,8 +117,10 @@ const scrollRenderer = useGlassOpticalRenderer({
transitionDuration: () => props.transitionDuration,
transitionStartedAt: () => props.transitionStartedAt,
wallpaperUrl: () => props.wallpaperUrl,
wallpaperSourceCache,
previousWallpaperUrl: () => props.previousWallpaperUrl,
pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '',
pendingWallpaperRevision: () => props.pendingWallpaperRevision ?? 0,
surfaceSpace: 'scroll',
syncDocumentState: false,
})
@@ -107,16 +137,151 @@ watchEffect(() => {
: 'fallback'
setGlassRendererState(rendererState, state)
})
watchEffect(() => {
const url = props.pendingWallpaperUrl
if (url && fixedRenderer.preparedWallpaperUrl.value === url && scrollRenderer.preparedWallpaperUrl.value === url) {
emit('wallpaperPrepared', url)
if (import.meta.env.DEV && timingWindow.__glassPerformanceProbeEnabled) {
timingWindow.__glassLaunchTimings?.push({
detail: state,
stage: 'optical-layer-state',
time: performance.now(),
})
}
})
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(() => {
if (activationFrame !== null) cancelAnimationFrame(activationFrame)
setGlassRendererState(rendererState, 'fallback')
})
</script>

View File

@@ -1,10 +1,30 @@
import { shallowMount } from '@vue/test-utils'
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'
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 wallpaperSourceCache = vi.hoisted(() => ({ get: vi.fn() }))
const setRendererState = vi.hoisted(() =>
vi.fn((state: { value: string }, value: string) => {
state.value = value
@@ -12,21 +32,82 @@ const setRendererState = vi.hoisted(() =>
)
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,
useGlassOpticalInteractionSource: vi.fn(() => interactionSource),
useGlassOpticalRenderer: vi.fn((options: Record<string, unknown>) => {
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),
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', () => {
it('uses exactly two visible presentation contexts with one interaction source', () => {
rendererCalls.length = 0
rendererResults.length = 0
setRendererState.mockClear()
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
@@ -52,6 +133,7 @@ describe('GlassOpticalLayer', () => {
expect(canvases.map(canvas => canvas.attributes('data-presentation-space'))).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.wallpaperSourceCache === wallpaperSourceCache)).toBe(true)
expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true)
expect(rendererCalls[0].pageMotion).toBeUndefined()
expect(rendererCalls[1].pageMotion).toEqual(
@@ -64,4 +146,241 @@ describe('GlassOpticalLayer', () => {
wrapper.unmount()
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

View File

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

View File

@@ -16,6 +16,7 @@ beforeEach(() => {
callbacks = new Map()
frameId = 0
document.documentElement.dataset.theme = 'glass'
delete document.documentElement.dataset.launchLoading
vi.spyOn(performance, 'now').mockReturnValue(1000)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
frameId += 1
@@ -30,13 +31,40 @@ beforeEach(() => {
afterEach(() => {
motion.cancel()
document.getElementById('loading-bg')?.remove()
delete document.documentElement.dataset.theme
delete document.documentElement.dataset.launchLoading
delete document.documentElement.dataset.pagePresentationMotion
document.documentElement.style.removeProperty('--mp-page-motion-opacity')
document.documentElement.style.removeProperty('--mp-page-motion-translate-y')
})
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', () => {
const routeRoot = document.createElement('div')
let routeHeight = 2096

View File

@@ -6,11 +6,33 @@ import {
previewGlassSettings,
readThemeCustomizerSettings,
THEME_CUSTOMIZER_STORAGE_KEY,
useThemeCustomizer,
useEffectiveGlassSettings,
} 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'
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(() => {
cancelGlassPreview()
localStorage.clear()
@@ -22,14 +44,15 @@ describe('useThemeCustomizer glass settings', () => {
const settings = readThemeCustomizerSettings()
expect(settings.glassAppearance).toBe('clear')
expect(settings.glassDeformationStrength).toBe(50)
expect(settings.glassFlowStrength).toBe(50)
expect(settings.glassDeformationStrength).toBe(40)
expect(settings.glassFlowStrength).toBe(40)
expect(settings.glassPreset).toBe('natural')
expect(settings.glassPresetOverrides).toEqual({})
expect(settings.glassQuality).toBe('balanced')
expect(settings.glassReflectionStrength).toBe(35)
expect(settings.glassTransmissionStrength).toBe(70)
expect(settings.glassTranslationStrength).toBe(50)
expect(settings.glassTransparencyStrength).toBe(70)
expect(settings.glassTransmissionStrength).toBe(54)
expect(settings.glassTranslationStrength).toBe(40)
expect(settings.glassTransparencyStrength).toBe(46)
})
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.glassPreset).toBe('natural')
expect(settings.glassPresetOverrides).toHaveProperty('clear:balanced:natural')
expect(settings.glassQuality).toBe('balanced')
})
@@ -94,6 +118,19 @@ describe('useThemeCustomizer glass settings', () => {
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify({ glassAppearance: 'clear' }))
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', () => {
@@ -111,10 +148,14 @@ describe('useThemeCustomizer glass settings', () => {
expect(document.body.dataset.glassQuality).toBe('high')
expect(document.documentElement.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(Number(document.documentElement.style.getPropertyValue('--glass-transparency'))).toBeCloseTo(0.96, 2)
expect(Number(document.body.style.getPropertyValue('--glass-transparency'))).toBeCloseTo(0.96, 2)
expect(Number(document.documentElement.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(
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', () => {
@@ -134,7 +175,17 @@ describe('useThemeCustomizer glass settings', () => {
glassAppearance: 'tinted',
glassDeformationStrength: 74,
glassFlowStrength: 63,
glassPreset: 'glide',
glassPreset: 'natural',
glassPresetOverrides: {
'tinted:css:natural': {
deformation: 74,
flow: 63,
reflection: 81,
transmission: 76,
translation: 69,
transparency: 80,
},
},
glassQuality: 'css',
glassReflectionStrength: 81,
glassTransmissionStrength: 76,
@@ -148,7 +199,17 @@ describe('useThemeCustomizer glass settings', () => {
glassAppearance: 'tinted',
glassDeformationStrength: 74,
glassFlowStrength: 63,
glassPreset: 'glide',
glassPreset: 'natural',
glassPresetOverrides: {
'tinted:css:natural': {
deformation: 74,
flow: 63,
reflection: 81,
transmission: 76,
translation: 69,
transparency: 80,
},
},
glassQuality: 'css',
glassReflectionStrength: 81,
glassTransmissionStrength: 76,
@@ -175,6 +236,16 @@ describe('useThemeCustomizer glass settings', () => {
glassAppearance: 'tinted',
glassDeformationStrength: 42,
glassFlowStrength: 44,
glassPresetOverrides: {
'tinted:balanced:natural': {
deformation: 42,
flow: 44,
reflection: 66,
transmission: 64,
translation: 46,
transparency: 72,
},
},
glassReflectionStrength: 66,
glassTransmissionStrength: 64,
glassTranslationStrength: 46,
@@ -184,6 +255,16 @@ describe('useThemeCustomizer glass settings', () => {
glassAppearance: 'clear',
glassDeformationStrength: 90,
glassFlowStrength: 88,
glassPresetOverrides: {
'clear:balanced:natural': {
deformation: 90,
flow: 88,
reflection: 12,
transmission: 92,
translation: 86,
transparency: 94,
},
},
glassReflectionStrength: 12,
glassTransmissionStrength: 92,
glassTranslationStrength: 86,
@@ -206,10 +287,146 @@ describe('useThemeCustomizer glass settings', () => {
glassAppearance: 'tinted',
glassDeformationStrength: 42,
glassFlowStrength: 44,
glassPresetOverrides: {
'tinted:balanced:natural': {
deformation: 42,
flow: 44,
reflection: 66,
transmission: 64,
translation: 46,
transparency: 72,
},
},
glassReflectionStrength: 66,
glassTransmissionStrength: 64,
glassTranslationStrength: 46,
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

View File

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

View File

@@ -121,9 +121,7 @@ function sampleLayoutHold(timestamp: number, motionEpoch: number, root: HTMLElem
return
}
animationFrame = window.requestAnimationFrame(nextTimestamp =>
sampleLayoutHold(nextTimestamp, motionEpoch, root),
)
animationFrame = window.requestAnimationFrame(nextTimestamp => sampleLayoutHold(nextTimestamp, motionEpoch, root))
}
function settleMotion() {
@@ -178,6 +176,13 @@ function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) {
const motionEpoch = epoch.value
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) {
settleMotion()
revision.value += 1

View File

@@ -6,12 +6,17 @@ import vuetify from '@/plugins/vuetify'
import {
GLASS_OPTICAL_STRENGTH_DEFAULT,
GLASS_OPTICAL_STRENGTH_MAX,
getGlassCssFrostBlur,
getGlassMaterialResponse,
getGlassOpticalCssTransmissionBrightness,
getGlassOpticalPresetKey,
getGlassOpticalPresetParameters,
getGlassOpticalPresetParametersWithOverrides,
getGlassOpticalTransmissionStrength,
getGlassOpticalTransparency,
normalizeGlassOpticalStrength,
type GlassOpticalParameters,
type GlassOpticalPreset,
type GlassOpticalPresetOverrides,
} from '@/utils/glassOptics'
import { themeManager } from '@/utils/themeManager'
import { syncThemeFavicon } from '@/utils/themePalette'
@@ -80,6 +85,8 @@ export interface ThemeCustomizerSettings {
glassFlowStrength: number
/** 当前玻璃方案;具体参数独立保存,滑杆调整不会丢失方案归属。 */
glassPreset: GlassOpticalPreset
/** 按材质、质量与方案保存的六参数覆盖;缺失组合使用预设矩阵。 */
glassPresetOverrides: GlassOpticalPresetOverrides
/** 玻璃主题的渲染质量,决定使用标准 CSS 或共享光学渲染器。 */
glassQuality: ThemeCustomizerGlassQuality
/** 玻璃亮边、镜面高光与焦散光照强度,范围 0 到 100。 */
@@ -155,6 +162,7 @@ function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
glassDeformationStrength: glassParameters.deformation,
glassFlowStrength: glassParameters.flow,
glassPreset: 'natural',
glassPresetOverrides: {},
glassQuality: defaultGlassQuality,
glassReflectionStrength: glassParameters.reflection,
glassTransmissionStrength: glassParameters.transmission,
@@ -183,6 +191,49 @@ function normalizeMigratedGlassStrength(value: unknown, legacyValue: unknown, fa
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 数值档位。 */
function normalizeThemeCustomizerShadow(shadow: unknown): 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 radius = storedRadius === 'huge' ? 'extra' : storedRadius
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)
? (settings.glassAppearance as ThemeCustomizerGlassAppearance)
: fallback.glassAppearance,
@@ -215,9 +269,8 @@ function normalizeThemeCustomizerSettings(
settings.glassMotionStrength,
fallback.glassFlowStrength,
),
glassPreset: validGlassPresets.includes(settings.glassPreset as GlassOpticalPreset)
? (settings.glassPreset as GlassOpticalPreset)
: fallback.glassPreset,
glassPreset: storedPreset,
glassPresetOverrides: normalizeGlassPresetOverrides(settings.glassPresetOverrides),
glassQuality: validGlassQualities.includes(settings.glassQuality as ThemeCustomizerGlassQuality)
? (settings.glassQuality as ThemeCustomizerGlassQuality)
: fallback.glassQuality,
@@ -254,6 +307,19 @@ function normalizeThemeCustomizerSettings(
? (settings.theme as ThemeCustomizerTheme)
: 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'
| 'glassFlowStrength'
| 'glassPreset'
| 'glassPresetOverrides'
| 'glassQuality'
| 'glassReflectionStrength'
| 'glassTransmissionStrength'
@@ -294,6 +361,7 @@ const effectiveGlassSettings = computed(() => ({
glassPreviewState.value?.glassDeformationStrength ?? settingsState.value.glassDeformationStrength,
glassFlowStrength: glassPreviewState.value?.glassFlowStrength ?? settingsState.value.glassFlowStrength,
glassPreset: glassPreviewState.value?.glassPreset ?? settingsState.value.glassPreset,
glassPresetOverrides: glassPreviewState.value?.glassPresetOverrides ?? settingsState.value.glassPresetOverrides,
glassQuality: glassPreviewState.value?.glassQuality ?? settingsState.value.glassQuality,
glassReflectionStrength:
glassPreviewState.value?.glassReflectionStrength ?? settingsState.value.glassReflectionStrength,
@@ -375,6 +443,18 @@ export function applyThemeCustomizerRootSettings(
) {
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-quality', settings.glassQuality)
document.documentElement.style.setProperty(
@@ -389,10 +469,7 @@ export function applyThemeCustomizerRootSettings(
'--glass-transmission-brightness',
String(getGlassOpticalCssTransmissionBrightness(settings.glassTransmissionStrength)),
)
document.documentElement.style.setProperty(
'--glass-transparency',
String(getGlassOpticalTransparency(settings.glassTransparencyStrength)),
)
applyGlassResponse(document.documentElement)
document.documentElement.setAttribute('data-theme-layout', settings.layout)
document.documentElement.setAttribute('data-theme-radius', settings.radius)
document.documentElement.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
@@ -412,10 +489,7 @@ export function applyThemeCustomizerRootSettings(
'--glass-transmission-brightness',
String(getGlassOpticalCssTransmissionBrightness(settings.glassTransmissionStrength)),
)
document.body.style.setProperty(
'--glass-transparency',
String(getGlassOpticalTransparency(settings.glassTransparencyStrength)),
)
applyGlassResponse(document.body)
document.body.setAttribute('data-theme-layout', settings.layout)
document.body.setAttribute('data-theme-radius', settings.radius)
document.body.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
@@ -496,6 +570,7 @@ export function previewGlassSettings(patch: Partial<ThemeCustomizerGlassSettings
glassDeformationStrength: previewSettings.glassDeformationStrength,
glassFlowStrength: previewSettings.glassFlowStrength,
glassPreset: previewSettings.glassPreset,
glassPresetOverrides: previewSettings.glassPresetOverrides,
glassQuality: previewSettings.glassQuality,
glassReflectionStrength: previewSettings.glassReflectionStrength,
glassTransmissionStrength: previewSettings.glassTransmissionStrength,
@@ -539,6 +614,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
glassDeformationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassFlowStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassPreset: 'natural',
glassPresetOverrides: {},
glassQuality: defaultGlassQuality,
glassReflectionStrength: glassParameters.reflection,
glassTransmissionStrength: glassParameters.transmission,
@@ -558,6 +634,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
settings.glassDeformationStrength === defaults.glassDeformationStrength &&
settings.glassFlowStrength === defaults.glassFlowStrength &&
settings.glassPreset === defaults.glassPreset &&
JSON.stringify(settings.glassPresetOverrides) === JSON.stringify(defaults.glassPresetOverrides) &&
settings.glassQuality === defaults.glassQuality &&
settings.glassReflectionStrength === defaults.glassReflectionStrength &&
settings.glassTransmissionStrength === defaults.glassTransmissionStrength &&
@@ -607,49 +684,137 @@ export function useThemeCustomizer() {
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) {
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) {
return updateSettings({ glassDeformationStrength })
return updateGlassPresetOverride({ deformation: normalizeGlassOpticalStrength(glassDeformationStrength) })
}
/** 更新玻璃轨迹、尾波与惯性强度。 */
function setGlassFlowStrength(glassFlowStrength: number) {
return updateSettings({ glassFlowStrength })
return updateGlassPresetOverride({ flow: normalizeGlassOpticalStrength(glassFlowStrength) })
}
/** 更新当前玻璃方案,不隐式覆盖用户已保存的具体参数。 */
/** 切换方案时优先恢复该组合已保存的覆盖。 */
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) {
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) {
return updateSettings({ glassReflectionStrength })
return updateGlassPresetOverride({ reflection: normalizeGlassOpticalStrength(glassReflectionStrength) })
}
/** 更新玻璃内部壁纸采样的透射亮度。 */
function setGlassTransmissionStrength(glassTransmissionStrength: number) {
return updateSettings({ glassTransmissionStrength })
return updateGlassPresetOverride({ transmission: normalizeGlassOpticalStrength(glassTransmissionStrength) })
}
/** 更新玻璃统一采样平移强度。 */
function setGlassTranslationStrength(glassTranslationStrength: number) {
return updateSettings({ glassTranslationStrength })
return updateGlassPresetOverride({ translation: normalizeGlassOpticalStrength(glassTranslationStrength) })
}
/** 更新玻璃材质的真实壁纸可见度。 */
function setGlassTransparencyStrength(glassTransparencyStrength: number) {
return updateSettings({ glassTransparencyStrength })
return updateGlassPresetOverride({ transparency: normalizeGlassOpticalStrength(glassTransparencyStrength) })
}
/** 更新全局圆角档位。 */
@@ -691,6 +856,7 @@ export function useThemeCustomizer() {
glassDeformationStrength: glassParameters.deformation,
glassFlowStrength: glassParameters.flow,
glassPreset: 'natural',
glassPresetOverrides: {},
glassQuality: defaultGlassQuality,
glassReflectionStrength: glassParameters.reflection,
glassTransmissionStrength: glassParameters.transmission,

View File

@@ -631,7 +631,7 @@ onUnmounted(() => {
</VListItem>
<!-- 👉 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 }">
<VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover>
<template #prepend>
@@ -666,7 +666,7 @@ onUnmounted(() => {
</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 }">
<VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover>
<template #prepend>
@@ -730,7 +730,7 @@ onUnmounted(() => {
</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 }">
<VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover>
<template #prepend>

View File

@@ -175,7 +175,7 @@ export default {
glassOpticalStrengthHint:
'Sample Translation moves the shared wallpaper. Deformation controls local bending. Flow Strength controls trail range and inertia.',
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',
custom: 'Custom Style',
transparency: 'Transparency',
@@ -3810,8 +3810,7 @@ export default {
password: 'Password',
passwordHint: 'Login password',
accessCode: 'Access Code',
accessCodeHint:
'Access code enabled in fnOS Settings -> Security -> Access Settings. Leave empty if not enabled',
accessCodeHint: 'Access code enabled in fnOS Settings -> Security -> Access Settings. Leave empty if not enabled',
syncLibraries: 'Sync Libraries',
syncLibrariesHint: 'Only selected libraries will be synchronized',
scanMode: 'Scan Mode',

View File

@@ -170,7 +170,7 @@ export default {
glassTransmissionStrength: '透射亮度',
glassTransparencyStrength: '通透度',
glassOpticalStrengthHint: '采样平移控制整体滑移,形变强度控制局部弯曲,流动强度控制轨迹范围与惯性。',
glassOpticalStrengthUnavailableHint: '标准质量保留三项材质参数切换到均衡或高质量后,可调整采样平移、形变和流动。',
glassOpticalStrengthUnavailableHint: '标准质量保留三项材质参数切换到均衡或高质量后,可调整采样平移、形变和流动。',
purple: '幻紫',
custom: '附加样式',
transparency: '透明度',

View File

@@ -170,7 +170,7 @@ export default {
glassTransmissionStrength: '透射亮度',
glassTransparencyStrength: '通透度',
glassOpticalStrengthHint: '採樣平移控制整體滑移,形變強度控制局部彎曲,流動強度控制軌跡範圍與慣性。',
glassOpticalStrengthUnavailableHint: '標準品質保留三項材質參數切換到均衡或高品質後,可調整採樣平移、形變和流動。',
glassOpticalStrengthUnavailableHint: '標準品質保留三項材質參數切換到均衡或高品質後,可調整採樣平移、形變和流動。',
purple: '幻紫',
custom: '附加樣式',
transparency: '透明度',

View File

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

View File

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

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);
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-tint: linear-gradient(
rgb(var(--plugin-card-accent-rgb)) 0%,
rgb(var(--plugin-card-accent-rgb)) 100%
rgb(var(--plugin-card-accent-rgb, 40, 169, 225)) 0%,
rgb(var(--plugin-card-accent-rgb, 40, 169, 225)) 100%
);
}

View File

@@ -3,9 +3,9 @@
// 材质只覆盖统一表面 token质量档只替换光学层不改变业务组件契约。
html[data-theme='glass'] {
--glass-surface: rgba(11, 19, 34, calc(0.3 - var(--glass-transparency, 0.5) * 0.24));
--glass-surface-soft: rgba(11, 19, 34, calc(0.36 - var(--glass-transparency, 0.5) * 0.24));
--glass-surface-raised: rgba(11, 19, 34, calc(0.44 - 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.04 + var(--glass-surface-density, 0.62) * 0.32));
--glass-surface-raised: rgba(11, 19, 34, calc(0.07 + var(--glass-surface-density, 0.62) * 0.36));
--glass-control: rgba(11, 19, 34, 52%);
--glass-control-prominent: rgba(255, 255, 255, 7%);
--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%);
--glass-surface-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-prominent-backdrop-filter: none;
--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-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-control-icon-color: rgba(242, 245, 250, 70%);
--glass-control-placeholder-color: rgba(242, 245, 250, 54%);
@@ -112,18 +114,18 @@ html[data-theme='glass'] {
&[data-glass-appearance='tinted'] {
--glass-surface: color-mix(
in srgb,
rgba(11, 19, 34, calc(0.3 - var(--glass-transparency, 0.5) * 0.24)) 88%,
rgba(var(--v-theme-primary), 28%)
rgba(11, 19, 34, calc(0.02 + var(--glass-surface-density, 0.72) * 0.3)) 88%,
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.28))
);
--glass-surface-soft: color-mix(
in srgb,
rgba(11, 19, 34, calc(0.36 - var(--glass-transparency, 0.5) * 0.24)) 89%,
rgba(var(--v-theme-primary), 26%)
rgba(11, 19, 34, calc(0.04 + var(--glass-surface-density, 0.72) * 0.32)) 89%,
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.26))
);
--glass-surface-raised: color-mix(
in srgb,
rgba(11, 19, 34, calc(0.44 - var(--glass-transparency, 0.5) * 0.24)) 84%,
rgba(var(--v-theme-primary), 32%)
rgba(11, 19, 34, calc(0.07 + var(--glass-surface-density, 0.72) * 0.36)) 84%,
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.32))
);
--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%));
@@ -164,13 +166,21 @@ html[data-theme='glass'] {
rgba(var(--v-theme-primary), 6%) 72%,
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'] {
--glass-surface: rgba(255, 255, 255, calc(0.14 - var(--glass-transparency, 0.5) * 0.1));
--glass-surface-soft: rgba(255, 255, 255, calc(0.13 - var(--glass-transparency, 0.5) * 0.08));
--glass-surface-raised: rgba(255, 255, 255, calc(0.18 - var(--glass-transparency, 0.5) * 0.11));
--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.03 + var(--glass-surface-density, 0.86) * 0.07));
--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-prominent: rgba(255, 255, 255, 10%);
--glass-control-prominent-focus: rgba(255, 255, 255, 13%);
@@ -194,13 +204,14 @@ html[data-theme='glass'] {
brightness(var(--glass-transmission-brightness));
--glass-raised-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate))
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-overlay-surface: rgba(
var(--v-theme-background),
calc(0.76 - var(--glass-transparency, 0.5) * 0.12)
);
--glass-overlay-blur: var(--glass-blur-raised);
--glass-overlay-saturate: var(--glass-saturate);
--glass-overlay-surface: rgba(var(--v-theme-background), calc(0.24 + var(--glass-surface-density, 0.86) * 0.12));
--glass-overlay-blur: min(var(--glass-blur-raised), 36px);
--glass-overlay-saturate: 135%;
--glass-overlay-scrim: rgba(3, 7, 18, 36%);
--glass-blur-surface: 40px;
--glass-blur: 40px;
--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))
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 > form > :where(.v-card, .v-sheet) {
@@ -438,8 +445,8 @@ html[data-theme='glass'] {
position: absolute;
z-index: -1;
border-inline-end: 1px solid var(--glass-border-raised);
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
backdrop-filter: var(--glass-raised-backdrop-filter);
-webkit-backdrop-filter: var(--glass-fixed-shell-backdrop-filter);
backdrop-filter: var(--glass-fixed-shell-backdrop-filter);
background-color: var(--glass-surface-raised);
background-image: var(--glass-sheen);
box-shadow: var(--glass-shadow-raised);
@@ -575,7 +582,7 @@ html[data-theme='glass'] {
.v-overlay__scrim {
-webkit-backdrop-filter: none;
backdrop-filter: none;
background: rgba(3, 7, 18, 48%);
background: var(--glass-overlay-scrim);
}
.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'] {
.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);
--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.46 - var(--glass-transparency, 0.5) * 0.16)) 100%
rgba(11, 19, 34, calc(0.2 + var(--glass-surface-density, 0.62) * 0.115)) 0%,
rgba(11, 19, 34, calc(0.28 + var(--glass-surface-density, 0.62) * 0.16)) 100%
);
--plugin-card-banner-tint: linear-gradient(
135deg,
rgba(var(--plugin-card-accent-rgb), 0.42) 0%,
rgba(var(--plugin-card-accent-rgb), 0.24) 58%,
rgba(var(--plugin-card-accent-rgb), 0.12) 100%
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.2 + var(--glass-tint-density, 0.65) * 0.34)) 0%,
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.11 + var(--glass-tint-density, 0.65) * 0.2)) 58%,
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.055 + var(--glass-tint-density, 0.65) * 0.1)) 100%
);
}
@@ -842,14 +868,14 @@ html[data-theme='glass'] {
// 磨砂材质透光更强,染色需要同步减弱以免头部压过卡片本体。
&[data-glass-appearance='frosted'] .plugin-card__banner {
--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.34 - var(--glass-transparency, 0.5) * 0.14)) 100%
rgba(11, 19, 34, calc(0.1 + var(--glass-surface-density, 0.86) * 0.09)) 0%,
rgba(11, 19, 34, calc(0.14 + var(--glass-surface-density, 0.86) * 0.145)) 100%
);
--plugin-card-banner-tint: linear-gradient(
135deg,
rgba(var(--plugin-card-accent-rgb), 0.32) 0%,
rgba(var(--plugin-card-accent-rgb), 0.18) 58%,
rgba(var(--plugin-card-accent-rgb), 0.08) 100%
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.15 + var(--glass-tint-density, 0.65) * 0.2)) 0%,
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.07 + var(--glass-tint-density, 0.65) * 0.13)) 58%,
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.03 + var(--glass-tint-density, 0.65) * 0.06)) 100%
);
}
@@ -857,15 +883,25 @@ html[data-theme='glass'] {
&[data-glass-appearance='tinted'] .plugin-card__banner {
--plugin-card-banner-tint: linear-gradient(
135deg,
color-mix(in srgb, rgba(var(--plugin-card-accent-rgb), 0.42) 74%, rgba(var(--v-theme-primary), 0.42)) 0%,
color-mix(in srgb, rgba(var(--plugin-card-accent-rgb), 0.24) 74%, rgba(var(--v-theme-primary), 0.24)) 58%,
rgba(var(--plugin-card-accent-rgb), 0.12) 100%
color-mix(
in srgb,
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.14 + var(--glass-tint-density, 0.65) * 0.33)) 74%,
rgba(var(--v-theme-primary), calc(0.14 + var(--glass-tint-density, 0.65) * 0.33))
)
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 {
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 {
@@ -879,8 +915,8 @@ html[data-theme='glass'] {
.plugin-folder-card__overlay {
background: linear-gradient(
rgba(11, 19, 34, calc(0.34 - var(--glass-transparency, 0.5) * 0.14)) 0%,
rgba(11, 19, 34, calc(0.46 - var(--glass-transparency, 0.5) * 0.16)) 100%
rgba(11, 19, 34, calc(0.2 + var(--glass-surface-density, 0.62) * 0.115)) 0%,
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;
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 {
@@ -1123,7 +1164,6 @@ html[data-theme='glass'] {
inset: 0;
opacity: 0;
pointer-events: none;
transition: opacity 180ms ease-out;
}
.glass-optical-layer--fixed {
@@ -1152,6 +1192,7 @@ html[data-theme='glass']:is(
)[data-glass-renderer-state='ready'] {
--glass-surface-backdrop-filter: none;
--glass-raised-backdrop-filter: none;
--glass-fixed-shell-backdrop-filter: none;
--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-renderer-state='ready'] {
--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']

View File

@@ -3,7 +3,9 @@ import {
GLASS_OPTICAL_MOTION_MAX_SCALE,
GLASS_OPTICAL_REFLECTION_MAX_SCALE,
getAvailableGlassOpticalPresets,
getGlassCssFrostBlur,
getGlassCoverScale,
getGlassMaterialResponse,
getGlassOpticalCssTransmissionBrightness,
getGlassOpticalDecay,
getGlassOpticalBufferSize,
@@ -12,6 +14,8 @@ import {
getGlassOpticalMotionExpansion,
getGlassOpticalMotionStrengthScale,
getGlassOpticalPresetParameters,
getGlassOpticalPresetParametersWithOverrides,
getGlassOpticalPresetKey,
getGlassOpticalReflectionStrengthScale,
getGlassOpticalRenderProfile,
getGlassOpticalTransparency,
@@ -57,7 +61,9 @@ describe('glass optics geometry', () => {
expect(getGlassOpticalMotionStrengthScale(75) - getGlassOpticalMotionStrengthScale(50)).toBeGreaterThan(
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(100)).toBe(1)
expect(getGlassOpticalMaxRefractionPixels(9, 50)).toBe(9)
@@ -87,12 +93,12 @@ describe('glass optics geometry', () => {
const liquid = getGlassOpticalPresetParameters('frosted', 'high', 'liquid')
expect(natural).toEqual({
deformation: 50,
flow: 50,
deformation: 40,
flow: 40,
reflection: 35,
transmission: 70,
translation: 50,
transparency: 70,
transmission: 54,
translation: 40,
transparency: 46,
})
expect(glide.translation).toBeGreaterThan(glide.deformation)
expect(liquid.deformation).toBeGreaterThan(glide.deformation)
@@ -101,11 +107,166 @@ describe('glass optics geometry', () => {
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', () => {
const first = getGlassOpticalPresetParameters('tinted', 'high', 'glide')
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', () => {
@@ -346,8 +507,8 @@ describe('glass optics geometry', () => {
})
it('crossfades active surface weights monotonically', () => {
expect(getGlassOpticalSurfaceTransitionWeights(0, 96)).toEqual({ incoming: 0.35, outgoing: 1 })
expect(getGlassOpticalSurfaceTransitionWeights(48, 96)).toEqual({ incoming: 0.675, outgoing: 0.5 })
expect(getGlassOpticalSurfaceTransitionWeights(0, 96)).toEqual({ incoming: 1, outgoing: 1 })
expect(getGlassOpticalSurfaceTransitionWeights(48, 96)).toEqual({ incoming: 1, outgoing: 0.5 })
expect(getGlassOpticalSurfaceTransitionWeights(96, 96)).toEqual({ incoming: 1, outgoing: 0 })
})

View File

@@ -1,7 +1,17 @@
import { DEFAULT_GLASS_WALLPAPER_TONE_PROFILE, getGlassWallpaperToneProfile } from '@/utils/glassWallpaperTone'
import { describe, expect, it } from 'vitest'
import {
DEFAULT_GLASS_WALLPAPER_TONE_PROFILE,
getGlassWallpaperToneProfile,
loadGlassWallpaperTone,
takeGlassWallpaperDecodedSource,
} from '@/utils/glassWallpaperTone'
import { afterEach, describe, expect, it, vi } from 'vitest'
describe('glass wallpaper tone profile', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
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])
@@ -32,4 +42,127 @@ describe('glass wallpaper tone profile', () => {
it('falls back to the neutral profile when no valid samples exist', () => {
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)
})
})

View File

@@ -13,6 +13,8 @@ export const GLASS_OPTICAL_REFERENCE_STRENGTH = 70
export type GlassAppearance = 'clear' | 'frosted' | 'tinted'
export type GlassOpticalCapability = 'balanced' | 'css' | 'high'
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 GlassCornerRadii = [number, number, number, number]
@@ -31,6 +33,19 @@ export interface GlassOpticalParameters {
transparency: number
}
export interface GlassMaterialResponse {
/** 真实壁纸在表面材质后的感知可见程度。 */
backgroundVisibility: number
/** 磨砂预滤纹理允许保留的高频细节比例。 */
frostDetailLevel: number
/** 标准档 CSS 磨砂半径相对既有 40px 基线的缩放。 */
frostBlurScale: number
/** 表面遮罩对真实壁纸的覆盖密度。 */
surfaceDensity: number
/** 色调材质的主体染色密度。 */
tintDensity: number
}
export interface GlassInteractionPoint {
/** 指针或触点相对视口的横坐标。 */
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)))
}
const GLASS_OPTICAL_PRESET_MATRIX: Record<
GlassAppearance,
Record<GlassOpticalCapability, Record<GlassOpticalPreset, GlassOpticalParameters>>
> = {
type GlassOpticalPresetSet = Record<GlassOpticalPreset, GlassOpticalParameters>
type GlassOpticalCapabilityPresets = {
balanced: GlassOpticalPresetSet
css: Pick<GlassOpticalPresetSet, 'natural'>
high: GlassOpticalPresetSet
}
const GLASS_OPTICAL_PRESET_MATRIX: Record<GlassAppearance, GlassOpticalCapabilityPresets> = {
clear: {
css: {
natural: { deformation: 50, flow: 50, reflection: 35, transmission: 70, translation: 50, transparency: 70 },
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 },
natural: { deformation: 40, flow: 40, reflection: 35, transmission: 56, translation: 40, transparency: 48 },
},
balanced: {
natural: { deformation: 50, flow: 50, reflection: 35, transmission: 70, translation: 50, transparency: 70 },
glide: { deformation: 30, flow: 44, reflection: 29, transmission: 72, translation: 72, transparency: 78 },
liquid: { deformation: 70, flow: 76, reflection: 36, transmission: 66, translation: 56, transparency: 74 },
natural: { deformation: 40, flow: 40, reflection: 35, transmission: 54, translation: 40, transparency: 46 },
glide: { deformation: 24, flow: 35, reflection: 29, transmission: 58, translation: 58, transparency: 56 },
liquid: { deformation: 56, flow: 61, reflection: 36, transmission: 51, translation: 45, transparency: 51 },
},
high: {
natural: { deformation: 50, flow: 50, reflection: 32, transmission: 70, translation: 50, transparency: 70 },
glide: { deformation: 32, flow: 46, reflection: 28, transmission: 74, translation: 74, transparency: 80 },
liquid: { deformation: 74, flow: 80, reflection: 35, transmission: 68, translation: 58, transparency: 76 },
natural: { deformation: 40, flow: 40, reflection: 32, transmission: 53, translation: 40, transparency: 45 },
glide: { deformation: 26, flow: 37, reflection: 28, transmission: 56, translation: 59, transparency: 54 },
liquid: { deformation: 59, flow: 64, reflection: 35, transmission: 50, translation: 46, transparency: 50 },
},
},
tinted: {
css: {
natural: { deformation: 50, flow: 50, reflection: 38, transmission: 68, translation: 50, transparency: 46 },
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 },
natural: { deformation: 40, flow: 40, reflection: 38, transmission: 54, translation: 40, transparency: 34 },
},
balanced: {
natural: { deformation: 52, flow: 50, reflection: 38, transmission: 72, translation: 50, transparency: 46 },
glide: { deformation: 32, flow: 44, reflection: 34, transmission: 78, translation: 70, transparency: 52 },
liquid: { deformation: 72, flow: 76, reflection: 39, transmission: 68, translation: 56, transparency: 48 },
natural: { deformation: 42, flow: 40, reflection: 38, transmission: 56, translation: 40, transparency: 32 },
glide: { deformation: 26, flow: 35, reflection: 34, transmission: 61, translation: 56, transparency: 40 },
liquid: { deformation: 58, flow: 61, reflection: 39, transmission: 53, translation: 45, transparency: 36 },
},
high: {
natural: { deformation: 52, flow: 50, reflection: 35, transmission: 76, translation: 50, transparency: 48 },
glide: { deformation: 34, flow: 46, reflection: 32, transmission: 82, translation: 72, transparency: 54 },
liquid: { deformation: 76, flow: 80, reflection: 38, transmission: 72, translation: 58, transparency: 50 },
natural: { deformation: 42, flow: 40, reflection: 35, transmission: 54, translation: 40, transparency: 30 },
glide: { deformation: 27, flow: 37, reflection: 32, transmission: 59, translation: 58, transparency: 38 },
liquid: { deformation: 61, flow: 64, reflection: 38, transmission: 51, translation: 46, transparency: 34 },
},
},
frosted: {
css: {
natural: { deformation: 50, flow: 50, reflection: 31, transmission: 54, translation: 50, transparency: 42 },
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 },
natural: { deformation: 40, flow: 40, reflection: 31, transmission: 50, translation: 40, transparency: 31 },
},
balanced: {
natural: { deformation: 58, flow: 52, reflection: 31, transmission: 58, translation: 48, transparency: 42 },
glide: { deformation: 38, flow: 44, reflection: 27, transmission: 62, translation: 68, transparency: 46 },
liquid: { deformation: 78, flow: 76, reflection: 32, transmission: 54, translation: 52, transparency: 44 },
natural: { deformation: 46, flow: 42, reflection: 31, transmission: 52, translation: 38, transparency: 29 },
glide: { deformation: 30, flow: 35, reflection: 27, transmission: 56, translation: 54, transparency: 40 },
liquid: { deformation: 62, flow: 61, reflection: 32, transmission: 49, translation: 42, transparency: 34 },
},
high: {
natural: { deformation: 60, flow: 52, reflection: 29, transmission: 62, translation: 48, transparency: 44 },
glide: { deformation: 40, flow: 46, reflection: 25, transmission: 66, translation: 70, transparency: 48 },
liquid: { deformation: 82, flow: 80, reflection: 31, transmission: 58, translation: 54, transparency: 46 },
natural: { deformation: 48, flow: 42, reflection: 29, transmission: 50, translation: 38, transparency: 27 },
glide: { deformation: 32, flow: 37, reflection: 25, transmission: 54, translation: 56, transparency: 38 },
liquid: { deformation: 66, flow: 64, reflection: 31, transmission: 47, translation: 43, transparency: 32 },
},
},
}
@@ -191,7 +204,10 @@ export function getGlassOpticalPresetParameters(
quality: GlassOpticalCapability,
preset: GlassOpticalPreset,
): 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']
}
/** 生成持久化覆盖使用的稳定组合键;标准档始终归入自然方案。 */
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 双纹理保持同一时钟。 */
export function getGlassWallpaperTransitionProgress(elapsed: number, duration: number) {
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)
}
/** 高于默认值的流动强度逐步扩大作用范围,低区间不会意外改变既有空间尺度。 */
/** 流动强度在完整滑杆区间连续控制轨迹范围,低值也能明显收紧空间足迹。 */
export function getGlassOpticalMotionExpansion(value: unknown) {
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
}
/** 活动表面立即可感知,随后与离场表面完成短时单调交叉过渡。 */
/** 活动表面立即接管输入,离场表面只做短时单调淡出。 */
export function getGlassOpticalSurfaceTransitionWeights(elapsed: number, duration: number) {
const progress = Math.min(1, Math.max(0, elapsed / Math.max(1, duration)))
const eased = progress * progress * (3 - 2 * progress)
return {
incoming: 0.35 + eased * 0.65,
incoming: 1,
outgoing: 1 - eased,
}
}

View File

@@ -1,3 +1,5 @@
import { preloadCorsImage } from '@/@core/utils/corsImage'
export interface GlassWallpaperToneProfile {
/** 进入材质曲线前的有限整体曝光,避免壁纸明暗差异直接放大到操作表面。 */
exposure: number
@@ -7,7 +9,24 @@ export interface GlassWallpaperToneProfile {
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 DECODED_SOURCE_CACHE_LIMIT = 3
const PROFILE_CACHE_LIMIT = 32
const PROFILE_LOAD_TIMEOUT_MS = 3000
const EXPOSURE_MIN = 0.88
@@ -21,7 +40,8 @@ export const DEFAULT_GLASS_WALLPAPER_TONE_PROFILE: GlassWallpaperToneProfile = {
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) {
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) {
const oldestKey = profileCache.keys().next().value
if (oldestKey) profileCache.delete(oldestKey)
@@ -96,37 +116,105 @@ function rememberProfile(url: string, profile: Promise<GlassWallpaperToneProfile
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
* 避免亮度分析阻断壁纸本身的 CSS 显示能力
* renderer 取得已完成 tone 分析的图片后立即移出缓存。
* fixed 与 scroll context 后续通过 renderer 自身的 CPU source cache 共享缩放结果
*/
export function loadGlassWallpaperToneProfile(url: string): Promise<GlassWallpaperToneProfile> {
if (!url || typeof Image === 'undefined') return Promise.resolve({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE })
export function takeGlassWallpaperDecodedSource(url: string): GlassWallpaperDecodedSource | undefined {
const source = decodedSourceCache.get(url)
if (source) decodedSourceCache.delete(url)
const cached = profileCache.get(url)
if (cached) return cached
return source
}
const profile = new Promise<GlassWallpaperToneProfile>(resolve => {
function loadCorsReadableToneProfile(url: string): Promise<GlassWallpaperToneLoadResult> {
return new Promise(resolve => {
const image = new Image()
let settled = false
const finish = (result: GlassWallpaperToneProfile) => {
const finish = (result: GlassWallpaperToneLoadResult) => {
if (settled) return
settled = true
window.clearTimeout(timeout)
resolve(result)
}
const timeout = window.setTimeout(
() => finish({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }),
() => finish({ corsReady: false, profile: { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE } }),
PROFILE_LOAD_TIMEOUT_MS,
)
image.crossOrigin = 'anonymous'
image.decoding = 'async'
image.onload = () =>
finish(analyzeGlassWallpaperTone(image, image.naturalWidth || image.width, image.naturalHeight || image.height))
image.onerror = () => finish({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE })
image.onload = () => {
const profile = analyzeGlassWallpaperTone(
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
})
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
}