dsh-spellbook
Verifieddsh-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 里出,归档不是删除,它只是不再自动出现在你面前。
预览承诺的边界:图版证明的是「这段示例代码跑得起来」, 不是「照这句描述就能生成出这个效果」。后者是描述的责任,前者是图版的责任,两者别混。
出处:许可先过,再谈收录
「记得住出处」这条硬门槛要先过许可。判定顺序是:
- 仓库里得有 LICENSE 文件。 没有就是全权保留,不能用。
connoratherton/loaders.css、IanLunn/Hover都因此排除。 - 许可得是宽松的那几种:MIT / Apache-2.0 / CC0 / BSD / ISC。
写着「开源」但带额外限制的不算——
animate-css/animate.css用的是 Hippocratic License 2.1(按人权合规限制使用,非 OSI 认证),已排除。 - 少数项目把许可拆成「商用 / 开源」两套(如
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 没有循环语句 | 预处理器生成,或换一个能传进去的参数 |
共同点是都不会报错——所以只能靠门禁去查,不能靠眼睛看。
设计
暗黑哥特的魔法书:夜色石板底、羊皮纸色正文、陈年金做编号与饰线、朱批红标出机制, 图版和缩略图镶双细线画框。默认就是暗的——这本书本来就该在夜里读, 所以不跟随系统主题,只有明确点过「昼」才变成一页旧羊皮纸。
三条自律:
- 金是贵的。 只给编号、饰线、画框这些「器物」用,正文不上金。
- 页面唯一被强调的东西是机制,用朱批红。 中世纪抄写员标记要紧处就是用红墨 (rubrication)——所以「排版权重 = 迁移权重」这条规则,在这里变成了一个真实的手艺, 而不是随便挑的一个 accent 色。
- 装饰要像印上去的,不像发光的。 不画光晕、不用紫色、不上彩色渐变
(唯一的例外是
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),层级一眼可辨。 条目序号全书连续,不随章节重置。