适用读者:用 Hexo + Butterfly 的人。上一篇讲了玻璃的原理,这一篇讲怎么把它装进 Butterfly:两个文件,不 fork 主题、不需要构建工具、不改一行主题源码。完整源码在文末,MIT 授权,随便用。


三条约束

动手之前,先把限制写下来。这三条决定了后面所有的设计选择:

  1. 不能 fork 主题。 Butterfly 会更新,改了主题源码就等于放弃升级。
  2. 同屏几十个玻璃面。 文章页上卡片、侧栏、导航、代码块、表格、引用块全是玻璃。
  3. 不能降级成毛玻璃。 浏览器不支持时可以少一些效果,但不能退回 blur() 就算完事。

整体思路

1
CSS 变量  →  距离场  →  位移贴图  →  SVG 滤镜  →  backdrop-filter
  1. CSS 定义参数。 每档玻璃是一组自定义属性:折射强度、折射带宽度、倒角锐度、色散强度。
  2. JS 读参数、生成贴图。 用距离场算出每个像素的表面斜率,编码进一张图的红绿通道。
  3. SVG 执行。 feDisplacementMap 按贴图去弯曲 backdrop。

所有的面都是玻璃,只是权重不同

最容易犯的错是「给卡片加个玻璃 class」,那样会变成玻璃卡片和普通卡片混在一起。页面上所有的面都是玻璃,只是权重不同:

权重 折射 折射带宽 用在哪
soft 34 20 导航栏、标签、分页、页脚
medium 60 74 侧栏卡片、目录、提示块
strong 94 100 文章卡片、正文容器
dense 58 48 代码块、表格、引用
floating 84 88 搜索框、侧边菜单

参数全部写成 CSS 自定义属性,不是 JS 常量:

1
2
3
4
5
6
.glass--strong {
--glass-refraction: 94; /* 边缘位移峰值,px */
--glass-bezel-width: 100; /* 折射带有多宽,px */
--glass-edge-depth: 1.55; /* 倒角转折锐度,越大越急 */
--glass-chromatic: 0.026; /* 色散强度,0 = 关闭 */
}

这样深浅主题、媒体查询、降级都能靠 CSS 自己完成。JS 只在需要生成贴图时把它们读出来。

主题的知识只放在一张表里

哪个元素是哪一档玻璃,集中写在 glass.js 开头的一张表里:

1
2
3
4
5
6
7
var SURFACES = [
['#nav', 'glass--soft'],
['#aside-content .card-widget', 'glass--medium'],
['#recent-posts .recent-post-item', 'glass--strong'],
['#article-container figure.highlight', 'glass--dense'],
// ...
];

JS 按表给元素打上 .glass 和权重类,再生成滤镜。想给别的元素加玻璃,往表里加一行;想换主题,重写这张表,别的一行不用动。

几十个面,只生成几张贴图

位移贴图只和几何有关:尺寸、圆角、折射参数。内容和位置都不影响它。所以按几何做 key 缓存,同尺寸的面共用一个滤镜:

1
key = [w, h, radius, bezel, refraction, edgeDepth, aberration].join('|')

再加一步尺寸量化到 8px:栅格里的卡片宽度会有亚像素差异(433.39px 和 433.41px),不量化就会各生成一张。我博客上 30 个玻璃面,实际只生成 6–10 张贴图。


安装

第 1 步:放文件

1
2
3
4
5
your-blog/
└── source/
└── butterfly-glass/
├── glass.css
└── glass.js

放在 source/ 下,Hexo 会原样拷贝到 public/,不需要额外配置。两个文件的源码在文末。

第 2 步:注入

编辑 _config.butterfly.yml 的 inject:

1
2
3
4
5
inject:
head:
- <link rel="stylesheet" href="/butterfly-glass/glass.css">
bottom:
- <script src="/butterfly-glass/glass.js"></script>

CSS 放 head,JS 放 bottom:JS 要读 CSS 里的自定义属性来决定折射参数,样式得先到位。

同时建议关掉顶部大图,它会和玻璃打架:

1
disable_top_img: true

第 3 步:给它一个值得折射的背景

