feat(reader): reading papers as a reader setting

白纸 / 暖黄 / 浅绿 / 灰调 / 夜间 (prototypes 4 and 23) tint only the reading
page — paper, ink, readouts — while the chrome keeps the reading theme.
Stored per device in AppSettings (paper, paperFollowsDark), set from the
reader's 外观 sheet or 外观与主题. 夜间 makes the reader dark; a light paper
gives way to the theme's dark values while the app is dark unless
跟随系统深色模式 is off. Old sepia/green/dark page themes migrate to the
matching paper. Tokens are mirrored in TS and CSS and held to contrast by
theme-tokens.test.ts.
This commit is contained in:
Kiro Agent committed 2026-10-20 04:26:51 +00:00
1 parent 858a7479bf
commit c30e07c270
16 files changed
+304 -34

No files matched your search

+2 -1
View File
@@ -547,7 +547,8 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
- 顶部左/右、底部左/右阅读信息可在“外观”分别选择书名、章节名、章节进度(章内页码与全书百分比)、时间或不显示。默认顶部左侧章节名,底部左侧章节进度,底部右侧时间。配置按设备保存,工具栏隐藏后仍显示;长文本省略。关闭信息仍保留固定边界,避免改变正文分页。日夜圆形快捷按钮随工具栏一起显隐。
- 正文宿主的上下边界不依赖工具栏状态。工具栏覆盖正文,显隐不得改变页码、滚动位置、正文尺寸。
- 四个面板使用统一底部半屏布局;外观放排版与主题,设置放翻页行为,朗读放语音设置和播放控制。
- “外观”面板按使用频率排序:字号、行距、主题(阅读主题:“跟随应用”或黑白灰,用各主题真实的底色与墨色绘制的色块)、明暗(跟随系统/浅色/深色)、亮度排在首屏;“更多排版”(页边距、对齐、字体,以及 TXT 的缩进、段间距、编码)和“页眉页脚”(四个阅读信息)收进默认折叠的区块。漫画不显示文字排版,只保留主题、明暗、亮度和“页眉页脚”。
- “外观”面板按使用频率排序:纸色(跟随主题、白纸、暖黄、浅绿、灰调、夜间)、字号、行距、主题(阅读主题:“跟随应用”或黑白灰,用各主题真实的底色与墨色绘制的色块)、明暗(跟随系统/浅色/深色)、亮度排在首屏;“更多排版”(页边距、对齐、字体,以及 TXT 的缩进、段间距、编码)和“页眉页脚”(四个阅读信息)收进默认折叠的区块。漫画不显示文字排版,只保留主题、明暗、亮度和“页眉页脚”。
- 纸色是阅读页单独的设置(`AppSettings.paper`,按设备保存,与字号一样),只改页面的底色、文字和页眉页脚(`.reader-screen[data-paper]` 覆盖 `--reader-paper/ink/muted/line`),工具栏和面板仍用阅读主题。夜间纸色让阅读器整体变深;浅色纸色在应用为深色时默认让位给主题的深色取值,可在“跟随系统深色模式”开关关掉。外观与主题页也提供同一设置。V3 之前的 sepia/green/dark 页面主题首次读取时迁移成暖黄/浅绿/夜间。
- TXT 默认字号为基准的 1.125 倍、行距 1.8,支持两字缩进与段间距;EPUB 默认保留作者排版。
- 阅读主题默认“跟随应用”;也可选阅读专用的黑白灰,只写在阅读器容器上(`data-theme="gray"`),浅色和夜间跟随应用的明暗。日夜快捷按钮把明暗设为浅色或深色(离开“跟随系统”),保留当前应用主题。电纸书预设使用黑白灰浅色。漫画隐藏文字排版选项。
+7 -2
View File
@@ -71,11 +71,16 @@
}
/* ---- reading pages wear the theme too; their --reader-* names alias the tokens
* (theme-tokens.css). The reader may wear 黑白灰 on its own container. ---- */
:is(.shelf-screen, .library-screen, .sources-screen, .home-page, .reader-screen) {
* (theme-tokens.css). The reader may wear 黑白灰 on its own container, and a
* reading paper on its page (--reader-paper / --reader-ink). ---- */
:is(.shelf-screen, .library-screen, .sources-screen, .home-page) {
background: var(--color-bg);
color: var(--color-ink);
}
.reader-screen {
background: var(--reader-paper);
color: var(--reader-ink);
}
/* The old fixed channel rail is gone; its top gutter goes with it. */
@media (min-width: 701px) {
@@ -4,6 +4,7 @@ import {PlaybackSettings} from './playback-settings.tsx';
import {MediaThemeSettings} from './theme-settings.tsx';
import {themeSummary} from '../../../shared/theme/theme.ts';
import {useThemeState} from '../../../shared/theme/use-theme.ts';
import type {AppSettings} from '../../../store/settings.ts';
export type SettingsPanel = 'home'|'theme'|'browse'|'playback'|'plugins'|'account';
import { useState } from 'react';
import {ChevronLeft,ChevronRight,Clock3,Heart,Headphones,FolderOpen,SlidersHorizontal,RefreshCw,UserRound,Palette} from 'lucide-react';
@@ -12,7 +13,7 @@ import type {MediaApi,MediaAccount} from '../api/media-api.ts';
import {MediaProviderSettings} from './provider-settings.tsx';
import {defaultMediaPreferences,saveMediaPreferences,type MediaPreferences} from '../services/preferences.ts';
export function MediaSettings({scope,preferences,player,admin,api: _api,account,onSaved,onBack,onPersonal,onManage,onTasks,onPlayback,panel:routePanel,onPanelChange}:{scope:string;preferences:MediaPreferences;player:MediaPlayer;admin:boolean;api?:MediaApi;account?:MediaAccount|null;onSaved:(value:MediaPreferences)=>void;onBack:()=>void;onPersonal:(view:'favorites'|'history'|'queue')=>void;onManage:()=>void;onTasks?:()=>void;onPlayback?:()=>void;panel?:SettingsPanel;onPanelChange?:(panel:SettingsPanel)=>void;channelLabel?:string}){
export function MediaSettings({scope,preferences,player,admin,api: _api,account,onSaved,onBack,onPersonal,onManage,onTasks,onPlayback,panel:routePanel,onPanelChange,onReaderSettings}:{scope:string;preferences:MediaPreferences;player:MediaPlayer;admin:boolean;api?:MediaApi;account?:MediaAccount|null;onSaved:(value:MediaPreferences)=>void;onBack:()=>void;onPersonal:(view:'favorites'|'history'|'queue')=>void;onManage:()=>void;onTasks?:()=>void;onPlayback?:()=>void;panel?:SettingsPanel;onPanelChange?:(panel:SettingsPanel)=>void;channelLabel?:string;onReaderSettings?:(patch:Partial<AppSettings>)=>void}){
const {preference:themePreference}=useThemeState(scope);
const [localPanel,setLocalPanel]=useState<SettingsPanel>('home');
const panel=routePanel??localPanel;
@@ -41,7 +42,7 @@ export function MediaSettings({scope,preferences,player,admin,api: _api,account,
</section>
{admin&&<section className="media-settings-group"><h2>管理员</h2>{link(FolderOpen,'媒体库管理','全部影音媒体库',onManage)}{onTasks&&link(RefreshCw,'扫描与刮削','任务与匹配结果',onTasks)}</section>}
{account&&<section className="media-settings-group"><h2>账号</h2>{link(UserRound,'账号与连接','',()=>setPanel('account'))}</section>}
</>:panel==='theme'?<MediaThemeSettings scope={scope}/>:panel==='browse'?<form className="media-form media-settings-panel" onSubmit={event=>{event.preventDefault();save();}}>
</>:panel==='theme'?<MediaThemeSettings scope={scope} {...(onReaderSettings?{onReaderSettings}:{})}/>:panel==='browse'?<form className="media-form media-settings-panel" onSubmit={event=>{event.preventDefault();save();}}>
<p>仅影响影音页面,在当前设备为此服务器和账号保存。</p>
<label className="media-setting-field">封面密度<MediaSelect aria-label="封面密度" value={draft.density} onChange={event=>change({density:event.currentTarget.value as MediaPreferences['density']})}><option value="compact">紧凑(手机三列)</option><option value="comfortable">宽松(手机两列)</option></MediaSelect></label>
<label className="media-setting-field">默认排序<MediaSelect aria-label="默认排序" value={draft.sort??'default'} onChange={event=>change({sort:event.currentTarget.value as NonNullable<MediaPreferences['sort']>})}><option value="default">默认顺序</option><option value="title-asc">名称升序</option><option value="title-desc">名称降序</option></MediaSelect></label>
@@ -1,10 +1,12 @@
import { useState } from 'react';
import { Check, SunMoon } from 'lucide-react';
import {
appThemes, resolveAppTheme, saveThemePreference, themes,
type AppThemeId, type ThemeId, type ThemeMode, type ThemePreference,
appThemes, paperIds, readerPapers, resolveAppTheme, resolveReadingTheme, saveThemePreference, themes,
type AppThemeId, type ReaderPaper, type ThemeId, type ThemeMode, type ThemePreference,
} from '../../../shared/theme/theme.ts';
import { useThemeState } from '../../../shared/theme/use-theme.ts';
import { useSettingsStore } from '../../../shared/stores/settings.store.ts';
import type { AppSettings } from '../../../store/settings.ts';
/** Both halves of a theme, light on the left and dark on the right, in its own colours. */
function ThemePreview({ id }: { id: ThemeId }) {
@@ -37,8 +39,35 @@ const modeOptions: readonly { value: ThemeMode; label: string }[] = [{ value: 'l
* 外观与主题 (V3.1): the app theme (暖陶, 雾蓝, 青绿), its light/dark mode or the
* follow-system pair, and the reading theme (跟随应用 or the reading-only 黑白灰).
*/
export function MediaThemeSettings({ scope }: { scope: string }) {
/**
* The reading paper: the page's own colours, a per-device reader setting (the same
* one as the reader's 外观 sheet), shown here so the whole look is set in one place.
*/
function ReadingPapers({ themeId, settings, onChange }: { themeId: ThemeId; settings: Pick<AppSettings, 'paper' | 'paperFollowsDark'>; onChange(patch: Partial<AppSettings>): void }) {
const themed = themes[themeId].light;
const options: Array<{ value: ReaderPaper; label: string; bg: string; ink: string }> = [
{ value: 'theme', label: '跟随主题', bg: themed.bg, ink: themed.ink },
...paperIds.map(id => ({ value: id, label: readerPapers[id].name, bg: readerPapers[id].bg, ink: readerPapers[id].ink })),
];
const lightPaper = settings.paper !== 'theme' && !readerPapers[settings.paper].dark;
return <>
<div className="theme-papers" role="group" aria-label="阅读纸色">{options.map(option =>
<button type="button" key={option.value} aria-label={option.label} aria-pressed={settings.paper === option.value} onClick={() => onChange({ paper: option.value })}>
<span aria-hidden="true" style={{ background: option.bg, color: option.ink }}>Aa</span>{option.label}
</button>)}</div>
{lightPaper && <div className="theme-settings-card theme-paper-follow">
<button type="button" className="theme-follow" aria-label="跟随系统深色模式" aria-pressed={settings.paperFollowsDark} onClick={() => onChange({ paperFollowsDark: !settings.paperFollowsDark })}>
<span className="theme-follow-text"><strong>跟随系统深色模式</strong><small>应用为深色时,纸色换成主题的深色</small></span>
<span className="theme-switch" aria-hidden="true" />
</button>
</div>}
</>;
}
export function MediaThemeSettings({ scope, onReaderSettings }: { scope: string; onReaderSettings?: (patch: Partial<AppSettings>) => void }) {
const { preference, systemDark } = useThemeState(scope);
const paper = useSettingsStore(state => state.settings.paper);
const paperFollowsDark = useSettingsStore(state => state.settings.paperFollowsDark);
const [error, setError] = useState(''), [saved, setSaved] = useState(false);
const following = preference.mode === 'system';
const resolved = resolveAppTheme(preference, systemDark);
@@ -74,6 +103,12 @@ export function MediaThemeSettings({ scope }: { scope: string }) {
<ThemeCard id="gray" label={themes.gray.name} note="阅读专用 · 浅色 · 深色" pressed={preference.reading === 'gray'} onClick={() => save({ reading: 'gray' })} />
</div>
<p className="theme-settings-note">黑白灰只用于阅读器,可以与应用主题分开设置;浅色和夜间跟随应用的明暗。主题偏好按服务器和账号保存在此设备。</p>
{onReaderSettings && <>
<h2>阅读纸色</h2>
<ReadingPapers themeId={resolveReadingTheme(preference, systemDark).theme} settings={{ paper, paperFollowsDark }} onChange={patch => { onReaderSettings(patch); setSaved(true); setError(''); }} />
<p className="theme-settings-note">纸色只改阅读页的底色和文字,工具栏仍用阅读主题;也可在阅读页的“外观”里切换。纸色按设备保存。</p>
</>}
{saved && <p role="status">主题已保存</p>}{error && <p className="media-error" role="alert">{error}</p>}
</section>;
}
@@ -45,6 +45,7 @@ export function MediaSettingsPage() {
onManage={() => navigate(`/media/${channel}/settings/libraries`)}
onTasks={() => navigate(`/media/${channel}/settings/tasks`, { state: { returnTo: `/media/${channel}/settings` } })}
onPlayback={() => navigate(`/media/${channel}/player`)}
onReaderSettings={patch => { void runtime.updateSettings(patch); }}
/>
</MediaPageFrame>;
}
@@ -1012,3 +1012,8 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.theme-card>small {display:block;font-size:12px;color:var(--muted)}
.theme-settings-note {margin:12px 0 0;font-size:12px;line-height:1.6;color:var(--muted)}
.theme-settings>p[role] {margin:12px 0 0;font-size:12px}
.theme-papers {display:flex;flex-wrap:wrap;gap:12px 8px;justify-content:space-between}
.media-screen .theme-papers>button {display:flex;flex-direction:column;align-items:center;gap:6px;min-height:0;padding:0;font-size:12px;color:var(--text)}
.theme-papers>button>span {display:grid;place-items:center;width:48px;height:48px;box-sizing:border-box;border:1px solid var(--border);border-radius:50%;font-family:'Songti SC','Noto Serif SC','Source Han Serif SC',serif;font-size:18px;font-weight:700}
.theme-papers>button[aria-pressed=true]>span {border:3px solid var(--media-accent)}
.theme-paper-follow {margin-top:12px}
+36
View File
@@ -177,6 +177,42 @@ export function resolveReadingTheme(preference: ThemePreference, systemDark: boo
export const themeTokens = ({ theme, mode }: ResolvedTheme): ThemeTokens => themes[theme][mode];
/**
* Reading papers: the page's own colours, a per-device reader setting (like the font
* size). They tint only the reading surface — paper, ink, the readouts — while the
* reader's chrome keeps the reading theme. `theme` means "no paper, use the theme".
* The stylesheet mirrors these in theme-tokens.css (`.reader-screen[data-paper]`).
*/
export const readerPapers = {
white: { id: 'white', name: '白纸', dark: false, bg: '#FFFFFF', ink: '#1A1A1A', muted: '#666666', line: '#E0E0E0' },
warm: { id: 'warm', name: '暖黄', dark: false, bg: '#F4ECD8', ink: '#3B3226', muted: '#6B5D47', line: '#E2D5B8' },
green: { id: 'green', name: '浅绿', dark: false, bg: '#DDE9D6', ink: '#26331F', muted: '#4C5E43', line: '#C5D6BB' },
gray: { id: 'gray', name: '灰调', dark: false, bg: '#E6E6E3', ink: '#2A2A2A', muted: '#575754', line: '#D1D1CD' },
night: { id: 'night', name: '夜间', dark: true, bg: '#1D1C1A', ink: '#D8D4CA', muted: '#9D988D', line: '#34322E' },
} as const;
export type PaperId = keyof typeof readerPapers;
export type ReaderPaper = 'theme' | PaperId;
export const paperIds = Object.keys(readerPapers) as PaperId[];
export const validReaderPaper = (value: unknown): ReaderPaper => value === 'theme' || paperIds.includes(value as PaperId) ? value as ReaderPaper : 'theme';
/** What the reading screen wears: a theme and mode for its chrome, and the paper (if any) for its page. */
export interface ReaderSurface extends ResolvedTheme { paper: PaperId | null }
/**
* - No paper: the reading theme, in the app's mode.
* - 夜间: a dark page, so the reader's chrome goes dark with it.
* - A light paper: shown in light mode. When the app is dark and the paper follows
* the system's dark mode (the default), the page gives way to the theme's dark
* values; otherwise the reader stays light around the paper.
*/
export function resolveReaderSurface(preference: ThemePreference, systemDark: boolean, paper: ReaderPaper, followDark: boolean): ReaderSurface {
const reading = resolveReadingTheme(preference, systemDark);
if (paper === 'theme') return { ...reading, paper: null };
if (readerPapers[paper].dark) return { theme: reading.theme, mode: 'dark', paper };
if (reading.mode === 'dark' && followDark) return { ...reading, paper: null };
return { theme: reading.theme, mode: 'light', paper };
}
/** One subscription for every place that shows the theme: same-tab saves, other tabs, the OS. */
export function subscribeTheme(scope: string, notify: () => void): () => void {
const query = typeof window.matchMedia === 'function' ? window.matchMedia('(prefers-color-scheme: dark)') : undefined;
+21
View File
@@ -1,5 +1,6 @@
import type { KeyValueStore } from '../core/platform.ts';
import type { ViewSettings } from '../ui/reader-view.ts';
import { validReaderPaper, type ReaderPaper } from '../shared/theme/theme.ts';
/**
* Reader preferences, persisted per device.
@@ -53,6 +54,13 @@ export interface AppSettings extends ViewSettings {
txtEncoding: string;
/** Only meaningful for fixed-layout books. */
comicDirection: 'ltr' | 'rtl';
/**
* The page's own colours (阅读纸色): 白纸, 暖黄, 浅绿, 灰调, 夜间, or `theme` for the
* reading theme's. Per device, like the font size; the chrome keeps its theme.
*/
paper: ReaderPaper;
/** A light paper gives way to the theme's dark values while the app is dark. */
paperFollowsDark: boolean;
/** Read-aloud preferences. The voice itself is per device, like the rest. */
ttsRate: number;
ttsPitch: number;
@@ -111,6 +119,8 @@ export const DEFAULT_APP_SETTINGS: AppSettings = {
txtParagraphGap: 0.55,
txtEncoding: '',
comicDirection: 'ltr',
paper: 'theme',
paperFollowsDark: true,
ttsRate: 1,
ttsPitch: 1,
ttsVolume: 1,
@@ -154,6 +164,17 @@ export class SettingsStore {
* `title` and `author` are untouched: they still mean what they meant.
*/
if (this.settings.shelfSort === ('updated' as string)) this.settings.shelfSort = 'added';
/*
* The reading paper is the reader's old page theme coming back as its own
* setting: a device that had chosen sepia, green or dark before V3 gets the
* matching paper the first time, instead of silently losing it.
*/
if (parsed.paper === undefined) {
const legacy: Record<string, ReaderPaper> = { sepia: 'warm', green: 'green', dark: 'night' };
this.settings.paper = legacy[String(parsed.theme)] ?? 'theme';
}
this.settings.paper = validReaderPaper(this.settings.paper);
if (typeof this.settings.paperFollowsDark !== 'boolean') this.settings.paperFollowsDark = true;
if (!['recent', 'added', 'title', 'author'].includes(this.settings.shelfSort)) {
this.settings.shelfSort = DEFAULT_APP_SETTINGS.shelfSort;
}
+17 -8
View File
@@ -1792,19 +1792,22 @@ book-content {
/* ---- shelf settings sheet ---- */
.shelf-settings .switch-field {
.shelf-settings .switch-field,
.reader-screen .panel .switch-field {
flex-direction: row;
align-items: center;
gap: 0.75rem;
}
.shelf-settings .switch-text {
.shelf-settings .switch-text,
.reader-screen .panel .switch-text {
flex: 1 1 auto;
min-width: 0;
font-size: 0.9rem;
}
.shelf-settings .switch-text .muted {
.shelf-settings .switch-text .muted,
.reader-screen .panel .switch-text .muted {
font-size: 0.72rem;
margin-top: 0.15rem;
}
@@ -1817,7 +1820,8 @@ book-content {
* this on" — a separate class would be a second one, and the two would eventually
* disagree.
*/
.shelf-settings .switch {
.shelf-settings .switch,
.reader-screen .panel .switch {
flex: 0 0 auto;
position: relative;
width: 2.7rem;
@@ -1829,7 +1833,8 @@ book-content {
transition: background var(--motion-duration) var(--motion-ease);
}
.shelf-settings .switch::after {
.shelf-settings .switch::after,
.reader-screen .panel .switch::after {
content: '';
position: absolute;
top: 0.2rem;
@@ -1842,11 +1847,13 @@ book-content {
transition: inset-inline-start var(--motion-duration) var(--motion-ease);
}
.shelf-settings .switch[aria-pressed='true'] {
.shelf-settings .switch[aria-pressed='true'],
.reader-screen .panel .switch[aria-pressed='true'] {
background: var(--reader-fill);
}
.shelf-settings .switch[aria-pressed='true']::after {
.shelf-settings .switch[aria-pressed='true']::after,
.reader-screen .panel .switch[aria-pressed='true']::after {
inset-inline-start: 1.3rem;
}
@@ -1862,7 +1869,9 @@ book-content {
}
.shelf-settings .switch,
.shelf-settings .switch::after {
.shelf-settings .switch::after,
.reader-screen .panel .switch,
.reader-screen .panel .switch::after {
transition: none;
}
+12
View File
@@ -293,3 +293,15 @@
:root, [data-theme][data-mode="light"] { color-scheme: light; }
[data-theme][data-mode="dark"] { color-scheme: dark; }
@media (prefers-color-scheme: dark) { :root:not([data-mode]) { color-scheme: dark; } }
/*
* Reading papers (阅读纸色): a reader setting that tints only the page — paper, ink,
* readouts — and leaves the reader's chrome on its theme. Mirrors readerPapers in
* shared/theme/theme.ts. Same specificity as the alias block above, and later, so a
* paper wins on the reader container even when it carries its own data-theme.
*/
.reader-screen[data-paper="white"] { --reader-paper: #FFFFFF; --reader-ink: #1A1A1A; --reader-muted: #666666; --reader-line: #E0E0E0; }
.reader-screen[data-paper="warm"] { --reader-paper: #F4ECD8; --reader-ink: #3B3226; --reader-muted: #6B5D47; --reader-line: #E2D5B8; }
.reader-screen[data-paper="green"] { --reader-paper: #DDE9D6; --reader-ink: #26331F; --reader-muted: #4C5E43; --reader-line: #C5D6BB; }
.reader-screen[data-paper="gray"] { --reader-paper: #E6E6E3; --reader-ink: #2A2A2A; --reader-muted: #575754; --reader-line: #D1D1CD; }
.reader-screen[data-paper="night"] { --reader-paper: #1D1C1A; --reader-ink: #D8D4CA; --reader-muted: #9D988D; --reader-line: #34322E; }
+53 -4
View File
@@ -1,5 +1,5 @@
import type { CSSProperties } from 'react';
import { themes, type AppThemeId, type ModePreference, type ResolvedTheme, type ThemeMode, type ThemePreference } from '../shared/theme/theme.ts';
import { paperIds, readerPapers, themes, type AppThemeId, type ModePreference, type ReaderPaper, type ReaderSurface, type ResolvedTheme, type ThemeMode, type ThemePreference } from '../shared/theme/theme.ts';
import type { ChapterQuality } from '../api/sources.ts';
import { Modal } from './modal.tsx';
import type { SourcePage } from '../api/sources.ts';
@@ -27,7 +27,7 @@ import { DEFAULT_APP_SETTINGS, READOUT_FIELDS, READOUT_OPTIONS, type ReadoutMode
import { ReaderIndicators } from './reader-indicators.tsx';
import type { SpeechEngineKind } from '../render/speech.ts';
import { type ReactNode, type JSX, useLayoutEffect, useRef, useState } from 'react';
import { Button, IconButton, SectionTitle } from './toolkit.tsx';
import { Button, IconButton, SectionTitle, SwitchRow } from './toolkit.tsx';
import { Icon, type IconName } from './icon.tsx';
/**
@@ -124,6 +124,8 @@ export interface ChromeState {
txtIndent: number;
txtParagraphGap: number;
comicDirection: AppSettings['comicDirection'];
paper: ReaderPaper;
paperFollowsDark: boolean;
ttsRate: number;
ttsPitch: number;
ttsVolume: number;
@@ -212,7 +214,20 @@ export function ReaderChrome({ state, stage, handlers, tools }: ReaderChromeProp
const pageLabel = '本章第 ' + currentPage + '/' + totalPages + ' 页';
const tab = state.settingsTab ?? 'behavior';
const theme = handlers.theme();
const dark = theme.reading.mode === 'dark';
const dark = theme.surface.mode === 'dark';
const paper = state.paper ?? 'theme';
const lightPaper = paper !== 'theme' && !readerPapers[paper].dark;
// Day/night is about what the page looks like now, paper included.
const toggleNight = () => {
if (dark) {
if (paper !== 'theme' && readerPapers[paper].dark) handlers.onSetting({ paper: 'theme' });
if (theme.app.mode === 'dark') handlers.onTheme({ mode: 'light', theme: theme.app.theme });
} else if (lightPaper && !(state.paperFollowsDark ?? true)) {
handlers.onSetting({ paper: 'night' });
} else {
handlers.onTheme({ mode: 'dark', theme: theme.app.theme });
}
};
return (
<>
{tools}
@@ -235,7 +250,7 @@ export function ReaderChrome({ state, stage, handlers, tools }: ReaderChromeProp
{/* Day/night picks the mode explicitly (leaving 跟随系统), keeping the theme on screen. */}
<RailButton icon={dark ? 'sun' : 'moon'}
label={dark ? '日间' : '夜间'}
onClick={() => handlers.onTheme({ mode: dark ? 'light' : 'dark', theme: theme.app.theme })} />
onClick={toggleNight} />
</div>
<div className="footer" hidden={!state.chromeVisible}>
<div className="progress-row">
@@ -428,6 +443,11 @@ function SettingsBody({ state, handlers }: { state: ChromeState; handlers: Chrom
{/* 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. */}
<PaperRow state={state} surface={handlers.theme().surface} onChange={(paper) => handlers.onSetting({ paper })} />
{state.paper !== 'theme' && state.paper !== 'night' && state.paper !== undefined ? (
<SwitchRow label="跟随系统深色模式" hint="应用为深色时,纸色换成主题的深色"
checked={state.paperFollowsDark ?? true} onChange={(value) => handlers.onSetting({ paperFollowsDark: value })} />
) : null}
{state.layout !== 'fixed' ? <>
<SliderRow
label="字号"
@@ -822,6 +842,35 @@ export interface ReaderThemeState {
preference: ThemePreference;
app: { theme: AppThemeId; mode: ThemeMode };
reading: ResolvedTheme;
/** The reading theme and paper together: what the reading screen actually wears. */
surface: ReaderSurface;
}
/**
* The reading paper (阅读纸色), drawn in each paper's own colours: 跟随主题 is the
* reading theme's page, then 白纸, 暖黄, 浅绿, 灰调, 夜间. A reader setting, per device.
*/
function PaperRow({ state, surface, onChange }: { state: ChromeState; surface: ReaderSurface; onChange(paper: ReaderPaper): void }): JSX.Element {
const current = state.paper ?? 'theme';
const themed = themes[surface.theme][surface.paper ? (readerPapers[surface.paper].dark ? 'dark' : 'light') : surface.mode];
const options: Array<{ value: ReaderPaper; label: string; bg: string; ink: string; line: string }> = [
{ value: 'theme', label: '跟随主题', bg: themed.bg, ink: themed.ink, line: themed.line },
...paperIds.map((id) => ({ value: id, label: readerPapers[id].name, bg: readerPapers[id].bg, ink: readerPapers[id].ink, line: readerPapers[id].line })),
];
return (
<div className="field is-stacked">
<label>纸色</label>
<div className="theme-swatches paper-swatches" role="group" aria-label="纸色">
{options.map((option) => (
<button type="button" key={option.value} className="theme-swatch" aria-pressed={current === option.value} onClick={() => onChange(option.value)}>
<span className="theme-swatch-chip" aria-hidden="true"
style={{ background: option.bg, color: option.ink, '--chip-line': option.line } as CSSProperties}>Aa</span>
<span className="theme-swatch-label">{option.label}</span>
</button>
))}
</div>
</div>
);
}
const MODE_OPTIONS = [
+17 -7
View File
@@ -1,4 +1,4 @@
import { readThemePreference, resolveAppTheme, resolveReadingTheme, saveThemePreference, subscribeTheme, systemPrefersDark } from '../shared/theme/theme.ts';
import { readThemePreference, resolveAppTheme, resolveReaderSurface, resolveReadingTheme, saveThemePreference, subscribeTheme, systemPrefersDark } from '../shared/theme/theme.ts';
import { applyCorrections, emptyOverrides, type ReadingOverrides } from './reading-overrides.ts';
import { loadTxt } from '../formats/txt.ts';
import { ApiError } from '../api/errors.ts';
@@ -235,8 +235,8 @@ export class ReaderScreen {
// is what makes "cannot be half-hidden" true by construction rather than by two
// rules that have to be kept in agreement.
this.element.dataset['chrome'] = 'visible';
// The reading theme: either the app's (no attributes, inherit <html>) or 黑白灰 on
// this container only, in the app's light/dark mode.
// The reading theme (the app's, or 黑白灰) and the reading paper, on this
// container only; see resolveReaderSurface.
this.applyReadingTheme();
this.themeOff = subscribeTheme(this.themeScope(), () => { this.applyReadingTheme(); if (!this.disposed) this.patch({}); });
this.element.style.cssText = 'flex:1 1 auto;min-height:0;display:flex;flex-direction:column;position:relative;';
@@ -248,10 +248,15 @@ export class ReaderScreen {
private themeScope(): string { return this.options.themeScope ?? 'default'; }
private readerSurface() {
return resolveReaderSurface(readThemePreference(this.themeScope()), systemPrefersDark(), this.settings.paper ?? 'theme', this.settings.paperFollowsDark ?? true);
}
private applyReadingTheme(): void {
const reading = resolveReadingTheme(readThemePreference(this.themeScope()), systemPrefersDark());
if (reading.theme === 'gray') { this.element.dataset['theme'] = 'gray'; this.element.dataset['mode'] = reading.mode; }
else { delete this.element.dataset['theme']; delete this.element.dataset['mode']; }
const surface = this.readerSurface();
this.element.dataset['theme'] = surface.theme;
this.element.dataset['mode'] = surface.mode;
if (surface.paper) this.element.dataset['paper'] = surface.paper; else delete this.element.dataset['paper'];
}
private renderChrome(): void {
@@ -279,7 +284,7 @@ export class ReaderScreen {
theme: () => {
const preference = readThemePreference(this.themeScope());
const systemDark = systemPrefersDark();
return { preference, app: resolveAppTheme(preference, systemDark), reading: resolveReadingTheme(preference, systemDark) };
return { preference, app: resolveAppTheme(preference, systemDark), reading: resolveReadingTheme(preference, systemDark), surface: this.readerSurface() };
},
onTheme: (patch) => { try { saveThemePreference(this.themeScope(), patch); } catch { /* the device refused storage; the choice simply does not stick */ } this.patch({}); },
onSpeechSetting: (patch) => this.updateSpeechSetting(patch),
@@ -1680,6 +1685,7 @@ export class ReaderScreen {
private async updateSetting(patch: Partial<AppSettings>): Promise<void> {
this.options.onSettingsChange(patch);
Object.assign(this.settings, patch);
if ('paper' in patch || 'paperFollowsDark' in patch) this.applyReadingTheme();
// Reading information changes only the chrome, never the book's pagination.
if (Object.keys(patch).some(key => !READOUT_FIELDS.some(field => field.key === key))) {
this.view?.applySettings(this.viewSettings());
@@ -2103,6 +2109,8 @@ function emptyChromeSettings(): Partial<ChromeState> {
txtParagraphGap: 0.55,
txtEncoding: '',
comicDirection: 'ltr',
paper: 'theme',
paperFollowsDark: true,
ttsRate: 1,
ttsPitch: 1,
ttsVolume: 1,
@@ -2175,6 +2183,8 @@ function settingsView(
txtIndent: settings.txtIndent,
txtParagraphGap: settings.txtParagraphGap,
comicDirection: settings.comicDirection,
paper: settings.paper,
paperFollowsDark: settings.paperFollowsDark,
ttsRate: settings.ttsRate,
ttsPitch: settings.ttsPitch,
ttsVolume: settings.ttsVolume,
+33 -5
View File
@@ -19,8 +19,9 @@ import { defaultThemePreference, type ThemePreference } from '../src/shared/them
const themeState = (preference: Partial<ThemePreference> = {}, dark = false): ReaderThemeState => {
const merged = { ...defaultThemePreference, ...preference };
const mode = dark ? 'dark' : 'light';
return { preference: merged, app: { theme: 'warm', mode }, reading: { theme: merged.reading === 'gray' ? 'gray' : 'warm', mode } };
const mode: 'dark' | 'light' = dark ? 'dark' : 'light';
const reading = { theme: merged.reading === 'gray' ? 'gray' as const : 'warm' as const, mode };
return { preference: merged, app: { theme: 'warm', mode }, reading, surface: { ...reading, paper: null } };
};
function chromeState(overrides: Partial<ChromeState> = {}): ChromeState {
@@ -68,6 +69,8 @@ function chromeState(overrides: Partial<ChromeState> = {}): ChromeState {
txtIndent: 2,
txtParagraphGap: 0.55,
comicDirection: 'ltr',
paper: 'theme',
paperFollowsDark: true,
ttsRate: 1,
ttsPitch: 1,
ttsVolume: 1,
@@ -183,7 +186,7 @@ describe('ReaderChrome', () => {
);
// 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:页眉页脚']);
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();
@@ -191,7 +194,7 @@ describe('ReaderChrome', () => {
it('offers the reading themes as swatches, follow-app or 黑白灰, and marks the current one', () => {
const tree = paint({ settingsOpen: true, settingsTab: 'appearance' });
const swatches = [...tree.querySelectorAll<HTMLButtonElement>('.theme-swatch')];
const swatches = [...tree.querySelectorAll<HTMLButtonElement>('[aria-label="主题"] .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.querySelector('.theme-swatch-label')?.textContent)).toEqual(['跟随应用']);
// Each chip is drawn in that theme's own background (light mode here): 黑白灰 #F5F5F5.
@@ -206,7 +209,7 @@ describe('ReaderChrome', () => {
const patches: Array<Partial<ThemePreference>> = [];
render(h(ReaderChrome, { state: chromeState({ settingsOpen: true, settingsTab: 'appearance' }), stage: document.createElement('div'), handlers: handlers(themeState({}, true), (patch) => patches.push(patch)) }), container);
const tree = container;
[...tree.querySelectorAll<HTMLButtonElement>('.theme-swatch')][1]!.click();
[...tree.querySelectorAll<HTMLButtonElement>('[aria-label="主题"] .theme-swatch')][1]!.click();
[...tree.querySelectorAll<HTMLButtonElement>('[aria-label="明暗"] button')][1]!.click();
// The rail's day/night button: dark now, so it offers day and leaves follow-system.
const rail = tree.querySelector<HTMLButtonElement>('.reader-rail button')!;
@@ -215,6 +218,31 @@ describe('ReaderChrome', () => {
expect(patches).toEqual([{ reading: 'gray' }, { mode: 'light', theme: 'warm' }, { mode: 'light', theme: 'warm' }]);
});
it('offers the reading papers as a page setting, and the follow-dark switch only for a light paper', () => {
const settings: Array<Record<string, unknown>> = [];
const paint2 = (state: Partial<ChromeState>) => {
const h2 = handlers();
(h2 as { onSetting: ChromeHandlers['onSetting'] }).onSetting = (patch) => settings.push(patch);
render(h(ReaderChrome, { state: chromeState({ settingsOpen: true, settingsTab: 'appearance', ...state }), stage: document.createElement('div'), handlers: h2 }), container);
return container;
};
let tree = paint2({});
const papers = [...tree.querySelectorAll<HTMLButtonElement>('[aria-label="纸色"] .theme-swatch')];
expect(papers.map((button) => button.querySelector('.theme-swatch-label')?.textContent)).toEqual(['跟随主题', '白纸', '暖黄', '浅绿', '灰调', '夜间']);
expect(papers[0]!.getAttribute('aria-pressed')).toBe('true');
// 暖黄 is drawn in its own paper: #F4ECD8.
expect(papers[2]!.querySelector<HTMLElement>('.theme-swatch-chip')!.style.background).toContain('rgb(244, 236, 216)');
expect(tree.textContent).not.toContain('跟随系统深色模式');
papers[2]!.click();
expect(settings).toEqual([{ paper: 'warm' }]);
tree = paint2({ paper: 'warm' });
expect(tree.textContent).toContain('跟随系统深色模式');
tree.querySelector<HTMLButtonElement>('.switch')!.click();
expect(settings).toEqual([{ paper: 'warm' }, { paperFollowsDark: false }]);
tree = paint2({ paper: 'night' });
expect(tree.textContent).not.toContain('跟随系统深色模式');
});
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);
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { SettingsStore } from '../src/store/settings.ts';
import type { KeyValueStore } from '../src/core/platform.ts';
/** A KeyValueStore over a Map, for SettingsStore.load(). */
function memory(initial: Record<string, string>): KeyValueStore {
const values = new Map(Object.entries(initial));
return { get: async (key: string) => values.get(key) ?? null, set: async (key: string, value: string) => { values.set(key, value); }, remove: async (key: string) => { values.delete(key); } };
}
const load = (stored: object) => new SettingsStore(memory({ 'reader.settings.v1': JSON.stringify(stored) })).load();
describe('reading paper setting', () => {
it('defaults to the theme, following the system dark mode', async () => {
const settings = await new SettingsStore(memory({})).load();
expect([settings.paper, settings.paperFollowsDark]).toEqual(['theme', true]);
});
it('brings the pre-V3 page theme back as the matching paper, once', async () => {
expect((await load({ theme: 'sepia' })).paper).toBe('warm');
expect((await load({ theme: 'green' })).paper).toBe('green');
expect((await load({ theme: 'dark' })).paper).toBe('night');
expect((await load({ theme: 'light' })).paper).toBe('theme');
// A paper chosen since wins over the old value.
expect((await load({ theme: 'dark', paper: 'white' })).paper).toBe('white');
});
it('ignores an unknown paper or a malformed switch', async () => {
const settings = await load({ paper: 'sepia', paperFollowsDark: 'yes' });
expect([settings.paper, settings.paperFollowsDark]).toEqual(['theme', true]);
});
});
+13 -1
View File
@@ -1,6 +1,6 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { channels, themes, type ThemeTokens } from '../src/shared/theme/theme.ts';
import { channels, paperIds, readerPapers, themes, type ThemeTokens } from '../src/shared/theme/theme.ts';
/**
* The V3.1 palettes: the stylesheet and the TypeScript mirror carry the same values,
@@ -58,6 +58,18 @@ describe('theme tokens', () => {
});
}
for (const id of paperIds) {
const paper = readerPapers[id];
it(`纸色 ${paper.name}: the stylesheet matches, and ink and muted text are legible`, () => {
const match = stylesheet.match(new RegExp(`\\.reader-screen\\[data-paper="${id}"\\] \\{([^}]*)\\}`));
const declared = Object.fromEntries([...(match?.[1] ?? '').matchAll(/--reader-([\w-]+):\s*([^;]+);/g)].map(m => [m[1]!, m[2]!.trim()]));
expect(declared).toEqual({ paper: paper.bg, ink: paper.ink, muted: paper.muted, line: paper.line });
const failures = [['ink', paper.ink], ['muted', paper.muted]].map(([name, fg]) => ({ name, value: ratio(fg!, paper.bg) }))
.filter(item => item.value < 4.5).map(item => `${item.name}: ${item.value.toFixed(2)} < 4.5`);
expect(failures).toEqual([]);
});
}
it('starts from 暖陶 before a theme is applied, in the device mode', () => {
expect(stylesheet).toMatch(/:root,\s*\[data-theme="warm"\]\[data-mode="light"\] \{/);
expect(stylesheet).toMatch(/@media \(prefers-color-scheme: dark\) \{\s*:root:not\(\[data-mode\]\) \{\s*--color-bg: #1F1713;/);
+15 -1
View File
@@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import {
ThemeController, defaultThemePreference, migrateLegacyTheme, readThemePreference, resolveAppTheme,
resolveReadingTheme, saveThemePreference, themeSummary,
resolveReaderSurface, resolveReadingTheme, saveThemePreference, themeSummary,
} from '../src/shared/theme/theme.ts';
import { useAppThemeDark } from '../src/shared/theme/use-theme.ts';
import { MediaThemeSettings } from '../src/features/media/components/theme-settings.tsx';
@@ -77,6 +77,20 @@ describe('theme preference', () => {
});
});
describe('reading paper', () => {
const light = { ...defaultThemePreference, mode: 'light' as const };
const dark = { ...defaultThemePreference, mode: 'dark' as const };
it('tints only light mode by default, gives way to the theme when the app is dark, and 夜间 is dark', () => {
expect(resolveReaderSurface(light, false, 'theme', true)).toEqual({ theme: 'warm', mode: 'light', paper: null });
expect(resolveReaderSurface(light, false, 'warm', true)).toEqual({ theme: 'warm', mode: 'light', paper: 'warm' });
expect(resolveReaderSurface(dark, false, 'warm', true)).toEqual({ theme: 'warm', mode: 'dark', paper: null });
// Not following the system's dark mode: the paper stays, and the reader stays light around it.
expect(resolveReaderSurface(dark, false, 'warm', false)).toEqual({ theme: 'warm', mode: 'light', paper: 'warm' });
expect(resolveReaderSurface(light, false, 'night', true)).toEqual({ theme: 'warm', mode: 'dark', paper: 'night' });
expect(resolveReaderSurface({ ...light, reading: 'gray' }, false, 'green', true)).toEqual({ theme: 'gray', mode: 'light', paper: 'green' });
});
});
describe('ThemeController', () => {
it('writes data-theme and data-mode on <html>, follows the system, and cleans up', () => {
const system = stubSystem(false);