feat(reader): reorder appearance and read-aloud sheets by use

Appearance: font size, line height, theme (drawn as swatches in each theme's
own paper and ink) and brightness now fill the first screen. Margins,
alignment, font, TXT rows and the four readouts move into closed <details>.

Read aloud: the start button is first and primary; rate gains preset steps;
pitch and volume are folded under 更多. Capability hiding is unchanged.

Spec updated in docs/ui.md; chrome tests cover order, folds and swatches.
This commit is contained in:
Claude authored and hectorqin committed 2026-10-19 22:33:17 +08:00
1 parent d4eb2e7a34
commit bf325ec072
4 files changed
+281 -61

No files matched your search

+2 -1
View File
@@ -547,13 +547,14 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
- 顶部左/右、底部左/右阅读信息可在“界面”分别选择书名、章节名、章节进度(章内页码与全书百分比)、时间或不显示。默认顶部左侧章节名,底部左侧章节进度,底部右侧时间。配置按设备保存,工具栏隐藏后仍显示;长文本省略。关闭信息仍保留固定边界,避免改变正文分页。日夜圆形快捷按钮随工具栏一起显隐。
- 正文宿主的上下边界不依赖工具栏状态。工具栏覆盖正文,显隐不得改变页码、滚动位置、正文尺寸。
- 四个面板使用统一底部半屏布局;界面放排版与主题,设置放翻页行为,朗读放语音设置和播放控制。
- “界面”面板按使用频率排序:字号、行距、主题(用各主题真实纸色与墨色绘制的色块)、亮度排在首屏;“更多排版”(页边距、对齐、字体,以及 TXT 的缩进、段间距、编码)和“页眉页脚”(四个阅读信息)收进默认折叠的区块。漫画不显示文字排版,只保留主题、亮度和“页眉页脚”。
- TXT 默认字号为基准的 1.125 倍、行距 1.8,支持两字缩进与段间距;EPUB 默认保留作者排版。
- 白、米黄、浅绿、夜间四个主题。漫画隐藏文字排版选项。
### 3.11 朗读
朗读控制集中在朗读面板。上一句、播放/暂停、下一句、停止均有独立按钮;关闭面板不停止播放,再从底部“朗读”进入可暂停或停止。
“从当前位置朗读”使用当前可见正文或选区作为起点。不可用的语音设置根据引擎能力隐藏。
“从当前位置朗读”使用当前可见正文或选区作为起点,是面板里的第一项和唯一主按钮。其后依次是语速(滑杆加 0.8/1.0/1.2/1.5/2.0 常用档)、语音、章节播完;音调与音量收进默认折叠的“更多”。不可用的语音设置根据引擎能力隐藏。
## 4. 文案
+112 -1
View File
@@ -28,7 +28,8 @@
* Host shell: the viewer, not the book. Safe to style freely.
* ------------------------------------------------------------------ */
:root {
:root,
[data-theme='light'] {
--reader-ink: #1a1a1a;
--reader-paper: #fbfaf7;
--reader-surface: #ffffff;
@@ -767,6 +768,8 @@ book-content {
.chip:focus-visible,
.switch:focus-visible,
.segmented button:focus-visible,
.theme-swatch:focus-visible .theme-swatch-chip,
.panel-fold > summary:focus-visible,
.toc-list li button:focus-visible,
.manager-row:focus-visible,
.manager-crumb:focus-visible,
@@ -2725,6 +2728,114 @@ book-content {
display: none;
}
/* ---- settings sheet: theme swatches and folded rows ---- */
/* Each chip is drawn in its own theme's paper and ink: the reader sees the choice
* instead of decoding a name. Selection is a thicker accent ring *and* a bolder
* label, so it never depends on colour alone. */
.theme-swatches {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-2);
}
.theme-swatch {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
min-height: 44px;
padding: 0;
border: 0;
background: none;
color: var(--reader-ink);
font: inherit;
font-size: var(--text-label);
cursor: pointer;
}
.theme-swatch-chip {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border: 1px solid var(--reader-line);
border-radius: 50%;
background: var(--reader-paper);
color: var(--reader-ink);
font-size: var(--text-title);
font-weight: 600;
transition: border-color var(--motion-duration) var(--motion-ease);
}
.theme-swatch[aria-pressed='true'] .theme-swatch-chip {
border: 2px solid var(--reader-accent);
}
.theme-swatch[aria-pressed='true'] .theme-swatch-label {
font-weight: 600;
}
/* Rows that are set once and left alone sit behind a native <details>. */
.panel-fold {
margin-top: var(--space-2);
border-top: 1px solid color-mix(in srgb, var(--reader-line) 60%, transparent);
}
.panel-fold > summary {
display: flex;
align-items: center;
gap: var(--space-3);
min-height: 44px;
cursor: pointer;
list-style: none;
}
.panel-fold > summary::-webkit-details-marker {
display: none;
}
.panel-fold > summary::after {
content: '';
flex-shrink: 0;
width: 0.5rem;
height: 0.5rem;
margin-inline-start: auto;
border-right: 2px solid var(--reader-muted);
border-bottom: 2px solid var(--reader-muted);
transform: rotate(45deg);
transition: transform var(--motion-duration) var(--motion-ease);
}
.panel-fold[open] > summary::after {
transform: rotate(225deg);
}
.panel-fold-title {
font-size: var(--text-body);
font-weight: 600;
}
.panel-fold-hint {
font-size: var(--text-meta);
color: var(--reader-muted);
}
.panel-fold-body {
padding-bottom: var(--space-2);
}
/* The one action the read-aloud sheet exists for: full width, first, and tall enough to hit. */
.speech-start {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 48px;
margin-bottom: var(--space-3);
}
/* ---- status ---- */
/* ---- misc ---- */
+121 -59
View File
@@ -414,13 +414,9 @@ function SettingsBody({ state, handlers }: { state: ChromeState; handlers: Chrom
return (
<>
{(state.settingsTab ?? 'behavior') === 'appearance' ? <>
<SectionTitle>阅读信息</SectionTitle>
{READOUT_FIELDS.map(({ key, label }) => (
<SelectRow key={key} label={label} options={READOUT_OPTIONS}
value={state[key] ?? DEFAULT_APP_SETTINGS[key]}
onChange={(value) => handlers.onSetting({ [key]: value as ReadoutMode })} />
))}
<SectionTitle>正文与主题</SectionTitle>
{/* Most-used first. Size, spacing, theme and brightness are what a reader opens
this sheet to change, so they fill its first screen; the rows that are set once
and left alone are folded underneath instead of pushing these below the fold. */}
{state.layout !== 'fixed' ? <>
<SliderRow
label="字号"
@@ -440,7 +436,35 @@ function SettingsBody({ state, handlers }: { state: ChromeState; handlers: Chrom
value={state.lineHeight}
onChange={(value) => handlers.onSetting({ lineHeight: value })}
/>
</> : null}
<ThemeSwatchRow
label="主题"
options={[
{ value: 'light', label: '白' },
{ value: 'sepia', label: '米黄' },
{ value: 'green', label: '浅绿' },
{ value: 'dark', label: '夜间' },
]}
value={state.theme}
onChange={(value) => handlers.onSetting({ theme: value as AppSettings['theme'] })}
/>
<SliderRow
label="亮度"
value={state.brightness}
min={0.35}
max={1}
step={0.05}
format={(value) => `${Math.round(value * 100)}%`}
onChange={(value) => handlers.onSetting({ brightness: value })}
/>
{/* Plain text is the one format with no typography of its own, so its indent, gap
and encoding rows live with the other typography rows. They are absent for every
other format, where the answer to all three is "whatever the book said". */}
{state.layout !== 'fixed' || state.showTxtRows ? (
<FoldSection title="更多排版" hint="页边距 · 对齐 · 字体">
{state.layout !== 'fixed' ? <>
<SliderRow
label="页边距"
value={state.pageMargin}
min={0}
@@ -474,35 +498,8 @@ function SettingsBody({ state, handlers }: { state: ChromeState; handlers: Chrom
value={state.fontFamily}
onChange={(value) => handlers.onSetting({ fontFamily: value })}
/>
</> : null}
<SegmentedRow
label="主题"
options={[
{ value: 'light', label: '白' },
{ value: 'sepia', label: '米黄' },
{ value: 'green', label: '浅绿' },
{ value: 'dark', label: '夜间' },
]}
value={state.theme}
onChange={(value) => handlers.onSetting({ theme: value as AppSettings['theme'] })}
/>
<SliderRow
label="亮度"
value={state.brightness}
min={0.35}
max={1}
step={0.05}
format={(value) => `${Math.round(value * 100)}%`}
onChange={(value) => handlers.onSetting({ brightness: value })}
/>
{/* Plain text is the one format with no typography of its own, so these are
the rows that give it some. They sit with 排版 rather than at the end,
because they are the *same kind of setting* — how the text looks — and a
reader looking for the indent should not have to scroll past the
read-aloud controls to find it. They are absent for every other format,
where the answer to all three is "whatever the book said". */}
{state.showTxtRows ? (
</> : null}
{state.showTxtRows ? (
<>
<SectionTitle>正文</SectionTitle>
<SliderRow
@@ -536,8 +533,16 @@ function SettingsBody({ state, handlers }: { state: ChromeState; handlers: Chrom
onChange={(value) => handlers.onSetting({ txtEncoding: value })}
/>
</>
) : null}
</FoldSection>
) : null}
<FoldSection title="页眉页脚" hint="顶部与底部显示的信息">
{READOUT_FIELDS.map(({ key, label }) => (
<SelectRow key={key} label={label} options={READOUT_OPTIONS}
value={state[key] ?? DEFAULT_APP_SETTINGS[key]}
onChange={(value) => handlers.onSetting({ [key]: value as ReadoutMode })} />
))}
</FoldSection>
</> : <>
{state.layout !== 'fixed' ?
<SegmentedRow
@@ -620,8 +625,13 @@ function SpeechSettings({ state, handlers }: { state: ChromeState; handlers: Chr
</div>
);
}
const RATE_PRESETS = [0.8, 1, 1.2, 1.5, 2];
return (
<>
{/* The thing a reader opens this sheet to do comes first; the tuning follows. */}
<button type="button" className="button primary speech-start" onClick={handlers.onSpeakFromHere}>
从当前位置朗读
</button>
{/* The engine picker only appears when there is a choice to make. One
engine is not a setting. */}
{state.showEngineRow ? (
@@ -641,25 +651,11 @@ function SpeechSettings({ state, handlers }: { state: ChromeState; handlers: Chr
format={(value) => `${value.toFixed(1)}×`}
onChange={(value) => handlers.onSpeechSetting({ ttsRate: value })}
/>
{state.showPitchRow ? (
<SliderRow
label="音调"
value={state.ttsPitch}
min={0.5}
max={2}
step={0.1}
format={(value) => value.toFixed(1)}
onChange={(value) => handlers.onSpeechSetting({ ttsPitch: value })}
/>
) : null}
<SliderRow
label="音量"
value={state.ttsVolume}
min={0}
max={1}
step={0.05}
format={(value) => `${Math.round(value * 100)}%`}
onChange={(value) => handlers.onSpeechSetting({ ttsVolume: value })}
<SegmentedRow
label="常用"
options={RATE_PRESETS.map((value) => ({ value: String(value), label: `${value.toFixed(1)}×` }))}
value={String(RATE_PRESETS.find((value) => Math.abs(value - state.ttsRate) < 0.05) ?? '')}
onChange={(value) => handlers.onSpeechSetting({ ttsRate: Number(value) })}
/>
{state.showVoiceRow ? (
<SelectRow label="语音" options={state.voices} value={state.ttsVoice} onChange={handlers.onVoice} />
@@ -673,9 +669,28 @@ function SpeechSettings({ state, handlers }: { state: ChromeState; handlers: Chr
value={state.ttsAutoAdvance ? 'auto' : 'stop'}
onChange={(value) => handlers.onSpeechSetting({ ttsAutoAdvance: value === 'auto' })}
/>
<button type="button" className="button" onClick={handlers.onSpeakFromHere}>
从当前位置朗读
</button>
<FoldSection title="更多" hint={state.showPitchRow ? '音调 · 音量' : '音量'}>
{state.showPitchRow ? (
<SliderRow
label="音调"
value={state.ttsPitch}
min={0.5}
max={2}
step={0.1}
format={(value) => value.toFixed(1)}
onChange={(value) => handlers.onSpeechSetting({ ttsPitch: value })}
/>
) : null}
<SliderRow
label="音量"
value={state.ttsVolume}
min={0}
max={1}
step={0.05}
format={(value) => `${Math.round(value * 100)}%`}
onChange={(value) => handlers.onSpeechSetting({ ttsVolume: value })}
/>
</FoldSection>
</>
);
}
@@ -789,6 +804,53 @@ function SegmentedRow({
);
}
/** A theme picker drawn in the theme's own paper and ink, so the choice is seen, not decoded from a name. */
function ThemeSwatchRow({
label,
options,
value,
onChange,
}: {
label: string;
options: Array<{ value: string; label: string }>;
value: string;
onChange(value: string): void;
}): JSX.Element {
return (
<div className="field is-stacked">
<label>{label}</label>
<div className="theme-swatches" role="group" aria-label={label}>
{options.map((option) => (
<button
type="button"
key={option.value}
className="theme-swatch"
aria-pressed={option.value === value}
onClick={() => onChange(option.value)}
>
{/* data-theme scopes the reading tokens to the chip, so it shows this theme's real colours. */}
<span className="theme-swatch-chip" data-theme={option.value} aria-hidden="true">Aa</span>
<span className="theme-swatch-label">{option.label}</span>
</button>
))}
</div>
</div>
);
}
/** Rows that are set once and left alone. A native <details>: keyboard and screen-reader behaviour for free. */
function FoldSection({ title, hint, children }: { title: string; hint?: string; children: ReactNode }): JSX.Element {
return (
<details className="panel-fold">
<summary>
<span className="panel-fold-title">{title}</span>
{hint ? <span className="panel-fold-hint">{hint}</span> : null}
</summary>
<div className="panel-fold-body">{children}</div>
</details>
);
}
/** A labelled range input whose value label follows the drag. */
function SliderRow({
label,
+46
View File
@@ -164,6 +164,52 @@ describe('ReaderChrome', () => {
expect(tree.querySelector('.panel')?.textContent).not.toContain('字号');
});
it('puts the most-used appearance rows first and folds the rarely changed ones', () => {
const tree = paint({ settingsOpen: true, settingsTab: 'appearance' });
const body = tree.querySelector('.panel-body')!;
const order = [...body.children].map((element) =>
element.matches('details.panel-fold')
? `fold:${element.querySelector('.panel-fold-title')?.textContent}`
: (element.querySelector('label')?.textContent ?? '').split(' ')[0],
);
// Size, spacing, theme and brightness fill the first screen; the readouts that
// used to come first are behind a fold, closed by default.
expect(order).toEqual(['字号', '行距', '主题', '亮度', 'fold:更多排版', 'fold:页眉页脚']);
for (const fold of body.querySelectorAll<HTMLDetailsElement>('details.panel-fold')) expect(fold.open).toBe(false);
expect(body.querySelector('details.panel-fold input[aria-label="页边距"]')).not.toBeNull();
expect(body.querySelector('details.panel-fold select[aria-label="顶部左侧"]')).not.toBeNull();
});
it('draws the theme choice as swatches and marks the selected one', () => {
const tree = paint({ settingsOpen: true, settingsTab: 'appearance', theme: 'sepia' });
const swatches = [...tree.querySelectorAll<HTMLButtonElement>('.theme-swatch')];
expect(swatches.map((button) => button.querySelector('.theme-swatch-label')?.textContent)).toEqual(['白', '米黄', '浅绿', '夜间']);
expect(swatches.filter((button) => button.getAttribute('aria-pressed') === 'true').map((button) => button.textContent)).toEqual(['Aa米黄']);
// The chip is scoped to its own theme so it is drawn in that theme's colours.
expect(swatches.map((button) => button.querySelector('.theme-swatch-chip')?.getAttribute('data-theme'))).toEqual(['light', 'sepia', 'green', 'dark']);
});
it('keeps typography folded away for fixed layouts and offers only the readouts', () => {
const tree = paint({ settingsOpen: true, settingsTab: 'appearance', layout: 'fixed' });
const folds = [...tree.querySelectorAll('details.panel-fold .panel-fold-title')].map((element) => element.textContent);
expect(folds).toEqual(['页眉页脚']);
expect(tree.querySelector('.panel')?.textContent).not.toContain('字号');
expect(tree.querySelector('.panel')?.textContent).toContain('浅绿');
});
it('puts the start action first in the read-aloud sheet and folds pitch and volume', () => {
const tree = paint({ settingsOpen: true, settingsTab: 'speech', ttsRate: 1.2, showPitchRow: true });
const body = tree.querySelector('.panel-body')!;
// The action the sheet exists for is not buried under five sliders.
expect(body.firstElementChild?.textContent).toBe('从当前位置朗读');
expect(body.firstElementChild?.classList.contains('primary')).toBe(true);
const presets = [...body.querySelectorAll('[aria-label="常用"] button')];
expect(presets.map((button) => button.textContent)).toEqual(['0.8×', '1.0×', '1.2×', '1.5×', '2.0×']);
expect(presets.filter((button) => button.getAttribute('aria-pressed') === 'true').map((button) => button.textContent)).toEqual(['1.2×']);
expect(body.querySelector('details.panel-fold input[aria-label="音量"]')).not.toBeNull();
expect(body.querySelector('details.panel-fold input[aria-label="音调"]')).not.toBeNull();
});
it('draws every read-aloud control, including stop, in their own row', () => {
const tree = paint({
settingsOpen: true, settingsTab: 'speech',