From 7f74baab685bedc5546426fb99d4a95f4eeda7f3 Mon Sep 17 00:00:00 2001
From: hectorqin <8m0v8c12sZDrY2infddElF+hectorqin@noreply.cnb.cool>
Date: Sat, 19 Sep 2026 14:46:15 +0800
Subject: [PATCH] =?UTF-8?q?fix(web):=20=E6=8F=92=E5=9B=BE=E6=94=B6?=
=?UTF-8?q?=E8=BF=9B=E7=89=88=E5=BF=83=EF=BC=8C=E5=BF=AB=E6=8D=B7=E5=88=97?=
=?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=B9=B6=E5=8F=AF=E6=BB=9A=E5=8A=A8=EF=BC=8C?=
=?UTF-8?q?=E7=82=B9=E5=87=BB=E5=8C=BA=E6=8C=89=E5=B8=83=E5=B1=80=E5=87=A0?=
=?UTF-8?q?=E4=BD=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/ui-review/README.md | 17 +++-
docs/ui-review/report.json | 50 +++++++++-
docs/ui.md | 57 ++++++++++-
web/src/styles/reader.css | 44 +++++++++
web/src/ui/gestures.ts | 42 ++++++++-
web/src/ui/reader-chrome.tsx | 104 +++++++++++++++++---
web/src/ui/shadow.ts | 134 +++++++++++++++++++++++++-
web/test/gestures.test.ts | 28 ++++++
web/test/shadow.test.ts | 32 +++++++
web/tools/ui-review/run.mjs | 167 ++++++++++++++++++++++++++++++++-
web/tools/ui-review/server.mjs | 34 ++++++-
11 files changed, 674 insertions(+), 35 deletions(-)
diff --git a/docs/ui-review/README.md b/docs/ui-review/README.md
index be1eec5..57a0c6a 100644
--- a/docs/ui-review/README.md
+++ b/docs/ui-review/README.md
@@ -18,13 +18,18 @@
| `11-library.png` | 书库 | 面包屑、行、「书籍」徽章、不在书架的书带「不在书架」标记,以及书架/书库之间的切换 |
| `12-library-paged.png` | 书库 · 第二页 | 同一个分页器组件;当前页与首尾页码 |
| `13-reader-paged-scrub.png` | 阅读页 · 滑杆拖到本章第 3 页 | 分栏模式下拖动底部滑杆:读数与画面都停在本章内,不换章 |
-| `14-reader-epub-image.png` | EPUB · 插图 | 章节文档里的图片真的画出来了,而不是浏览器的破图占位符 |
+| `14-reader-epub-image.png` | EPUB · 插图 | 章节文档里的图片真的画出来了,而不是浏览器的破图占位符;比页面宽的那张也在栏内 |
## 自动测量
| 检查 | 结果 | 说明 |
| --- | --- | --- |
-| 展开工具栏: 快捷按钮与两条栏一起出现 | ✅ | visible=true 按钮 4 个 pointer-events=auto |
+| 展开工具栏: 快捷按钮与两条栏一起出现 | ✅ | visible=true 按钮 7 个 pointer-events=auto |
+| 快捷列: 整组控件都在页内 | ✅ | 列 y=186..534 / 屏高 844 |
+| 快捷列: 装不下的控件有滚动条,说明还有更多 | ✅ | 溢出 否 overflow-y=true 滚动条 0px |
+| 快捷列: 一列里含整组快捷操作,而不是只露出前几个 | ✅ | 按钮 7 个:目录 / 日间 / 字号 / 行距 / 页边距 / 听书 / 阅读设置 |
+| 快捷列: 听书在列内 | ✅ | 列内控件:目录 / 日间 / 字号 / 行距 / 页边距 / 听书 / 阅读设置 |
+| 快捷列(420px 短屏): 列在页内,超出的控件能滚到 | ✅ | 列底 322 / 屏高 420,滚动条 0px,滚到底后末个控件可见=true |
| 收起工具栏: 顶栏与底栏一起走 | ✅ | state=hidden 顶栏 hidden / 底栏 hidden |
| 收起工具栏: 两条栏都不可见,也不可点 | ✅ | topbar=none footer=none |
| 收起工具栏: 正文一行的位置不变(工具栏是浮层) | ✅ | 正文首行 y: 显示时 78 / 收起后 78 |
@@ -51,13 +56,17 @@
| 底栏: 拖动滑杆切到对应页 | ✅ | 拖到 4 后读数="第 4/4 页" |
| 翻页模式: 滑杆拖到第 3 页,仍在本章 | ✅ | min=1 max=4 value=3 拖前="第 4/4 页" 拖后="第 3/4 页" |
| 状态提示不推动正文 | ✅ | 正文顶部 y: 54.390625 / 54.390625 / 54.390625 |
-| 触控目标不小于 28px | ✅ | 9 个控件全部合格 |
-| 每个控件都有可读的名字 | ✅ | 9 个控件全部有名字 |
+| 触控目标不小于 28px | ✅ | 12 个控件全部合格 |
+| 每个控件都有可读的名字 | ✅ | 12 个控件全部有名字 |
| 没有横向溢出 | ✅ | document 溢出 0px |
| 翻页不再每次都发两次 sync | ✅ | 8 次翻页 + 6 秒静默:1 次 POST / 1 次 GET(共 2 次 sync 请求) |
| EPUB: 章节里的图片真的画出来了 | ✅ | naturalWidth=240 显示 240x160 src="/api/v1/books/review-illustrated/assets?__reader-book-resource__=1&ref=OEBPS%2FI" |
| EPUB: 插图下面的正文也在 | ✅ | 正文开头="台版 转自 天使动漫
+
插图下面还有正文,这样图片没显示出来的时候,空出来的位置也是看得见的。" |
+| EPUB: 比页面宽的插图被收进栏内 | ✅ | 出版方写的是 1400px,画出来 342px / 栏宽 342px(布局 342x39,dpr=2) |
+| EPUB: 收窄插图没有改变它的比例 | ✅ | 原图 8.750 / 画出来 8.752 |
+| EPUB: 宽插图没有让页面横向溢出 | ✅ | document 溢出 0px / 阅读面溢出 0px |
| 搜索框: 放大镜与输入框中线对齐 | ✅ | 放大镜中线 97.7 / 输入框中线 98.1(偏差 -0.4px,字形 16px,框高 44.203125px) |
全部通过。
diff --git a/docs/ui-review/report.json b/docs/ui-review/report.json
index 5eb7dff..b534e58 100644
--- a/docs/ui-review/report.json
+++ b/docs/ui-review/report.json
@@ -72,14 +72,39 @@
{
"name": "14-reader-epub-image",
"label": "EPUB · 插图",
- "what": "章节文档里的图片真的画出来了,而不是浏览器的破图占位符"
+ "what": "章节文档里的图片真的画出来了,而不是浏览器的破图占位符;比页面宽的那张也在栏内"
}
],
"results": [
{
"name": "展开工具栏: 快捷按钮与两条栏一起出现",
"ok": true,
- "detail": "visible=true 按钮 4 个 pointer-events=auto"
+ "detail": "visible=true 按钮 7 个 pointer-events=auto"
+ },
+ {
+ "name": "快捷列: 整组控件都在页内",
+ "ok": true,
+ "detail": "列 y=186..534 / 屏高 844"
+ },
+ {
+ "name": "快捷列: 装不下的控件有滚动条,说明还有更多",
+ "ok": true,
+ "detail": "溢出 否 overflow-y=true 滚动条 0px"
+ },
+ {
+ "name": "快捷列: 一列里含整组快捷操作,而不是只露出前几个",
+ "ok": true,
+ "detail": "按钮 7 个:目录 / 日间 / 字号 / 行距 / 页边距 / 听书 / 阅读设置"
+ },
+ {
+ "name": "快捷列: 听书在列内",
+ "ok": true,
+ "detail": "列内控件:目录 / 日间 / 字号 / 行距 / 页边距 / 听书 / 阅读设置"
+ },
+ {
+ "name": "快捷列(420px 短屏): 列在页内,超出的控件能滚到",
+ "ok": true,
+ "detail": "列底 322 / 屏高 420,滚动条 0px,滚到底后末个控件可见=true"
},
{
"name": "收起工具栏: 顶栏与底栏一起走",
@@ -214,12 +239,12 @@
{
"name": "触控目标不小于 28px",
"ok": true,
- "detail": "9 个控件全部合格"
+ "detail": "12 个控件全部合格"
},
{
"name": "每个控件都有可读的名字",
"ok": true,
- "detail": "9 个控件全部有名字"
+ "detail": "12 个控件全部有名字"
},
{
"name": "没有横向溢出",
@@ -239,7 +264,22 @@
{
"name": "EPUB: 插图下面的正文也在",
"ok": true,
- "detail": "正文开头=\"台版 转自 天使动漫\n插图下面还有正文,这样图片没显示出来的时候,空出来的位置也是看得见的。\""
+ "detail": "正文开头=\"台版 转自 天使动漫\n\n插图下面还有正文,这样图片没显示出来的时候,空出来的位置也是看得见的。\""
+ },
+ {
+ "name": "EPUB: 比页面宽的插图被收进栏内",
+ "ok": true,
+ "detail": "出版方写的是 1400px,画出来 342px / 栏宽 342px(布局 342x39,dpr=2)"
+ },
+ {
+ "name": "EPUB: 收窄插图没有改变它的比例",
+ "ok": true,
+ "detail": "原图 8.750 / 画出来 8.752"
+ },
+ {
+ "name": "EPUB: 宽插图没有让页面横向溢出",
+ "ok": true,
+ "detail": "document 溢出 0px / 阅读面溢出 0px"
},
{
"name": "搜索框: 放大镜与输入框中线对齐",
diff --git a/docs/ui.md b/docs/ui.md
index 1ea12ed..657074a 100644
--- a/docs/ui.md
+++ b/docs/ui.md
@@ -410,6 +410,13 @@ URL 再回落到 `showPage(page)`。这一趟往返就是页能活过 Back、For
「可滚动的祖先拿走手势」这条规则必须排除它——否则它吃掉每一个手势,每一次
点击都归属某个滚动容器,翻页层级正确但永远到不了。这一条是 2026-09 那轮
「点击页面没反应」的真正原因。
+- **三段点击区是*布局*几何,不是视觉几何**。`getBoundingClientRect()` 和
+ `event.clientX` 都在**视觉**视口坐标里,两者相减得到的是视觉距离;而点击区是
+ 版心宽度的三等分,版心宽度是**布局**像素。页面被捏合放大(或任何祖先变换)时
+ 两者不相等,误差可以超过判定阈值的一半——读者点中间三分之一想收起工具栏,
+ 结果翻了页。所以宽度取 `offsetWidth`,偏移按两个盒子的比例 `rect.width /
+ offsetWidth` 换算回布局空间;无变换、无缩放时这个比例正好是 1,那一行是恒等
+ 变换而不是一次单位转换。
---
@@ -460,6 +467,30 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
---
+### 3.9.1 章节内容的最小约束(图片与非 HTML 正文)
+
+读者报的是「图片缺少样式,移动端显示溢出了」——一张 1400px 宽的插图在一栏
+342px 的版心里画出来 1400px,两头都出了屏幕。它属于这一类:**没有任何断言覆盖
+过它**,因为 UI 评审的插图 fixture 只有 240px 宽,塞得进版心,于是评审一边说
+「插图能画出来」一边让真实的缺陷完全复现。
+
+所以有一条独立于书的样式表(`web/src/ui/shadow.ts` 的
+`CHAPTER_CONTENT_STYLESHEET`),它只声明**作者自己也会声明**的那些属性,并且
+位置在**书自己的样式表之前**——同优先级下后写的赢,所以作者写
+`img { width: 60% }` 仍然有效。三条规则,各有各的理由:
+
+- **图片收进版心**:`max-inline-size`/`max-width` 取 `min(100%, …)`,
+ `height: auto`。只夹宽度会让图**压扁**,这是修第一个数字时最容易引入的缺陷,
+ 而它在截图里看不出来(对角线的角度变了但你没得比);`auto` 才保住原比例。
+ 另外给 `max-block-size`,否则一张竖图会把图注挤到下一页,两者一起变得不可读。
+- **居中显示**:`display: block` + `margin-inline: auto`。行内图片坐在文字基线上,
+ 底下会多出降部那一截行距;`block` 也是「比版心窄的图居中」这条能成立的前提。
+- **没有尺寸的图片留出比例**:`aspect-ratio` 让章节目录在图片到达之前就有正确的
+ 高度,否则读者正在读的那一段会被一张还没加载完的图往下顶,位置就丢了。
+
+§3.9 的 TXT 排版走的是另一条路(`.txt-body`,只挂在纯文本上);这一节管的是
+**所有格式**的章节内容,包括 EPUB 里作者自己写的标记。
+
### 3.10 沉浸式阅读
**顶栏和底栏是一个状态,不是一个状态乘以二。** 读者要收起导航就是要看到正文,
@@ -485,11 +516,22 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
中标题还在,动画走完它就既看不见也点不到了。少写这一行,读者能点到一个看不见的
返回按钮。
- **右侧的快捷按钮列和两条栏一起出现、一起收起**(`.reader-rail`),由同一个
- `data-chrome` 属性开关,和顶栏底栏共用一套动效。读者最常用的四个动作(目录 /
- 主题 / 字号 / 设置)一步可达,而且按钮住在正文的版心旁边(宽屏时用
- `--reader-measure` 算出的天头地脚里),不压字。它**不属于沉浸态**:那四个动作
- 和顶栏是同一批目的地,属于「读者要控件」这个状态;沉浸态多出一列浮在正文右
- 边缘的按钮,就不是读者要的「只剩正文」。
+ `data-chrome` 属性开关,和顶栏底栏共用一套动效。按钮住在正文的版心旁边
+ (宽屏时用 `--reader-measure` 算出的天头地脚里),不压字。它**不属于沉浸态**:
+ 这些动作和顶栏是同一批目的地,属于「读者要控件」这个状态;沉浸态多出一列浮在
+ 正文右边缘的按钮,就不是读者要的「只剩正文」。
+- **快捷列装的是整组调整,而不是其中的几个**:目录 / 主题 / 字号 / 行距 / 页边距 /
+ 听书 / 设置。每个按钮是该项设置的**一档**,值和设置面板里的行取自**同一张
+ 阶梯**(`LINE_HEIGHT_LADDER`、`PAGE_MARGIN_*`),所以两处不会各自漂移出一个
+ 对方到不了的值。
+ 这条是读者报的「竖排工具栏显示为 主题切换、听书」逼出来的:当时这一列
+ **没有高度上限也不会滚**,屏幕一矮就直接画出屏幕外——读者数得到几个就以为这一列
+ 只有几个,于是报告了他看得见的那两个。所以有三条实现约束:
+ `max-height` 由两条栏的高度算出来(和正文让开的是同两个数),装不下时
+ `overflow-y: auto`,并且**滚动条是画出来的**(3px 细线而不是
+ `scrollbar-width: none`)——一个只能靠滚动找到的控件必须看起来能滚,否则
+ 对读者就等于不存在。`overscroll-behavior: contain` 也在,免得滑到头那一下把
+ 手势交给下面的正文。
- **收起时正文一角仍留两个读数**(`.reading-indicator`):当前章节名在左上、
页码与总进度在左下。这是「沉浸式」与「应用停止绘制」的区别——读者收起工具栏
就是要看正文,但**仍然要知道自己在哪一页**,让他为此再点一下正是收起状态要
@@ -601,6 +643,11 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
且只留两个读数、面板不遮住整页且留出 ≥120px 正文、面板有遮罩和把手、滑杆量
的是章节内页数并且拖到第 N 页就停在第 N 页、状态提示不推动正文、每个控件
≥28px 且有名字、没有横向溢出、翻页不再成对发 sync。
+ 还有三条是这一轮的读者报告补进来的,每一条都先是**截图**再是断言:
+ **出版方写了 1400px 的插图**收进栏内且**比例不变**(只夹宽度会把它压扁,
+ 所以比例必须单独量);**快捷列**在 390×844 和 390×420 两种屏高下都在页内,
+ 装不下的控件滚得到、滚动条画了出来(`scrollbarWidth > 0`,而不是读回
+ `overflow-y`);**快捷列**里确实有「听书」。
它跑在**真实的生产产物**上:`npm run build` 之后由 `tools/ui-review/server.mjs`
起一个替身 API,再让真实 Chromium 以 390×844、2x 逐屏加载。不是 storybook——
diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css
index a2536a4..a9d35b7 100644
--- a/web/src/styles/reader.css
+++ b/web/src/styles/reader.css
@@ -2382,6 +2382,31 @@ book-content {
* the centre of the screen is where the reading is.
*/
top: calc(22% + var(--safe-top));
+ /*
+ * A ceiling, and it is the whole of why the rail can be trusted to show what it
+ * contains.
+ *
+ * Without one the column was as tall as its buttons and simply ran off the bottom
+ * of a short screen: a phone in landscape, or a phone whose status bar and
+ * browser chrome leave 500px, showed the first four controls and nothing else —
+ * with no scrollbar, no fade and no hint that there was more. That is the
+ * standing report ("竖排工具栏显示为 主题切换、听书") described exactly: a reader who
+ * counts what they can see and concludes that is what the rail does.
+ *
+ * `--reader-chrome-bottom` and `--reader-chrome-top` are the same two numbers the
+ * reading column reserves for the bars, so the rail cannot extend under either of
+ * them however many controls it holds; `max-height` rather than `height` so a rail
+ * with three buttons is three buttons tall and does not float in an empty column.
+ */
+ max-height: calc(100% - 22% - var(--safe-top) - var(--reader-chrome-bottom) - 0.5rem);
+ overflow-y: auto;
+ /*
+ * `overscroll-behavior: contain`, so reaching the end of the rail does not hand
+ * the rest of the gesture to the page underneath it. Without it, a flick that
+ * finds the last button turns into a chapter scroll — the reader's finger was on a
+ * control and the book moved.
+ */
+ overscroll-behavior: contain;
z-index: var(--reader-layer-rail);
display: flex;
flex-direction: column;
@@ -2395,6 +2420,25 @@ book-content {
visibility 0s linear;
}
+/*
+ * The rail's scrollbar is *drawn*, not hidden.
+ *
+ * `scrollbar-width: none` is the usual treatment for a decorative strip, and it is
+ * precisely wrong here: a control that can only be found by scrolling has to look
+ * scrollable, or the reader never learns it is there — which is the bug above. A
+ * hairline on a translucent column is also the honest amount of chrome for it: the
+ * rail floats over the text, and a 12px opaque gutter would be a band of the page
+ * the reader cannot see through for no information.
+ */
+.reader-rail::-webkit-scrollbar {
+ inline-size: 3px;
+}
+
+.reader-rail::-webkit-scrollbar-thumb {
+ background: color-mix(in srgb, var(--reader-ink) 22%, transparent);
+ border-radius: 999px;
+}
+
/*
* Hidden: it slides out to the right and fades, the direction its own entry motion
* came from, so a reader who taps twice sees one movement rather than two.
diff --git a/web/src/ui/gestures.ts b/web/src/ui/gestures.ts
index 0295f2d..be9ba7e 100644
--- a/web/src/ui/gestures.ts
+++ b/web/src/ui/gestures.ts
@@ -226,13 +226,47 @@ export function attachGestures(element: HTMLElement, handlers: GestureHandlers):
if (distance <= TAP_MAX_DISTANCE && duration <= TAP_MAX_DURATION) {
suppressClick = true;
+ // The zone is a *layout* box, so it is read from layout geometry rather than
+ // from client coordinates.
+ //
+ // The two agree only while the page is unscaled. `getBoundingClientRect()`
+ // and `event.clientX` are both in *visual viewport* units, so under a pinch
+ // zoom — or any transformed ancestor — they disagree with up to half the
+ // tolerance above, and a tap lands in the neighbouring zone: the reader taps
+ // the middle third to hide the toolbar and turns a page instead, or taps the
+ // right third to turn a page and gets nothing but a toolbar. `offsetWidth`
+ // and `offsetLeft` are in the element's own untransformed pixel space, which
+ // is the space the three zones are defined in.
+ // Two boxes, and the reason is that they answer two different questions.
+ //
+ // The *content* box (`offsetWidth`) is what the three zones are a division of,
+ // and `offsetWidth` is in the element's own untransformed pixel space — which
+ // is the space the zones are defined in. The *border* box
+ // (`getBoundingClientRect`) is where the element is on screen right now, which
+ // is the space `event.clientX` is in. Dividing one by the other is exact while
+ // the page is unscaled and wrong under a pinch zoom, where half a third of a
+ // 390px screen is several pixels of error and the arithmetic above has already
+ // accepted the tap.
+ //
+ // `offsetWidth` is preferred and falls back to the border box: a host with no
+ // layout (jsdom, an embedded WebView before its first layout) reports zero for
+ // it, and a stage with no measurable width cannot have zones — reporting a tap
+ // in one would be inventing a position.
const rect = element.getBoundingClientRect();
- const width = rect.width || element.clientWidth;
- // A stage with no measurable width cannot have zones; reporting a tap in
- // one would be inventing a position.
+ const layoutWidth = element.offsetWidth;
+ const width = layoutWidth > 0 ? layoutWidth : rect.width || element.clientWidth;
if (width <= 0) return;
+ // Where the tap landed, in the element's own space: on the border box's left
+ // edge, less the border, less anything the element has been scrolled by.
+ //
+ // `rect` is in visual units and so is `clientX`, so the *difference* is a
+ // visual distance; it is divided by the ratio between the two boxes to bring
+ // it back into the space the zones are measured in. Without an ancestor
+ // transform and without a zoom the ratio is exactly 1 and the line is a
+ // no-op — which is what makes this a correction rather than a conversion.
+ const scale = layoutWidth > 0 && rect.width > 0 ? rect.width / layoutWidth : 1;
+ const x = (event.clientX - rect.left - element.clientLeft * scale) / scale + element.scrollLeft;
const third = width / 3;
- const x = event.clientX - rect.left;
if (x < third) handlers.onTapZone('previous');
else if (x > third * 2) handlers.onTapZone('next');
else handlers.onTapZone('toggle-chrome');
diff --git a/web/src/ui/reader-chrome.tsx b/web/src/ui/reader-chrome.tsx
index fb39af5..798d956 100644
--- a/web/src/ui/reader-chrome.tsx
+++ b/web/src/ui/reader-chrome.tsx
@@ -217,17 +217,37 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
destinations the top bar names (目录 / 主题 / 字号 / 设置), so it belongs to
the same "the reader has asked for the controls" state as the bars. Drawn
while the chrome is hidden, it was the one piece of chrome that survived
- immersion — a reader who tapped to hide the navigation still had seven
- controls floating over the right edge of the text, which is not the
- "only the page" they asked for.
- It is still *not* the toolbar's duplicate: the rail holds the four
- controls a reader nudges while reading (contents, theme, size, settings)
- and is a floating column in the page margin rather than a band, so it
- covers no line of text on a wide screen.
+ immersion — a reader who tapped to hide the navigation still had controls
+ floating over the right edge of the text, which is not the "only the
+ page" they asked for.
+ It is still *not* the toolbar's duplicate: the rail holds the controls a
+ reader nudges while reading and is a floating column in the page margin
+ rather than a band, so it covers no line of text on a wide screen.
Drawn unconditionally and collapsed by `data-chrome` in CSS, exactly like
the two bars: `visibility: hidden` at the end of the fade is what takes it
out of reach of both a finger and a screen reader, and one attribute for
- three bands is what makes "the chrome is hidden" a single fact. */}
+ three bands is what makes "the chrome is hidden" a single fact.
+
+ ## What is in it, and why the list is not shorter
+ —
+ The rail used to hold four controls, and the standing report was "竖排
+ 工具栏显示为 主题切换、听书" — which is what a reader sees when the row
+ they are looking at is a *scroll container* that nothing told them was
+ one. Every control past the fourth on a short phone screen was below the
+ fold of a 200px column with no scrollbar, no fade and no hint that it
+ continued; a rail whose contents depend on the height of the phone is a
+ rail whose contents the reader cannot enumerate. Two things follow, and
+ both are stated in CSS rather than left to the layout:
+ - the column scrolls when it must (`.reader-rail`), and its scrollbar is
+ drawn rather than hidden — a control the reader must scroll to reach
+ has to look scrollable, or it is a control that does not exist;
+ - the buttons are one *step* of the settings each, so the rail is the
+ whole set of adjustments the top bar reaches, not a sample of them:
+ 目录 / 主题 / 字号 / 行距 / 页边距 / 听书 / 设置. The read-aloud control
+ was the one a reader could only reach by discovering the settings sheet,
+ and it is the one they reach for *without* leaving the page.
+ Every one of them is a destination the screen can actually reach: a
+ button that does nothing is worse than a missing one. */}
@@ -485,7 +521,7 @@ function SettingsBody({ state, handlers }: { state: ChromeState; handlers: Chrom
/>
({
+ options={LINE_HEIGHT_LADDER.map((value) => ({
value,
label: value === 'inherit' ? '原书' : value,
}))}
@@ -882,9 +918,27 @@ function TopButton({ icon, label, onClick }: { icon: IconName; label: string; on
* circles because a reader aiming at a 2.75rem disc in the margin hits it, while a
* reader aiming at a 2.75rem square in a list of five does not.
*/
-function RailButton({ icon, label, onClick }: { icon: IconName; label: string; onClick(): void }): JSX.Element {
+function RailButton({
+ icon,
+ label,
+ onClick,
+ pressed,
+}: {
+ icon: IconName;
+ label: string;
+ onClick(): void;
+ /** Set for a control whose state the icon already shows, so it reports it too. */
+ pressed?: boolean;
+}): JSX.Element {
return (
-