Skip to content

dsh-spellbook

Verified

dsh-spellbook · v0.1.0 · MIT · Web UI

DSH 插件:咒语书 —— 前端效果库,右侧栏一个「咒语书」tab。一条咒语 = 描述 + 示例代码,预览是真实运行的(332 条)。

Install

dsh plugin add dsh-spellbook

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Published to npm without a public repository. Inspect the package contents before installing.

Tags

Readme

咒语书 · 前端效果库

一条咒语 = 一段描述 + 一段示例代码。描述负责说清机制,代码负责证明它跑得起来。

站点把每条咒语排成一页「对开」:左边页边注(章节 / 收录时间 / 出处),右边正文(描述、可交互图版、代码、备注)。 图版里跑的是真实代码,不是录屏,也不是截图。

装进 DSH(右侧栏一个「咒语书」tab)

这个包同时是一个 DSH 插件:

packages/dsh-spellbook/
├── src/plugin/index.ts    宿主:把 dist/ 挂到 /dsh-spellbook/ 前缀
├── src/plugin/client.js   客户端:往 dsh-better-sidebar 注册「咒语书」tab
├── cordis.patch.yml       插进 profile 的层栈
├── lib/                   编译产物(宿主 .js + 客户端 .js)
└── dist/                  站点构建产物(插件端出去的就是它)

装:

dsh plugin --profile web add link:/Users/kp/DEV/dsh-plugins/packages/dsh-spellbook

用 link: 而不是 file::file: 会把包拷贝进 profile,而 dist/ 是构建产物、 天天重建 —— 拷进去之后每次重建都要重装一遍。link: 是软链,重建完刷新就有。 装完重启 DSH,右侧栏「+」里出现「咒语书」。

desktop profile 由 Electron 应用独占管理,dsh plugin 会拒绝; 那条路要手工改 ~/.dsh/profiles/desktop/package.json(dependencies 用 link:, 同时把 dsh-spellbook 加进 dsh.profile.bundles)再 pnpm install。

两半各自的职责:

宿主只做一件事 —— 把 dist/ 原样端出去。不在插件里重新渲染一遍,因为咒语书 本身已经是构建产物:332 条咒语、检索索引、按条目数算出的编号栏宽,都在 build.mjs 里一次成型。再写一套渲染器就是维护第二个真相,而它迟早跟构建期那套长得不一样。 路径先规范化再判断是否还在 dist/ 里(.. 出不去),只认 GET/HEAD。

客户端只做一件事 —— 把站点用 iframe 嵌进侧边栏,并且不加 sandbox 属性。 这一条是刻意的:嵌套 iframe 的 sandbox 标记往下取并集,外层若写了 allow-same-origin, 里面那 332 个 sandbox="allow-scripts" 的预览文档会一并拿到同源 —— 而它们被关进沙箱, 正是为了拿不到同源。不加 sandbox 就是普通同源 iframe:站点要的同源有了(检索索引、 localStorage),预览的隔离原样保留。test/plugin.test.mjs 钉住了这条。

嵌入时站点收两件事:

参数 作用
?theme=dark|light 首屏就跟宿主明暗一致(优先于 localStorage,宿主说了算)
?embed=1 收起书封那截刊头;查词口与卡片开关留着(到处都要用的工具)

之后宿主切明暗走 postMessage(dsh-spellbook-theme),不刷新侧边栏。 首屏用参数、后续用消息 —— 两条路都要有:参数管不闪,消息管切换。

页面结构

首页的第一屏是检索,不是标语。这本书的用法是「说一句你要什么 → 找到那条 → 抄走」, 所以最该占先的是一个查词口。原来那句「一条咒语 = 一段描述 + 一段示例代码」挪去了页脚, 位置换了,话没变。

查词口下面不再有分类筹码。原先有一排(全书 / 材质 / 动效 / …)用来把结果限在一章里, 后来整排去掉了:分类就是章,左边那条章目导航已经把同一个分类列过一遍, 再横着摆一排按钮是把同一个入口做两遍。去掉之后首屏是「刊头 → 分隔纹 → 目录」, 没有夹层。检索仍然是全库检索。

