Files
reader/web/tools/icons/stroke.mjs
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

73 lines
2.7 KiB
JavaScript

/**
* Stroke expansion.
*
* The set is authored as stroked polylines, and a font renders filled outlines. The
* bridge is to give each segment its own closed ring — a rectangle with a
* semicircular cap at each end — and let a boolean union merge the rings.
*
* Rounding both ends the same way is what makes a polyline read as one continuous
* stroke: square ends leave the two halves of an arc visibly separate, and mitred
* joins put a spike on the outside of every bend at small sizes.
*
* The winding is the part that is easy to get subtly wrong. The ring has to travel
* *out along one side of the segment and back along the other*, turning through the
* head and the tail; if both caps sweep the same way the ring self-intersects and the
* union returns two half-shapes instead of one bar.
*/
/** Cap sampling: a semicircle in 8 chords is well under one font unit of error. */
const CAP_STEPS = 8;
/**
* One segment's outline as a closed ring, or `null` for a zero-length segment.
*
* Coordinates are in the same units as the input; the caller scales.
*/
export function segmentPolygon(x1, y1, x2, y2, width) {
const dx = x2 - x1;
const dy = y2 - y1;
const length = Math.hypot(dx, dy);
if (length === 0) return null;
const radius = width / 2;
const direction = Math.atan2(dy, dx);
const ring = [];
/*
* Start cap: sweep from the side at `direction + 90°` round through the tail
* (`direction + 180°`) to the side at `direction + 270°`. In a y-down coordinate
* system that is half a turn, and it ends where the second cap will begin.
*/
for (let i = 0; i <= CAP_STEPS; i++) {
const t = direction + Math.PI / 2 + (Math.PI * i) / CAP_STEPS;
ring.push([x1 + Math.cos(t) * radius, y1 + Math.sin(t) * radius]);
}
/*
* End cap: the other half turn, starting at `direction - 90°` so it continues from
* the first cap's last point and closes back onto its first.
*/
for (let i = 0; i <= CAP_STEPS; i++) {
const t = direction - Math.PI / 2 + (Math.PI * i) / CAP_STEPS;
ring.push([x2 + Math.cos(t) * radius, y2 + Math.sin(t) * radius]);
}
return close(ring);
}
/**
* Removes consecutive duplicates and the explicit closing point.
*
* Clipper treats a repeated first/last vertex as a degenerate edge, and degenerate
* edges are reported as "unable to complete output ring".
*/
function close(ring) {
const out = [];
for (const point of ring) {
const previous = out[out.length - 1];
if (previous && Math.abs(previous[0] - point[0]) < 1e-9 && Math.abs(previous[1] - point[1]) < 1e-9) {
continue;
}
out.push([+point[0].toFixed(4), +point[1].toFixed(4)]);
}
return out.length >= 3 ? out : null;
}