这一步不能跳过。 折射的本质是把背景弄弯,纯色弯了还是纯色,你会觉得装了跟没装一样。最小可用的背景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
body {
background: linear-gradient(135deg, #f6c88a, #f0956b 45%, #d9748a 100%);
}

/* 花纹层:单独一层,缓慢漂移,专门给玻璃边缘折射用 */
body::before {
content: "";
position: fixed;
top: -80px; left: -80px; right: -80px; bottom: -80px; /* 比视口大一圈,漂移时不露边 */
z-index: -1;
pointer-events: none;
opacity: 0.5;
background-image:
repeating-linear-gradient(0deg, rgba(255,255,255,.30) 0 1px, transparent 1px 40px),
repeating-linear-gradient(90deg, rgba(255,255,255,.30) 0 1px, transparent 1px 40px),
radial-gradient(circle at 20% 30%, rgba(255,255,255,.35), transparent 22%),
radial-gradient(circle at 75% 65%, rgba(255,255,255,.30), transparent 20%);
background-size: 40px 40px, 40px 40px, 320px 320px, 320px 320px;
animation: g-drift 60s linear infinite;
}

@keyframes g-drift {
from { transform: translate3d(0, 0, 0); }
to { transform: translate3d(-320px, -320px, 0); } /* 花纹周期的整数倍,循环无缝 */
}

两个细节:

  • 动 transform,不要动 background-position。 后者是绘制属性,铺满视口的图层每帧重绘,而上面压着几十个 backdrop-filter 面,每次重绘都逼它们重新采样、重跑折射滤镜。我博客上最大的性能问题就是这一条,换掉之后基本消失了。
  • 位移量必须整除花纹周期。 40px 网格和 320px 光斑,位移 320px 正好无缝;写成 300px,每分钟就会跳一次。

hexo clean && hexo s,就能看到效果了。


Butterfly 上的坑

这些都已经在代码里处理掉了,但值得知道为什么。

id 选择器压过类

Butterfly 用 id 选择器给外壳上样式(#recent-posts .recent-post-item),id 特异性碾压类,.glass 的材质属性会被压掉。glass.css 里那一大串选择器就是把材质按同等特异性重述一遍。加了新面板发现样式没生效,多半是要往这个列表里补一行。

transition: all

Butterfly 有十几处 transition: all .3s。all 会给每一个可动画属性注册过渡,包括 height、padding、margin 这些布局属性。我博客首页量出来是 417 个已注册过渡,其中约 130 个是布局属性,任何无关的样式变化都会在几十个跑着 backdrop-filter 的面上同时动画布局属性。桥接块里换成显式的两项之后,布局属性的过渡基本清零。

DOM 会晚到

Butterfly 在 DOMContentLoaded 之后才把宽表格包进 .table-wrap、构建目录,第一遍扫描时这些元素还不存在,所以要在 load 之后补扫。开了 pjax 的话,切页后整个内容区被替换,要监听 pjax:complete 重新打标。

选择器匹配 0 个,不会报错

我写过 .relatedPosts .relatedPosts-list > div,而这个版本的 Butterfly 实际生成的是 <a class="pagination-related">。匹配 0 个元素,不报错,文章底部因此成了全站唯一没有玻璃的区域,过了好几天才发现。往表里加选择器之后,一定要验证:

1
document.querySelectorAll('你的选择器').length

尺寸变了,贴图要重算

贴图是按尺寸缓存的。窗口 resize 可以监听,但长代码块点「展开」时,只有元素自己变高,resize 事件覆盖不到——不重算的话,折射带会停在原来的位置,看起来是一条横在代码中间的畸变。所以每个玻璃面都挂了 ResizeObserver。

代码块单独一档

代码块如果做成深色实心板,永远不可能像玻璃:浅色文字需要不透明的深底才能读,而不透明就不是玻璃。glass--dense 把墨色做成令牌:浅色主题下是浅玻璃 + 深字,深色主题下是深玻璃 + 浅字,两边都既通透又可读。

子元素的 backdrop-filter 什么都采样不到

如果给 .glass 加了 isolation: isolate,它就成了 backdrop root,子元素的 backdrop-filter 只能采样到父元素内部,也就是空的。所以代码块的展开按钮用的是渐变遮罩,不是再套一层玻璃。

可见的「边」不一定是 border

我想隐藏卡片的某条边,改 border-color、改变量,都没用。最后发现可见的那条边根本不是 border,而是 box-shadow 里 inset 0 0 0 1px 那一层。想隐藏一个视觉元素之前,先确认它是由哪条声明画出来的。 也别太信 getComputedStyle:它回答的是「哪条声明赢了级联」,不是「这个像素最后是什么颜色」,一个盖在上面的子元素就能让两者对不上。判断视觉效果就截图,判断结构(类加没加、滤镜挂没挂)才用 JS 查。


做完又删掉的

我还做过一个「水融水」的交互:鼠标靠近卡片边缘,边缘像水一样被吸过去,离开后回弹。轮廓采样、弹簧物理、单元测试全做完了,每帧 220µs。最后整个删掉了:

  • 折射跟不上。 轮廓 60fps 连续运动,但重建贴图要 canvas.toDataURL(),跑不到每帧。轮廓在流动、折射在瞬移,比斜边干脆不动还难看。
  • 材质接缝。 凸起是贴在卡片外面的另一块材质,卡片自己的矩形边界永远消不掉,像贴了一张贴纸。
  • 测试全绿,效果是隐形的。 我只在浅色主题下验证过,切到深色才发现那是一块刺眼的白色水泡。

删掉之后,只为它存在的实时滤镜节点、透镜偏移参数也一起清掉了。一个功能的真实成本,包括它在别处留下的钩子。 回头看,应该先把光学做对,再考虑交互——光学对了,就会发现根本不需要那个交互。


降级

情况 表现
Chromium(Chrome / Edge) 完整折射 + 色散 + 菲涅尔
Safari / Firefox 加 8px 模糊,保留边缘光和着色
完全没有 backdrop-filter 着色提到 82% 不透明,自己扛对比度
prefers-reduced-transparency 直接不透明
prefers-reduced-motion 关掉过渡

JS 会在 <html> 上打 glass-refract 或 glass-plain,想针对某种情况单独调样式,挂在这两个类上就行。

能力检测不能只靠 CSS.supports——Safari 也支持 backdrop-filter,只是不支持在里面引用 SVG 滤镜。所以要判断是不是 Chromium,并且排除 iOS 上的 Chrome / Firefox / Edge,它们其实是套壳 WebKit。


自定义

想要 怎么调
更明显的折射 --glass-refraction 往上,--glass-bezel-width 同步往上
薄玻璃 --glass-bezel-width 降到 20 以下
更强的彩边 --glass-chromatic 提到 0.04
边缘更「硬」 --glass-edge-depth 提到 3 以上
更有厚度感 --g-shine 第四层菲涅尔的 0.44 往上

排错

现象 检查
完全没变化 控制台看 window.butterflyGlass 在不在;不在说明 JS 没加载
有玻璃但没扭曲 你用的不是 Chrome / Edge,看 <html> 上是 glass-refract 还是 glass-plain
装了跟没装一样 背景太素,回到第 3 步
某个面板没变 document.querySelectorAll('选择器').length 是不是 0
某个面板样式被覆盖 往特异性桥接的选择器列表里加一行
切页后玻璃没了 确认 glass.js 注入在 bottom 而不是 head

完整源码

两个文件,MIT 授权。这是为了「好读、好改」整理出来的版本;我博客上跑的版本另外还有按帧率分级降级、滚出视口释放滤镜这些,页面元素不多的话,这一版就够用了。

glass.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
/*!
* Butterfly Liquid Glass — portable edition
* ---------------------------------------------------------------------------
* 配合 glass.js 使用。授权 MIT。
*
* 结构:
* ① 设计令牌 颜色、光效、圆角、时长
* ② .glass 材质 着色 + backdrop-filter + 边缘光
* ③ 特异性桥接 Butterfly 用 id 选择器,必须按同等特异性重述
* ④ 五档权重 每档一组光学参数
* ⑤ 文章内元素 代码块 / 表格 / 引用
* ⑥ 降级 无 backdrop-filter / 低动效 / 低透明度
*/

/* ==========================================================================
① 设计令牌
========================================================================== */
:root {
--g-bg: #f7e6cf;
--g-ink: #2b201a;
--g-ink-dim: #7d6a5c;
--g-accent: #e8973c;

--g-tint: rgba(255, 249, 240, 0.07);
--g-tint-hover: rgba(255, 249, 240, 0.14);
--g-blur: 0px; /* 液态玻璃靠折射,不靠磨砂 */
--g-saturate: 1.45;
--g-brightness: 1.02;
--g-contrast: 1.06;

/* 边缘光。前两层是方向性光照(光从左上来),不是描边:
模糊半径为 0 的 box-shadow 是一条硬线,绕四边一圈就成了「画出来的边框」。
第四层是菲涅尔——掠射角反射,整圈提亮向内衰减,「厚度感」主要靠它。 */
--g-shine:
inset 1px 1px 1.5px -0.5px rgba(255, 255, 255, 0.50),
inset -1px -1px 2px -1px rgba(255, 255, 255, 0.30),
inset 0 0 0 1px rgba(255, 255, 255, 0.07),
inset 0 0 22px -11px rgba(255, 255, 255, 0.44),
inset 0 0 12px -7px rgba(120, 62, 24, 0.18);
--g-edge: rgba(255, 255, 255, 0.50);

--g-shadow:
0 6px 18px rgba(96, 50, 20, 0.14),
0 1px 3px rgba(96, 50, 20, 0.10);

/* 代码块 / 表格 / 引用的墨色。单独一组,因为这些面在深浅主题下
是浅玻璃深字 / 深玻璃浅字两种完全相反的配置。 */
--g-code-ink: #33261e;
--g-code-ink-dim: rgba(51, 38, 30, 0.42);
--g-code-edge: rgba(120, 80, 50, 0.20);

--g-r-md: 20px;
--g-r-lg: 26px;
--g-r-xl: 32px;

--g-dur: 0.42s;
--g-ease: cubic-bezier(0.22, 1, 0.36, 1);
}

@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--g-bg: #1b120c;
--g-ink: #f4e6d8;
--g-ink-dim: #bda694;

--g-tint: rgba(92, 68, 50, 0.14);
--g-tint-hover: rgba(112, 84, 62, 0.26);
--g-saturate: 1.35;
--g-brightness: 1.05;
--g-contrast: 1.05;

--g-shine:
inset 1px 1px 1.5px -0.5px rgba(255, 236, 212, 0.38),
inset -1px -1px 2px -1px rgba(255, 236, 212, 0.19),
inset 0 0 0 1px rgba(255, 236, 212, 0.05),
inset 0 0 22px -11px rgba(255, 236, 212, 0.26),
inset 0 0 12px -7px rgba(0, 0, 0, 0.32);
--g-edge: rgba(255, 236, 212, 0.34);

--g-shadow:
0 6px 18px rgba(0, 0, 0, 0.45),
0 1px 3px rgba(0, 0, 0, 0.30);

--g-code-ink: #f2e7dc;
--g-code-ink-dim: rgba(242, 231, 220, 0.38);
--g-code-edge: rgba(255, 226, 196, 0.14);
}
}

