界面图标显示正常但歪,实测每个字形都偏离所在盒子中心 0.496em(半个盒子), 也就是 9px 以上,且每一行歪的幅度还不一样。代价是三条互相独立的缺陷叠在 一起,而此前所有图标测试都只读 `cmap` 和字形名,一条都不覆盖这些位置。 一、`.icon` 把字体自己的行盒换成了一个方块(主因) `line-height: 0; display: inline-block; width: 1em; height: 1em` 会得到 一个**锚在基线上、向下一整个 em** 的盒子,而字形是围绕基线画的。于是墨迹贴在 盒子上沿、盒子下方整片是空的。UI 里每个容器都在居中这个盒子,盒子确实居中了, 字形却差了半个 em——这就是「显示正常但不对齐」,也是每行幅度不同的来源:各行的 容器不同,锚法就不同。 改为用字体自己的行盒(`line-height: 1`,不再给高度),只保留 `width: 1em` 这个字体给不了的东西(可预测的前进宽度),并补 `vertical-align: middle` 给 正文里行内出现的图标。**刻意不用 `transform`**:它能在 flex/grid 里生效 (`vertical-align` 在那里是惰性的),但它改的是绘制期位置,会让「图标占的位置」 和「看起来占的位置」不再一致。 二、字体度量与字形不自洽(次因) 表格声明上升部 960 / 下降部 -64(基线靠近 em 底部),而字形是按 `(GRID/2 - y)` 围绕 em 中心画的。两者各自自洽、互相矛盾,而输的是盒子。 现在上升部与下降部各取半个 em:**对称**不是审美——UI 里每个容器都在居中盒子, 只有度量对称时「居中盒子」和「居中字形」才是一回事。 同时修掉三处字段错位(都会静默通过解析): - `OS/2` 的 `sTypoAscender/Descender` 一直写在 panose 区域,真正的排印窗口是 0/0; - `usWinAscent/Descent`(**无符号**)也写错了位置,读出来是 0 / 65506; - `fsSelection` 原本声明 BOLD,`usWeightClass` 是 500——一个不粗的字体谎称自己粗, 引擎就会合成加粗,而合成出来的图标是糊的。改为 `REGULAR | USE_TYPO_METRICS`。 `head` 的每个字形包围盒补一格下限:`more` 的三个点是用 0.06 单位描边写的, 四舍五入后两个轴各只剩一个点,包围盒为空——空包围盒是栅格化器有权跳过的图形, 于是「更多」按钮画出来是白的,而 `cmap` 依然映射它的码位,所以读表的断言全都 满意。`settings` 的路径解析出了一组跑到网格外的坐标(y 到 24.8、x 到 0.8), 重画成真正的齿轮;`chevron-right` 与 `chevron-left` 共用同一个框却差一个单位。 三、图标按钮有两条构建路径 `manager-more` 是手搭的 `<button class="icon-button">`,而 `.icon-button` 的 对齐靠的是「里面有 `.icon`」这个约定——手搭的那条路径绕过了它。现在一律走 `IconButton`,`label` 必填、字形是名字而不是任意子节点;图标加文字的新组合收进 `IconTextButton`,因为那正是最容易歪、且每次都歪得一样的组合。 验证:新增 10 条断言,覆盖字体度量、字形包围盒与 `.icon` 规则本身;把改动逐条 还原后它们会红(已验证)。真浏览器验收:37 个字形逐个测量,墨迹中心与盒子中心 最大偏差 0.88px(改前是 9px+);书架与阅读器两屏在真实产物上截图核对。客户端 `typecheck` / 317 个测试 / `build`、`check-shell.sh`、`verify-web-assets.sh` 全过。 产物 259.68KB → 259.68KB,字体 16.34KB,CSS 24.54KB。 Ref: #34
15 KiB
界面规范
这份文档规定界面长什么样、为什么。它约束的是客户端的外观层,不涉及排版
(书的排版见 architecture.md §8 —— 那里的原则相反:对书不动手)。
之所以需要一份成文规范:这个客户端的界面层不是一个人一次写完的,而是被反复 改出来的。每一次改动单独看都合理,合起来就成了「这个控件 0.35rem、那个 0.6rem, 这个图标是 Unicode 字符、那个是 emoji」。规范的作用是让「再改一处」这件事有一个 可以对照的基准。
1. 图标
1.1 用字体,不用内联 SVG
图标以字体形式提供:web/src/styles/reader-icons.ttf,37 个字形,
16KB,随 bundle 一起走。
选择字体的理由不是「省事」,而是一致性可以被强制:
| 字体 | 内联 SVG | |
|---|---|---|
| 颜色 | 继承 color |
每个组件各自写 fill/stroke |
| 尺寸 | 继承 font-size,跟着布局缩放 |
每个组件各自写 width/height |
| 线宽 | 一个(生成时定死) | 每个字形都可能不同 |
| 新增图标 | 加字符 | 加组件 |
第二列里的每一项都是「单个字形可以漂移」的位置。内联 SVG 集合最常见的失败 就是这个:三十个组件里总有几个的线宽或视觉重量跟其余不一样,而每个单独看都 说得通。字体把字形变成字符——它没有自己的几何,也就没有可以漂移的地方。
这条约束还有一层:一份产物两个宿主(H5 与 Android WebView)。字体是 bundle 里的
一个相对路径文件,file:// 下能加载;组件化的 SVG 集合则意味着每个宿主都要
正确解析组件,而这一步在 WebView 里是额外的风险面。
1.2 图标集是生成的
web/tools/icons/ 下的脚本把描边路径编译成字体:
paths.mjs 37 条路径,都画在 24 单位网格上,线宽 3
↓ (这是唯一由人编辑的文件)
path.mjs 路径 → 折线
stroke.mjs 折线 → 圆头矩形(每条线段一个)
union.mjs 精确布尔并(Clipper)
build.mjs → src/styles/reader-icons.ttf + src/ui/icon-names.ts
重新生成:npm run icons。
为什么是「描边 + 布尔并」而不是直接写轮廓:字形要的是填充区域,而图标是 线。两条相接的线段如果只把各自的轮廓放进去,重叠处会有一条发丝缝——在 16px 下那条缝就是整个图标。所以必须做并集,而且必须是精确的并集。
这一步踩过的坑值得记下来,因为它决定了实现方式:先用「栅格化 + 边界追踪」
做过一版,失败了。原因是所有笔画的宽度都只有一个栅格单元,而逐单元的边界
规则无法表达「边界在一格宽的图形上左右交替」——结果是一个圆被追踪成 58 段
碎片。精确布尔没有这种情形。union.mjs 的注释里写了这段。
1.3 垂直度量:为什么图标能对齐
图标「显示正常但对不齐」曾经是个查不出来的问题,因为坏的地方在字体的
垂直度量里,而当时所有测试都只读 cmap 和字形名。记下来,因为只要再动
一次生成器就会重新踩到。
约定(生成器与样式表之间的契约):
- 网格中点(12)就是
y = 0,即 em 的中心线;绘制代码用(GRID / 2 - y)。 hhea与OS/2的上升部/下降部各为半个 em(512 / -512),也就是 对称。对称不是审美:UI 里每个容器都在「居中一个盒子」,只有度量对称时 「居中盒子」和「居中字形」才是同一件事。度量不对称时,两者相差正好是那个 不对称量——这就是当初每一行图标歪的幅度都不一样的原因。- 每个字形的墨迹必须落在
±0.5em内。生成器会检查,越界直接报错。 OS/2的 Windows 度量是无符号的,且必须包住所有字形;fsSelection打开USE_TYPO_METRICS(否则引擎会去用 Windows 那一对)。
当初错在哪: 表格声明上升部 960、下降部 -64(基线靠近 em 底部),而字形是
围绕 em 中心画的。于是每个字形的墨迹都画在它所在盒子上边界之上约 0.75em,
而所有布局居中的都是盒子——menu、close、folder 实测偏移一致是 0.496em。
样式表那一半:
.icon {
line-height: 1; /* 用字体自己的行盒,而不是自建一个方块 */
display: inline-block;
width: 1em; /* 唯一需要显式给的东西:可预测的前进宽度 */
text-align: center;
vertical-align: middle; /* 非 flex/grid 的行(正文里的行内图标) */
}
line-height: 0 + height: 1em 是错的:那把盒子锚在基线上、向下一整个
em,而墨迹画在基线周围,于是墨迹贴在盒子上沿、盒子下方全空。居中盒子的容器
把盒子居中了,字形却差了半个 em。transform 也不行——它在绘制期移动字形,让
图标占的位置和看起来占的位置不再一致。
代价:web/test/icons.test.ts 会读字体的 hhea / OS/2 / head 与每个字形的
包围盒来断言上面这些,也会读 reader.css 里的 .icon 规则。两边都是之前没被
任何断言覆盖的地方。
1.4 字符编码
- 码位从
U+E900起,连续分配,位于私有使用区。 - 顺序与
paths.mjs中定义的顺序一致。 src/ui/icon-names.ts由生成器写出,不要手改:码位与字体cmap不一致 时渲染成豆腐块,而类型系统查不出这件事。web/test/icons.test.ts直接解析字体的cmap来核对这张表,并断言字体 不映射私有使用区之外的任何码位(否则普通文字在应用了这个字族的元素里 会变成图标)。
1.5 无障碍
Icon 组件的 label 决定语义:
label |
行为 |
|---|---|
| 字符串 | role="img" + aria-label,可访问名就是这个字符串 |
省略 / null |
aria-hidden="true",纯装饰 |
IconButton 的 label 是必填的,图标本身始终 aria-hidden。
一个没有文字的图标按钮,如果没有名字,屏幕阅读器就只能读出「按钮」——
而它旁边可能有五个同样的按钮。
这是本次改动之前真实存在的问题:原来的 IconButton 接收任意 children,
调用方传的是 Unicode 字符(☰ ⚙ ✕ ⟨ ⟩ + 🗂 📁)。这带来三个后果:
- 八个字形来自八个不同的字族,光学重量各不相同,工具栏看起来是拼的;
🗂📁📄⏮这些是 emoji,在部分平台被渲染成彩色的——一个彩色 emoji 出现在单色工具栏里,是「这个应用没有设计过」最直接的信号;- 不同平台的字体回退不同,同一个界面在 Android 和桌面浏览器上长得不一样。
现在 IconButton 的 icon 是 IconName,即字形的名字,
传一个不存在的名字是编译错误。
1.6 现在有哪些图标
见 web/tools/icons/paths.mjs,或 npm run icons:sheet 生成的预览图。
命名按用途而非形状:sliders(阅读设置)而不是 knobs;
folder-open(书库管理)而不是 folder2。这样同一个语义在改动时不会被
换成另一个形状。
2. 设计令牌
全部定义在 src/styles/reader.css 的 :root 里。界面层不应出现字面量。
2.1 间距:四档
--space-1 0.35rem 控件内部的缝隙
--space-2 0.6rem 控件内部的内边距
--space-3 0.9rem 区块之间的间距
--space-4 1.5rem 页面级区块的内边距
只有四档,这是重点。八档的尺度读起来像精确,用起来像建议:一旦
1.75rem 和 2rem 同时存在,一半样式表用一个、一半用另一个,而任何单条规则里
都看不出矛盾。四档强制回答「这是哪一档」——这个问题有答案,而「再大一点点」
没有。
2.2 字号:按角色
--text-title 1.05rem 屏幕标题
--text-body 0.95rem 正文与按钮
--text-label 0.82rem 字段标签、区块标题
--text-meta 0.72rem 标题下方的元信息
按角色命名而非按尺寸,因为尺寸会变、角色不会:--text-meta 永远是「标题下面
那行」,无论今天是 0.72rem 还是明天 0.75rem。
2.3 动效:一个时长、一条曲线
--motion-duration 0.18s
--motion-ease cubic-bezier(0.32, 0.72, 0, 1)
一个时长,一条曲线,所有过渡都用它们。两个时长和一个时长的问题是同一个: 各自单独看都合理,成对出现就是错的。
prefers-reduced-motion 通过把 --motion-duration 折叠成 0.001s 来满足,
而不是给每条动画各加一个媒体查询。后者的失败是可预期的:下一个新增的动画就是
唯一没加的那条。折叠令牌之后,所有使用令牌的规则(现在是全部)自动被覆盖,
而硬编码自己时长的规则成为显式的例外而不是隐式的漏洞。
2.4 焦点环
--focus-ring 2px solid color-mix(in srgb, var(--reader-accent) 70%, transparent)
--focus-offset 2px
这是本次改动前完全缺失的一层。 样式表里搜索框有 :focus-visible 规则,
而所有按钮都没有——书架上一整屏控件,用键盘 Tab 走一遍看不到焦点在哪。
现在是一条规则覆盖一组选择器(.button、.icon-button、.chip……),
而不是每个组件各写一份。新增控件把它加进列表即可,这就是全部工作。
用 :focus-visible 而非 :focus:鼠标点击按钮不应留下焦点环,键盘 Tab 必须
始终显示。
2.5 悬停
统一包在 @media (hover: hover) and (pointer: fine) 里。不加这层的话,触屏上
每次点击都会让最后点的控件停在「悬停」外观上——手指抬起就进入 hover,看起来
像坏了,而不是像有反馈。
3. 布局
3.1 安全区
四个 --safe-* 令牌在 :root 定义一次,各处只引用不直接读 env()。
两个理由,第二个才是关键:一个边一个名字,意味着手机、浏览器和 Android 壳不会
各自对「屏幕从哪开始」有略微不同的理解;而且未定义的自定义属性会让整个
calc() 失效并静默丢弃声明——padding: calc(0.9rem + var(--safe-left))
在变量未设置时不是「0.9rem 的内边距」,而是完全没有内边距。
3.2 一列,封顶
内容列封顶 68rem(--shelf-measure),宽屏加的是留白而不是更多列。
1600px 窗口排十一个封面、每个书名十二个字,比手机上的三列更难扫——
这与「多出来的宽度是为了好读」正好相反。
3.3 触控目标
图标按钮 2.5rem(40px),页脚里 2.1rem(34px)。页脚那两个翻页按钮是唯一 小于 40px 的交互目标,理由是它们最常用、且紧邻一个同样大的兄弟控件;四个 2.5rem 的控件加一个章节标签会超出 360px 手机的宽度。
SwitchRow 用按钮而不是复选框:整行是 44px 的命中区,复选框会把命中区缩到
16px 的方块上,拇指按不中。
4. 文案
- 中文,句末不加句号(
我的书架、正在刷新)。界面文本不是段落。 - 说清楚发生了什么,而不是说「出错了」。
已更新 2 本、这一章暂时无法跳转、连不上这个地址。确认服务端已启动,且地址包含端口。 - 不承诺不能兑现的事。 只读挂载上不显示上传按钮(用
hidden而非不渲染, 见 §5),而不是显示一个按了会 403 的按钮。 - 空状态给一条出路。 空书库不是「这里什么都没有」,而是「打开书库管理 / 刷新」两个按钮;搜不到结果是「清除搜索」。
5. 状态的表达
| 状态 | 用法 | 反例 |
|---|---|---|
| 禁用 | disabled + opacity: 0.55 |
隐藏按钮,让读者以为功能不存在 |
| 不可用(权限) | hidden |
隐藏整个区块,让读者不知道这里能管理书库 |
| 忙碌 | 按钮文案变「正在切换…」,控件 disabled |
只留一个转圈,读者不知道在等什么 |
| 无能力 | 不渲染该行 | 显示一个按了没反应的滑块 |
最后一行值得展开:朗读设置面板在 HTTP 引擎下不显示音调那一行,因为
<audio> 根本没有音调。而不是显示一个拖动无效的滑块。
hidden 而非不渲染的那一条同样是刻意的:能做的和不能做的区别本身就是
信息。全部藏起来,读者学到的是「这个应用的按钮时有时无」。
6. 暗色与主题
三套主题:light / sepia / dark,通过 :root 上的
data-theme 切换调色板变量。
- 主题只改界面的颜色。书的颜色由书自己决定——一个硬编码
color: #333的段落,在暗色主题下保持它自己的颜色。这是正确的取舍: 暗色主题悄悄破坏一本印刷取向的书的对比度是缺陷,而一本书在「暗色」背景上 显示深色文字,是读者能看见并自行判断的。 - 亮度是
mix-blend-mode: multiply的覆盖层,不是调色板。换主题应该保留 纸与字的对比关系;直接改颜色会把文字一起压暗——那正是「亮度」滑杆最常见的 失败方式。 - 新增颜色一律走
:root变量。样式表里现存三个状态色 (#4a8f5f/#c08a2e/#b6453a)和一处危险色(#c0392b)是已知例外, 它们是语义色不是主题色,但应当在下次改动时提升为令牌。
7. 改动这份界面时的检查单
- 新颜色 / 新间距 / 新时长 → 先看
:root里有没有,没有就加令牌, 不要在规则里写字面量。 - 新图标 → 加一条路径到
tools/icons/paths.mjs,跑npm run icons, 用IconButton引用名字。 - 新控件 → 确认它出现在 §2.4 的焦点选择器列表里。
- 新动画 → 用
--motion-duration和--motion-ease,不要自己写时长。 - 有状态变化的控件 → 对照 §5 那张表,确认用的是对的那一种。
- 图标按钮 →
label必填,且这条 label 是这个按钮自己的作用, 不是它所在的区块的名字。 - 图标 + 文字并排 → 用
IconTextButton,不要自己拼一个<button>加一个<span class="icon">;图标加文字正是最容易歪的组合,而它每次歪的方式都 一样(字形落到文字基线上)。 - 动图标相关的东西 → 先读 §1.3。图标对齐是字体度量和 CSS 两半一起决定的, 只改一半会得到一个更小的偏移,而不是没有偏移。
8. 相关文件
| 路径 | 内容 |
|---|---|
web/src/ui/icon.tsx |
Icon 组件 |
web/src/ui/icon-names.ts |
码位表(生成) |
web/src/styles/reader-icons.ttf |
字体(生成) |
web/tools/icons/ |
生成器 |
web/test/icons.test.ts |
字体度量、字形包围盒、码位表与 .icon 规则的契约 |
web/src/ui/toolkit.tsx |
IconButton / Button / Segmented / SwitchRow / Scrim |
web/src/styles/reader.css |
令牌、组件样式、交互状态、prefers-reduced-motion |