From c30e07c270f90f227facf814ba279c02fccc2fa6 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:26:51 +0000 Subject: [PATCH] feat(reader): reading papers as a reader setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 白纸 / 暖黄 / 浅绿 / 灰调 / 夜间 (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. --- docs/ui.md | 3 +- web/src/app/shell.css | 9 ++- .../features/media/components/settings.tsx | 5 +- .../media/components/theme-settings.tsx | 41 ++++++++++++- .../media/pages/MediaSettingsPage.tsx | 1 + .../features/media/styles/presentation.css | 5 ++ web/src/shared/theme/theme.ts | 36 ++++++++++++ web/src/store/settings.ts | 21 +++++++ web/src/styles/reader.css | 25 +++++--- web/src/styles/theme-tokens.css | 12 ++++ web/src/ui/reader-chrome.tsx | 57 +++++++++++++++++-- web/src/ui/reader-screen.tsx | 24 +++++--- web/test/reader-chrome.test.ts | 38 +++++++++++-- web/test/reader-paper-settings.test.ts | 31 ++++++++++ web/test/theme-tokens.test.ts | 14 ++++- web/test/theme.test.tsx | 16 +++++- 16 files changed, 304 insertions(+), 34 deletions(-) create mode 100644 web/test/reader-paper-settings.test.ts diff --git a/docs/ui.md b/docs/ui.md index e435719..9ad5439 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -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"`),浅色和夜间跟随应用的明暗。日夜快捷按钮把明暗设为浅色或深色(离开“跟随系统”),保留当前应用主题。电纸书预设使用黑白灰浅色。漫画隐藏文字排版选项。 diff --git a/web/src/app/shell.css b/web/src/app/shell.css index 3169a7f..953d575 100644 --- a/web/src/app/shell.css +++ b/web/src/app/shell.css @@ -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) { diff --git a/web/src/features/media/components/settings.tsx b/web/src/features/media/components/settings.tsx index dc11cee..8d92579 100644 --- a/web/src/features/media/components/settings.tsx +++ b/web/src/features/media/components/settings.tsx @@ -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)=>void}){ const {preference:themePreference}=useThemeState(scope); const [localPanel,setLocalPanel]=useState('home'); const panel=routePanel??localPanel; @@ -41,7 +42,7 @@ export function MediaSettings({scope,preferences,player,admin,api: _api,account, {admin&&

管理员

{link(FolderOpen,'媒体库管理','全部影音媒体库',onManage)}{onTasks&&link(RefreshCw,'扫描与刮削','任务与匹配结果',onTasks)}
} {account&&

账号

{link(UserRound,'账号与连接','',()=>setPanel('account'))}
} - :panel==='theme'?:panel==='browse'?
{event.preventDefault();save();}}> + :panel==='theme'?:panel==='browse'?{event.preventDefault();save();}}>

仅影响影音页面,在当前设备为此服务器和账号保存。

diff --git a/web/src/features/media/components/theme-settings.tsx b/web/src/features/media/components/theme-settings.tsx index f2a2b85..3f47195 100644 --- a/web/src/features/media/components/theme-settings.tsx +++ b/web/src/features/media/components/theme-settings.tsx @@ -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; onChange(patch: Partial): 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 <> +
{options.map(option => + )}
+ {lightPaper &&
+ +
} + ; +} + +export function MediaThemeSettings({ scope, onReaderSettings }: { scope: string; onReaderSettings?: (patch: Partial) => 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 }) { save({ reading: 'gray' })} />

黑白灰只用于阅读器,可以与应用主题分开设置;浅色和夜间跟随应用的明暗。主题偏好按服务器和账号保存在此设备。

+ + {onReaderSettings && <> +

阅读纸色

+ { onReaderSettings(patch); setSaved(true); setError(''); }} /> +

纸色只改阅读页的底色和文字,工具栏仍用阅读主题;也可在阅读页的“外观”里切换。纸色按设备保存。

+ } {saved &&

主题已保存

}{error &&

{error}

} ; } diff --git a/web/src/features/media/pages/MediaSettingsPage.tsx b/web/src/features/media/pages/MediaSettingsPage.tsx index 5f8e87d..8dc871b 100644 --- a/web/src/features/media/pages/MediaSettingsPage.tsx +++ b/web/src/features/media/pages/MediaSettingsPage.tsx @@ -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); }} /> ; } diff --git a/web/src/features/media/styles/presentation.css b/web/src/features/media/styles/presentation.css index 1e87e40..5ba08c8 100644 --- a/web/src/features/media/styles/presentation.css +++ b/web/src/features/media/styles/presentation.css @@ -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} diff --git a/web/src/shared/theme/theme.ts b/web/src/shared/theme/theme.ts index 4001419..1734380 100644 --- a/web/src/shared/theme/theme.ts +++ b/web/src/shared/theme/theme.ts @@ -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; diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index 4486af7..3cfa509 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -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 = { 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; } diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index abd6ee3..ea7a07e 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -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; } diff --git a/web/src/styles/theme-tokens.css b/web/src/styles/theme-tokens.css index 3de4a04..336d7b1 100644 --- a/web/src/styles/theme-tokens.css +++ b/web/src/styles/theme-tokens.css @@ -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; } diff --git a/web/src/ui/reader-chrome.tsx b/web/src/ui/reader-chrome.tsx index 5d7bc2b..0248832 100644 --- a/web/src/ui/reader-chrome.tsx +++ b/web/src/ui/reader-chrome.tsx @@ -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. */} handlers.onTheme({ mode: dark ? 'light' : 'dark', theme: theme.app.theme })} /> + onClick={toggleNight} />