/* [data-theme="dark"] 是 Butterfly 的手动切换,要和上面保持一致 */
[data-theme="dark"] {
--g-bg: #1b120c;
--g-ink: #f4e6d8;
--g-ink-dim: #bda694;
--g-tint: rgba(92, 68, 50, 0.14);
--g-tint-hover: rgba(112, 84, 62, 0.26);
--g-saturate: 1.35;
--g-brightness: 1.05;
--g-contrast: 1.05;
--g-shine:
inset 1px 1px 1.5px -0.5px rgba(255, 236, 212, 0.38),
inset -1px -1px 2px -1px rgba(255, 236, 212, 0.19),
inset 0 0 0 1px rgba(255, 236, 212, 0.05),
inset 0 0 22px -11px rgba(255, 236, 212, 0.26),
inset 0 0 12px -7px rgba(0, 0, 0, 0.32);
--g-edge: rgba(255, 236, 212, 0.34);
--g-shadow:
0 6px 18px rgba(0, 0, 0, 0.45),
0 1px 3px rgba(0, 0, 0, 0.30);
--g-code-ink: #f2e7dc;
--g-code-ink-dim: rgba(242, 231, 220, 0.38);
--g-code-edge: rgba(255, 226, 196, 0.14);
}

/* ==========================================================================
② 材质
========================================================================== */
.glass {
/* 自定义属性会继承,嵌套的玻璃面会悄悄用上父级的滤镜(尺寸不对)。
在每个面上声明为空,就把继承断在了边界上。 */
--g-filter: ;

position: relative;
border-radius: var(--g-radius, var(--g-r-lg));
background-color: var(--g-tint);
border: 1px solid var(--g-edge);
box-shadow: var(--g-shine), var(--g-shadow);
color: var(--g-ink);

-webkit-backdrop-filter:
var(--g-filter, ) blur(var(--g-blur)) saturate(var(--g-saturate))
brightness(var(--g-brightness)) contrast(var(--g-contrast));
backdrop-filter:
var(--g-filter, ) blur(var(--g-blur)) saturate(var(--g-saturate))
brightness(var(--g-brightness)) contrast(var(--g-contrast));

transition:
background-color var(--g-dur) var(--g-ease),
box-shadow var(--g-dur) var(--g-ease);
}

