Files
reader/docs/ui.md
T
hectorqin 3c3e461a49 feat(web): 界面图标换成自带字体,补 UI/UX 规范并统一设计令牌
按钮里放的是 Unicode 字符——`☰` `⚙` `✕` `⟨` `⟩` `+` `🗂` `📁` `⮝`——
和 emoji。八个字形来自八个字族,光学重量各不相同;`🗂` `📁` `⏮` 在部分平台
被渲染成彩色的,而一个彩色 emoji 出现在单色工具栏里,是「没有设计过」最直接的
信号。不同平台的字体回退还不同,同一个界面在 Android 和桌面浏览器上长得不一样。
此外样式表里所有按钮都没有 `:focus-visible` 规则,键盘走一遍看不到焦点在哪。

所以这次做三件事:图标改成自带字体、把缺失的交互状态补齐、把散落的字面量收成
设计令牌,并把规范写成文档。

图标用字体而不是内联 SVG,理由是**一致性可以被强制**:字体把字形变成字符,它
继承 `color` 和 `font-size`,没有自己的几何,因此没有可以漂移的地方;而三十个
内联 SVG 组件里,颜色、尺寸、线宽每一项都是单个字形可以漂移的位置。字体是
bundle 里的一个 16KB 相对路径文件,`file://` 下能加载,与「一份产物两个宿主」
的既有约束一致。

字形由 `web/tools/icons/` 下的脚本从描边路径编译:路径 → 折线 → 圆头矩形 →
精确布尔并(Clipper)→ TTF。先做过一版「栅格化 + 边界追踪」,失败了:笔画宽度
只有一个栅格单元,而逐单元的边界规则无法表达「边界在一格宽的图形上左右交替」,
结果是一个圆被追踪成 58 段碎片。精确布尔没有这种情形。

放弃的方案:内联 SVG(前面说的漂移问题)、把图标做成 React 组件并逐个手绘
(每个都是独立的几何,光学重量必然不一致)、从 CDN 引用图标字体(本产品必须
在无外网环境下工作)。

界面改动:
- `Icon` 组件 + 37 个字形,码位表由生成器写出,测试直接解析字体 `cmap` 核对
- `IconButton` 的 `icon` 是字形名而非任意 `children`,传错名字是编译错误;
  `label` 必填,字形始终 `aria-hidden`
- 补上全部按钮的 `:focus-visible`(本次之前完全缺失)
- 间距/字号/动效收成令牌;`prefers-reduced-motion` 通过折叠时长令牌实现,
  而不是给每条动画各加一个媒体查询
- 悬停包进 `@media (hover: hover) and (pointer: fine)`,避免触屏上按钮停在悬停态

验证:`web` 336 个测试通过(新增 9 个图标契约测试);`typecheck`、`build`、
`check-shell.sh`、`verify-web-assets.sh` 全过;字体已确认被打进 Android assets。
真浏览器(Chromium + 真实服务端)实测了书架、书库管理、阅读器三个屏幕的浅色与
深色主题,以及键盘 Tab 下的焦点环。产物 259.5KB → 259.5KB(gzip 81.4KB),
新增 16KB 字体、CSS 减到 24.4KB。

Ref: #29
2026-09-18 07:08:02 +08:00

12 KiB
Raw Blame History

界面规范

这份文档规定界面长什么样、为什么。它约束的是客户端的外观层,不涉及排版 (书的排版见 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 字符编码

  • 码位从 U+E900 起,连续分配,位于私有使用区。
  • 顺序与 paths.mjs 中定义的顺序一致。
  • src/ui/icon-names.ts 由生成器写出,不要手改:码位与字体 cmap 不一致 时渲染成豆腐块,而类型系统查不出这件事。
  • web/test/icons.test.ts 直接解析字体的 cmap 来核对这张表,并断言字体 不映射私有使用区之外的任何码位(否则普通文字在应用了这个字族的元素里 会变成图标)。

1.4 无障碍

Icon 组件的 label 决定语义:

label 行为
字符串 role="img" + aria-label,可访问名就是这个字符串
省略 / null aria-hidden="true",纯装饰

IconButton 的 label 是必填的,图标本身始终 aria-hidden。 一个没有文字的图标按钮,如果没有名字,屏幕阅读器就只能读出「按钮」—— 而它旁边可能有五个同样的按钮。

这是本次改动之前真实存在的问题:原来的 IconButton 接收任意 children, 调用方传的是 Unicode 字符(☰ ⚙ ✕ ⟨ ⟩ + 🗂 📁)。这带来三个后果:

  1. 八个字形来自八个不同的字族,光学重量各不相同,工具栏看起来是拼的;
  2. 🗂 📁 📄 ⏮ 这些是 emoji,在部分平台被渲染成彩色的——一个彩色 emoji 出现在单色工具栏里,是「这个应用没有设计过」最直接的信号;
  3. 不同平台的字体回退不同,同一个界面在 Android 和桌面浏览器上长得不一样。

现在 IconButton 的 icon 是 IconName,即字形的名字, 传一个不存在的名字是编译错误。

1.5 现在有哪些图标

见 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. 改动这份界面时的检查单

  1. 新颜色 / 新间距 / 新时长 → 先看 :root 里有没有,没有就加令牌, 不要在规则里写字面量。
  2. 新图标 → 加一条路径到 tools/icons/paths.mjs,跑 npm run icons, 用 IconButton 引用名字。
  3. 新控件 → 确认它出现在 §2.4 的焦点选择器列表里。
  4. 新动画 → 用 --motion-duration 和 --motion-ease,不要自己写时长。
  5. 有状态变化的控件 → 对照 §5 那张表,确认用的是对的那一种。
  6. 图标按钮 → label 必填,且这条 label 是这个按钮自己的作用, 不是它所在的区块的名字。

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 字体与码位表的一致性契约
web/src/ui/toolkit.tsx IconButton / Button / Segmented / SwitchRow / Scrim
web/src/styles/reader.css 令牌、组件样式、交互状态、prefers-reduced-motion