合并来自 auto/immersive-toolbar-5fbf 的合并请求 #38

feat(web): 顶栏底栏按参照稿重做,沉浸态保留章节与页码

PR-URL: #38
This commit is contained in:
hectorqin authored and cnb committed 2026-09-18 22:49:32 +08:00
commit 7c9aa29faa
12 files changed
+516 -143

No files matched your search

+12 -8
View File
@@ -6,8 +6,8 @@
| 截图 | 场景 | 看什么 |
| --- | --- | --- |
| `01-shelf.png` | 书架 | 第一屏:先看到书,再看到控件 |
| `02-reader.png` | 阅读页 | 顶栏 / 正文 / 底部工具栏 / 状态药丸 |
| `03-reader-no-chrome.png` | 阅读页 · 收起工具栏 | 顶栏与底栏同时收起,正文占满,右侧留出快捷按钮列 |
| `02-reader.png` | 阅读页 | 顶栏(图标+文字)/ 正文 / 底栏滑杆与上一章下一章 / 状态药丸 |
| `03-reader-no-chrome.png` | 阅读页 · 收起工具栏 | 顶栏与底栏同时收起,正文占满,右上留快捷列、左上左下留章节与页码 |
| `04-panel-toc.png` | 目录 · 半屏 | 下半屏,上半屏正文仍可见 |
| `05-panel-settings.png` | 阅读设置 · 半屏 | 一行式行:标签在左、控件在右 |
| `06-reader-paged.png` | 阅读页 · 翻页模式 | 分栏后的一页,页数应与可翻次数一致 |
@@ -23,13 +23,17 @@
| 收起工具栏: 两条栏都不可见,也不可点 | ✅ | topbar=hidden footer=hidden |
| 收起工具栏: 正文占满整屏 | ✅ | 正文 1..844 / 视口 844 |
| 收起工具栏: 右侧仍有快捷按钮 | ✅ | rail=true |
| 目录: 面板不遮住整页 | ✅ | 面板从 y=321 开始,正文区 y=48 |
| 目录: 面板留出可预览的正文 | ✅ | 正文可见高度 273px |
| 收起工具栏: 保留章节与页码指示 | ✅ | chapter="第一章 惊蛰" progress="第 1/4 页 0%" |
| 收起工具栏: 阅读指示不吃手势 | ✅ | pointer-events=none |
| 目录: 面板不遮住整页 | ✅ | 面板从 y=321 开始,正文区 y=54 |
| 目录: 面板留出可预览的正文 | ✅ | 正文可见高度 266px |
| 目录: 面板有遮罩和把手 | ✅ | scrim=true grip=true |
| 目录: 面板高度不超过阅读区 | ✅ | 面板 523px / 阅读区 727px |
| 状态提示不推动正文 | ✅ | 正文顶部 y: 48 / 48 / 48 |
| 触控目标不小于 28px | ✅ | 11 个控件全部合格 |
| 每个控件都有可读的名字 | ✅ | 11 个控件全部有名字 |
| 目录: 面板高度不超过阅读区 | ✅ | 面板 523px / 阅读区 701px |
| 底栏: 进度是可拖动的滑杆 | ✅ | type=range 宽 304px label="阅读进度" |
| 底栏: 有页码与阅读进度读数 | ✅ | page="第 1/4 页" nav="阅读进度:0%" |
| 状态提示不推动正文 | ✅ | 正文顶部 y: 54.390625 / 54.390625 / 54.390625 |
| 触控目标不小于 28px | ✅ | 9 个控件全部合格 |
| 每个控件都有可读的名字 | ✅ | 9 个控件全部有名字 |
| 没有横向溢出 | ✅ | document 溢出 0px |
全部通过。
+28 -8
View File
@@ -12,12 +12,12 @@
{
"name": "02-reader",
"label": "阅读页",
"what": "顶栏 / 正文 / 底部工具栏 / 状态药丸"
"what": "顶栏(图标+文字)/ 正文 / 底栏滑杆与上一章下一章 / 状态药丸"
},
{
"name": "03-reader-no-chrome",
"label": "阅读页 · 收起工具栏",
"what": "顶栏与底栏同时收起,正文占满,右侧留出快捷按钮列"
"what": "顶栏与底栏同时收起,正文占满,右上留快捷列、左上左下留章节与页码"
},
{
"name": "04-panel-toc",
@@ -71,15 +71,25 @@
"ok": true,
"detail": "rail=true"
},
{
"name": "收起工具栏: 保留章节与页码指示",
"ok": true,
"detail": "chapter=\"第一章 惊蛰\" progress=\"第 1/4 页 0%\""
},
{
"name": "收起工具栏: 阅读指示不吃手势",
"ok": true,
"detail": "pointer-events=none"
},
{
"name": "目录: 面板不遮住整页",
"ok": true,
"detail": "面板从 y=321 开始,正文区 y=48"
"detail": "面板从 y=321 开始,正文区 y=54"
},
{
"name": "目录: 面板留出可预览的正文",
"ok": true,
"detail": "正文可见高度 273px"
"detail": "正文可见高度 266px"
},
{
"name": "目录: 面板有遮罩和把手",
@@ -89,22 +99,32 @@
{
"name": "目录: 面板高度不超过阅读区",
"ok": true,
"detail": "面板 523px / 阅读区 727px"
"detail": "面板 523px / 阅读区 701px"
},
{
"name": "底栏: 进度是可拖动的滑杆",
"ok": true,
"detail": "type=range 宽 304px label=\"阅读进度\""
},
{
"name": "底栏: 有页码与阅读进度读数",
"ok": true,
"detail": "page=\"第 1/4 页\" nav=\"阅读进度:0%\""
},
{
"name": "状态提示不推动正文",
"ok": true,
"detail": "正文顶部 y: 48 / 48 / 48"
"detail": "正文顶部 y: 54.390625 / 54.390625 / 54.390625"
},
{
"name": "触控目标不小于 28px",
"ok": true,
"detail": "11 个控件全部合格"
"detail": "9 个控件全部合格"
},
{
"name": "每个控件都有可读的名字",
"ok": true,
"detail": "11 个控件全部有名字"
"detail": "9 个控件全部有名字"
},
{
"name": "没有横向溢出",
+19
View File
@@ -353,6 +353,25 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
「必须离开才能操作」的状态:改个字号要「点回来 → 调 → 再点走」三次交互,
而正文在每一次下面重排。有了它,读者最常用的四个动作永远一步可达,而且按钮
住在正文的版心旁边(宽屏时用 `--reader-measure` 算出的天头地脚里),不压字。
- **收起时正文一角仍留两个读数**(`.reading-indicator`):当前章节名在左上、
页码与总进度在左下。这是「沉浸式」与「应用停止绘制」的区别——读者收起工具栏
就是要看正文,但**仍然要知道自己在哪一页**,让他为此再点一下正是收起状态要
省掉的那次交互。两个读数 `pointer-events: none`(它们是读数不是控件,点它们
必须落到下面的翻页区),且只在收起时绘制,不和工具栏重复。
顶栏与底栏各自的形态(对应参照稿):
- **顶栏**是一排「图标在上、文字在下」的等宽按钮(`.top-button`),不是「标题 +
返回箭头」。参照稿里它是读者**扫**着用的一行工具栏,每个控件一样重、一样一步
可达。书的名字曾经写在这里,现在它属于 `.reading-indicator`——读者找标题时
眼睛去的地方是正文角上,不是工具栏。按钮只保留这个屏幕**真能做到**的动作
(返回 / 目录 / 设置):参照稿的「首页/书架/书源」里有阅读页内部走不到的目的地,
一个点了没处的按钮比少一个按钮更糟。
- **底栏**是两行(`.progress-row` + `.chapter-nav`):一行可拖动的进度滑杆加页码
读数,一行「上一章 / 阅读进度 / 下一章」。滑杆取代了原来那条 3px 的进度条——
条只能**报告**位置,读者想到一章末尾得连点四十次「下一页」;`input[type=range]`
一个手势就到,而且拇指、轨道、拖动惯性和无障碍(方向键、`aria-valuenow`)都是
原生的,不用自己实现一遍。
手势上,**中间三分之一在两个方向上都是开关**;左右三分之一只在 chrome 已经
收起时才充当「叫回来」的出口(§3.6 的优先级第 2 条)。收起状态下任何一条区都
Binary file not shown.
+212 -86
View File
@@ -352,24 +352,27 @@ book-content {
* and zero, and the stage — which is `flex: 1 1 auto` — takes whatever is left,
* so a single number here is what keeps the page from being measured mid-slide.
*/
--reader-chrome-top: 3rem;
--reader-chrome-top: 3.4rem;
}
.topbar {
display: flex;
/*
* Centred, not baselined.
*
* `align-items: baseline` reads as the more careful choice for a row that mixes a
* glyph and two lines of text, and it is the wrong one: the title block is two
* lines, so its first baseline is the *title's*, and the icon buttons would then be
* aligned to a line of text three quarters of a control away. Centre is the one
* line every box in this row shares.
* `stretch`, so each button owns the full height of the band. The buttons are
* icon-over-label now, and a button that only owned its two lines would leave a
* strip of the bar above and below that looked identical but did nothing — on
* the one row where the reader is aiming at controls rather than at text.
*/
align-items: center;
align-items: stretch;
/*
* `space-evenly` rather than `space-between`: three controls spread to the two
* ends of a wide screen stop reading as one toolbar and start reading as two
* stray icons at opposite corners. Even gaps keep them a row at any width.
*/
justify-content: space-evenly;
gap: var(--space-1);
min-height: 3rem;
padding: 0 0.5rem 0 0.35rem;
min-height: 3.4rem;
padding: 0 0.35rem;
background: var(--reader-surface);
/* Same hairline treatment as the footer, so the two edges of the reading area
are the same weight. */
@@ -404,23 +407,96 @@ book-content {
transform: translateY(-100%);
}
.topbar h1 {
flex: 1 1 auto;
margin: 0;
font-size: 1.05rem;
font-weight: 600;
/*
* One icon-over-label control in the top bar.
*
* A column, so the glyph and the word are one hit target rather than two; the word
* is drawn rather than left to a tooltip because the glyphs here are not universal
* (a list and a slider read as the same thing without it). The label is the small
* `--text-meta` size — it is a caption for the glyph, not a second title.
*/
.top-button {
flex: 1 1 0;
max-width: 6rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.15rem;
padding: 0.3rem 0.25rem;
border: 0;
border-radius: var(--reader-radius);
background: transparent;
color: var(--reader-ink);
font: inherit;
font-size: 1.15rem;
cursor: pointer;
touch-action: manipulation;
transition: background var(--motion-duration) var(--motion-ease);
}
.top-button:active {
background: color-mix(in srgb, var(--reader-ink) 8%, transparent);
}
.top-button-label {
font-size: var(--text-meta);
font-weight: 400;
color: var(--reader-muted);
line-height: 1;
white-space: nowrap;
}
/*
* The reading indicator.
*
* Two quiet readouts pinned to the corners of the stage, drawn only while the
* chrome is hidden. `position: absolute` inside `.reader-screen` (which is
* `position: relative`) so they can sit over the top and bottom of the text
* without being part of the flow — they must not take height from the page, or
* showing them would re-measure the columns and move the text.
*
* `pointer-events: none` because they are readouts, not controls: a tap on the
* chapter name has to reach the tap zone underneath it and turn the page (or
* bring the chrome back), not be swallowed by a label.
*/
.reading-indicator {
position: absolute;
inset: 0;
z-index: 12;
pointer-events: none;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: calc(0.5rem + var(--safe-top)) calc(var(--reader-page-padding-inline) + var(--safe-right))
calc(0.5rem + var(--safe-bottom)) calc(var(--reader-page-padding-inline) + var(--safe-left));
font-size: var(--text-meta);
color: var(--reader-muted);
/* A slow fade of its own, offset by the chrome's duration: the indicator comes
up as the bars go down, so the reader never sees an empty screen. */
animation: indicator-in var(--motion-duration) var(--motion-ease);
}
@keyframes indicator-in {
from { opacity: 0; }
to { opacity: 1; }
}
.indicator-chapter {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--text-label);
}
.topbar .subtitle {
display: block;
font-size: 0.72rem;
font-weight: 400;
color: var(--reader-muted);
overflow: hidden;
text-overflow: ellipsis;
.indicator-progress {
font-variant-numeric: tabular-nums;
color: var(--reader-ink);
opacity: 0.72;
}
@media (prefers-reduced-motion: reduce) {
.reading-indicator { animation: none; }
}
/*
@@ -1717,95 +1793,137 @@ book-content {
visibility 0s linear var(--motion-duration);
}
.progress-bar {
height: 3px;
/*
* The scrubber row: a draggable progress slider and the page readout beside it.
*
* The slider replaces the old 3px bar. That bar could only *report* the position;
* a reader who wanted the end of a long chapter had to tap "next" forty times. A
* range input is the one control that answers "take me there" in a single gesture,
* and it is native — the thumb, the track, the drag physics and the accessibility
* (arrow keys, `aria-valuenow`) all come with it rather than being reimplemented.
*/
.progress-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) 0.75rem 0.25rem;
}
.progress-scrubber {
flex: 1 1 auto;
min-width: 0;
/* Native range inputs default to a chunky, theme-blind widget; `appearance: none`
is what lets the tokens below draw it. The track is drawn on the element and
the thumb on `::-webkit-slider-thumb` / `::-moz-range-thumb`, which are two
separate rule sets because the two engines never agreed on a shared one. */
appearance: none;
-webkit-appearance: none;
height: 1.5rem;
background: transparent;
cursor: pointer;
touch-action: manipulation;
}
.progress-scrubber::-webkit-slider-runnable-track {
height: 0.3rem;
border-radius: 0.15rem;
background: color-mix(in srgb, var(--reader-line) 70%, transparent);
}
.progress-bar span {
display: block;
height: 100%;
background: var(--reader-accent);
border-radius: 0 2px 2px 0;
transition: width var(--motion-duration) var(--motion-ease);
.progress-scrubber::-moz-range-track {
height: 0.3rem;
border-radius: 0.15rem;
background: color-mix(in srgb, var(--reader-line) 70%, transparent);
}
.footer-row {
.progress-scrubber::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 1.1rem;
height: 1.1rem;
/* Pull the thumb up onto the middle of the track: the track is 0.3rem and the
thumb 1.1rem, so half the difference is the offset. Without this the thumb
sits below the line it is supposed to ride. */
margin-top: -0.4rem;
border-radius: 50%;
background: var(--reader-surface);
border: 2px solid var(--reader-accent);
box-shadow: 0 1px 4px color-mix(in srgb, #000 18%, transparent);
}
.progress-scrubber::-moz-range-thumb {
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
background: var(--reader-surface);
border: 2px solid var(--reader-accent);
box-shadow: 0 1px 4px color-mix(in srgb, #000 18%, transparent);
}
.progress-scrubber:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-offset);
}
/* The page readout: tabular so "第 9/32 页" does not twitch sideways as the
denominator changes width. */
.progress-page {
flex: 0 0 auto;
font-size: var(--text-meta);
font-variant-numeric: tabular-nums;
color: var(--reader-muted);
white-space: nowrap;
}
/*
* The chapter navigation row.
*
* Three cells: previous chapter, the reading progress as a label, next chapter.
* The two buttons are text-with-chevron rather than bare chevrons — the whole row
* is a labelled destination, and a bare "‹" beside a bare "›" is a guess.
*/
.footer-row.chapter-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-1);
padding: var(--space-1) 0.6rem 0;
color: var(--reader-muted);
gap: var(--space-2);
padding: 0.15rem 0.75rem 0.65rem;
}
.footer-row .chapter {
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
/* The chapter label is the second way into the contents panel, because the ☰
button beside it is a 2.1rem square and the label is the thing a reader
actually aims at when they want to pick a chapter. */
.nav-chapter {
display: inline-flex;
align-items: center;
gap: 0.15rem;
border: 0;
background: transparent;
color: var(--reader-ink);
font: inherit;
font-size: var(--text-label);
padding: 0.45rem 0.3rem;
padding: 0.45rem 0.4rem;
border-radius: var(--reader-radius);
cursor: pointer;
touch-action: manipulation;
white-space: nowrap;
}
.footer-row .chapter:active {
.nav-chapter:active {
background: color-mix(in srgb, var(--reader-ink) 8%, transparent);
}
/*
* The chapter buttons share the row with the chapter label, so they are a size
* down from the topbar's icon buttons: four 2.5rem controls plus a label is more
* than a 360px phone has, and the two that turn pages deserve the room.
*/
.footer-row .icon-button {
width: 2.1rem;
height: 2.1rem;
font-size: 1rem;
flex: 0 0 auto;
}
.footer-row .icon-button:disabled {
opacity: 0.3;
.nav-chapter:disabled {
opacity: 0.35;
cursor: default;
}
/* The readout. One line, three positions, numbers that do not jitter as they
change — `tabular-nums` is what stops "1/3 章" from shifting sideways when it
becomes "2/3 章", which at this size is a visible twitch on every page turn. */
.footer-meta {
padding: 0.15rem 0.75rem 0.5rem;
font-size: var(--text-meta);
font-variant-numeric: tabular-nums;
}
.footer-meta span {
flex: 1 1 0;
}
.footer-meta span:first-child {
text-align: start;
}
.footer-meta span:nth-child(2) {
.nav-progress {
flex: 1 1 auto;
text-align: center;
}
.footer-meta .progress-label {
text-align: end;
color: var(--reader-ink);
font-weight: 600;
font-size: var(--text-label);
color: var(--reader-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ---- immersion rail ----
@@ -2075,7 +2193,15 @@ book-content {
.status-bar {
position: absolute;
inset-inline: 0;
top: calc(var(--space-3) + var(--safe-top));
/*
* Just *below* the top bar rather than over it. It used to sit at
* `--space-3 + safe-top`, i.e. inside the 3.4rem band — which put the middle of
* the pill on top of the middle button of the top bar, so a reader who had just
* opened a book could not see (or reach) 目录 until the message faded. The pill
* belongs over the *page*, which is where the reader is looking, and it must not
* cover the controls they might be reaching for.
*/
top: calc(var(--reader-chrome-top) + var(--space-2));
z-index: 12;
display: flex;
align-items: center;
+96 -32
View File
@@ -119,6 +119,8 @@ export interface ChromeHandlers {
onSwitchEngine(kind: AppSettings['ttsEngine']): void;
onTocEntry(ref: string): void;
onChapter(delta: 1 | -1): void;
/** Jump to a fraction of the whole book, from the footer scrubber. */
onScrub(fraction: number): void;
onTurnPage(direction: 'next' | 'previous'): void;
onSpeechToggle(): void;
onSpeechPrevious(): void;
@@ -143,13 +145,23 @@ export interface ReaderChromeProps {
export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX.Element {
return (
<>
{/* The top bar.
—
A row of labelled buttons, not a title with a back arrow. The reference
layout is a toolbar the reader *scans*: five controls of equal weight,
each an icon over its own name, so nothing has to be learned and nothing
is announced twice. The book's title lived here before; it is still on
screen when the chrome is hidden (see `.reading-indicator`), which is
where a reader actually looks for it — a title that disappears with the
toolbar was the reason the indicator had to exist at all.
Buttons are kept to the ones this screen can really do. The reference's
首页/书架/书源/目录/设置 collapses to 返回/目录/设置 here because there is
no book-source browser *inside* the reader: a button that navigates
somewhere the screen cannot go is worse than one fewer button. */}
<div className="topbar" hidden={!state.chromeVisible}>
<IconButton label="返回书架" icon="arrow-left" onClick={handlers.onBack} />
<div className="title-block" style="flex:1 1 auto;min-width:0;">
<h1>{state.title}</h1>
{state.author ? <span className="subtitle">{state.author}</span> : null}
</div>
<IconButton label="目录" icon="menu" onClick={handlers.toggleToc} />
<TopButton icon="arrow-left" label="返回" onClick={handlers.onBack} />
<TopButton icon="menu" label="目录" onClick={handlers.toggleToc} />
<TopButton icon="sliders" label="设置" onClick={handlers.toggleSettings} />
</div>
{/* Outside the flex flow: see the comment on `.status-bar`. It has to be a
@@ -169,6 +181,27 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
<StageHost stage={stage} />
{/* The reading indicator.
—
What is left on screen when the chrome is hidden. The reference keeps two
quiet facts pinned to the corners — which chapter, and how far in — and
nothing else. That is the difference between "沉浸式" and "the app stopped
drawing": a reader who has hidden the toolbar still wants to know where
they are, and making them tap to find out is what the hidden state was
supposed to save them from.
Both live in the page margin at the very top and bottom of the stage, are
`pointer-events: none` (they are readouts, not controls), and are drawn
only while the chrome is hidden so they do not double up with it. */}
{!state.chromeVisible ? (
<div className="reading-indicator" aria-hidden="true">
<span className="indicator-chapter">{state.chapterLabel}</span>
<span className="indicator-progress">
{state.chapterPages > 0 ? `第 ${state.pageInChapter}/${state.chapterPages} 页 ` : ''}
{percentOf(state.progress)}
</span>
</div>
) : null}
{/* The immersion rail.
—
A column of controls on the right edge, drawn *only* while the chrome is
@@ -196,39 +229,52 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
</div>
) : null}
{/* The bottom bar.
—
Two rows, and the split is the reference's: a scrubber the reader can
drag to any page, then a navigation row with the chapter buttons at the
ends and the reading progress in the middle. The scrubber replaces the
old 3px bar plus the "本章 x/y 页" readout — a bar that only reports could
not be used to *go* anywhere, and the reader who wanted the end of the
chapter had to tap the next button forty times. */}
<div className="footer" hidden={!state.chromeVisible}>
<div className="progress-bar">
<span style={`width:${percentOf(state.progress)}`} />
<div className="progress-row">
<input
className="progress-scrubber"
type="range"
min={0}
max={100}
step={1}
value={Math.round(Math.min(1, Math.max(0, state.progress)) * 100)}
aria-label="阅读进度"
onInput={(event) =>
handlers.onScrub(Number((event.currentTarget as HTMLInputElement).value) / 100)
}
/>
<span className="progress-page">
{state.chapterPages > 0 ? `第 ${state.pageInChapter}/${state.chapterPages} 页` : ''}
</span>
</div>
<div className="footer-row">
<IconButton label="目录" icon="menu" onClick={handlers.toggleToc} />
<IconButton
label="上一章"
icon="chevron-left"
<div className="footer-row chapter-nav">
<button
type="button"
className="nav-chapter"
disabled={state.navigating || state.chapterIndex <= 1}
onClick={() => handlers.onChapter(-1)}
/>
<button type="button" className="chapter" onClick={handlers.toggleToc}>
{state.navigating ? '正在切换…' : state.chapterLabel || '目录'}
>
<span className="icon" aria-hidden="true">{iconGlyph('chevron-left')}</span>
上一章
</button>
<IconButton
label="下一章"
icon="chevron-right"
<span className="nav-progress">阅读进度:{percentOf(state.progress)}</span>
<button
type="button"
className="nav-chapter"
disabled={state.navigating || state.chapterIndex >= state.chapterCount}
onClick={() => handlers.onChapter(1)}
/>
<IconButton label="阅读设置" icon="sliders" onClick={handlers.toggleSettings} />
</div>
<div className="footer-row footer-meta">
<span>
{state.chapterCount > 0 ? `${state.chapterIndex}/${state.chapterCount} 章` : ''}
</span>
<span>
{state.chapterPages > 0
? `本章 ${state.pageInChapter}/${state.chapterPages} 页`
: ''}
</span>
<span className="progress-label">{percentOf(state.progress)}</span>
>
下一章
<span className="icon" aria-hidden="true">{iconGlyph('chevron-right')}</span>
</button>
</div>
</div>
@@ -782,6 +828,24 @@ function SelectRow({
);
}
/**
* One control in the top bar.
*
* An icon **over** a name, matching the reference: the label is not a tooltip and
* not an `aria-label` alone, it is drawn, because the icons here are not universal
* (a "目录" list glyph and a "设置" slider glyph read as the same thing when there
* is no word under them). Centred, equal-width, and full-height so the whole
* column of the bar is the target rather than the glyph.
*/
function TopButton({ icon, label, onClick }: { icon: IconName; label: string; onClick(): void }): JSX.Element {
return (
<button type="button" className="top-button" aria-label={label} onClick={onClick}>
<span className="icon" aria-hidden="true">{iconGlyph(icon)}</span>
<span className="top-button-label">{label}</span>
</button>
);
}
/**
* One control on the immersion rail.
*
+24
View File
@@ -214,6 +214,7 @@ export class ReaderScreen {
onSwitchEngine: (kind) => this.switchSpeechEngine(kind),
onTocEntry: (ref) => void this.goToChapterRef(ref),
onChapter: (delta) => void this.goToChapter(delta),
onScrub: (fraction) => void this.scrubTo(fraction),
onTurnPage: (direction) => void this.turnPage(direction),
onSpeechToggle: () => this.toggleSpeech(),
onSpeechPrevious: () => void this.tts?.previous(),
@@ -979,6 +980,29 @@ export class ReaderScreen {
return entry?.spine ?? null;
}
/**
* Jumps to a fraction of the whole book, from the footer's scrubber.
*
* The slider is `input[type=range]`, which fires on every pixel of a drag, so
* this is deliberately the *cheap* path: `seekPercentage` asks the view to open
* the section at that fraction, and the view's own position reporting feeds the
* numbers back through the same `onPosition` every other movement uses. There is
* no separate "scrubbing" state to get out of sync with — the reader drags, the
* book follows, and letting go leaves them where they let go.
*
* Position writes are untouched on purpose: `onPosition` already debounces the
* network write by 1.5s, so a drag does not produce a request per frame.
*/
private async scrubTo(fraction: number): Promise<void> {
const view = this.view;
if (!view) return;
await view.seekPercentage(fraction);
// The drag can end between two scroll events, and a book with no reported
// position after the seek would leave the bar showing the pre-drag value.
this.pendingPosition = view.position();
this.onPosition(view.position());
}
/**
* Steps one chapter at a time, the way the footer's ‹ / › buttons do.
*
+43
View File
@@ -4,6 +4,7 @@ import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { ICON_CODEPOINTS } from '../src/ui/icon-names.ts';
import { GLYPHS } from '../tools/icons/paths.mjs';
import { pathToSubpaths } from '../tools/icons/path.mjs';
/**
* The icon set's contract with the font.
@@ -85,6 +86,48 @@ describe('the icon font and its code point table', () => {
expect(tableNames).toEqual(sourceNames);
});
it('does not give two different icons the same path', () => {
// A duplicated path is a wrong icon that no other assertion here can see: the
// name is in the table, the code point maps, the glyph has ink and fits the em —
// and it draws the other icon. `chevron-right` was exactly this, byte for byte
// the same as `chevron-left`, so the "next chapter" button pointed backwards.
const byPath = new Map<string, string[]>();
for (const [name, path] of Object.entries(GLYPHS as Record<string, string>)) {
const key = path.replace(/\s+/g, '');
byPath.set(key, [...(byPath.get(key) ?? []), name]);
}
const duplicates = [...byPath.values()].filter((names) => names.length > 1);
expect(duplicates, `these icons share a path: ${duplicates.map((n) => n.join(' = ')).join('; ')}`).toEqual([]);
});
it('points the two chevrons in opposite directions', () => {
// The mirror is what makes this pair useful; two left chevrons are a working
// glyph and a broken control. Measured on the flattened polylines rather than
// on the strings, because "which way does it point" is a fact about the
// geometry and a regex over the path data is not that fact.
const points = (name: 'chevron-left' | 'chevron-right') =>
pathToSubpaths((GLYPHS as Record<string, string>)[name]!).flat();
const tip = (name: 'chevron-left' | 'chevron-right') => {
const xs = points(name).map((p) => p[0]);
// The tip is the extreme point along the direction the chevron points.
return name === 'chevron-left' ? Math.min(...xs) : Math.max(...xs);
};
const back = (name: 'chevron-left' | 'chevron-right') => {
const xs = points(name).map((p) => p[0]);
return name === 'chevron-left' ? Math.max(...xs) : Math.min(...xs);
};
// A chevron's tip is its leading edge; for it to point left, the leftmost x
// has to be the tip and the two back corners have to be further right.
expect(tip('chevron-left'), 'chevron-left points left').toBeLessThan(back('chevron-left'));
expect(tip('chevron-right'), 'chevron-right points right').toBeGreaterThan(back('chevron-right'));
// And the two are a matched pair: the same span, mirrored about the centre.
const span = (name: 'chevron-left' | 'chevron-right') => {
const xs = points(name).map((p) => p[0]);
return Math.max(...xs) - Math.min(...xs);
};
expect(span('chevron-right')).toBeCloseTo(span('chevron-left'), 5);
});
it('assigns code points in the same order as the source, starting at 0xE900', () => {
const names = Object.keys(GLYPHS);
names.forEach((name, index) => {
+8
View File
@@ -0,0 +1,8 @@
/**
* Declarations for `path.mjs`, following the same convention as `paths.d.mts`:
* the build scripts stay plain JavaScript and this states the shape the test
* consumes.
*/
/** Flattens an SVG path string into polylines of `[x, y]` points. */
export function pathToSubpaths(d: string): Array<Array<[number, number]>>;
+1 -1
View File
@@ -13,7 +13,7 @@
export const GLYPHS = {
menu: 'M4 6h16M4 12h16M4 18h16',
'chevron-left': 'M15 5l-7 7 7 7',
'chevron-right': 'M15 5l-7 7 7 7',
'chevron-right': 'M9 5l7 7-7 7',
'arrow-left': 'M11 5l-7 7 7 7M4 12h16',
folder: 'M3 7h6l2 2.5h10V19H3z',
file: 'M6.5 3h7l4.5 4.5V21h-11.5z',
+71 -6
View File
@@ -39,11 +39,11 @@ const SCALE = 2;
const SCENES = [
{ name: '01-shelf', label: '书架', what: '第一屏:先看到书,再看到控件' },
{ name: '02-reader', label: '阅读页', what: '顶栏 / 正文 / 底部工具栏 / 状态药丸', openBook: true },
{ name: '03-reader-no-chrome', label: '阅读页 · 收起工具栏', what: '顶栏与底栏同时收起,正文占满,右侧留出快捷按钮列', openBook: true, tapCenter: true },
{ name: '02-reader', label: '阅读页', what: '顶栏(图标+文字)/ 正文 / 底栏滑杆与上一章下一章 / 状态药丸', openBook: true },
{ name: '03-reader-no-chrome', label: '阅读页 · 收起工具栏', what: '顶栏与底栏同时收起,正文占满,右上留快捷列、左上左下留章节与页码', openBook: true, tapCenter: true },
{ name: '04-panel-toc', label: '目录 · 半屏', what: '下半屏,上半屏正文仍可见', openBook: true, openPanel: '目录' },
{ name: '05-panel-settings', label: '阅读设置 · 半屏', what: '一行式行:标签在左、控件在右', openBook: true, openPanel: '阅读设置' },
{ name: '06-reader-paged', label: '阅读页 · 翻页模式', what: '分栏后的一页,页数应与可翻次数一致', openBook: true, choose: ['阅读设置', '翻页'], closePanel: true },
{ name: '05-panel-settings', label: '阅读设置 · 半屏', what: '一行式行:标签在左、控件在右', openBook: true, openPanel: '设置' },
{ name: '06-reader-paged', label: '阅读页 · 翻页模式', what: '分栏后的一页,页数应与可翻次数一致', openBook: true, choose: ['设置', '翻页'], closePanel: true },
{ name: '07-reader-sepia', label: '阅读页 · 米黄', what: '主题切换后的同一页', openBook: true, theme: '米黄' },
{ name: '08-reader-dark', label: '阅读页 · 夜间', what: '暗色下的正文与工具栏', openBook: true, theme: '夜间' },
{
@@ -51,7 +51,7 @@ const SCENES = [
label: 'TXT · 正文排版',
what: '纯文本专属的缩进/段间距/编码三行,且面板仍是半屏',
openBook: true,
openPanel: '阅读设置',
openPanel: '设置',
},
];
@@ -97,6 +97,19 @@ async function audit(cdp, scenes, results) {
stageBottom: Math.round(s.bottom),
viewportHeight: window.innerHeight,
rail: !!document.querySelector('.reader-rail'),
indicator: (() => {
const el = document.querySelector('.reading-indicator');
if (!el) return null;
const r = el.getBoundingClientRect();
const chapter = el.querySelector('.indicator-chapter');
const progress = el.querySelector('.indicator-progress');
return {
visible: getComputedStyle(el).visibility !== 'hidden' && r.height > 0,
pointerEvents: getComputedStyle(el).pointerEvents,
chapter: chapter ? chapter.textContent.trim() : '',
progress: progress ? progress.textContent.trim() : '',
};
})(),
};
})()`);
check(
@@ -119,6 +132,29 @@ async function audit(cdp, scenes, results) {
!hidden.missing && hidden.rail,
hidden.missing ? 'n/a' : `rail=${hidden.rail}`,
);
// Immersion keeps the *where am I* readouts. Hiding the chrome should leave a
// reader who knows which chapter they are in and how far through — not a blank
// screen they have to tap to interrogate.
check(
'收起工具栏: 保留章节与页码指示',
!hidden.missing &&
hidden.indicator !== null &&
hidden.indicator.visible &&
hidden.indicator.chapter.length > 0 &&
hidden.indicator.progress.length > 0,
hidden.missing
? 'n/a'
: hidden.indicator
? `chapter="${hidden.indicator.chapter}" progress="${hidden.indicator.progress}"`
: '没有找到阅读指示',
);
// The indicator is a readout, not a control: a tap on it has to fall through to
// the page-turning zone underneath, or hiding the chrome would create a dead band.
check(
'收起工具栏: 阅读指示不吃手势',
!hidden.missing && hidden.indicator !== null && hidden.indicator.pointerEvents === 'none',
hidden.missing ? 'n/a' : `pointer-events=${hidden.indicator?.pointerEvents ?? 'missing'}`,
);
// Back to the chrome: the panel checks that follow need a page they can tap, and
// the screen is left in the state a reader spends most of their time in.
await cdp.tapMiddle();
@@ -172,6 +208,35 @@ async function audit(cdp, scenes, results) {
}
}
// The footer scrubber has to be a real control: visible, at least the width of a
// thumb, reachable by keyboard, and labelled. A progress bar that only *reports*
// is the thing this replaced, so the check is that it can be dragged.
const scrubber = await cdp.evaluate(`(() => {
const el = document.querySelector('.progress-scrubber');
if (!el) return { missing: true };
const r = el.getBoundingClientRect();
return {
type: el.type,
width: Math.round(r.width),
height: Math.round(r.height),
label: el.getAttribute('aria-label') ?? '',
page: document.querySelector('.progress-page')?.textContent.trim() ?? '',
nav: document.querySelector('.chapter-nav .nav-progress')?.textContent.trim() ?? '',
};
})()`);
check(
'底栏: 进度是可拖动的滑杆',
!scrubber.missing && scrubber.type === 'range' && scrubber.width >= 120 && scrubber.label.length > 0,
scrubber.missing
? '没有找到滑杆'
: `type=${scrubber.type} 宽 ${scrubber.width}px label="${scrubber.label}"`,
);
check(
'底栏: 有页码与阅读进度读数',
!scrubber.missing && scrubber.page.length > 0 && scrubber.nav.length > 0,
scrubber.missing ? 'n/a' : `page="${scrubber.page}" nav="${scrubber.nav}"`,
);
// The status line must not change the page's geometry. Measured by moving it
// from empty to a message and back, and comparing the reading column's top.
const statusAudit = await cdp.evaluate(`(() => {
@@ -310,7 +375,7 @@ async function main() {
}
const theme = scene.theme ?? (scene.openBook ? '白' : null);
if (theme) {
await cdp.click('button[aria-label="阅读设置"]');
await cdp.click('button[aria-label="设置"]');
await cdp.waitFor('document.querySelector(".panel") !== null');
await cdp.clickText('.segmented button', theme);
await cdp.sleep(250);
+2 -2
View File
@@ -33,13 +33,13 @@ export const SCENES = [
{
name: 'reader-light',
label: '阅读页 · 白',
note: '顶栏、正文、底部工具栏、状态药丸',
note: '顶栏(图标+文字)、正文、底栏滑杆与章节导航、状态药丸',
steps: [],
},
{
name: 'reader-chrome-hidden',
label: '阅读页 · 收起工具栏',
note: '点中间三分之一收起后,正文应当占满,且没有布局跳动',
note: '点中间三分之一收起后,正文占满、右侧留快捷列、角上留章节与页码',
steps: [{ tap: { x: 'center', y: 'center' } }],
},
{