
6 分钟#design #meta
这个站是怎么长出来的
从「换一套主题」到「自己写一个生成器」,再到把边框当成透镜来算——记录几轮推倒重来的判断过程,以及最后留下的取舍。
这篇不是教程,是一份工程记录。写它是因为几个月后我会忘掉当初为什么这么做,而「为什么」比「怎么做」更容易消失。
起点
原来的站是 Hugo 加一套改过的主题。用起来没问题,改起来处处别扭:想调一层玻璃的折射,要先在模板里找它属于哪个 partial;想换首页的结构,得跟主题的短代码较劲。
真正让我下决心的是两件事:
- 我想要的首页是「一张图 + 一层玻璃」,主题的骨架里没有这个概念;
- 每次改完都要重新理解一遍别人的抽象。
于是先做减法:整站只有两层——壁纸,和浮在壁纸上的窗口。
这句话后来成了所有设计决策的标尺。窗口是玻璃,它折射壁纸;窗口是纸,光就退回边缘。没有第三层,也就不需要去解释第三层。
第一版:把窗口拆成三层
一个窗口拆成三个兄弟节点,各自负责一件事:
<div class="window">
<span class="window__backdrop"></span> <!-- 材质:折射 或 纸面 -->
<span class="window__sheen"></span> <!-- 反射:只有玻璃有 -->
<div class="window__content">…</div> <!-- 内容 -->
</div>
这样拆的理由不是好看,是顺序:反射必须在内容之下,否则文字会被一起糊掉。第一版我把 sheen 放在内容之上,页面上所有白字都变成了雾——这是最早踩的坑,也是最值得记的一个:分层要按光的路径排,不是按重要性排。
材质:从「看起来像」到「算出来」
玻璃的第一版是老实做法:backdrop-filter: blur() 加几个渐变高光。像,但不对——真实玻璃的边缘会弯折背后的画面。
于是有了第二版:把壁纸复制进窗口,用 feTurbulence 加 feDisplacementMap 推一下。问题随之而来:位移是假的(噪声位移没有方向),而且只对固定背景成立。
第三版换了思路,把边框当成一块有厚度的斜面透镜:
σ(d) = σ_max d ≤ rs ← 与玻璃内部同模糊
σ_max · (1 − (d−rs)/(rl−rs)) rs < d < rl ← 线性衰减
0 d ≥ rl ← 中心清晰
Δ(d) = rs · tan(θt − θ) · (1 − d/rs)^1.5
距离用圆角矩形的 SDF 求,位移方向取外法线,θt 由 Snell 定律给出。这样直边、圆角、角落的模糊带宽度自动一致,位移尺度也由折射率和斜面倾角决定,而不是拍脑袋的常数。
实现落在 WebGL:三级模糊纹理、逐像素算 SDF、R/B 通道按 n(λ) 错开做色散。中心是清晰的,边缘是糊的,斜线在边界处有一点点彩边——这就是液态玻璃区别于「毛玻璃」的地方。
首页:让一张图变成一张卡
原本的设计是「满屏图 → 滚动 → 变成玻璃卡」两个状态,中间靠 CSS 过渡。做出来很难看:两个状态之间是跳的。
改成一个变量驱动:
| 量 | --p = 0 |
--p = 1 |
|---|---|---|
| 宽度 | 100vw | 正文宽度 |
| 高度 | 视口高 | 460px |
| 圆角 | 0 | 24px |
| 材质 | 全关(纯图) | 玻璃 + 边框 + 投影 |
--p 由 scrollY / (0.38 × 视口高) 算出来,写到 <html> 上。整个形变就是一段插值,没有状态机。
顺带的收益:透镜的强度也跟着 --p 走——p=0 时画布就是一张原图,p=1 时才是完整玻璃。一个变量管两件事,改起来不会脱节。
内容层:把解析器交出去
Markdown 最初是我手写的,一千多行。写的时候很爽,因为每个 Obsidian 语法都能照顾到;维护起来很痛,因为每一条语法都是我自己的责任。
转折点是一篇带 date: 2026-11-4 的文章——没补零,new Date() 返回 Invalid Date,整个构建挂掉。那一刻我意识到:这类坑不该由我埋。
于是换成 markdown-it 加三个插件,自己只留 Obsidian 方言那一层:callout、内链、标题层级对齐、中文软换行。渲染器从 1139 行降到 321 行。
迁移时做了一件事:同一批文章用新旧两个渲染器各跑一遍,统计标题、callout、表格、任务项、脚注、内链、目录条数。十个文件全部一致,才敢删旧的。这种比对很笨,但比「看起来没问题」可靠。
交互:目录是块玻璃
目录换过两次。
第一版是侧栏里的一棵静态树,缩进加序号。问题是它太安静——你永远不知道自己在文章的哪一段。
第二版加了实时高亮和折叠,但序号和箭头堆在一起,缩进又吃掉一半宽度,读起来像文件管理器。
现在是第三版:目录是一块贴在右缘的玻璃面板,位置在导航之下、文章之上。
- 有子级才给箭头,没有子级是圆点,序号全部去掉;
- 收起按钮 38×38 放在面板左上角(上一版是 14px 的
»,点不中); - 收起后文章居中,点右侧空白或边缘的
‹再唤出,唤出时把文章往左推。
推开这个动作是有意的:它让「目录」和「文章」成为两个独立的东西,而不是一列固定的装饰。
可读性:把「感觉能读」变成数字
玻璃和纸两套材质、亮暗两个模式,四种组合。我一直以为都没问题,直到真的去量。
方法是:同一页在四种组合下各截两张图,一张正常、一张把所有文字设为透明,然后按元素矩形在「只有背景」的那张上取真实像素,算 WCAG 对比度。
量出来的问题都很朴素:
- Hero 名片的材质层被一条后代选择器误关,白字直接压在亮照片上(3.15:1);
- 纸模式的 Hero 只有浅色 veil,白字在没有暗角的照片上(1.5:1);
- 导航当前项、目录当前项的底色写死成浅色,暗色模式下变成「浅底浅字」(1.5–1.8:1)。
修完再量,四种组合的最低值都在 4.6:1 以上。方法记在 DESIGN.md,复查时直接照做。
这件事的教训不是「要测对比度」,而是:写死的颜色终会在某个模式里翻车,而模式的数量总是比你以为的多一个。
取舍
有几件事最后没做,理由值得记下来。
没有上 liquid-glass-react。 装了、接了、也真跑通了,但它靠 feImage 生成位移图,而 Chrome 的 backdrop-filter 不执行滤镜里的 feImage——核心效果在主浏览器上是空操作。打出来的包 41 KB gzip,静止状态又几乎没有可见材质。最后只取了它的高光层和饱和度配方。
构建依赖不再坚持为零。 「零依赖」曾经是个卖点,但它换来的是我自己维护一个 Markdown 解析器。现在构建期依赖 markdown-it,产物仍然是纯静态文件——运行时不依赖任何库,这条线才是真的重要。
纸材质冻结。 两套材质都持续迭代的结果是两边都半成品。定下「纸只修 bug」之后,玻璃反而推进得更快。
现状
- 12 篇文章,30 个页面,构建约 0.1 秒;
- 首屏 JS 3.9 KB(gzip),CSS 17.4 KB,光学引擎 10.3 KB 按需加载;
- 同屏最多两扇真折射窗口;
- 四种「材质 × 明暗」组合,正文对比度 ≥ 4.6:1。
这个站现在大概到了「想改哪里都知道该动哪个文件」的状态。对我来说,这比任何一项具体的效果都更重要。