搜索时目录整片让位给结果;清空后目录原样回来。状态行(.concordance-status) **只在检索时有字** —— 「正在取索引…」「N 条命中」「搜索不可用:…」都从那里报; 不查的时候它空着,高度为 0,不占地方。它带着 role="status"/aria-live="polite", 所以空着也不能 display:none`,否则读屏就播不出来了。

命中的结果不只给标题,还要把命中的那句机制摆出来(左边一道朱批)。 全库的立论是「机制是承重墙」,检索也照这条立论解释自己为什么给这一条—— 先看机制,其次标签,再次标题,最后才是场合。

没有在查词的时候,下面是一本书的目录:分类即章,每章有章首标题与饰线; 左侧一条章目导航,点它跳转、滚动时高亮当前章(是跳转,不是筛选——全书始终完整可读)。 每条咒语右侧有一个 104×75 的实时缩略图,镶一圈双细线画框,点它放大成弹窗。

目录行只有「序号 + 标题 + 引点 + 缩略图 + 抄咒语」,不再逐行挂分类: 整章都是同一个分类,章首标题已经写过一次,重复一遍是废话。

缩略图不是画的色块,是把那份真实的 demo 文档按比例缩小(320×200 缩放 0.24375, 正好填满 104 减去两侧 13px 画框的净宽 78px)。 所以缩略图上出现什么颜色,图版里就是什么颜色——预览不许有第二个真相。 demo 只有几 KB,缩略图走 loading="lazy",实测 125 条里只有滚到附近的 27 条真的取。 搜索结果里不放 iframe:每敲一个字再挂 40 个会很沉,想看动的就点「预览」, 那时只挂一个,关掉就卸掉(否则里面的动画会在后台一直跑)。

点图就预览,不是点一个悬停冒出来的按钮。 缩略图本身就是一个 <button>, 可见部分就是那块 iframe。这里有个安静的坑:iframe 是另一个文档,会自己吃掉点击。 没关掉指针事件的时候,elementFromPoint 在缩略图正中心取到的是 IFRAME, 不是外面那个按钮 —— 于是整张图上只有悬停时冒出来的 13px 角标点得动, 「点一下图就预览」是空话,而且不抛错、不报警、控制台干干净净。

所以 .row-preview-doc 必须 pointer-events: none;角上的提示记号也常驻 (只压暗,悬停提亮),不靠 :hover 才出现 —— 触屏没有悬停, 把唯一的提示藏进 :hover,等于对触屏用户不存在。

缩略图是 <button> 且与标题链接并列,不能塞进 <a> 里:塞进去点图就会跳条目页, 预览反而开不了(而且 <a> 套 <button> 本身也是无效 HTML)。 一行三块各管各的:点标题进条目页,点「抄咒语」抄,点图开预览。

对开页:页边注标出所属章节,正文里图版是真在跑的。

抄咒语

每条右边那个「抄咒语」抄的是构建期生成的 dist/spell/<slug>/prompt.txt—— 点下去才取,一次几 KB。它同时也是一个能直接 curl 的纯文本咒语:

curl http://127.0.0.1:5180/spell/liquid-glass/prompt.txt

拼装规则在 shared/prompt.mjs:任务(把「X」这个效果做进我的项目)+ 三句框架

  • 类别/什么时候用它 + 要的效果 + 靠什么成立 + 容易失效的地方 + 参考实现 + 出处。 代码里站内自己的 /* @mechanism 说明 */ 批注会被转成普通注释, 光秃秃的 /* @mechanism */ 整个删掉——复制出去的提示里不该混进一本站内约定手册。

这是给 AI 的任务书,不是一段要照抄的代码

提示的开头曾经是**「用 HTML/CSS 实现这个前端效果:」**。它替对方把技术栈定死了。

可库里之所以全是 HTML/CSS,只因为「一条咒语 = 描述 + 示例代码」总要挑一种语言 把机制演示出来,不因为 HTML/CSS 就是对方项目该用的那一栈。一条咒语拿去做 React 组件、Vue 指令、SwiftUI 界面还是 shader,都该成立。落到什么栈上, 只有对方那个项目知道——提示里不该替它决定。

于是现在分两层,程序上也是分开的:

层 内容 与栈的关系
承重的一层 要的效果、靠什么成立、容易失效的地方 与技术栈无关,一条都不能丢
演示的一层 示例代码 带一个块头标签(--- CSS ---),随时可被改写

具体到文本上:

  • 开场两句把「什么可搬、什么不可搬」先说清:「要的是效果和实现思路,代码只是 其中一种写法——用什么都行,不限于示例里那一种」「栈、框架、兼容范围取决于 你那个项目;示例里的类名、尺寸、结构都是随手取的,不是接口」。
  • 不报语言清单。 曾经做过一版:从 entry_code.lang 现算一份「示例用 A、B、C 写成」。那是多余的机件——这份提示要交出去的是效果与实现思路,对方用什么语言 是他那一头的事,不归提示管。代码块自己在哪门语言里,块头标签已经写明了,够用。 标签查不到就用原样(--- swiftui ---),不吞不猜:例子不限于 html/css, React 行,别的一样行。
  • 约束排在示例前面:效果 → 机制 → 边界 → 参考实现。给 AI 的任务书该先说清 要什么、什么会坏,再给一个参考;把代码摆在最后,它读起来才像例子而不像模板。

三个测试盯着这一层,而且是逐份验那 125 份 prompt.txt,不是验一份样例 (这个错误当初 125 份全中,网站上却看不出任何异常——页面上只显示「抄咒语」四个字)。

检索是怎么算的

静态站没有后端,用不了 SQLite 的 FTS5。但不能因此有两个真相:同一个查询在 node scripts/vault.mjs search 和站点上给出不同答案,是这个库最不该发生的事。

所以排序逻辑抽成了纯函数 shared/rank.mjs(BM25,分词与列权重都来自 shared/vault-text.mjs, 和建库时用的是同一份),构建期把每条的原始列文本写进 dist/search-index.json, 浏览器加载 rank.mjs 在本地算分。索引是按需加载的:不碰搜索框的人不下载那 244KB。

有一条测试盯着这条不变量:拿 25 个查询同时问两边,要求 Top-1 一致 (test/rank.test.mjs)。哪天有人改了列权重、分词规则或打分公式,它会立刻红。

中文按重叠双字短语匹配,不按单字。所以「番茄炒蛋」是 0 条, 而不是因为某个字恰好出现在某条的场合描述里就凑出结果。 代价是「斜纹」搜不到写「斜纹」但查询被切成「斜条/条纹」的情况——精度换回来的。

跑起来

pnpm build:site     # content/ → dist/
pnpm dev:site       # 本地预览,改 content/ 或 src/ 自动重建
pnpm test:site      # 解析与校验的单元测试

零运行时依赖,不需要联网,也不装任何东西。字体已自托管(src/fonts/,SIL OFL); 画框是自绘的双细线,来历见 src/frames/README.md。

构建是加锁的,dist/ 是整体换名的。 dev:site 同时监听 content/ 与 src/, 改一个文件就重建一次;手动再敲一次 pnpm build:site 就会两个构建重叠。 原先两边各自 rm -rf dist,于是互相删掉对方正在写的目录,dist/ 停在半成品上 —— 首页直接 404、spell/ 只剩十来个目录,而页面上看不出任何异常。

现在两道保障:.build.lock 把并发的构建串起来(写进程 pid,死进程的锁会被后来者掀掉), 产物先全部落到 .dist-staging-<pid>/ 再整体换名成 dist/。 换名到「已存在的非空目录」在 POSIX 上是 ENOTEMPTY,所以光分头写还不够,必须串行。 test/build-lock.test.mjs 守着这三件事:四路并发产物仍完整、死锁能被掀掉、 构建期间 dist/ 不会缺 index.html。

目录

content/effects/*.md   内容源。一条咒语一个文件,是唯一真相
shared/                纯逻辑:解析、校验、检索文本、数据库协议。不含 DOM,未来插件直接复用
  parse.mjs            frontmatter 与围栏
  schema.mjs           字段校验
  param.mjs            参数到 CSS 值
  vault-text.mjs       中文拆字、停用词、FTS 查询、bm25 列权重
  rank.mjs             纯函数 BM25 排序器(浏览器与 Node 共用,与 FTS5 同口径)
  prompt.mjs           把一条咒语拼成可复制的纯文本提示
  numeral.mjs          罗马数字的排版宽度(编号栏宽靠它算,免得撞进标题)
  vault.mjs            数据库协议(建表 + 八个操作)
src/render/            生成 html
src/styles/            设计系统
src/fonts/             自托管字体(SIL OFL)
src/frames/            画框素材(自绘,见其中的 README)
src/site.js            浏览器侧行为:明暗、检索、放大预览、章目高亮、图版通信、机制联动、抄录
scripts/vault.mjs      咒语库命令行
scripts/               素材加工与校验小工具(PNG 编解码、改色、截图转字符画)
data/vault.db          投影 + 本地事件流(不入库,见「数据库协议」)
build.mjs              读 content → 校验 → 产出 dist/
serve.mjs              本地预览

内容是唯一真相,渲染层不含任何内容。加一条咒语只动 content/。

写一条咒语

---
title: 液态玻璃面板
slug: liquid-glass          # 必须与文件名一致
category: 材质              # 材质 | 动效 | 排版 | 交互 | 布局 | 图形
tags: [玻璃, 模糊]
since: 2025-09              # YYYY-MM
source: 灵感来源 iOS 26,自行实现
when: 需要一块浮在内容之上的面板,又不想把底下的东西遮死
stage: photo                # plain | photo | grid | dark
params:
  - { name: blur, label: 模糊, type: range, min: 0, max: 40, step: 1, default: 18, unit: px }
---

## 描述

机制是 ==backdrop-filter 的 blur 与 saturate==。……

## 代码

```css
.glass {
  backdrop-filter: blur(var(--blur, 18px)); /* @mechanism */
}
```

```js
el.style.setProperty('--mx', dx + 'px') // @mechanism
```

## 边界

- 这条咒语什么时候不管用(可选,但强烈建议写)。

## 备注

- 一条其它零碎。

描述:机制是承重墙

代码只是示例,不是规格。模型迁移到别处时,代码会被重写,描述不会。所以描述必须点名机制—— 用 ==……== 把机制圈出来,它是整个页面上唯一被强调的东西。

一条描述要交代四件事:是什么感觉 / 靠什么成立 / 什么可变 / 什么会失效。 其中「靠什么成立」是承重墙,也是 ==……== 要圈的那一句。

边界:与机制同等重要的一域

机制回答「它靠什么成立」,边界回答「它什么时候不管用」。两者是一条咒语的两半: 机制是迁移时的承重墙,边界是用户真正会踩的坑。

所以边界单独成段、单独入库(entry_caveats 表),检索命中时跟着一起返回。 它不进全文索引——边界说的是「什么时候不管用」,拿它当召回依据会污染「我要什么」。

写的时候要具体到条件,别写「兼容性一般」:

好 差
祖先元素带 transform 时 backdrop-filter 会静默失效,不报错 部分场景有兼容问题
触屏上没有 hover,pointermove 只在按下时触发 移动端体验略差
示例代码没带 -webkit- 前缀,旧 Safari 上完全没有玻璃 Safari 需要前缀

可选段:想不清就不写。但一条写不出边界的咒语,多半是还没用够。

机制联动:排版权重 = 迁移权重

描述里 ==……== 圈住的机制,和代码里标了 @mechanism 的行,通过 data-mech 绑在一起。 鼠标划过描述里的机制,代码里对应的那几行会亮起来;反之亦然。

这条规则决定了排版:页面上被强调的,就是迁移时必须保住的那一句。 别处一律安静。

参数即 CSS 变量

参数写进 CSS 自定义属性,示例代码保持干净可抄:

backdrop-filter: blur(var(--blur, 18px));

滑杆只负责把值写到图版根元素上。unit 只声明一次,拼装由 shared/param.mjs 统一完成—— 构建时的默认值和页面上的滑杆用的是同一份规则,否则会出现「初始没效果、拖一下才有」这种最坑的预览。

声明了 params 却不在代码里用到 var(--名字),构建会直接失败。假的滑杆不许入库。

stage:必须是受控背景

模糊、混合、发光这类效果在纯色背景上是看不见的。所以每条咒语必须声明舞台:

stage 画面 用在哪
plain 素纸 排版、几何、颜色本身
photo 彩色渐层网格 模糊、混合、玻璃、遮罩
grid 网格线 位移、吸附、对齐
dark 暗底径向光 发光、粒子、霓虹

不给受控背景,预览就会和描述对不上。

收录标准

硬门槛(缺一不可):跑得起来 / 记得住出处 / 说得清是什么 / 说得出场合 / 依赖透明。

价值门槛:一句话就能说清的效果,不值得入库。能入库的要么机制不显然,要么坑很实。

分层:core / candidate / archive(frontmatter 里的 tier,不写默认 candidate)。 库求全,推送求准——自动提议只从 core 里出,归档不是删除,它只是不再自动出现在你面前。

预览承诺的边界:图版证明的是「这段示例代码跑得起来」, 不是「照这句描述就能生成出这个效果」。后者是描述的责任,前者是图版的责任,两者别混。

出处:许可先过,再谈收录

「记得住出处」这条硬门槛要先过许可。判定顺序是:

  1. 仓库里得有 LICENSE 文件。 没有就是全权保留,不能用。 connoratherton/loaders.css、IanLunn/Hover 都因此排除。
  2. 许可得是宽松的那几种:MIT / Apache-2.0 / CC0 / BSD / ISC。 写着「开源」但带额外限制的不算——animate-css/animate.css 用的是 Hippocratic License 2.1(按人权合规限制使用,非 OSI 认证),已排除。
  3. 少数项目把许可拆成「商用 / 开源」两套(如 IanLunn/Hover),当作没有许可处理。

source 字段要写到能回溯到具体那一条:项目 + 许可 + 原效果名 + 我改了什么。例:

source: miniMAC/magic(MIT) — openDownLeft,改写为独立最小示例

只写「参考了某库」是不够的:将来要核对「这真的是它的做法吗」,得有原效果名。

已有的两类出处(inbox.raw_ref 里可查):

类型 计 含义
MDN / 规范文档 107 机制的依据在这里;示例是自己写的
开放许可项目 12 效果本身来自该项目,示例是改写

后一类是第 9 批补上的。前 8 批这件事没做到——出处几乎全是 MDN,采的是 「机制」而不是「项目里的效果」。这是自查时发现的偏差,记在这里免得再犯。

数据库协议

一条铁律:md 是唯一被书写的真相,数据库是投影加一条事件流。

区 表 上游 丢了会怎样
A 区 库投影 entries 及六张子表 + match_fts content/effects/*.md 无所谓,rebuild 就回来
事件流 events 运行期唯一的来源是追加 不可恢复,这是唯一要备份的
C 区 用投影 proposals / inbox / slug_prior events 与人的策展动作 无所谓,可重算

所以 rebuild 的删除名单是写死的常量,不是「除了 events 全删」——后者哪天加了新表就会误伤历史。 测试里有一条专门盯着这件事。

改表结构的时候,按「能不能重建」走两条完全不同的路(migrate() 里就是照这个写的):

改哪 怎么办 为什么
A 区任何表 DROP 掉,让 rebuild 按新 DDL 造回来 它是投影,本来就不该为它写迁移脚本
events 只能 ALTER,绝不重建 它是唯一不可重建的部分,删了就真没了

判断依据是实际列(PRAGMA table_info)而不是版本号:版本号只在 migrate() 自己里写, 万一读到 0(库是先建的、还没重建过),光看版本号会漏掉加列,之后 INSERT 就炸。

rebuild 里不能写 schema_version:那会让每次重建都把版本号打回旧值, 下次打开又跑一遍迁移,把刚建好的投影表全丢掉。

操作 干什么
rebuild(force) 把 md 投影进 A 区。先全部校验、全部通过才写库,绝不写半个库
search(q,{tier,limit}) 检索。默认只从 core 出
get(slug) 取一条完整咒语
stats() / drift() 计数;以及报告投影有没有落后于 md
intake(spec) 运行期自动捡到的候选,一律先进收件箱(inbox)
propose(q) / resolve(id,accept|reject) 提议与拍板,都写进 events,并重算先验
promote(inboxId, spec) 唯一会写 md 的入口,内容不合规就回滚,绝不留下坏条目
checkEntry(spec) 只渲染+校验、不落盘。promote 内部走的就是它,所以预演通过等于转正能过
transaction(fn) 一次事务,抛错整批回滚。不支持嵌套(promote 内部要 rebuild,而 rebuild 自己开事务)
exportState() / importState(s) 把不可重建的那部分搬走 / 合并回来(按内容去重,可重复导入)
pnpm --dir packages/dsh-spellbook vault rebuild
pnpm --dir packages/dsh-spellbook vault search "要个跟着指针动的按钮"
pnpm --dir packages/dsh-spellbook vault export --out backup.json

排序:exp(bm25/4) × 分层权重(core 1 / 候选 0.4 / 归档 0.05) × 历史先验。

两处不显然的取舍:

  • 相关度用 exp(bm25/4) 而不是夹到 0。bm25 的 IDF 项在「同一个词出现在过半文档里」时是负的, 小语料上极易发生,夹到 0 会把所有弱命中压成同一个分数、顺序信息整个丢掉; 而直接乘负分又会让归档的 0.05 倍把它拉近 0,反而排到核心条目上面。
  • 中文必须按双字滑窗成短语。FTS5 默认分词器把连续汉字当成一个 token, 「玻璃」在 液态玻璃面板 里搜不到;拆成单字精度又太低(查「番茄炒蛋」会被「东西」两个字命中)。 双字短语既要求相邻、又不必猜词——实测把噪音从一堆压到 0。

已知边界:这是词法召回,完全无关的东西 番茄炒蛋 会因为「东西」真的出现在某条的场合句里而命中。 所以提议必须由人(或 AI)确认——那个确认步骤不是流程冗余,是这套设计的必要一环。

批量入库

采集来的效果不另建隔离目录:inbox 本来就是隔离区,而且它在不可重建的那一区, 所以「这条是从哪个 URL 扒来的」这类出处天然留存。整条路只有两个动作:先入收件箱,再转正。

草稿放在 drafts/batch-NN.mjs(也接受 .json),按批组织。三步走,中间那步是主要工作循环:

node scripts/prepare-draft.mjs drafts/batch-06.mjs   # 预处理:把好写的格式变成合法的 JS
node scripts/ingest.mjs drafts/batch-06.mjs --dry-run # 过门禁,一个字节都不写
node scripts/ingest.mjs drafts/batch-06.mjs           # 真入库

**为什么要有预处理这一步。**草稿关心的是内容,但 JS 语法有两处会拦下来,而且报错都指向症状、不指向原因:

写法 症状 预处理做的事
描述里写 `--mx` 模板字符串被提前截断,报 Unexpected token 只转义内部的反引号,保留分隔符
tags: [网格, 响应式] 语法合法,求值时报 网格 is not defined 给数组里的裸词加引号

第二处值得多说一句:汉字在 JS 里是合法标识符,所以 node --check 会通过、求值时才发现它被当成了变量。 这类「检查通过、运行才发现」的坑最费时间,所以才固化成脚本而不是每次手改。

草稿是 spec 数组(形状同 promote 的入参),外加几个下划线开头的采集元数据,它们不进 md:

键 去处
_rawRef 出处 URL,写进 inbox.raw_ref(不可重建,所以出处不会因为重建而丢)
_origin 来源分类,默认 crawl
_titleGuess / _categoryGuess 进收件箱的猜测值,方便日后回看

一条失败不影响其余:整批跑完再汇总,有任何一条没过则退出码非零。 预演用的就是 checkEntry,和 promote 内部同一条路径——不会出现「预演通过、真写却失败」。

门禁拦下来的常见原因整理在下面,它们都能在写草稿时提前避开:

拦截理由 典型现象 处理
假滑杆 声明了参数,代码里没有对应的 var(--name) 删掉参数:拖了不动的滑杆比没有更糟
代码块缺 @mechanism 机制与实现对不上号 在示例代码里标出机制所在的那一行
代码围栏为空 只有描述没有可跑的东西 补一个最小可运行示例
描述里没有 ==机制== 说不清靠什么成立 回到机制的三个问题:靠什么、什么可变、什么会失效

有三类参数天然做不成滑杆,都能在门禁里被拦下(假滑杆):

类别 例子 为什么够不到 出路
SVG 滤镜属性 feTurbulence 的 baseFrequency 滤镜属性不从元素继承 CSS 变量 复制多份滤镜按类名切换
无类型的自定义属性 --angle 直接写进 conic-gradient 字符串无法插值,值只会硬跳 用 @property 声明类型
需要循环才能生成的值 长阴影的层数 CSS 没有循环语句 预处理器生成,或换一个能传进去的参数

共同点是都不会报错——所以只能靠门禁去查,不能靠眼睛看。

设计

暗黑哥特的魔法书:夜色石板底、羊皮纸色正文、陈年金做编号与饰线、朱批红标出机制, 图版和缩略图镶双细线画框。默认就是暗的——这本书本来就该在夜里读, 所以不跟随系统主题,只有明确点过「昼」才变成一页旧羊皮纸。

三条自律:

  1. 金是贵的。 只给编号、饰线、画框这些「器物」用,正文不上金。
  2. 页面唯一被强调的东西是机制,用朱批红。 中世纪抄写员标记要紧处就是用红墨 (rubrication)——所以「排版权重 = 迁移权重」这条规则,在这里变成了一个真实的手艺, 而不是随便挑的一个 accent 色。
  3. 装饰要像印上去的,不像发光的。 不画光晕、不用紫色、不上彩色渐变 (唯一的例外是 photo 舞台——那是受控背景,不是装饰)。

圆角全站只有一个值 --radius: 3px。原生控件的默认圆角也一并盖掉, 不留第二套圆角。画框是唯一的例外,它的角必须是方的——边框用 border-image, 圆角会把角上的线切掉。

版心只有一个,行宽只有一个

版面宽度全部从 --shell(1180)和 --gutter(40)推出来,不再各处写死像素:

token 值 含义
--shell 1180px 整页外框(刊头、main 的 max-width)
--gutter 40px 外框内边距;窄屏降到 22px
--content calc(--shell - 2*--gutter) 版心 = 1100
--rail-w / --rail-gap / --rail 176 / 44 / 220 左侧边栏(章目导航、页边注)及其间距
--measure 880px 行宽 = 版心 − 边栏,正文与图版都用它
--prose 40rem 散文行宽。比行宽窄是有意的:一行 54 个汉字太累
--frame / --frame-thin 30px / 13px 画框的两档:大图版与小件
刊头两格 首格 calc(--rail - --masthead-gap),次格 minmax(0,1fr) 首格 + 格间距 = --rail,于是次格正好是 --measure——查词口的右缘因此也落在 1250 上

于是每一页都只有一条正文左边缘(370)和一条右边缘(1250):检索区、 章目、条目、页脚、对开页的正文栏与图版,全部落在上面。首屏那几块靠 main.has-rail 让开边栏那 220px 才对齐——没有边栏时(只有一章)不加位移。

散文窄、图版宽是对开页的规矩:标题、用于、描述、边界收在 --prose, 而图版和代码用满 --measure,两者左边缘对齐、右边缘不等。原先 .column 自己写着 max-width: 40rem,可它所在的轨道有 858px——右侧于是空出一块 218px、对不齐任何东西的空白。

刊头:书名与查词口一行,整条吸顶

刊头排成两格:第一格「书名 + 明暗开关」,第二格整格给查词口。 格子仍从边栏推出来——首格 calc(--rail - --masthead-gap) = 198,加上 22 的格间距, 第二格正好从 370 起、宽 880。于是查词口的左右两边都与下面的 检索结果、分隔纹、条目齐平,吸顶的时候也不会跳。

开关为什么要挤进书名那一格。 它原本自己占第三格(三格布局)。 那样查词口只拿到 807px,右边缘停在 1177 —— 比下面的条目短 73px, 也就是一个开关加一个格间距的宽度。首屏上就是一条缺了角的边: 左边上下对齐,右边上下差着一截。把开关收进第一格,第二格才整整是 880。

代价是副标题得改竖排(书名在上、副题在下)。横排时「咒语书 前端效果库」 一个块就占 198px,再塞不下一个开关;竖排后书名块收成 109px,同格还有富余。 刊头高度没变(仍 89px,由查词口定),两格只留两列。

内页刊头没有查词口(第二格留给「当前条目名」--masthead-current,暂时不显示), 所以内页刊头比首页矮(61px 对 89px)—— 一直如此,与这次改动无关。

查词口的记号是一柄魔杖,不是「查」字、也不是放大镜。放大镜在每一本工具书里 都长一样,魔杖才是这本书自己的器物。但魔杖是 aria-hidden 的画,读屏读不出它, 所以标签里另有一段 .visually-hidden 的「查词」当输入框的可访问名: 去掉文字留图标可以,去掉可访问名不行。

章节导航吸顶要让开刊头,靠 --masthead-h。刊头多高是内容定的(字号、 自托管字体到没到、窄屏换不换行都会变),写死数字迟早错位 —— 先前写 78px、 实测 89px,两级吸顶只差 5px 就贴上了。这个量由 syncMastheadHeight() 在 加载、字体就绪、缩放时各量一次写进变量。

窄屏分两档:≤860px 藏掉副标题、压紧行距,两格仍并一行(刊头 79px); ≤520px 退回两行——.masthead-id(书名靠左、开关靠右)占第一行,查词口独占第二行。 挤在一行时查词口只剩 88px,敲不进几个字,等于没有;宁可吸顶那坨高一点(112px), 也不能给一个用不了的检索口。开关现在住在 .masthead-id 里,第一行不用再单独管它—— 上一版为此写过一条 .masthead > .theme-toggle(还附带一段特异度说明), 现在那个坑随封装一起消失了。

编号栏必须定宽,但宽度得算出来

目录每一行是一张独立的栅格(.row-link),所以「编号」那一栏必须定宽, 标题才能跨行对齐。可罗马数字宽窄差很多:125 行里有 33 行的编号越出 42px 的栏、 直接撞进标题——LXXXVIII 要 69px,而同批最长的 CXXV 只要 44px。

宽度不能拍脑袋定,得按当下载库算:shared/numeral.mjs 存了一份离线实测的 单字宽(Cinzel 0.85rem/字距 0.1em),构建期扫 1..条目数 找出最宽的那个, 写进 dist/numeral.css 的 --numeral-w;页边注的大编号同理,按 176px 的栏 反解出一个字号上限 --numeral-lead(2.4rem 的 LXXXVIII 要 195px,是装不下的)。 库长到 400 条时栏会自己变宽,而不是悄悄糊在一起。

实测锚点(模型误差 < 1px):LXXXVIII 69px、DCCCLXXXVIII 116.8px、CXXV 44px。

昼本不是夜本的反相

亮色是同一本书的另一次印刷:底是旧羊皮纸 #e5ddce,字是墨 #1b1710, 金沉下去一点,朱批换成偏赭的红。token 名不变,只有值变,所以组件一个都不用改。

一个容易搞反的地方:「亮金」的意思是「比金更跳」,不是「颜色更浅」。 夜本里更跳是往白里走,昼本里更跳必须往深里走。原先 --gilt-bright 直接沿用了 夜本的 #b8933f,压在羊皮纸上对比度只有 2.34——「全书 / 材质 / 动效」 那几个选中筹码几乎看不见。现在夜本 #f0e0b8 → 昼本 #674d18。 (那排筹码后来整排去掉了,但这条账留着:踩过的地方不记下来,换个元素还会再踩。)

test/contrast.test.mjs 用 WCAG 公式把两套主题的 token 逐个算一遍(20 条断言), 以后谁再动颜色都会被立刻拦下。分隔线不走 4.5:它是一条装饰性的发丝线, WCAG 1.4.11 对纯装饰元素不设要求,所以只守「不能等于底色」。

另外 :root 上必须有 color-scheme: dark(昼本覆盖成 light)—— 不写的话,滚动条和 <input type=search> 自带的清除按钮会一直按浅色画, 在夜本上就是几块白斑。

字体全部自托管、全部 SIL OFL:

字体 用途 理由
Cinzel 400 题名、章名、编号 罗马碑铭体,衬线书里「题名」的那一路
IM Fell English 400 / italic 正文 17 世纪英格兰铅字,笔画有不匀的墨感
UnifrakturMaguntia 400 题跋 哥特黑体,全书只用在题跋上,用一次
IBM Plex Mono 400 / 500 注记、代码 等宽,读数用

中文回落到系统宋体(Songti SC)。 不引中文网络字体,理由不是为了省事: 书架会一直长大,而中文网络字体动辄几十 MB,做子集就得跟着内容重新生成—— 一旦有人加了一条含生僻字的咒语,页面上就会静静冒出豆腐块。 用系统宋体不漂亮,但它永远不会缺字。

书里的图形装置:

装置 位置 含义
魔法书 刊头 物。这本书自己:合着的封面 + 书脊 + 书扣,右上角一颗火星
魔杖 查词口 器械。一句话就是咒语,魔杖就是那句话
方钻 章首饰线的起点 章首纹(.chapter-rule::before,不是独立元素)

原先还有一道花饰,整条去掉了。 花饰是「细线—魔杖—细线」的分隔纹,夹在刊头与正文之间。去掉的理由不是不好看,是这道纹在跟另外两条横线打岔:刊头本来就有下边线,章首本来就有饰线,三条横线隔着几十像素排在一起,谁也不比谁更说得清自己在分隔什么。少一道,页面反而立得住。

省下来的 136px 里留 44px 当气口(取的是 --rail-gap 那个刻度,跟左边那条侧注列同一个节奏),其余收回给正文。于是首屏变成「刊头 → 目录」,中间没有夹层。

顺带的好处:一页上从此只剩查词口那一支魔杖。同一个记号在一页上出现两次就不再是记号——刊头原先也是魔杖,换成书之后,剩下的这一支才重新成为「器械」那个记号。

书为什么合着画、书扣为什么捅出封面。 22px 下摊开的书要画两页纸的弧度,那点弧度会糊成两条并列的横线,看着像等号;合着的书只有三条直线,小尺寸下轮廓反而最清楚。书扣那一横故意捅出封面右边线 0.8 个单位——不捅出去,这本书读起来就是一个方框。

线稿只有两种笔画:mark-line(描边)与 mark-spark(填实的火星)。两个装置共用这两种,所以名字不挂在某一个装置上 —— 原先叫 wand-shaft / wand-spark,书身上于是长着「魔杖的零件」。

画框是自绘的「双细线」,不是现成花框。 试过 Lustro 002、Ostell 1848 等几只 公有领域的欧式花框,都栽在同一个物理限制上:边条在缩略图上只有 13px 深,花纹细过 一个像素就糊成锯齿;边框加宽到看得清花纹时,花框又压过了内容。古书里的整版插图 本来也大多只用双细线锁边,所以最终回到它:scripts/make-frame.mjs 生成 96×96 的九宫格素材,两条线落在边框宽度的固定比例上,缩放时只会变粗变细。 试错过程与各候选出处见 src/frames/README.md。

两套记号刻意不同:章节用中文数字(第一章),条目用罗马数字(I、II),层级一眼可辨。 条目序号全书连续,不随章节重置。