+
+
({
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};
diff --git a/src/components/theme/GlassNavbarRefractionDefs.vue b/src/components/theme/GlassNavbarRefractionDefs.vue
index 88c4fe68..2306984e 100644
--- a/src/components/theme/GlassNavbarRefractionDefs.vue
+++ b/src/components/theme/GlassNavbarRefractionDefs.vue
@@ -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'))
)
diff --git a/src/components/theme/__tests__/GlassFixedShellBackplate.spec.ts b/src/components/theme/__tests__/GlassFixedShellBackplate.spec.ts
index d41a5d04..baa0e606 100644
--- a/src/components/theme/__tests__/GlassFixedShellBackplate.spec.ts
+++ b/src/components/theme/__tests__/GlassFixedShellBackplate.spec.ts
@@ -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
+}
+
+interface BackplateMountOptions {
+ [key: string]: unknown
+ attachTo?: Element
+ props: {
+ isOverlayNav: boolean
+ isOverlayNavActive: boolean
+ layers: readonly GlassFixedShellBackplateLayer[]
+ transitionDurationMs: number
+ }
+}
+
+const mountedWrappers: Array> = []
+let resizeCallback: ResizeObserverCallback | undefined
+let resizeDisconnect: ReturnType | 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)
+ })
})
diff --git a/src/styles/themes/_glass-v3.scss b/src/styles/themes/_glass-v3.scss
index d41c51e1..7a512e32 100644
--- a/src/styles/themes/_glass-v3.scss
+++ b/src/styles/themes/_glass-v3.scss
@@ -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
diff --git a/src/utils/__tests__/glassNavbarRefraction.spec.ts b/src/utils/__tests__/glassNavbarRefraction.spec.ts
index ca5ff034..1b8b870d 100644
--- a/src/utils/__tests__/glassNavbarRefraction.spec.ts
+++ b/src/utils/__tests__/glassNavbarRefraction.spec.ts
@@ -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])