diff --git a/src/components/theme/GlassNavbarRefractionDefs.vue b/src/components/theme/GlassNavbarRefractionDefs.vue
index db0342f8..2c311d25 100644
--- a/src/components/theme/GlassNavbarRefractionDefs.vue
+++ b/src/components/theme/GlassNavbarRefractionDefs.vue
@@ -1,35 +1,77 @@
@@ -46,8 +88,8 @@ const displacementMapUrl = `data:image/svg+xml,${encodeURIComponent(displacement
{
it('scopes live navbar refraction to Chromium clear and tinted floating shells', () => {
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
+ const filterDefinitions = readFileSync(resolve(cwd(), 'src/components/theme/GlassNavbarRefractionDefs.vue'), 'utf8')
+ const refractionUtilities = readFileSync(resolve(cwd(), 'src/utils/glassNavbarRefraction.ts'), 'utf8')
expect(styles).toContain("data-glass-navbar-refraction='chromium'")
expect(styles).toContain("url('#glass-navbar-live-refraction-balanced')")
@@ -402,6 +404,63 @@ describe('glass overlay material styles', () => {
expect(styles).toMatch(
/:is\(\[data-glass-appearance='clear'\], \[data-glass-appearance='tinted'\]\)[\s\S]*?\.layout-navbar-floating-eligible\.layout-navbar-away-from-top[\s\S]*?\.layout-navbar/,
)
+ expect(styles).toContain('inline-size: auto !important')
+ expect(styles).toContain('inset-block-start: 0 !important')
+ expect(styles).toContain('inset-inline: 0 !important')
+ expect(styles).toContain('inset-block-start: var(--shell-floating-navbar-inset) !important')
+ expect(styles).toContain('inset-inline: var(--shell-floating-navbar-inset) !important')
+ expect(styles).toContain('transform: none !important')
+ expect(styles).toContain(
+ 'inset-block-start var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing)',
+ )
+ expect(styles).toContain(
+ 'inset-inline-end var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing)',
+ )
+ expect(styles).toContain(
+ 'inset-inline-start var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing)',
+ )
+ const highQualityStart = styles.indexOf("html[data-theme='glass'][data-glass-quality='high']")
+ const liveRefractionStart = styles.lastIndexOf(
+ "html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])",
+ highQualityStart,
+ )
+ const liveRefractionRule = styles.slice(liveRefractionStart, highQualityStart)
+
+ expect(liveRefractionStart).toBeGreaterThanOrEqual(0)
+ expect(highQualityStart).toBeGreaterThan(liveRefractionStart)
+ expect(liveRefractionRule).toContain("backdrop-filter: url('#glass-navbar-live-refraction-balanced')")
+ expect(liveRefractionRule).toContain('border: 0 !important')
+ expect(liveRefractionRule).toContain('linear-gradient(145deg, rgba(255, 255, 255, 0.13), transparent 34%)')
+ expect(liveRefractionRule).not.toContain('inset 0 1px 0 rgba(255, 255, 255, 0.4)')
+ expect(styles).toContain('inline-size: min(100vw, variables.$layout-boxed-content-width)')
+ expect(styles).toContain('transform: translateX(-50%) !important')
+ expect(styles).toMatch(
+ /\[data-glass-appearance='tinted'\][\s\S]*?\.layout-navbar\s*\{[\s\S]*?--glass-material-accent-rgb/,
+ )
+ expect(liveRefractionRule).not.toContain('&::before')
+ expect(styles).not.toContain('&::after')
expect(styles).not.toContain("[data-glass-appearance='frosted'][data-glass-navbar-refraction='chromium']")
+ expect(filterDefinitions).toContain('createGlassNavbarDisplacementMap')
+ expect(filterDefinitions).toContain('NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP')
+ expect(filterDefinitions).toContain('getBoundingClientRect()')
+ expect(filterDefinitions).toContain('new ResizeObserver(scheduleDisplacementMapSync)')
+ expect(filterDefinitions).toContain('in2="map"')
+ expect(filterDefinitions).toContain(':width="displacementMapSize.width"')
+ expect(filterDefinitions).toContain(':height="displacementMapSize.height"')
+ expect(filterDefinitions).not.toContain('width="100%"')
+ expect(filterDefinitions).not.toContain('height="100%"')
+ expect(filterDefinitions).toContain('scale="-22"')
+ expect(filterDefinitions).toContain('scale="-34"')
+ expect(filterDefinitions).toContain('x="-8%"')
+ expect(filterDefinitions).toContain('width="116%"')
+ expect(filterDefinitions).toContain('x="-12%"')
+ expect(filterDefinitions).toContain('width="124%"')
+ expect(filterDefinitions).not.toContain('horizontal-continuity')
+ expect(filterDefinitions).not.toContain(' {
+ function pixelAt(field: ReturnType, x: number, y: number) {
+ const offset = (y * field.width + x) * 4
+
+ return [...field.pixels.slice(offset, offset + 4)]
+ }
+
+ it('keeps both contour boundary and interior neutral while bending only the narrow rim', () => {
+ const field = createGlassNavbarDisplacementField({ height: 41, radius: 12, width: 101 })
+
+ expect(field.width).toBe(101)
+ expect(field.height).toBe(41)
+ expect(pixelAt(field, 50, 0)).toEqual([128, 128, 128, 255])
+ expect(pixelAt(field, 50, 20)).toEqual([128, 128, 128, 255])
+ expect(pixelAt(field, 50, 5)[2]).toBeLessThan(64)
+ expect(pixelAt(field, 5, 20)[0]).toBeLessThan(64)
+ expect(pixelAt(field, 95, 20)[0]).toBeGreaterThan(192)
+ expect(pixelAt(field, 50, 35)[2]).toBeGreaterThan(192)
+ })
+
+ it('clamps invalidly small geometry to a renderable pixel surface', () => {
+ const field = createGlassNavbarDisplacementField({ height: 0, radius: 20, width: -10 })
+
+ expect(field.width).toBe(1)
+ expect(field.height).toBe(1)
+ expect([...field.pixels]).toEqual([128, 128, 128, 255])
+ })
+})
describe('supportsGlassNavbarLiveRefraction', () => {
it('enables the verified Chromium engine path for Chrome and Edge', () => {
diff --git a/src/utils/glassNavbarRefraction.ts b/src/utils/glassNavbarRefraction.ts
index c198e888..9aee0741 100644
--- a/src/utils/glassNavbarRefraction.ts
+++ b/src/utils/glassNavbarRefraction.ts
@@ -9,6 +9,124 @@ export interface GlassNavbarRefractionBrowserIdentity {
userAgent: string
}
+export interface GlassNavbarDisplacementGeometry {
+ /** 折射表面的实际 CSS 像素高度。 */
+ height: number
+ /** 最终可见外轮廓的圆角半径。 */
+ radius: number
+ /** 折射表面的实际 CSS 像素宽度。 */
+ width: number
+}
+
+export interface GlassNavbarDisplacementField {
+ /** 位移图的 CSS 像素高度。 */
+ height: number
+ /** 按 RGBA 顺序存储的非预乘像素通道。 */
+ pixels: Uint8ClampedArray
+ /** 位移图的 CSS 像素宽度。 */
+ width: number
+}
+
+export const NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP =
+ 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="1" height="1"%3E%3Cpath fill="%23808080" d="M0 0h1v1H0z"/%3E%3C/svg%3E'
+
+const DISPLACEMENT_NEUTRAL_CHANNEL = 128
+const DISPLACEMENT_CHANNEL_AMPLITUDE = 127
+const OUTER_NEUTRAL_GUARD_PX = 2
+const REFRACTION_BAND_PX = 12
+const REFRACTION_PROFILE_POWER = 2
+
+function normalizePixelSize(value: number) {
+ return Number.isFinite(value) ? Math.max(1, Math.round(value)) : 1
+}
+
+function roundedRectangleSignedDistance(x: number, y: number, width: number, height: number, radius: number) {
+ const offsetX = Math.abs(x - width / 2) - (width / 2 - radius)
+ const offsetY = Math.abs(y - height / 2) - (height / 2 - radius)
+ const outsideX = Math.max(offsetX, 0)
+ const outsideY = Math.max(offsetY, 0)
+
+ return Math.hypot(outsideX, outsideY) + Math.min(Math.max(offsetX, offsetY), 0) - radius
+}
+
+function clampChannel(value: number) {
+ return Math.max(0, Math.min(255, Math.round(value)))
+}
+
+/**
+ * 生成圆角表面的法线位移场。
+ * 外轮廓和内区都保持中性采样,避免折射在裁剪边界或主体内容区形成整带错位。
+ */
+export function createGlassNavbarDisplacementField({
+ height,
+ radius,
+ width,
+}: GlassNavbarDisplacementGeometry): GlassNavbarDisplacementField {
+ const pixelWidth = normalizePixelSize(width)
+ const pixelHeight = normalizePixelSize(height)
+ const maxRadius = Math.min(pixelWidth, pixelHeight) / 2
+ const pixelRadius = Number.isFinite(radius) ? Math.max(0, Math.min(maxRadius, radius)) : 0
+ const bandWidth = Math.min(REFRACTION_BAND_PX, Math.min(pixelWidth, pixelHeight) / 2)
+ const outerGuard = Math.min(OUTER_NEUTRAL_GUARD_PX, bandWidth / 4)
+ const pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4)
+
+ for (let offset = 0; offset < pixels.length; offset += 4) {
+ pixels[offset] = DISPLACEMENT_NEUTRAL_CHANNEL
+ pixels[offset + 1] = DISPLACEMENT_NEUTRAL_CHANNEL
+ pixels[offset + 2] = DISPLACEMENT_NEUTRAL_CHANNEL
+ pixels[offset + 3] = 255
+ }
+
+ for (let y = 0; y < pixelHeight; y += 1) {
+ for (let x = 0; x < pixelWidth; x += 1) {
+ const sampleX = x + 0.5
+ const sampleY = y + 0.5
+ const signedDistance = roundedRectangleSignedDistance(sampleX, sampleY, pixelWidth, pixelHeight, pixelRadius)
+ const distanceInside = -signedDistance
+
+ if (signedDistance > 0 || distanceInside <= outerGuard || distanceInside >= bandWidth) continue
+
+ const normalizedDistance = (distanceInside - outerGuard) / (bandWidth - outerGuard)
+ const profile = Math.sin(Math.PI * normalizedDistance) ** REFRACTION_PROFILE_POWER
+ const gradientX =
+ roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) -
+ roundedRectangleSignedDistance(sampleX - 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius)
+ const gradientY =
+ roundedRectangleSignedDistance(sampleX, sampleY + 0.5, pixelWidth, pixelHeight, pixelRadius) -
+ roundedRectangleSignedDistance(sampleX, sampleY - 0.5, pixelWidth, pixelHeight, pixelRadius)
+ const gradientLength = Math.hypot(gradientX, gradientY) || 1
+ const offset = (y * pixelWidth + x) * 4
+
+ pixels[offset] = clampChannel(
+ DISPLACEMENT_NEUTRAL_CHANNEL + (DISPLACEMENT_CHANNEL_AMPLITUDE * gradientX * profile) / gradientLength,
+ )
+ pixels[offset + 2] = clampChannel(
+ DISPLACEMENT_NEUTRAL_CHANNEL + (DISPLACEMENT_CHANNEL_AMPLITUDE * gradientY * profile) / gradientLength,
+ )
+ }
+ }
+
+ return { height: pixelHeight, pixels, width: pixelWidth }
+}
+
+/** 把位移场栅格化为浏览器可直接加载的无损 PNG。 */
+export function createGlassNavbarDisplacementMap(geometry: GlassNavbarDisplacementGeometry) {
+ const field = createGlassNavbarDisplacementField(geometry)
+ const canvas = document.createElement('canvas')
+ const context = canvas.getContext('2d')
+
+ if (!context) return NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP
+
+ canvas.width = field.width
+ canvas.height = field.height
+ const imageData = context.createImageData(field.width, field.height)
+
+ imageData.data.set(field.pixels)
+ context.putImageData(imageData, 0, 0)
+
+ return canvas.toDataURL('image/png')
+}
+
/** 仅在已验证 SVG backdrop 位移的 Chromium 引擎启用实时顶栏折射。 */
export function supportsGlassNavbarLiveRefraction(browserIdentity: GlassNavbarRefractionBrowserIdentity = navigator) {
const brands = browserIdentity.userAgentData?.brands