feat(glass): unify login and app optical presentation (#591)

This commit is contained in:
InfinityPacer
2026-07-27 10:26:46 +08:00
committed by GitHub
parent 0f8a817413
commit 3886909e9e
21 changed files with 1561 additions and 628 deletions
+54 -1
View File
@@ -1,4 +1,10 @@
import { commitPreloadedBackgroundRotation, preloadBackgroundRotationImages } from '@/utils/backgroundRotation'
import {
BACKGROUND_ROTATION_GRACE_MS,
commitPreloadedBackgroundRotation,
preloadBackgroundRotationImages,
preloadBackgroundSequence,
shouldAllowBackgroundRotation,
} from '@/utils/backgroundRotation'
import { describe, expect, it, vi } from 'vitest'
function deferred<T>() {
@@ -10,6 +16,18 @@ function deferred<T>() {
return { promise, resolve }
}
describe('background rotation lifecycle', () => {
it('keeps wallpaper rotation independent from paused interaction effects during the bounded grace period', () => {
expect(BACKGROUND_ROTATION_GRACE_MS).toBe(60_000)
expect(shouldAllowBackgroundRotation('active', false, false)).toBe(true)
expect(shouldAllowBackgroundRotation('passive', true, false)).toBe(true)
expect(shouldAllowBackgroundRotation('suspended', true, false)).toBe(true)
expect(shouldAllowBackgroundRotation('passive', false, false)).toBe(false)
expect(shouldAllowBackgroundRotation('idle', false, false)).toBe(false)
expect(shouldAllowBackgroundRotation('active', false, true)).toBe(false)
})
})
describe('commitPreloadedBackgroundRotation', () => {
it('drops a successful preload when the rotation becomes inactive before completion', async () => {
const preload = deferred<boolean>()
@@ -87,3 +105,38 @@ describe('preloadBackgroundRotationImages', () => {
expect(preload).toHaveBeenCalledTimes(2)
})
})
describe('preloadBackgroundSequence', () => {
it('preloads remaining wallpapers sequentially in rotation order', async () => {
const calls: string[] = []
await expect(
preloadBackgroundSequence({
canContinue: () => true,
preload: async url => {
calls.push(url)
return url !== 'two.jpg'
},
urls: ['one.jpg', 'two.jpg', 'three.jpg'],
}),
).resolves.toEqual([true, false, true])
expect(calls).toEqual(['one.jpg', 'two.jpg', 'three.jpg'])
})
it('stops an obsolete queue before starting the next image', async () => {
let active = true
const calls: string[] = []
await preloadBackgroundSequence({
canContinue: () => active,
preload: async url => {
calls.push(url)
active = false
return true
},
urls: ['one.jpg', 'two.jpg'],
})
expect(calls).toEqual(['one.jpg'])
})
})
+16 -7
View File
@@ -4,6 +4,7 @@ import {
GLASS_OPTICAL_REFLECTION_MAX_SCALE,
getAvailableGlassOpticalPresets,
getGlassCoverScale,
getGlassOpticalCssTransmissionBrightness,
getGlassOpticalDecay,
getGlassOpticalBufferSize,
getGlassOpticalMaxRefractionPixels,
@@ -14,6 +15,7 @@ import {
getGlassOpticalReflectionStrengthScale,
getGlassOpticalRenderProfile,
getGlassOpticalTransparency,
getGlassOpticalTransmissionStrength,
getGlassOpticalSurfaceTransitionWeights,
getGlassOpticalWakeDirection,
getGlassOpticalWakeSample,
@@ -66,14 +68,20 @@ describe('glass optics geometry', () => {
expect(getGlassOpticalReflectionStrengthScale(100)).toBeCloseTo(GLASS_OPTICAL_REFLECTION_MAX_SCALE)
expect(getGlassOpticalTransparency(0)).toBeCloseTo(0.28)
expect(getGlassOpticalTransparency(50)).toBeGreaterThan(0.6)
expect(getGlassOpticalTransparency(80)).toBeCloseTo(0.86)
expect(getGlassOpticalTransparency(100)).toBeCloseTo(0.96)
expect(getGlassOpticalTransparency(100) - getGlassOpticalTransparency(80)).toBeLessThan(
getGlassOpticalTransparency(80) - getGlassOpticalTransparency(50),
expect(getGlassOpticalTransparency(70)).toBeCloseTo(0.96)
expect(getGlassOpticalTransparency(100)).toBeCloseTo(1.1)
expect(getGlassOpticalTransparency(100) - getGlassOpticalTransparency(70)).toBeLessThan(
getGlassOpticalTransparency(70) - getGlassOpticalTransparency(50),
)
expect(getGlassOpticalTransmissionStrength(0)).toBe(0)
expect(getGlassOpticalTransmissionStrength(70)).toBe(1)
expect(getGlassOpticalTransmissionStrength(100)).toBe(1.3)
expect(getGlassOpticalCssTransmissionBrightness(0)).toBeCloseTo(0.82)
expect(getGlassOpticalCssTransmissionBrightness(70)).toBeCloseTo(1.28)
expect(getGlassOpticalCssTransmissionBrightness(100)).toBeCloseTo(1.48)
})
it('keeps presets as concrete five-parameter values', () => {
it('keeps presets as concrete six-parameter values', () => {
const natural = getGlassOpticalPresetParameters('clear', 'balanced', 'natural')
const glide = getGlassOpticalPresetParameters('clear', 'balanced', 'glide')
const liquid = getGlassOpticalPresetParameters('frosted', 'high', 'liquid')
@@ -81,9 +89,10 @@ describe('glass optics geometry', () => {
expect(natural).toEqual({
deformation: 50,
flow: 50,
reflection: 50,
reflection: 35,
transmission: 70,
translation: 50,
transparency: 50,
transparency: 70,
})
expect(glide.translation).toBeGreaterThan(glide.deformation)
expect(liquid.deformation).toBeGreaterThan(glide.deformation)
@@ -0,0 +1,74 @@
import {
activateLoginBackgroundLayer,
createLoginBackgroundLayers,
getLoginGlassOpticalSettings,
getLoginVisualProfile,
getLoginWallpaperRequestMode,
prepareLoginBackgroundLayer,
settleLoginBackgroundLayers,
} from '@/utils/loginPresentation'
import { describe, expect, it } from 'vitest'
describe('login presentation', () => {
it('maps resolved themes to mutually exclusive visual profiles', () => {
expect(getLoginVisualProfile('glass')).toBe('glass')
expect(getLoginVisualProfile('transparent')).toBe('transparent')
expect(getLoginVisualProfile('purple')).toBe('classic')
expect(getLoginVisualProfile('light')).toBe('classic')
})
it('forces high-quality capability while preserving all six user strengths', () => {
expect(
getLoginGlassOpticalSettings({
appearance: 'frosted',
deformationStrength: 73,
flowStrength: 68,
preset: 'liquid',
reflectionStrength: 41,
transmissionStrength: 62,
translationStrength: 57,
transparencyStrength: 46,
}),
).toEqual({
appearance: 'frosted',
deformationStrength: 73,
flowStrength: 68,
preset: 'liquid',
quality: 'high',
reflectionStrength: 41,
transmissionStrength: 62,
translationStrength: 57,
transparencyStrength: 46,
})
})
it('requests same-origin wallpapers only for glass', () => {
expect(getLoginWallpaperRequestMode('glass')).toBe('same-origin')
expect(getLoginWallpaperRequestMode('classic')).toBe('default')
expect(getLoginWallpaperRequestMode('transparent')).toBe('default')
})
it('keeps two stable wallpaper slots while their transition roles change', () => {
const initial = createLoginBackgroundLayers('one.jpg')
const prepared = prepareLoginBackgroundLayer(initial, 'two.jpg')
const activated = activateLoginBackgroundLayer(prepared)
const settled = settleLoginBackgroundLayers(activated)
expect(initial).toEqual([
{ key: 'front', role: 'active', url: 'one.jpg' },
{ key: 'back', role: 'standby', url: '' },
])
expect(prepared).toEqual([
{ key: 'front', role: 'active', url: 'one.jpg' },
{ key: 'back', role: 'standby', url: 'two.jpg' },
])
expect(activated).toEqual([
{ key: 'front', role: 'previous', url: 'one.jpg' },
{ key: 'back', role: 'active', url: 'two.jpg' },
])
expect(settled).toEqual([
{ key: 'front', role: 'standby', url: '' },
{ key: 'back', role: 'active', url: 'two.jpg' },
])
})
})
+31
View File
@@ -1,3 +1,13 @@
import type { AppActivityState } from '@/utils/appActivityLifecycle'
/** 壁纸在窗口失焦后继续轮换的最长时间,交互 renderer 仍由应用生命周期独立暂停。 */
export const BACKGROUND_ROTATION_GRACE_MS = 60_000
/** 壁纸轮换只在前台活动或失焦宽限期内运行,系统减少动态效果时始终停止。 */
export function shouldAllowBackgroundRotation(state: AppActivityState, graceActive: boolean, reducedMotion: boolean) {
return !reducedMotion && (state === 'active' || graceActive)
}
interface PreloadedBackgroundRotationOptions {
/** 提交前重新判断当前生命周期和请求版本是否仍允许切换。 */
canCommit: () => boolean
@@ -16,6 +26,15 @@ interface BackgroundRotationImagePreloadOptions {
preload: (url: string) => Promise<boolean>
}
interface BackgroundSequencePreloadOptions {
/** 每张图片完成后重新判断队列是否仍属于当前页面与请求代次。 */
canContinue: () => boolean
/** 按实际轮播顺序排列的待预加载地址。 */
urls: string[]
/** 执行单张图片预加载并返回可用状态。 */
preload: (url: string) => Promise<boolean>
}
/**
* 将壁纸预加载与最终提交分离,确保异步加载期间失效的轮换请求不会改变可见背景。
*/
@@ -40,3 +59,15 @@ export async function preloadBackgroundRotationImages(options: BackgroundRotatio
return results.every(Boolean)
}
/** 当前壁纸稳定后串行预加载剩余轮播项,避免并发争抢首屏带宽。 */
export async function preloadBackgroundSequence(options: BackgroundSequencePreloadOptions) {
const results: boolean[] = []
for (const url of options.urls) {
if (!options.canContinue()) break
results.push(await options.preload(url))
}
return results
}
+63 -34
View File
@@ -8,6 +8,7 @@ export const GLASS_OPTICAL_TRANSLATION_MAX_SCALE = 1.7
export const GLASS_OPTICAL_STRENGTH_DEFAULT = 50
export const GLASS_OPTICAL_STRENGTH_MAX = 100
export const GLASS_OPTICAL_STRENGTH_MIN = 0
export const GLASS_OPTICAL_REFERENCE_STRENGTH = 70
export type GlassAppearance = 'clear' | 'frosted' | 'tinted'
export type GlassOpticalCapability = 'balanced' | 'css' | 'high'
@@ -22,6 +23,8 @@ export interface GlassOpticalParameters {
flow: number
/** 方向高光、迎光棱镜与背光吸收强度。 */
reflection: number
/** 玻璃内部壁纸采样的明暗与暗部展开强度。 */
transmission: number
/** 共享壁纸采样在表面内的统一坐标平移强度。 */
translation: number
/** 壁纸可见度与材质遮罩强度。 */
@@ -127,58 +130,58 @@ const GLASS_OPTICAL_PRESET_MATRIX: Record<
> = {
clear: {
css: {
natural: { deformation: 50, flow: 50, reflection: 50, translation: 50, transparency: 50 },
glide: { deformation: 28, flow: 42, reflection: 42, translation: 72, transparency: 58 },
liquid: { deformation: 72, flow: 78, reflection: 54, translation: 56, transparency: 52 },
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 },
},
balanced: {
natural: { deformation: 50, flow: 50, reflection: 50, translation: 50, transparency: 50 },
glide: { deformation: 30, flow: 44, reflection: 42, translation: 72, transparency: 58 },
liquid: { deformation: 70, flow: 76, reflection: 52, translation: 56, transparency: 52 },
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 },
},
high: {
natural: { deformation: 50, flow: 50, reflection: 46, translation: 50, transparency: 50 },
glide: { deformation: 32, flow: 46, reflection: 40, translation: 74, transparency: 60 },
liquid: { deformation: 74, flow: 80, reflection: 50, translation: 58, transparency: 54 },
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 },
},
},
tinted: {
css: {
natural: { deformation: 50, flow: 50, reflection: 54, translation: 50, transparency: 46 },
glide: { deformation: 30, flow: 42, reflection: 48, translation: 70, transparency: 52 },
liquid: { deformation: 70, flow: 76, reflection: 58, translation: 54, transparency: 48 },
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 },
},
balanced: {
natural: { deformation: 52, flow: 50, reflection: 54, translation: 50, transparency: 46 },
glide: { deformation: 32, flow: 44, reflection: 48, translation: 70, transparency: 52 },
liquid: { deformation: 72, flow: 76, reflection: 56, translation: 56, transparency: 48 },
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 },
},
high: {
natural: { deformation: 52, flow: 50, reflection: 50, translation: 50, transparency: 48 },
glide: { deformation: 34, flow: 46, reflection: 46, translation: 72, transparency: 54 },
liquid: { deformation: 76, flow: 80, reflection: 54, translation: 58, transparency: 50 },
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 },
},
},
frosted: {
css: {
natural: { deformation: 50, flow: 50, reflection: 44, translation: 50, transparency: 42 },
glide: { deformation: 34, flow: 42, reflection: 38, translation: 66, transparency: 46 },
liquid: { deformation: 76, flow: 74, reflection: 48, translation: 50, transparency: 44 },
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 },
},
balanced: {
natural: { deformation: 58, flow: 52, reflection: 44, translation: 48, transparency: 42 },
glide: { deformation: 38, flow: 44, reflection: 38, translation: 68, transparency: 46 },
liquid: { deformation: 78, flow: 76, reflection: 46, translation: 52, transparency: 44 },
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 },
},
high: {
natural: { deformation: 60, flow: 52, reflection: 42, translation: 48, transparency: 44 },
glide: { deformation: 40, flow: 46, reflection: 36, translation: 70, transparency: 48 },
liquid: { deformation: 82, flow: 80, reflection: 44, translation: 54, transparency: 46 },
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 },
},
},
}
/** 返回材质、质量与预置共同确定的个具体参数,调用方可以安全修改返回值。 */
/** 返回材质、质量与预置共同确定的个具体参数,调用方可以安全修改返回值。 */
export function getGlassOpticalPresetParameters(
appearance: GlassAppearance,
quality: GlassOpticalCapability,
@@ -277,18 +280,44 @@ export function getGlassOpticalReflectionStrengthScale(value: unknown) {
return normalizedRatio ** Math.log2(GLASS_OPTICAL_REFLECTION_MAX_SCALE)
}
/** 通透度独立控制真实背景与可读性遮罩的占比,高区间继续增强但逐步收敛。 */
/** 通透度独立控制真实背景与可读性遮罩的占比,高区间仍受 shader 可读性上限保护。 */
export function getGlassOpticalTransparency(value: unknown) {
const normalized = normalizeGlassOpticalStrength(value)
if (normalized <= 80) {
const progress = normalized / 80
if (normalized <= GLASS_OPTICAL_REFERENCE_STRENGTH) {
const progress = normalized / GLASS_OPTICAL_REFERENCE_STRENGTH
return 0.28 + 0.58 * progress ** 1.25
return 0.28 + 0.68 * progress ** 1.25
}
const highRangeProgress = (normalized - 80) / 20
const highRangeProgress =
(normalized - GLASS_OPTICAL_REFERENCE_STRENGTH) / (GLASS_OPTICAL_STRENGTH_MAX - GLASS_OPTICAL_REFERENCE_STRENGTH)
return 0.86 + 0.1 * highRangeProgress ** 1.5
return 0.96 + 0.14 * highRangeProgress ** 1.35
}
/** 标准 CSS 材质使用受控亮度曲线,并在高区间保留有限余量。 */
export function getGlassOpticalCssTransmissionBrightness(value: unknown) {
const transmission = getGlassOpticalTransmissionStrength(value)
if (transmission <= 1) {
return 0.82 + 0.46 * transmission ** 1.1
}
const progress = (transmission - 1) / 0.3
return 1.28 + 0.2 * progress ** 1.2
}
/** 实时 renderer 接收透射响应,并在 shader 中按材质和质量执行高亮保护。 */
export function getGlassOpticalTransmissionStrength(value: unknown) {
const normalized = normalizeGlassOpticalStrength(value)
if (normalized <= GLASS_OPTICAL_REFERENCE_STRENGTH) {
return normalized / GLASS_OPTICAL_REFERENCE_STRENGTH
}
const highRangeProgress =
(normalized - GLASS_OPTICAL_REFERENCE_STRENGTH) / (GLASS_OPTICAL_STRENGTH_MAX - GLASS_OPTICAL_REFERENCE_STRENGTH)
return 1 + 0.3 * highRangeProgress ** 1.2
}
/** 质量决定合成缓冲与纹理上限;路由只切换纹理来源,不改变质量档位。 */
+87
View File
@@ -0,0 +1,87 @@
import type { GlassAppearance, GlassOpticalPreset } from '@/utils/glassOptics'
export type LoginVisualProfile = 'classic' | 'glass' | 'transparent'
export type LoginWallpaperRequestMode = 'default' | 'same-origin'
export interface LoginGlassPreference {
/** 用户选择的玻璃材质。 */
appearance: GlassAppearance
/** 用户保存的局部非均匀形变强度。 */
deformationStrength: number
/** 用户保存的轨迹、尾波与惯性强度。 */
flowStrength: number
/** 用户选择的方案;登录页保持方案身份但只强制高质量能力。 */
preset: GlassOpticalPreset
/** 用户保存的方向反射亮度。 */
reflectionStrength: number
/** 用户保存的玻璃内部透射亮度。 */
transmissionStrength: number
/** 用户保存的统一采样平移强度。 */
translationStrength: number
/** 用户保存的壁纸可见度与材质遮罩强度。 */
transparencyStrength: number
}
export interface LoginBackgroundLayer {
/** 跨登录状态保持稳定的呈现槽位。 */
key: 'back' | 'front'
/** 壁纸在交叉淡化中的职责;standby 槽位可提前准备下一张。 */
role: 'active' | 'previous' | 'standby'
/** 当前槽位显示的壁纸地址。 */
url: string
}
/** 将实际主题解析为互斥的登录视觉 profile。 */
export function getLoginVisualProfile(themeName: string): LoginVisualProfile {
if (themeName === 'glass') return 'glass'
if (themeName === 'transparent') return 'transparent'
return 'classic'
}
/** 玻璃登录页只强制高质量能力,六个用户强度在登录前后保持一致。 */
export function getLoginGlassOpticalSettings(preference: LoginGlassPreference) {
return {
appearance: preference.appearance,
deformationStrength: preference.deformationStrength,
flowStrength: preference.flowStrength,
preset: preference.preset,
quality: 'high' as const,
reflectionStrength: preference.reflectionStrength,
transmissionStrength: preference.transmissionStrength,
translationStrength: preference.translationStrength,
transparencyStrength: preference.transparencyStrength,
}
}
/** 玻璃主题需要同源纹理,其余主题保留现有外链返回行为。 */
export function getLoginWallpaperRequestMode(profile: LoginVisualProfile): LoginWallpaperRequestMode {
return profile === 'glass' ? 'same-origin' : 'default'
}
/** 建立两个始终存在的背景槽位,避免角色变化时复用错误的 DOM 合成层。 */
export function createLoginBackgroundLayers(activeUrl = ''): LoginBackgroundLayer[] {
return [
{ key: 'front', role: 'active', url: activeUrl },
{ key: 'back', role: 'standby', url: '' },
]
}
/** 把下一张壁纸放入隐藏槽位,不改变当前可见层。 */
export function prepareLoginBackgroundLayer(layers: LoginBackgroundLayer[], url: string): LoginBackgroundLayer[] {
return layers.map(layer => (layer.role === 'standby' ? { ...layer, url } : { ...layer }))
}
/** 在壁纸与纹理均就绪后原子交换两个槽位的职责。 */
export function activateLoginBackgroundLayer(layers: LoginBackgroundLayer[]): LoginBackgroundLayer[] {
if (!layers.some(layer => layer.role === 'standby' && layer.url)) return layers
return layers.map(layer => ({
...layer,
role: layer.role === 'active' ? 'previous' : layer.role === 'standby' ? 'active' : layer.role,
}))
}
/** 交叉淡化完成后清空旧图,使该稳定槽位可准备下一次切换。 */
export function settleLoginBackgroundLayers(layers: LoginBackgroundLayer[]): LoginBackgroundLayer[] {
return layers.map(layer => (layer.role === 'previous' ? { ...layer, role: 'standby', url: '' } : { ...layer }))
}