/* ==========================================================================
③ 特异性桥接
--------------------------------------------------------------------------
Butterfly 用 id 选择器给外壳上样式(#recent-posts .recent-post-item),
id 特异性碾压类,所以 .glass 的材质属性会被压掉。这里按同等特异性重述一遍。

顺带把 Butterfly 的 `transition: all .3s` 换成显式列表:`all` 会给每一个
可动画长属性注册过渡,包括 height / padding / margin / border-width,
于是任何无关的样式变化都会在几十个 backdrop-filter 面上同时动画布局属性。
========================================================================== */
#nav.glass,
#recent-posts .recent-post-item.glass,
#aside-content .card-widget.glass,
#card-toc.glass,
#post.glass, #page.glass, #archive.glass, #tag.glass, #category.glass,
#rightside button.glass,
#pagination .page-number.glass, #pagination .extend.glass,
#tag .tag-cloud-list a.glass,
.tag-cloud-list a.glass,
#article-container .tag-list a.glass,
#article-container figure.highlight.glass,
#article-container pre.glass,
#article-container blockquote.glass,
#article-container .table-wrap.glass,
#article-container .note.glass,
#local-search .search-dialog.glass,
#sidebar-menus.glass {
background-color: var(--g-tint);
background-image: none;
border: 1px solid var(--g-edge);
border-radius: var(--g-radius, var(--g-r-lg));
box-shadow: var(--g-shine), var(--g-shadow);
transition:
background-color var(--g-dur) var(--g-ease),
box-shadow var(--g-dur) var(--g-ease);
}

/* Butterfly 有些地方读 --card-bg,一并接管 */
.glass { --card-bg: var(--g-tint); }

/* ==========================================================================
④ 五档权重
--------------------------------------------------------------------------
refraction 边缘位移峰值(px) bezel 折射带宽度(px)
edge-depth 倒角转折锐度 chromatic 色散强度
========================================================================== */
.glass--soft {
--g-radius: var(--g-r-lg);
--glass-refraction: 34;
--glass-bezel-width: 20;
--glass-edge-depth: 2.2;
--glass-chromatic: 0.030;
}

