版本记录

版本记录

这个站是怎么造出来的

目录

传统书籍会在末页留一页版本记录,交代用了什么字体、什么纸、什么工艺。这一页是它的网页版本。

站点现在有四处:主页是门面,文章是里屋,另外还有一个导航站和一个图床。四处的性格不同——主页柔、文章静、导航站有色、图床几乎只剩一个投放口——但字体、构建系统、主色、那层玻璃的配方是同一套。接缝在那里,不装作没有。

标志#

ruuo 四个字母连成一条路径,从 r 的竖笔起笔,到 o 闭合收笔,中间不抬笔。买这个域名的理由就是这四个字母能连起来,所以标志应该把这件事画出来,而不是找一支字体把它排出来。

它不是手写坐标凑的,是按几何参数生成的:碗底是真半圆,竖笔切在圆的最左或最右点,因此每个转折都切线连续,弯是软的。o 比 x 高多探出 2.5 个单位——圆形字母在视觉上会显得比方形小,这是排版上的常规补偿。

最难的一处是 r。臂太宽就读成 n,太窄又只剩一根竖笔像 i。最后定在臂展 60(u 的碗跨度是 76),并且让肩缓缓抬起、到最右侧才陡然落下——n 的拱顶在正中,只要这个不对称还在,就不会认错。

页面载入时它会一笔写出来。因为整条路径本来就是连续的,这个动画演的正是“连得起来”这件事本身。

字体#

站点最开始只有四支字体。现在是九支、十一个子集,因为“字体”这件事被拆成了角色:同一页上,正文的汉字、正文的拉丁、巨标题、角落里的小标签、英文引文,各有各的脸。

角色 用的是 原始 子集后
正文中文 思源黑体 16.9 MB 365 KB
正文拉丁 Satoshi 0.1 MB 28 KB
界面中文 MiSans 官方分片 按需取片
巨标题 未来荧黑 Bold 8.7 MB 91 KB
展示级中文 未来荧黑 Light / Regular 17.6 MB 641 KB
分类标题 思源宋体 24.0 MB 39 KB
英文引文 Fraunces 0.3 MB 113 KB
小标签 一支像素体 6.7 MB 47 KB
等宽 JetBrains Mono 0.3 MB 41 KB
兜底 Inter(含斜体) 1.7 MB 195 KB

思源宋体压掉了 99.8%——它只负责导航站上那几个分类名,二十来个字,收整套是不划算的。

排在这些前面还有一支 HanPunct,只覆盖中日韩标点区段。它必须排在第一位,因为西文字体里也有这些码位,一旦轮到它们,引号和括号就会变成西文的形状。

回退顺序就是分工#

字体回退是逐字进行的,所以字体栈的顺序不是“首选和备胎”,而是分工表。正文那一栈把拉丁排在中文前面:拉丁字母交给 Satoshi,汉字自然落到后面的中文字体。反过来写也能出字,但那是让一支中文字体去排 English——它的拉丁只是附赠品。

这条也踩过坑:巨标题那一栈原来把一支只收了二十来个字的 Bold 排在最前,结果同一行标题里,凡是不在那二十来个字里的统统掉回 Regular,有的粗有的不粗。

子集是从渲染结果里扫出来的#

扫的是整段 HTML,不是剥完标签的纯文本——主页首屏那几句话写在 data-lines 属性里,标签一剥就没了,只按纯文本收集会让它们上线即豆腐块。同理,写作台复用站点的字体,它界面上的字、连同只存在于服务端代码里的报错文案,也一并喂进子集。

字符集只增不减地累积,因此发新文章不会让回访读者重新下载字体。

预载按页面区分#

各取所需:文章页预载中文黑体和圆体,导航站同理,主页只预载首屏那两支。

这一条最容易过期。主页首屏原来是一句中文,走圆体,于是预载写的是圆体加 Inter。后来那句话换成了英文、拉丁也从 Inter 换成了 Satoshi,预载却没跟着改——结果首屏真正要用的两支得等 CSS 解析完才开始下载,而抢先下来的两支首屏一个字都不用。预载是一份关于“第一眼看见什么”的断言,页面改了,断言就得跟着改。

中文排版#

文章那边的正文处理,读者不会注意到,但做错就会一直制造摩擦:

中西文间距
汉字与拉丁字母、数字相邻时补约八分之一字宽。用零宽元素的外边距实现,复制文本不会带出多余空格。跨标签的接缝同样处理。
智能引号
直引号自动转弯引号,跨标签配对,段落边界重置。撇号与引号分开判断,It's 不会被当成开引号。
标点压缩
只施加在显示级字号上。正文里全角标点占满一个字宽是对的,但到了大标题,一个「,」撑开的空洞会把版面撕开。压多少随字号增大而增大,这是印刷排版的老规矩。
着重号
含中文的强调渲染为字下加点,而不是几何倾斜的伪斜体——汉字没有斜体设计。构建期判定,运行时零成本。
避头尾
标点不落行首,开引号不落行尾。这条到处都成立。
悬挂标点
行首标点吊出版心,让左边界视觉齐平——只有 Safari 认这条属性。样式挂在 @supports 下面,别的浏览器上这一行不生效,也不会因此错位。写在这里是因为:一份规格说明如果只写自己想要的样子,那它记的就不是这个站。

脚注在宽屏上渲染为页边注,浮在正文右侧空白处,与引用它的那一行齐平;窄屏收起,点编号就地展开。这套交互没有 JavaScript 参与。正文里的上标编号和右边那条注互相点亮——一页有三条注的时候,读者不该自己去数。

界面的材质#

界面上那些“面”——导航站的卡片、文章页的目录、上下篇——是同一个配方做的,三条:

