feat(glass): unify inset navigation contours with the frosted backplate

This commit is contained in:
InfinityPacer
2026-09-06 08:14:14 +08:00
parent 999f67bb0c
commit d39b240bc8
5 changed files with 567 additions and 7 deletions
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
import type { GlassFixedShellBackplateLayer } from '@/composables/useGlassFixedShellBackplate'
interface Props {
@@ -12,17 +13,292 @@ interface Props {
transitionDurationMs: number
}
/** 共用稳定壁纸背板、但拥有独立外轮廓的导航表面。 */
type GeometrySurface = 'sidebar' | 'navbar'
/** SVG objectBoundingBox 坐标,分别以背板的实际宽和高归一化。 */
interface NormalizedClipRect {
/** 可见高度占背板高度的比例。 */
height: number
/** 水平方向圆角半径占背板宽度的比例。 */
rx: number
/** 垂直方向圆角半径占背板高度的比例。 */
ry: number
/** 可见宽度占背板宽度的比例。 */
width: number
/** 相对背板左边缘的位置。 */
x: number
/** 相对背板上边缘的位置。 */
y: number
}
/** 背板实际CSS像素边界,不使用可能包含滚动条的window.innerWidth。 */
interface BackplateBounds {
/** 真实高度。 */
height: number
/** 实际左侧视口坐标。 */
left: number
/** 实际顶部视口坐标。 */
top: number
/** 排除滚动条后的实际宽度。 */
width: number
}
const GEOMETRY_SURFACES: readonly GeometrySurface[] = ['sidebar', 'navbar']
const GEOMETRY_ATTRIBUTE_FILTER = [
'class',
'data-glass-appearance',
'data-glass-quality',
'data-shell-display-environment',
'data-shell-mode',
'data-shell-navbar-attachment',
'data-theme',
'style',
]
const props = defineProps<Props>()
const mainBackplateRef = ref<HTMLElement | null>(null)
const clipRects = ref<NormalizedClipRect[]>([])
const clipPathId = `glass-fixed-shell-clip-${useId().replace(/[^a-zA-Z0-9_-]/gu, '-')}`
const transitionStyle = computed(() => ({
'--glass-fixed-shell-transition-duration': `${Math.max(0, props.transitionDurationMs)}ms`,
}))
const mainBackplateStyle = computed(() => ({
...transitionStyle.value,
clipPath: clipRects.value.length === GEOMETRY_SURFACES.length ? `url(#${clipPathId})` : undefined,
}))
const observedElements: Record<GeometrySurface, HTMLElement | null> = {
navbar: null,
sidebar: null,
}
const transitionHandlers: Record<GeometrySurface, EventListener | null> = {
navbar: null,
sidebar: null,
}
let isMounted = false
let observedShell: HTMLElement | null = null
let resizeObserver: ResizeObserver | null = null
let stateObserver: MutationObserver | null = null
let geometrySyncQueued = false
let usesResizeFallback = false
let lastGeometryKey = ''
function getLayoutShell() {
return mainBackplateRef.value?.closest('.layout-wrapper') as HTMLElement | null
}
/** 仅为桌面连接式导航启用几何裁剪,其他壳层继续使用主题原有 CSS 裁剪。 */
function isConnectedDesktopShell(shell: HTMLElement) {
return (
document.documentElement.dataset.theme === 'glass' &&
!props.isOverlayNav &&
shell.dataset.shellMode === 'desktop' &&
shell.dataset.shellNavbarAttachment === 'connected' &&
!shell.classList.contains('layout-horizontal-nav-active') &&
!shell.classList.contains('layout-overlay-nav') &&
!shell.classList.contains('layout-app-shell') &&
!shell.classList.contains('layout-window-controls-overlay-shell')
)
}
function readBackplateBounds(): BackplateBounds | null {
const backplate = mainBackplateRef.value
if (!backplate) return null
const bounds = backplate.getBoundingClientRect()
const width = Number.isFinite(bounds.width) ? Math.max(0, bounds.width) : 0
const height = Number.isFinite(bounds.height) ? Math.max(0, bounds.height) : 0
if (width <= 0 || height <= 0) return null
return {
height,
left: Number.isFinite(bounds.left) ? bounds.left : 0,
top: Number.isFinite(bounds.top) ? bounds.top : 0,
width,
}
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(Math.max(value, minimum), maximum)
}
/** 固定导航四角共用等半径;读取计算后的像素值与CSS轮廓保持一致。 */
function readComputedRadius(element: HTMLElement) {
const radius = Number.parseFloat(window.getComputedStyle(element).borderTopLeftRadius)
return Number.isFinite(radius) ? Math.max(0, radius) : 0
}
function readNormalizedClipRect(element: HTMLElement, backplate: BackplateBounds) {
const bounds = element.getBoundingClientRect()
const left = Number.isFinite(bounds.left) ? bounds.left : 0
const top = Number.isFinite(bounds.top) ? bounds.top : 0
const width = Number.isFinite(bounds.width) ? Math.max(0, bounds.width) : 0
const height = Number.isFinite(bounds.height) ? Math.max(0, bounds.height) : 0
const right = Number.isFinite(bounds.right) ? bounds.right : left + width
const bottom = Number.isFinite(bounds.bottom) ? bounds.bottom : top + height
const visibleLeft = clamp(Math.min(left, right) - backplate.left, 0, backplate.width)
const visibleTop = clamp(Math.min(top, bottom) - backplate.top, 0, backplate.height)
const visibleRight = clamp(Math.max(left, right) - backplate.left, 0, backplate.width)
const visibleBottom = clamp(Math.max(top, bottom) - backplate.top, 0, backplate.height)
const visibleWidth = visibleRight - visibleLeft
const visibleHeight = visibleBottom - visibleTop
if (visibleWidth <= 0 || visibleHeight <= 0) return null
const radius = readComputedRadius(element)
const normalizedWidth = visibleWidth / backplate.width
const normalizedHeight = visibleHeight / backplate.height
return {
height: normalizedHeight,
rx: clamp(radius / backplate.width, 0, normalizedWidth / 2),
ry: clamp(radius / backplate.height, 0, normalizedHeight / 2),
width: normalizedWidth,
x: visibleLeft / backplate.width,
y: visibleTop / backplate.height,
} satisfies NormalizedClipRect
}
function bindGeometrySurface(surface: GeometrySurface, element: HTMLElement | null) {
const previousElement = observedElements[surface]
if (previousElement === element) return
if (resizeObserver && previousElement) resizeObserver.unobserve(previousElement)
const previousHandler = transitionHandlers[surface]
if (previousElement && previousHandler) {
previousElement.removeEventListener('transitionrun', previousHandler)
previousElement.removeEventListener('transitionend', previousHandler)
previousElement.removeEventListener('transitioncancel', previousHandler)
}
observedElements[surface] = element
transitionHandlers[surface] = null
if (!element) return
resizeObserver?.observe(element)
const transitionHandler: EventListener = () => scheduleGeometrySync()
transitionHandlers[surface] = transitionHandler
element.addEventListener('transitionrun', transitionHandler)
element.addEventListener('transitionend', transitionHandler)
element.addEventListener('transitioncancel', transitionHandler)
}
function observeStateSources() {
if (!stateObserver) return
stateObserver.disconnect()
stateObserver.observe(document.documentElement, {
attributeFilter: GEOMETRY_ATTRIBUTE_FILTER,
attributes: true,
})
if (document.body) {
stateObserver.observe(document.body, {
attributeFilter: GEOMETRY_ATTRIBUTE_FILTER,
attributes: true,
})
}
if (observedShell) {
stateObserver.observe(observedShell, {
attributeFilter: GEOMETRY_ATTRIBUTE_FILTER,
attributes: true,
})
}
}
function refreshObservedElements() {
const shell = getLayoutShell()
if (shell !== observedShell) {
observedShell = shell
observeStateSources()
}
bindGeometrySurface('sidebar', shell?.querySelector<HTMLElement>('.layout-vertical-nav:not(.overlay-nav)') ?? null)
bindGeometrySurface('navbar', shell?.querySelector<HTMLElement>('.layout-navbar') ?? null)
}
function applyGeometry(nextRects: NormalizedClipRect[]) {
const nextKey = JSON.stringify(nextRects)
if (nextKey === lastGeometryKey) return
lastGeometryKey = nextKey
clipRects.value = nextRects
}
function syncGeometry() {
if (!isMounted) return
refreshObservedElements()
const shell = observedShell
const backplate = readBackplateBounds()
const sidebar = observedElements.sidebar
const navbar = observedElements.navbar
if (!shell || !backplate || !isConnectedDesktopShell(shell) || !sidebar || !navbar) {
applyGeometry([])
return
}
const nextRects = GEOMETRY_SURFACES.map(surface =>
readNormalizedClipRect(observedElements[surface] as HTMLElement, backplate),
)
if (nextRects.some(rect => rect === null)) {
applyGeometry([])
return
}
applyGeometry(nextRects as NormalizedClipRect[])
}
function scheduleGeometrySync() {
if (geometrySyncQueued) return
geometrySyncQueued = true
queueMicrotask(() => {
geometrySyncQueued = false
syncGeometry()
})
}
watch(() => props.isOverlayNav, scheduleGeometrySync, { flush: 'sync' })
onMounted(() => {
isMounted = true
stateObserver = new MutationObserver(scheduleGeometrySync)
resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => scheduleGeometrySync())
usesResizeFallback = resizeObserver === null
if (usesResizeFallback) window.addEventListener('resize', scheduleGeometrySync, { passive: true })
else if (mainBackplateRef.value) resizeObserver?.observe(mainBackplateRef.value)
refreshObservedElements()
observeStateSources()
syncGeometry()
void nextTick(syncGeometry)
})
onBeforeUnmount(() => {
isMounted = false
stateObserver?.disconnect()
stateObserver = null
resizeObserver?.disconnect()
resizeObserver = null
if (usesResizeFallback) window.removeEventListener('resize', scheduleGeometrySync)
usesResizeFallback = false
for (const surface of GEOMETRY_SURFACES) bindGeometrySurface(surface, null)
observedShell = null
geometrySyncQueued = false
lastGeometryKey = ''
})
</script>
<template>
<div
ref="mainBackplateRef"
class="glass-fixed-shell-backplate glass-fixed-shell-backplate--main"
data-backplate-surface="main"
:style="transitionStyle"
:style="mainBackplateStyle"
aria-hidden="true"
>
<div
@@ -46,6 +322,24 @@ const transitionStyle = computed(() => ({
</div>
</div>
<svg class="glass-fixed-shell-backplate__geometry" width="0" height="0" aria-hidden="true" focusable="false">
<defs>
<clipPath :id="clipPathId" clipPathUnits="objectBoundingBox">
<rect
v-for="(rect, index) in clipRects"
:key="GEOMETRY_SURFACES[index]"
:data-clip-surface="GEOMETRY_SURFACES[index]"
:height="rect.height"
:rx="rect.rx"
:ry="rect.ry"
:width="rect.width"
:x="rect.x"
:y="rect.y"
/>
</clipPath>
</defs>
</svg>
<div
v-if="isOverlayNav"
class="glass-fixed-shell-backplate glass-fixed-shell-backplate--overlay-nav"
@@ -89,6 +383,12 @@ const transitionStyle = computed(() => ({
pointer-events: none;
}
.glass-fixed-shell-backplate__geometry {
position: fixed;
overflow: hidden;
pointer-events: none;
}
.glass-fixed-shell-backplate--main {
--glass-fixed-shell-nav-inline-size: #{variables.$layout-vertical-nav-width};
@@ -156,7 +156,9 @@ function isRefractionActive(surface: NavigationSurface) {
if (surface === 'navbar') {
return (
(shell.dataset.shellMode === 'desktop' && !shell.classList.contains('layout-horizontal-nav-active')) ||
(shell.dataset.shellMode === 'desktop' &&
!shell.classList.contains('layout-horizontal-nav-active') &&
!shell.classList.contains('layout-window-controls-overlay-shell')) ||
(shell.classList.contains('layout-navbar-floating-eligible') &&
shell.classList.contains('layout-navbar-away-from-top'))
)
@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue'
import type { GlassFixedShellBackplateLayer } from '@/composables/useGlassFixedShellBackplate'
@@ -26,9 +27,146 @@ const initialLayers: readonly GlassFixedShellBackplateLayer[] = [
},
]
interface TestRect {
bottom: number
height: number
left: number
right: number
top: number
width: number
toJSON: () => Record<string, never>
}
interface BackplateMountOptions {
[key: string]: unknown
attachTo?: Element
props: {
isOverlayNav: boolean
isOverlayNavActive: boolean
layers: readonly GlassFixedShellBackplateLayer[]
transitionDurationMs: number
}
}
const mountedWrappers: Array<ReturnType<typeof mount>> = []
let resizeCallback: ResizeObserverCallback | undefined
let resizeDisconnect: ReturnType<typeof vi.fn> | undefined
let backplateRect: TestRect
let sidebarRect: TestRect
let navbarRect: TestRect
function createRect(left: number, top: number, width: number, height: number): TestRect {
return {
bottom: top + height,
height,
left,
right: left + width,
top,
toJSON: () => ({}),
width,
}
}
function mountBackplate(options: BackplateMountOptions) {
const wrapper = mount(GlassFixedShellBackplate, options)
mountedWrappers.push(wrapper)
return wrapper
}
function mountConnectedShell(horizontal = false) {
const shell = document.createElement('div')
shell.className = `layout-wrapper${horizontal ? ' layout-horizontal-nav-active' : ''}`
shell.dataset.shellMode = 'desktop'
shell.dataset.shellNavbarAttachment = horizontal ? 'theme-qualified' : 'connected'
const sidebar = document.createElement('aside')
sidebar.className = 'layout-vertical-nav'
const navbar = document.createElement('header')
navbar.className = 'layout-navbar'
shell.append(sidebar, navbar)
document.body.append(shell)
return {
navbar,
shell,
sidebar,
wrapper: mountBackplate({
attachTo: shell,
props: {
isOverlayNav: false,
isOverlayNavActive: false,
layers: initialLayers,
transitionDurationMs: 1500,
},
}),
}
}
async function settleGeometry() {
await nextTick()
await flushPromises()
await nextTick()
await Promise.resolve()
}
describe('GlassFixedShellBackplate', () => {
beforeEach(() => {
resizeCallback = undefined
resizeDisconnect = vi.fn()
backplateRect = createRect(5, 3, 1185, 790)
sidebarRect = createRect(13, 11, 252, 774)
navbarRect = createRect(273, 11, 909, 72)
vi.stubGlobal(
'ResizeObserver',
class {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback
}
observe() {}
unobserve() {}
disconnect() {
resizeDisconnect?.()
}
},
)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.classList.contains('glass-fixed-shell-backplate--main')) return backplateRect as DOMRect
if (this.classList.contains('layout-vertical-nav')) return sidebarRect as DOMRect
if (this.classList.contains('layout-navbar')) return navbarRect as DOMRect
return createRect(0, 0, 0, 0) as DOMRect
})
vi.spyOn(window, 'getComputedStyle').mockImplementation(
element =>
({
borderBottomLeftRadius: element instanceof HTMLElement ? '16px' : '0px',
borderBottomRightRadius: element instanceof HTMLElement ? '16px' : '0px',
borderEndEndRadius: element instanceof HTMLElement ? '16px' : '0px',
borderEndStartRadius: element instanceof HTMLElement ? '16px' : '0px',
borderRadius: element instanceof HTMLElement ? '16px' : '0px',
borderStartEndRadius: element instanceof HTMLElement ? '16px' : '0px',
borderStartStartRadius: element instanceof HTMLElement ? '16px' : '0px',
borderTopLeftRadius: element instanceof HTMLElement ? '16px' : '0px',
borderTopRightRadius: element instanceof HTMLElement ? '16px' : '0px',
getPropertyValue: (property: string) => (property.includes('radius') ? '16px' : ''),
}) as unknown as CSSStyleDeclaration,
)
document.documentElement.dataset.theme = 'glass'
})
afterEach(() => {
for (const wrapper of mountedWrappers.splice(0)) wrapper.unmount()
document.querySelectorAll('.layout-wrapper').forEach(element => element.remove())
document.documentElement.removeAttribute('data-theme')
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('renders the App-owned slots once for the shared desktop shell', () => {
const wrapper = mount(GlassFixedShellBackplate, {
const wrapper = mountBackplate({
props: {
isOverlayNav: false,
isOverlayNavActive: false,
@@ -51,7 +189,7 @@ describe('GlassFixedShellBackplate', () => {
})
it('preserves slot nodes while their active and previous roles swap', async () => {
const wrapper = mount(GlassFixedShellBackplate, {
const wrapper = mountBackplate({
props: {
isOverlayNav: false,
isOverlayNavActive: false,
@@ -76,7 +214,7 @@ describe('GlassFixedShellBackplate', () => {
})
it('adds a separately clipped surface only for mobile overlay navigation', () => {
const wrapper = mount(GlassFixedShellBackplate, {
const wrapper = mountBackplate({
props: {
isOverlayNav: true,
isOverlayNavActive: true,
@@ -90,4 +228,71 @@ describe('GlassFixedShellBackplate', () => {
expect(overlay.classes()).toContain('is-visible')
expect(overlay.findAll('[data-backplate-slot]')).toHaveLength(2)
})
it('maps both connected surfaces to backplate-relative objectBoundingBox geometry', async () => {
const { wrapper } = mountConnectedShell()
await settleGeometry()
const main = wrapper.get('[data-backplate-surface="main"]')
const mainElement = main.element as HTMLElement
const clipPath = wrapper.get('clipPath')
const rects = clipPath.findAll('rect')
expect(clipPath.attributes('clipPathUnits')).toBe('objectBoundingBox')
expect(rects).toHaveLength(2)
expect(mainElement.style.clipPath).toMatch(/^url\(#glass-fixed-shell-clip-/u)
expect(Number(rects[0].attributes('x'))).toBeCloseTo(8 / 1185, 8)
expect(Number(rects[0].attributes('y'))).toBeCloseTo(8 / 790, 8)
expect(Number(rects[0].attributes('width'))).toBeCloseTo(252 / 1185, 8)
expect(Number(rects[0].attributes('height'))).toBeCloseTo(774 / 790, 8)
expect(Number(rects[0].attributes('rx'))).toBeCloseTo(16 / 1185, 8)
expect(Number(rects[0].attributes('ry'))).toBeCloseTo(16 / 790, 8)
expect(Number(rects[1].attributes('x'))).toBeCloseTo(268 / 1185, 8)
expect(Number(rects[1].attributes('y'))).toBeCloseTo(8 / 790, 8)
expect(Number(rects[1].attributes('width'))).toBeCloseTo(909 / 1185, 8)
expect(Number(rects[1].attributes('height'))).toBeCloseTo(72 / 790, 8)
expect(Number(rects[1].attributes('rx'))).toBeCloseTo(16 / 1185, 8)
expect(Number(rects[1].attributes('ry'))).toBeCloseTo(16 / 790, 8)
expect(wrapper.findAll('[data-backplate-surface="main"]')).toHaveLength(1)
expect(wrapper.findAll('[data-backplate-slot]')).toHaveLength(2)
})
it('does not activate the connected clip for horizontal navigation', async () => {
const { wrapper } = mountConnectedShell(true)
await settleGeometry()
expect((wrapper.get('[data-backplate-surface="main"]').element as HTMLElement).style.clipPath).toBe('')
expect(wrapper.findAll('clipPath rect')).toHaveLength(0)
})
it('refreshes dimensions and disconnects the resize observer on unmount', async () => {
const { wrapper } = mountConnectedShell()
await settleGeometry()
backplateRect = createRect(5, 3, 1180, 790)
sidebarRect = createRect(13, 11, 60, 774)
navbarRect = createRect(273, 11, 904, 96)
resizeCallback?.([], {} as ResizeObserver)
await settleGeometry()
const rects = wrapper.findAll('clipPath rect')
expect(Number(rects[0].attributes('width'))).toBeCloseTo(60 / 1180, 8)
expect(Number(rects[1].attributes('x'))).toBeCloseTo(268 / 1180, 8)
expect(Number(rects[1].attributes('height'))).toBeCloseTo(96 / 790, 8)
wrapper.unmount()
expect(resizeDisconnect).toHaveBeenCalledOnce()
})
it('uses window resize when ResizeObserver is unavailable', async () => {
vi.stubGlobal('ResizeObserver', undefined)
const { wrapper } = mountConnectedShell()
await settleGeometry()
sidebarRect = createRect(13, 11, 60, 774)
window.dispatchEvent(new Event('resize'))
await settleGeometry()
expect(Number(wrapper.find('clipPath rect').attributes('width'))).toBeCloseTo(60 / 1185, 8)
})
})
+51
View File
@@ -1,3 +1,5 @@
@use '@configured-variables' as variables;
// V3 表面按信息角色统一材料;只改变承载层,不改变图表、业务数据与交互命中区。
@mixin surfaces {
html[data-theme='glass'] {
@@ -106,6 +108,19 @@
border-inline-start: 1px solid rgba(255, 255, 255, 0.12);
}
.layout-page-content .site-card .border-t .text-medium-emphasis {
// 上传/下载是核心读数,信息权重高于网址和能力标识。
color: rgb(var(--v-theme-on-surface)) !important;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.layout-page-content .site-card h3,
.layout-page-content .plugin-card__banner .v-card-title {
font-weight: 650;
letter-spacing: 0;
}
.layout-page-content .plugin-card__banner {
--plugin-card-banner-scrim: linear-gradient(rgba(23, 27, 32, 0.035), rgba(23, 27, 32, 0.1));
--plugin-card-banner-tint: linear-gradient(
@@ -140,6 +155,42 @@
}
}
// 固定导航保留布局占位,玻璃实体在占位内留出空气,采样几何仍由真实元素提供。
html[data-theme='glass']
body[data-theme='glass']
.layout-wrapper[data-shell-mode='desktop']:not(
.layout-horizontal-nav-active,
.layout-window-controls-overlay-shell
) {
--glass-v3-nav-reserved-width: #{variables.$layout-vertical-nav-width};
&.layout-vertical-nav-collapsed {
--glass-v3-nav-reserved-width: #{variables.$layout-vertical-nav-collapsed-width};
}
.layout-vertical-nav {
border-radius: 16px;
box-shadow: var(--glass-v3-shadow) !important;
block-size: calc(100% - 16px);
inline-size: calc(var(--glass-v3-nav-reserved-width) - 8px) !important;
inset-block-start: 8px;
inset-inline-start: 8px;
overflow: clip;
}
&.layout-vertical-nav-collapsed .layout-vertical-nav.hovered {
inline-size: calc(#{variables.$layout-vertical-nav-width} - 8px) !important;
}
.layout-navbar {
border-radius: 16px !important;
inline-size: calc(100% - var(--glass-v3-nav-reserved-width) - 16px) !important;
inset-block-start: 8px !important;
inset-inline-start: calc(var(--glass-v3-nav-reserved-width) + 8px) !important;
overflow: clip;
}
}
@media (hover: hover) {
html[data-theme='glass'] body[data-theme='glass'] {
.layout-page-content
@@ -77,6 +77,8 @@ describe('createGlassNavbarDisplacementField', () => {
{ width: 127, height: 64, radius: 32 },
{ width: 260, height: 800, radius: 0 },
{ width: 68, height: 862, radius: 0 },
{ width: 252, height: 846, radius: 16 },
{ width: 60, height: 846, radius: 16 },
])('keeps two-dimensional sampling forward and inside the image for $width x $height r$radius', geometry => {
for (const deformation of [0, 48, 100])
for (const translation of [0, 48, 100])