.glass--medium {
--g-radius: var(--g-r-lg);
--glass-refraction: 60;
--glass-bezel-width: 74;
--glass-edge-depth: 1.65;
--glass-chromatic: 0.028;
--g-tint: rgba(255, 250, 243, 0.08);
}

.glass--strong {
--g-radius: var(--g-r-xl);
--glass-refraction: 94;
--glass-bezel-width: 100;
--glass-edge-depth: 1.55;
--glass-chromatic: 0.026;
--g-tint: rgba(255, 250, 243, 0.09);
--g-tint-hover: rgba(255, 250, 243, 0.18);
}

/* 代码 / 表格 / 引用。浅色下是浅玻璃 + 深字,深色下反过来。
做成深色实心板是这类面「永远不像玻璃」的根源:浅色文字需要
不透明的深底才能读,而不透明就不是玻璃。 */
.glass--dense {
--g-radius: var(--g-r-md);
--glass-refraction: 58;
--glass-bezel-width: 48;
--glass-edge-depth: 1.9;
--glass-chromatic: 0.024;
--g-tint: rgba(255, 252, 246, 0.44);
--g-saturate: 1.2;
color: var(--g-code-ink);
}

@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .glass--dense { --g-tint: rgba(24, 17, 13, 0.56); }
}
[data-theme="dark"] .glass--dense { --g-tint: rgba(24, 17, 13, 0.56); }

.glass--floating {
--g-radius: var(--g-r-xl);
--glass-refraction: 84;
--glass-bezel-width: 88;
--glass-edge-depth: 1.6;
--glass-chromatic: 0.030;
--g-tint: rgba(255, 250, 243, 0.12);
}

/* ==========================================================================
⑤ 文章内元素
--------------------------------------------------------------------------
20px 的圆角配 Butterfly 原本 10px 的纵向内边距,首末行会顶在圆弧上。
这些面比主题设计时更圆,内容要往里缩到大约半个半径才不挤。
========================================================================== */
#article-container figure.highlight.glass { padding: 6px; }
#article-container figure.highlight table { padding: 12px 16px; }
#article-container pre.glass { padding: 18px 24px; }
#article-container figure.highlight .highlight-tools { padding: 6px 14px; }

#article-container figure.highlight table,
#article-container figure.highlight pre {
background: transparent !important;
color: var(--g-code-ink);
}
#article-container figure.highlight .gutter pre { color: var(--g-code-ink-dim); }

/* 长代码块的展开按钮(需要 code_blocks.height_limit)。
主题原本给它 var(--hlexpand-bg)——代码主题背景色的实心渐变,那是为不透明
代码块画的,压在半透明玻璃上是一条颜色对不上的实心条。改成从透明渐到玻璃
自己的着色,读起来是代码在薄雾下淡出。

这里不能用 backdrop-filter:如果你给 .glass 加了 isolation: isolate,
它就是一个 backdrop root,子元素的 backdrop-filter 什么也采样不到。 */
#article-container figure.highlight .code-expand-btn {
/* 外层 6px padding 会让 width:100% 算到 padding box 上而右溢,显式拉两端 */
left: 0; right: 0; width: auto;
background: linear-gradient(
180deg,
transparent 0%,
color-mix(in srgb, var(--g-tint) 55%, transparent) 42%,
var(--g-tint) 100%);
border-radius: 0 0 calc(var(--g-r-md) - 6px) calc(var(--g-r-md) - 6px);
}
#article-container figure.highlight .code-expand-btn i { color: var(--g-code-ink-dim); }
#article-container figure.highlight .code-expand-btn.expand-done { background: none; }

@media (prefers-reduced-motion: reduce) {
#article-container figure.highlight .code-expand-btn i { animation: none; opacity: .7; }
}

#article-container .table-wrap.glass { padding: 10px 14px; overflow-x: auto; }
#article-container .table-wrap table { background: transparent; border: none; margin: 0; }
#article-container .table-wrap th,
#article-container .table-wrap td { border-color: var(--g-code-edge); background: transparent; }

#article-container blockquote.glass {
border-left: 3px solid var(--g-accent);
padding: 1.15em 1.5em;
color: var(--g-code-ink);
}

/* Butterfly 在版权块上盖了第二个版权图标,和每行已有的图标重复 */
#post .post-copyright::before { content: none; }
#post .post-copyright {
--g-tint: rgba(255, 250, 243, 0.30); /* 小字压在背景上,需要一点底 */
padding: 14px 20px;
}

/* ==========================================================================
⑥ 降级
========================================================================== */

/* 拿不到折射(Safari / Firefox)时,用一点模糊补上厚度感 */
.glass-plain .glass { --g-blur: 8px; }

/* 完全没有 backdrop-filter:着色必须自己扛起对比度 */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.glass {
background-color: color-mix(in srgb, var(--g-bg) 82%, transparent);
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
}