面要真的半透。 纯白压在有颜色的底上是一块挖空。留一点透光,底下的颜色透上来一点,面和它坐着的地方才像同一种材料。

描边是减出来的,不是画上去的。 单色 border 只能一圈一样亮,而真实的边是上沿亮、下沿带一点环境色。做法是铺一层渐变,再用 mask-composite 把中间挖掉,只留一圈一像素。

加白之前先把底压暗。 高光是往上加白,可一块底已经在 0.8 的面没有加白的余量。余量是压出来的:先让它暗一点,光才有地方亮。同一个光源要在三处同时成立——面上一团、被扫亮的那段棱、朝反方向倒的影子——才是一个光源,而不是三个特效。

一块面,不是一排闪#

列表里不给每一项各配一套 hover 高亮,而是整列只放一块面,指针移到哪一项它就滑过去、变形到那一项的尺寸。导航站的卡片、左侧分类轨、文章归档、文章页的目录,四处都是这个手法。N 个各自闪的高亮和一块会走的面,信息量一样,后者是一件东西在动。

文章页的目录那一块还多一层职责:它跟着阅读位置走,鼠标悬停时临时被抢过去,移开再交还——目录本来就要同时回答“我在哪”和“我要去哪”。

图标#

全站图标出自同一份源文件,都画在 24 的网格上,视觉外径统一。粗细不写在图标里,写在调用的地方:同一枚图标在 19px 和 14px 上要保持看起来一样粗,落地像素得反算回网格上的描边宽度。三挡尺寸各配一个值,小的那一挡刻意粗一点,是光学补偿。

动效#

主页的背景是五团颜色,由 SVG 的高斯模糊加 alpha 阈值滤镜做融合,靠近时像液体互相吞并——纯滤镜,没有 WebGL。花瓣会被光标推开,斥力随距离平方衰减。指针是一个滞后跟随的环加一个精确跟随的点。

缓动曲线是解阻尼弹簧方程导出的,不是手调的贝塞尔。位移幅度全部刻意压过一轮——廉价的特效感通常不来自“做了什么”,而来自“做过了头”。

入场是有先后的:头像浮现,标志一笔写完,然后那句话才开始打。主角先,配角后。

顶栏的毛玻璃过渡、阅读进度条、缩略标志的渐显都由 CSS 滚动驱动动画完成,跑在合成器上,没有一个滚动监听器。凡是要读位置的脚本,几何一律量一次存起来,改窗口才重量——在滚动的路径上读一次元素尺寸,就等于逼浏览器把整页重新算一遍。

跨页有过场动画。它捕捉的是新页面的第一帧,那一帧若字体还没到,过场中途字会换脸重排,看上去就是卡了一下;所以过场会等字体,但最多等 300 毫秒。往里走和往回走的位移方向相反。

prefers-reduced-motion 下全部关闭。

深浅#

文章、导航站、图床跟随系统深浅,也可以在顶栏手动指定,三态循环。

主页没有深色。那一屏本来就是一片流动的浅色,把它压暗不是换个模式,是换一件东西。

实现#

站点是纯静态的,由一套自写的生成器产出。

  • Markdown:markdown-it-py,扩展了脚注、定义列表、任务列表、容器块
  • 代码高亮:构建期由 Pygments 完成,页面不加载任何高亮脚本
  • 模板:Jinja2 字体子集:fontTools 图像:Pillow
  • 搜索索引构建期生成,浏览器端做子串与二元组匹配——中文没有词边界,这比引入分词器更划算,而且断网可用

一次完整构建两秒多。样式三份(文章、导航站、图床),各约 150–180 KB,gzip 后 48–56 KB;搜索索引 15 KB。文章页的运行时脚本 13 KB,gzip 后 4.5 KB。

全部关闭 JavaScript 后页面依然可读、可导航:所有“先隐藏、等脚本点亮”的规则都挂在一个由内联脚本打上的标记下面,脚本没跑,页面自动退回全部可见。

图床#

对象存储只是个放字节的地方——它不能查、不能排序,列举还按次计费。所以“有哪些图、多大、什么时候传的、谁传的”一律记在本地的 SQLite 里,对象存储那边只认一个键。

键是内容哈希。两个人传同一张图,数据库里是两行,存储上只有一份对象。

上传前会把图里的元数据摘掉,像素一个都不动。EXIF 是跟着照片走的:机型、拍摄时间,手机拍的还带 GPS 坐标。转成 WebP 时这些自然就掉了,但“转完反而更大就留原样”那条路,会把原始字节原封不动送到一个公开链接上——传一张街拍,别人从链接里读得出你当时站在哪个路口。所以摘不干净的一律不走原样,改用转码出来的那份:宁可文件大一点,也不能因为没把握就把原文放出去。

写作与发布#

文章在站点自带的网页编辑器里写,预览走的是和生产完全相同的渲染管线与正式样式表,所以所见即所得是字节级的。

编辑器的结构按“作用域”分层,而不是把功能平铺在顶栏上。顶栏只留三样:文章列表、当前状态、发布。属性——文件名、日期、标签、摘要——不藏进抽屉,而是折在标题下方的一行摘要里,展开就在正文上方,不遮任何东西;写的时候它本来就该在眼前。

正文区整篇一起滚:输入框自己不滚动,由脚本撑到内容高度,于是标题和属性条会跟着正文走,读起来像一份文档而不是一个套在框里的输入区。

三处后台是一个规矩:在那一页的地址后面加 admin。博客是 /admin,导航站是 /nav/admin,图床是 /img/admin。不必记三个各不相同的路径。

保存后触发一次完整构建,产物写入新的版本目录,全部就绪才原子地切换软链接。因此发布过程中不存在页面半新半旧的中间态,回滚也只是把软链接指回上一版。

esc