diff --git a/docs/ui-review/README.md b/docs/ui-review/README.md index 894be22..b53c3a5 100644 --- a/docs/ui-review/README.md +++ b/docs/ui-review/README.md @@ -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 | 全部通过。 diff --git a/docs/ui-review/report.json b/docs/ui-review/report.json index eb3f48d..73f828f 100644 --- a/docs/ui-review/report.json +++ b/docs/ui-review/report.json @@ -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": "没有横向溢出", diff --git a/docs/ui.md b/docs/ui.md index 3fb437f..63bcea6 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -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 条)。收起状态下任何一条区都 diff --git a/web/src/styles/reader-icons.ttf b/web/src/styles/reader-icons.ttf index 9f1ddc7..a2df4d1 100644 Binary files a/web/src/styles/reader-icons.ttf and b/web/src/styles/reader-icons.ttf differ diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index dfcaf15..060b924 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -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; diff --git a/web/src/ui/reader-chrome.tsx b/web/src/ui/reader-chrome.tsx index a06cf3f..637daf3 100644 --- a/web/src/ui/reader-chrome.tsx +++ b/web/src/ui/reader-chrome.tsx @@ -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. */}