@media (prefers-reduced-transparency: reduce) {
.glass {
--g-filter: ;
background-color: var(--g-bg);
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
}

@media (prefers-reduced-motion: reduce) {
.glass { transition: none; }
}

glass.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
/*!
* Butterfly Liquid Glass — portable edition
* ---------------------------------------------------------------------------
* 把 Hexo Butterfly 主题的所有面板改造成带真实折射的液态玻璃。
* 不需要 fork 主题,不需要构建工具,一个文件搞定。
*
* 用法:和 glass.css 一起注入到 _config.butterfly.yml 的 inject 里。
*
* 原理:用有符号距离场算出每个像素的表面斜率,编码成一张位移贴图,
* 交给 SVG feDisplacementMap 去弯曲 backdrop。
*
* 授权:MIT
*/
(function () {
'use strict';

var SVG_NS = 'http://www.w3.org/2000/svg';
var XLINK = 'http://www.w3.org/1999/xlink';

var MAX_MAP = 192; /* 贴图最大边长;距离场平滑,低分辨率拉伸后看不出来 */
var SIZE_STEP = 8; /* 尺寸量化,让同尺寸的卡片共用一张贴图 */
var CACHE_LIMIT = 48;

/* ---------------------------------------------------------------
Butterfly 选择器 → 玻璃权重
换主题只需要改这张表。
--------------------------------------------------------------- */
var SURFACES = [
['#nav', 'glass--soft'],
['#rightside > div > button, #rightside > button', 'glass--soft'],
['#pagination .page-number, #pagination .extend', 'glass--soft'],
['.tag-cloud-list a', 'glass--soft'],
['#article-container .tag-list a', 'glass--soft'],
['#post .post-copyright', 'glass--soft'],
['#footer-wrap', 'glass--soft'],
['.pagination-post .pagination-related', 'glass--soft'],
['.relatedPosts .relatedPosts-list .pagination-related', 'glass--soft'],
['#archive .article-sort-item:not(.year)', 'glass--soft'],

['#aside-content .card-widget', 'glass--medium'],
['#card-toc, #toc-div', 'glass--medium'],
['#article-container .note', 'glass--medium'],

['#recent-posts .recent-post-item', 'glass--strong'],
['#post, #page, #archive, #tag, #category', 'glass--strong'],

['#article-container figure.highlight', 'glass--dense'],
['#article-container pre:not(figure.highlight pre)','glass--dense'],
['#article-container blockquote', 'glass--dense'],
['#article-container .table-wrap', 'glass--dense'],

['#local-search .search-dialog, .search-dialog', 'glass--floating'],
['#sidebar-menus', 'glass--floating']
];

/* ---------------------------------------------------------------
能力检测
只有 Chromium 会在 backdrop-filter 里合成 SVG 滤镜;
Safari / Firefox 解析后会静默丢弃整条声明,连 blur 都没了。
--------------------------------------------------------------- */
function backdropSupported() {
if (!window.CSS || !CSS.supports) return false;
return CSS.supports('backdrop-filter', 'blur(1px)') ||
CSS.supports('-webkit-backdrop-filter', 'blur(1px)');
}

function chromiumLike() {
var uad = navigator.userAgentData;
if (uad && uad.brands && uad.brands.length) {
for (var i = 0; i < uad.brands.length; i++) {
if (/Chromium/i.test(uad.brands[i].brand)) return true;
}
return false;
}
var ua = navigator.userAgent;
/* CriOS / FxiOS / EdgiOS 是套壳 WebKit */
if (/CriOS|FxiOS|EdgiOS/.test(ua)) return false;
return /Chrome\/|Chromium\/|Edg\//.test(ua);
}

var mqReduce = window.matchMedia('(prefers-reduced-transparency: reduce)');
var refract = backdropSupported() && chromiumLike() && !mqReduce.matches;

/* ---------------------------------------------------------------
距离场 → 位移贴图
--------------------------------------------------------------- */
function roundedRectSDF(x, y, hw, hh, r) {
var qx = Math.abs(x) - hw + r;
var qy = Math.abs(y) - hh + r;
var mx = Math.max(qx, 0), my = Math.max(qy, 0);
return Math.min(Math.max(qx, qy), 0) + Math.sqrt(mx * mx + my * my) - r;
}

function buildMap(o) {
var w = o.w, h = o.h;
if (!(w > 0 && h > 0)) return null;

var k = Math.min(1, MAX_MAP / Math.max(w, h));
var mw = Math.max(8, Math.round(w * k));
var mh = Math.max(8, Math.round(h * k));

var hw = w / 2, hh = h / 2;
var r = Math.max(0, Math.min(o.radius || 0, Math.min(hw, hh)));
var bezel = Math.max(2, Math.min(o.bezel, Math.min(hw, hh)));
var strength = o.refraction;
if (strength <= 0.01) return null;

var depth = Math.max(1, o.edgeDepth);
/* 圆弧倒角截面:边缘处斜率最大。用 smoothstep 会在边缘变平,
那描述的是平切角,做出来的边看起来是「切」的不是「弯」的。 */
var lensK = 1 - 1 / (1 + depth * depth);
var lensNorm = Math.sqrt(1 - lensK);
var gate = bezel * 0.35;

var offs = new Float32Array(mw * mh * 2);
var peak = 0, e = 1;

for (var j = 0; j < mh; j++) {
var py = ((j + 0.5) / mh) * h - hh;
for (var i = 0; i < mw; i++) {
var px = ((i + 0.5) / mw) * w - hw;
var d = roundedRectSDF(px, py, hw, hh, r);

var u = (d + bezel) / bezel;
if (u <= 0) continue;
if (u > 1) u = 1;
var t = u * lensNorm / Math.sqrt(1 - u * u * lensK);
if (d + bezel < gate) {
var g = (d + bezel) / gate;
t *= g * g * (3 - 2 * g);
}
if (t <= 0.0005) continue;

/* 中心差分求梯度。解析解快 3.8 倍,但会在圆角的角平分线上
产生 90° 硬翻转(那里是距离场的真实尖点),留下折痕。 */
var gx = roundedRectSDF(px + e, py, hw, hh, r) -
roundedRectSDF(px - e, py, hw, hh, r);
var gy = roundedRectSDF(px, py + e, hw, hh, r) -
roundedRectSDF(px, py - e, hw, hh, r);
var len = Math.sqrt(gx * gx + gy * gy);
if (len < 1e-6) continue;

var amt = t * strength;
var p = (j * mw + i) * 2;
offs[p] = -(gx / len) * amt;
offs[p + 1] = -(gy / len) * amt;
if (Math.abs(offs[p]) > peak) peak = Math.abs(offs[p]);
if (Math.abs(offs[p + 1]) > peak) peak = Math.abs(offs[p + 1]);
}
}
if (peak < 0.01) return null;

var canvas = document.createElement('canvas');
canvas.width = mw; canvas.height = mh;
var ctx = canvas.getContext('2d');
var img = ctx.createImageData(mw, mh);
var data = img.data;

/* scale = 2*peak,这样 0.5 ± off/(2*peak) 能被
feDisplacementMap 的 (C - 0.5) * scale 精确还原 */
var scale = peak * 2;
for (var n = 0, q = 0; n < data.length; n += 4, q += 2) {
data[n] = (0.5 + offs[q] / scale) * 255;
data[n + 1] = (0.5 + offs[q + 1] / scale) * 255;
data[n + 2] = 0;
data[n + 3] = 255;
}
ctx.putImageData(img, 0, 0);
return { url: canvas.toDataURL(), scale: scale };
}

/* ---------------------------------------------------------------
滤镜构建与缓存
--------------------------------------------------------------- */
var host = null;
function defs() {
if (host && host.isConnected) return host.firstChild;
host = document.createElementNS(SVG_NS, 'svg');
host.setAttribute('width', '0');
host.setAttribute('height', '0');
host.setAttribute('aria-hidden', 'true');
host.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;pointer-events:none';
host.appendChild(document.createElementNS(SVG_NS, 'defs'));
document.body.appendChild(host);
return host.firstChild;
}

function el(name, attrs) {
var node = document.createElementNS(SVG_NS, name);
for (var k in attrs) {
if (Object.prototype.hasOwnProperty.call(attrs, k)) {
node.setAttribute(k, String(attrs[k]));
}
}
return node;
}

var cache = new Map(), held = new WeakMap(), seq = 0;

function buildFilter(filter, map, aberration) {
var feImage = el('feImage', { result: 'map', preserveAspectRatio: 'none' });
feImage.setAttribute('href', map.url);
feImage.setAttributeNS(XLINK, 'xlink:href', map.url);
filter.appendChild(feImage);

if (!aberration || aberration <= 0.001) {
filter.appendChild(el('feDisplacementMap', {
in: 'SourceGraphic', in2: 'map', scale: map.scale,
xChannelSelector: 'R', yChannelSelector: 'G'
}));
return feImage;
}

/* 色散:红偏折最多,蓝最少,三路各留一个通道再相加。
注意每一路都要「保留 alpha」——SVG 滤镜在预乘 alpha 空间运算,
把 alpha 归零会连颜色一起乘没,结果是整个页面渲染成纯绿色。 */
var scales = [map.scale * (1 + aberration), map.scale, map.scale * (1 - aberration)];
var keep = [
'1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0',
'0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0',
'0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0'
];
var ch = ['R', 'G', 'B'];
for (var i = 0; i < 3; i++) {
filter.appendChild(el('feDisplacementMap', {
in: 'SourceGraphic', in2: 'map', scale: scales[i],
xChannelSelector: 'R', yChannelSelector: 'G', result: 'd' + ch[i]
}));
filter.appendChild(el('feColorMatrix', {
in: 'd' + ch[i], type: 'matrix', values: keep[i], result: 'c' + ch[i]
}));
}
filter.appendChild(el('feComposite', {
in: 'cR', in2: 'cG', operator: 'arithmetic',
k1: 0, k2: 1, k3: 1, k4: 0, result: 'cRG'
}));
filter.appendChild(el('feComposite', {
in: 'cRG', in2: 'cB', operator: 'arithmetic', k1: 0, k2: 1, k3: 1, k4: 0
}));
return feImage;
}

function num(cs, prop, fallback) {
var v = parseFloat(cs.getPropertyValue(prop));
return isNaN(v) ? fallback : v;
}

function apply(element) {
if (!refract) return;
var rect = element.getBoundingClientRect();
if (rect.width < 24 || rect.height < 16) return;

var w = Math.ceil(rect.width / SIZE_STEP) * SIZE_STEP;
var h = Math.ceil(rect.height / SIZE_STEP) * SIZE_STEP;

var cs = getComputedStyle(element);
/* 贴图是单一圆角矩形,四角不同时取最大的那个 */
var radius = Math.max(
parseFloat(cs.borderTopLeftRadius) || 0,
parseFloat(cs.borderTopRightRadius) || 0,
parseFloat(cs.borderBottomRightRadius) || 0,
parseFloat(cs.borderBottomLeftRadius) || 0
);
if (radius > Math.min(w, h) / 2) radius = Math.min(w, h) / 2;

var o = {
w: w, h: h, radius: radius,
refraction: num(cs, '--glass-refraction', 60),
bezel: num(cs, '--glass-bezel-width', 40),
edgeDepth: num(cs, '--glass-edge-depth', 1.8),
aberration: num(cs, '--glass-chromatic', 0)
};

var key = [o.w, o.h, o.radius, o.bezel, o.refraction,
o.edgeDepth, o.aberration].join('|');
if (held.get(element) === key && element.style.getPropertyValue('--g-filter')) return;

var entry = cache.get(key);
if (!entry) {
var map = buildMap(o);
if (!map) { element.style.removeProperty('--g-filter'); return; }
var id = 'bfglass-' + (++seq);
var filter = el('filter', {
id: id, filterUnits: 'userSpaceOnUse',
'color-interpolation-filters': 'sRGB',
x: 0, y: 0, width: o.w, height: o.h
});
var feImage = buildFilter(filter, map, o.aberration);
feImage.setAttribute('x', 0); feImage.setAttribute('y', 0);
feImage.setAttribute('width', o.w); feImage.setAttribute('height', o.h);
defs().appendChild(filter);
entry = { id: id, node: filter };
cache.set(key, entry);

if (cache.size > CACHE_LIMIT) {
var first = cache.keys().next().value;
var old = cache.get(first);
if (old && old !== entry) { old.node.remove(); cache.delete(first); }
}
}
held.set(element, key);
element.style.setProperty('--g-filter', 'url(#' + entry.id + ')');
}

/* ---------------------------------------------------------------
打标 + 生命周期
--------------------------------------------------------------- */
var tagged = [];

/* 元素尺寸变了,贴图就得重算。
最典型的场景是长代码块的「展开」按钮:点一下高度从 460px 变成完整高度,
而滤镜是按旧尺寸缓存的——不重算的话折射带会停在原来的位置,
看起来就是一条横在代码中间的畸变。窗口 resize 事件覆盖不到这种情况。 */
var ro = window.ResizeObserver ? new ResizeObserver(function (entries) {
for (var i = 0; i < entries.length; i++) apply(entries[i].target);
}) : null;

function tag() {
SURFACES.forEach(function (pair) {
var nodes;
try { nodes = document.querySelectorAll(pair[0]); }
catch (err) { return; } /* 这个 Butterfly 版本没有这个选择器 */
for (var i = 0; i < nodes.length; i++) {
var element = nodes[i];
if (element.classList.contains('glass')) continue;
element.classList.add('glass');
pair[1].split(' ').forEach(function (c) { if (c) element.classList.add(c); });
tagged.push(element);
apply(element);
if (ro) ro.observe(element);
}
});
}

function refreshAll() {
tagged = tagged.filter(function (e) { return e.isConnected; });
tagged.forEach(apply);
}

var root = document.documentElement;
root.classList.add('glass-ready');
root.classList.add(refract ? 'glass-refract' : 'glass-plain');

function boot() {
tag();
/* Butterfly 在 DOMContentLoaded 之后还会改 DOM:
把宽表格包进 .table-wrap、构建目录。所以要补扫。 */
setTimeout(tag, 400);
}

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
window.addEventListener('load', function () { tag(); setTimeout(tag, 400); });

/* pjax 切页后整个 DOM 换了,要重新打标 */
['pjax:complete', 'pjax:success'].forEach(function (evt) {
document.addEventListener(evt, function () {
if (ro) ro.disconnect();
tagged = [];
tag();
setTimeout(tag, 400);
});
});

/* 尺寸变了贴图要重算,但一定要防抖 */
var timer = 0;
window.addEventListener('resize', function () {
clearTimeout(timer);
timer = setTimeout(refreshAll, 180);
});

/* Butterfly 切换深浅色时改 <html> 的 data-theme */
new MutationObserver(refreshAll).observe(root, {
attributes: true, attributeFilter: ['data-theme']
});

window.butterflyGlass = { retag: tag, refresh: refreshAll, surfaces: SURFACES };
})();