fix(glass): normalize tinted material tones (#659)

This commit is contained in:
InfinityPacer
2026-08-10 13:07:17 +08:00
committed by GitHub
parent 1d0ca1d163
commit 288d46c32a
10 changed files with 183 additions and 18 deletions
+13 -3
View File
@@ -12,7 +12,11 @@ import { globalLoadingStateManager } from '@/utils/loadingStateManager'
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
import { applyStoredThemeCustomizerAppearance, useEffectiveGlassSettings } from '@/composables/useThemeCustomizer'
import {
applyStoredThemeCustomizerAppearance,
themeCustomizerPrimaryColors,
useEffectiveGlassSettings,
} from '@/composables/useThemeCustomizer'
import {
applyStoredTransparencySettings,
TRANSPARENCY_SETTINGS_CHANGED_EVENT,
@@ -24,6 +28,7 @@ import { usePWA } from '@/composables/usePWA'
import { themeManager } from '@/utils/themeManager'
import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { normalizeThemeMaterialAccent } from '@/utils/glassColor'
import { configureApexChartsTheme } from '@/utils/apexCharts'
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
@@ -118,6 +123,11 @@ function recordGlassLaunchTiming(stage: string, detail?: string) {
// 生效主题
const vuetifyTheme = useTheme()
const { global: globalTheme } = vuetifyTheme
const glassMaterialTintColor = computed(
() =>
normalizeThemeMaterialAccent(globalTheme.current.value.colors.primary)?.hex ??
normalizeThemeMaterialAccent(themeCustomizerPrimaryColors[0].value)!.hex,
)
let themeValue = localStorage.getItem('theme') || 'auto'
let resumeThemeSyncTimer: number | null = null
globalTheme.name.value = resolveInitialThemeName(themeValue)
@@ -1191,7 +1201,7 @@ onUnmounted(() => {
:transmission-strength="opticalTransmissionStrength"
:translation-strength="opticalTranslationStrength"
:route-key="route.fullPath"
:tint-color="globalTheme.current.value.colors.primary"
:tint-color="glassMaterialTintColor"
:transition-duration="BACKGROUND_CROSSFADE_DURATION_MS"
:transition-started-at="backgroundCrossfadeStartedAt"
:wallpaper-url="activeOpticalBackgroundImage"
@@ -1314,7 +1324,7 @@ html[data-glass-appearance='tinted'] .background-container.is-glass-theme .backg
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.previous::after {
background:
radial-gradient(circle at 50% 18%, transparent 22%, rgba(6, 10, 19, 14%) 100%),
linear-gradient(rgba(6, 10, 19, 10%) 0%, rgba(6, 10, 19, 32%) 100%), rgba(var(--v-theme-primary), 3%);
linear-gradient(rgba(6, 10, 19, 10%) 0%, rgba(6, 10, 19, 32%) 100%), rgba(var(--glass-material-accent-rgb), 3%);
}
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active,
+1 -1
View File
@@ -37,7 +37,7 @@ const props = defineProps<{
translationStrength: number
/** 路由变化标识,用于在页面内容稳定后重新发现高价值表面。 */
routeKey: string
/** 当前主题主色,用于同步色调材质的光学高光。 */
/** 由用户主色派生的大面积玻璃材料色,用于同步色调材质的光学高光。 */
tintColor: string
/** 外层壁纸交叉淡化的时长,shader 使用同一时钟混合双纹理。 */
transitionDuration: number
@@ -126,7 +126,7 @@ afterEach(() => {
})
describe('GlassOpticalLayer', () => {
it('uses exactly two visible presentation contexts with one interaction source', () => {
it('uses exactly two visible presentation contexts with one interaction source', async () => {
rendererCalls.length = 0
rendererResults.length = 0
setRendererState.mockClear()
@@ -159,6 +159,7 @@ describe('GlassOpticalLayer', () => {
expect(rendererCalls.every(options => options.wallpaperSourceCache === wallpaperSourceCache)).toBe(true)
expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true)
expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true)
expect(rendererCalls.map(options => (options.tintColor as () => string)())).toEqual(['#8D51F9', '#8D51F9'])
expect(rendererCalls[0].pageMotion).toBeUndefined()
expect(rendererCalls[1].pageMotion).toEqual(
expect.objectContaining({
@@ -167,6 +168,9 @@ describe('GlassOpticalLayer', () => {
}),
)
await wrapper.setProps({ tintColor: '#00A6B8' })
expect(rendererCalls.map(options => (options.tintColor as () => string)())).toEqual(['#00A6B8', '#00A6B8'])
wrapper.unmount()
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')
})
@@ -12,6 +12,7 @@ import {
useEffectiveGlassSettings,
} from '@/composables/useThemeCustomizer'
import vuetify from '@/plugins/vuetify'
import { normalizeThemeMaterialAccent } from '@/utils/glassColor'
import { mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it } from 'vitest'
@@ -212,6 +213,28 @@ describe('useThemeCustomizer glass settings', () => {
expect(Number(document.body.style.getPropertyValue('--glass-tint-density'))).toBeCloseTo(0.65)
expect(document.documentElement.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('6.7px')
expect(document.body.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('6.7px')
expect(document.documentElement.style.getPropertyValue('--glass-material-accent-rgb')).toBe(
normalizeThemeMaterialAccent(settings.primaryColor)?.rgb,
)
expect(document.body.style.getPropertyValue('--glass-material-accent-rgb')).toBe(
normalizeThemeMaterialAccent(settings.primaryColor)?.rgb,
)
})
it('keeps the user primary color while publishing its material tone', async () => {
const { customizer, wrapper } = mountThemeCustomizer()
await customizer.setPrimaryColor('#00BCD4')
expect(customizer.settings.value.primaryColor).toBe('#00BCD4')
expect(vuetify.theme.current.value.colors.primary).toBe('#00BCD4')
expect(document.documentElement.style.getPropertyValue('--glass-material-accent-rgb')).toBe(
normalizeThemeMaterialAccent('#00BCD4')?.rgb,
)
expect(document.body.style.getPropertyValue('--glass-material-accent-rgb')).toBe(
normalizeThemeMaterialAccent('#00BCD4')?.rgb,
)
wrapper.unmount()
})
it('keeps overlay clarity synchronized across preview, cancel, and commit', () => {
+5
View File
@@ -19,6 +19,7 @@ import {
type GlassOpticalPreset,
type GlassOpticalPresetOverrides,
} from '@/utils/glassOptics'
import { normalizeThemeMaterialAccent } from '@/utils/glassColor'
import { themeManager } from '@/utils/themeManager'
import { syncThemeFavicon } from '@/utils/themePalette'
@@ -471,6 +472,7 @@ export function applyThemeCustomizerRootSettings(
| 'glassTransmissionStrength'
| 'glassTransparencyStrength'
| 'layout'
| 'primaryColor'
| 'radius'
| 'semiDarkMenu'
| 'shadow'
@@ -482,6 +484,8 @@ export function applyThemeCustomizerRootSettings(
const materialResponse = getGlassMaterialResponse(settings.glassAppearance, settings.glassTransparencyStrength)
const frostBlur = getGlassCssFrostBlur(settings.glassTransparencyStrength)
const overlayClarityBlur = getGlassOverlayClarityBlur(settings.glassTransparencyStrength)
const materialAccent =
normalizeThemeMaterialAccent(settings.primaryColor) ?? normalizeThemeMaterialAccent(defaultPrimaryColor)!
const applyGlassResponse = (element: HTMLElement) => {
element.style.setProperty('--glass-background-visibility', String(materialResponse.backgroundVisibility))
element.style.setProperty('--glass-frost-blur-scale', String(materialResponse.frostBlurScale))
@@ -491,6 +495,7 @@ export function applyThemeCustomizerRootSettings(
element.style.setProperty('--glass-blur-surface', `${frostBlur.surface}px`)
element.style.setProperty('--glass-blur-raised', `${frostBlur.raised}px`)
element.style.setProperty('--glass-overlay-clarity-blur', `${overlayClarityBlur}px`)
element.style.setProperty('--glass-material-accent-rgb', materialAccent.rgb)
}
document.documentElement.setAttribute('data-glass-appearance', settings.glassAppearance)
@@ -35,6 +35,56 @@ describe('glass overlay material styles', () => {
expect(styles).toContain('rgba(255, 255, 255, calc(0.045 + var(--glass-surface-density, 0.86) * 0.09))')
})
it('uses the derived theme tone only for tinted material surfaces', () => {
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
const appStyles = readFileSync(resolve(cwd(), 'src/App.vue'), 'utf8')
const tintedRule = styles.match(/&\[data-glass-appearance='tinted'\]\s*\{(?<declarations>[\s\S]*?)\n {2}\}/u)
?.groups?.declarations
const wallpaperTintStart = appStyles.indexOf(
"html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active::after,",
)
const wallpaperTintEnd = appStyles.indexOf("html[data-glass-appearance='frosted']", wallpaperTintStart)
const wallpaperTintRule = appStyles.slice(wallpaperTintStart, wallpaperTintEnd)
const loginRule = styles.match(
/html\[data-theme='glass'\]\[data-glass-appearance='tinted'\] body\[data-theme='glass'\]\s*\{(?<declarations>[\s\S]*?)\n\}/u,
)?.groups?.declarations
const workflowRule = styles.match(
/&\[data-glass-appearance='tinted'\] \.workflow-task-card\s*\{(?<declarations>[\s\S]*?)\n {2}\}/u,
)?.groups?.declarations
const getPropertyValue = (rule: string | undefined, token: string) =>
rule?.match(new RegExp(`${token}:\\s*(?<value>[\\s\\S]*?);`))?.groups?.value
expect(tintedRule).toBeDefined()
for (const token of [
'--glass-surface',
'--glass-surface-soft',
'--glass-surface-raised',
'--glass-overlay-surface',
]) {
expect(getPropertyValue(tintedRule, token)).toContain('var(--glass-material-accent-rgb)')
}
for (const token of [
'--glass-control',
'--glass-control-prominent',
'--glass-control-prominent-focus',
'--glass-border',
'--glass-border-raised',
'--glass-border-hover',
'--glass-highlight',
'--glass-sheen',
]) {
expect(getPropertyValue(tintedRule, token)).toContain('var(--v-theme-primary)')
}
expect(wallpaperTintStart).toBeGreaterThanOrEqual(0)
expect(wallpaperTintEnd).toBeGreaterThan(wallpaperTintStart)
expect(wallpaperTintRule).toContain('rgba(var(--glass-material-accent-rgb), 3%)')
expect(wallpaperTintRule).not.toContain('var(--v-theme-primary)')
expect(loginRule).toMatch(/\.login-card__surface[\s\S]*?var\(--glass-material-accent-rgb\)/)
expect(loginRule).toMatch(/\.native-login-field[\s\S]*?var\(--v-theme-primary\)/)
expect(workflowRule).toContain('var(--workflow-status-rgb)')
expect(workflowRule).toContain('var(--v-theme-primary)')
})
it('renders colored chips as shadowless glass without flattening their variants', () => {
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
@@ -20,9 +20,13 @@ describe('plugin card accent styles', () => {
it('limits tinted theme mixing to six percent', () => {
const glassStyles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
const tintedBannerRule = glassStyles.match(
/&\[data-glass-appearance='tinted'\] \.plugin-card__banner\s*\{(?<declarations>[\s\S]*?)\n {2}\}/u,
)?.groups?.declarations
expect(glassStyles.match(/rgba\(var\(--plugin-card-effective-accent-rgb\)/g)).toHaveLength(9)
expect(glassStyles.match(/rgba\(var\(--plugin-card-effective-accent-rgb\)[\s\S]*?\) 94%/g)).toHaveLength(2)
expect(tintedBannerRule?.match(/rgba\(var\(--glass-material-accent-rgb\)[\s\S]*?\)\s*\)/g)).toHaveLength(2)
expect(glassStyles).not.toContain('var(--plugin-card-accent-rgb, 40, 169, 225)')
})
})
+7 -7
View File
@@ -131,17 +131,17 @@ html[data-theme='glass'] {
--glass-surface: color-mix(
in srgb,
rgba(11, 19, 34, calc(0.12 + var(--glass-surface-density, 0.72) * 0.2)) 88%,
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.28))
rgba(var(--glass-material-accent-rgb), calc(var(--glass-tint-density, 0.65) * 0.28))
);
--glass-surface-soft: color-mix(
in srgb,
rgba(11, 19, 34, calc(0.13 + var(--glass-surface-density, 0.72) * 0.23)) 89%,
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.26))
rgba(var(--glass-material-accent-rgb), calc(var(--glass-tint-density, 0.65) * 0.26))
);
--glass-surface-raised: color-mix(
in srgb,
rgba(11, 19, 34, calc(0.07 + var(--glass-surface-density, 0.72) * 0.36)) 84%,
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.32))
rgba(var(--glass-material-accent-rgb), calc(var(--glass-tint-density, 0.65) * 0.32))
);
--glass-control: color-mix(in srgb, rgba(11, 19, 34, 52%) 88%, rgba(var(--v-theme-primary), 28%));
--glass-control-prominent: color-mix(in srgb, rgba(255, 255, 255, 7%) 82%, rgba(var(--v-theme-primary), 24%));
@@ -185,7 +185,7 @@ html[data-theme='glass'] {
--glass-overlay-surface: color-mix(
in srgb,
rgba(11, 19, 34, calc(0.09 + var(--glass-surface-density, 0.72) * 0.2)) 88%,
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.22))
rgba(var(--glass-material-accent-rgb), calc(var(--glass-tint-density, 0.65) * 0.22))
);
--glass-overlay-blur: var(--glass-overlay-clarity-blur, 6px);
--glass-overlay-saturate: 120%;
@@ -1102,13 +1102,13 @@ html[data-theme='glass'] {
color-mix(
in srgb,
rgba(var(--plugin-card-effective-accent-rgb), calc(0.14 + var(--glass-tint-density, 0.65) * 0.33)) 94%,
rgba(var(--v-theme-primary), calc(0.14 + var(--glass-tint-density, 0.65) * 0.33))
rgba(var(--glass-material-accent-rgb), calc(0.14 + var(--glass-tint-density, 0.65) * 0.33))
)
0%,
color-mix(
in srgb,
rgba(var(--plugin-card-effective-accent-rgb), calc(0.07 + var(--glass-tint-density, 0.65) * 0.2)) 94%,
rgba(var(--v-theme-primary), calc(0.07 + var(--glass-tint-density, 0.65) * 0.2))
rgba(var(--glass-material-accent-rgb), calc(0.07 + var(--glass-tint-density, 0.65) * 0.2))
)
58%,
rgba(var(--plugin-card-effective-accent-rgb), calc(0.04 + var(--glass-tint-density, 0.65) * 0.095)) 100%
@@ -1573,7 +1573,7 @@ html[data-theme='glass'][data-glass-appearance='tinted'] body[data-theme='glass'
.login-card__surface {
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.1), transparent 36%),
linear-gradient(rgba(var(--v-theme-primary), 0.08), rgba(var(--v-theme-primary), 0.03)),
linear-gradient(rgba(var(--glass-material-accent-rgb), 0.08), rgba(var(--glass-material-accent-rgb), 0.03)),
linear-gradient(rgba(7, 14, 25, 0.16), rgba(7, 14, 25, 0.3)) !important;
}
+54 -1
View File
@@ -1,4 +1,4 @@
import { normalizePluginAccentColor } from '@/utils/glassColor'
import { normalizePluginAccentColor, normalizeThemeMaterialAccent } from '@/utils/glassColor'
import { describe, expect, it } from 'vitest'
interface OklchColor {
@@ -69,3 +69,56 @@ describe('normalizePluginAccentColor', () => {
expect(normalizePluginAccentColor('#gg0000')).toBeUndefined()
})
})
describe('normalizeThemeMaterialAccent', () => {
it.each([
'#8D51F9',
'#3F51B5',
'#1976D2',
'#00BCD4',
'#009688',
'#4CAF50',
'#FFB400',
'#FF9800',
'#FF4C51',
'#E91E63',
'#16B1FF',
'#607D8B',
])('keeps preset %s within the material tone contract', sourceHex => {
const normalized = normalizeThemeMaterialAccent(sourceHex)
expect(normalized).toBeDefined()
expect(normalized?.hex).toMatch(/^#[0-9a-f]{6}$/)
expect(normalized?.rgb).toMatch(/^\d{1,3}, \d{1,3}, \d{1,3}$/)
const source = hexToOklch(sourceHex)
const output = hexToOklch(normalized!.hex)
expect(output.lightness).toBeGreaterThanOrEqual(0.555)
expect(output.lightness).toBeLessThanOrEqual(0.725)
expect(output.chroma).toBeLessThanOrEqual(Math.min(source.chroma, 0.14) + 0.005)
if (source.chroma >= 0.03 && output.chroma >= 0.03)
expect(hueDistanceDegrees(source.hue, output.hue)).toBeLessThanOrEqual(2)
})
it.each(['#000000', '#ffffff', '#7d7d7d', '#ffff00', '#00ffff', '#ff0000'])(
'keeps extreme %s in gamut without inventing chroma',
sourceHex => {
const normalized = normalizeThemeMaterialAccent(sourceHex)
const source = hexToOklch(sourceHex)
const output = hexToOklch(normalized!.hex)
expect(output.lightness).toBeGreaterThanOrEqual(0.555)
expect(output.lightness).toBeLessThanOrEqual(0.725)
expect(output.chroma).toBeLessThanOrEqual(Math.min(source.chroma, 0.14) + 0.005)
if (source.chroma >= 0.03 && output.chroma >= 0.03)
expect(hueDistanceDegrees(source.hue, output.hue)).toBeLessThanOrEqual(2)
},
)
it('is deterministic, case-insensitive, and rejects non-contract inputs', () => {
expect(normalizeThemeMaterialAccent('#12ABef')).toEqual(normalizeThemeMaterialAccent('#12abef'))
expect(normalizeThemeMaterialAccent('#fff')).toBeUndefined()
expect(normalizeThemeMaterialAccent('12abef')).toBeUndefined()
expect(normalizeThemeMaterialAccent('#gg0000')).toBeUndefined()
})
})
+21 -5
View File
@@ -20,6 +20,9 @@ export interface GlassAccentColor {
const PLUGIN_ACCENT_MIN_LIGHTNESS = 0.48
const PLUGIN_ACCENT_MAX_LIGHTNESS = 0.76
const PLUGIN_ACCENT_MAX_CHROMA = 0.18
const THEME_MATERIAL_MIN_LIGHTNESS = 0.56
const THEME_MATERIAL_MAX_LIGHTNESS = 0.72
const THEME_MATERIAL_MAX_CHROMA = 0.14
const NEUTRAL_CHROMA_THRESHOLD = 0.02
const GAMUT_SEARCH_ITERATIONS = 24
@@ -117,19 +120,32 @@ function formatAccentColor(color: OklchColor): GlassAccentColor {
}
}
/** 将插件 Logo 主色限制在可读范围内,同时保持品牌色相与中性色属性。 */
export function normalizePluginAccentColor(color: string): GlassAccentColor | undefined {
function normalizeAccentColor(color: string, minLightness: number, maxLightness: number, maxChroma: number) {
const srgb = parseHexColor(color)
if (!srgb) return undefined
const source = oklabToOklch(srgbToOklab(srgb))
const chroma =
source.chroma < NEUTRAL_CHROMA_THRESHOLD ? source.chroma : Math.min(source.chroma, PLUGIN_ACCENT_MAX_CHROMA)
const chroma = source.chroma < NEUTRAL_CHROMA_THRESHOLD ? source.chroma : Math.min(source.chroma, maxChroma)
const normalized = mapChromaToSrgb({
lightness: clamp(source.lightness, PLUGIN_ACCENT_MIN_LIGHTNESS, PLUGIN_ACCENT_MAX_LIGHTNESS),
lightness: clamp(source.lightness, minLightness, maxLightness),
chroma,
hue: source.hue,
})
return formatAccentColor(normalized)
}
/** 将插件 Logo 主色限制在可读范围内,同时保持品牌色相与中性色属性。 */
export function normalizePluginAccentColor(color: string): GlassAccentColor | undefined {
return normalizeAccentColor(color, PLUGIN_ACCENT_MIN_LIGHTNESS, PLUGIN_ACCENT_MAX_LIGHTNESS, PLUGIN_ACCENT_MAX_CHROMA)
}
/** 派生大面积色调玻璃使用的材料色,不改变用户选择的真实主色。 */
export function normalizeThemeMaterialAccent(color: string): GlassAccentColor | undefined {
return normalizeAccentColor(
color,
THEME_MATERIAL_MIN_LIGHTNESS,
THEME_MATERIAL_MAX_LIGHTNESS,
THEME_MATERIAL_MAX_CHROMA,
)
}