From dd20dc6a87cf180a4e68a5a058b78bc8c7ea976a Mon Sep 17 00:00:00 2001 From: hectorqin Date: Wed, 4 Nov 2026 13:24:16 +0000 Subject: [PATCH] fix(web): settings page draws shelf switches; theme saves report via Notyf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SwitchRow styles were scoped to `.sheet`, so on 偏好设置 › 书架 the 显示作者/显示阅读进度 switches rendered as empty transparent buttons. Unscope them, and restore the pill over the media page's generic button rule. 主题已保存 and theme save errors were inline

lines; they now go through the global notice (ui.md §3.6), one notice updated in place per panel. ui:review captures the shelf section and measures the switches and the floating theme notice. Co-Authored-By: Claude Opus 5.5 --- web/src/features/account/styles/mine.css | 4 ++++ .../media/components/theme-settings.tsx | 13 ++++++------ web/src/features/media/styles/v2.css | 2 +- web/src/styles/reader.css | 20 +++++++++--------- web/src/styles/sheet.css | 2 +- web/test/theme.test.tsx | 7 +++++-- web/tools/ui-review/react-app.mjs | 21 +++++++++++++++++++ 7 files changed, 49 insertions(+), 20 deletions(-) diff --git a/web/src/features/account/styles/mine.css b/web/src/features/account/styles/mine.css index 6347f36..8dcc0da 100644 --- a/web/src/features/account/styles/mine.css +++ b/web/src/features/account/styles/mine.css @@ -142,6 +142,10 @@ * plain sub-labels, not sticky bars. */ .settings-page .shelf-settings .section-title { position: static; margin: 22px 0 10px; padding: 0; border: 0; background: none; color: var(--color-muted); } .settings-page .shelf-settings .segmented { width: 100%; } +/* The page's generic button rule (min-height, padding, a square radius, no background) + * outranks the toolkit switch; give the switch back its pill (ui.md: one on/off control). */ +.settings-page .shelf-settings .switch { min-height: 0; padding: 0; border: 0; border-radius: var(--radius-pill); background: var(--reader-line); } +.settings-page .shelf-settings .switch[aria-pressed='true'] { background: var(--reader-fill); } .settings-page .settings-link { display: flex; diff --git a/web/src/features/media/components/theme-settings.tsx b/web/src/features/media/components/theme-settings.tsx index 43cc39f..13610d3 100644 --- a/web/src/features/media/components/theme-settings.tsx +++ b/web/src/features/media/components/theme-settings.tsx @@ -1,5 +1,6 @@ import { Icon } from '../../../ui/icon.tsx'; -import { useState } from 'react'; +import { useRef } from 'react'; +import { notify } from '../../../ui/notifications.ts'; import { appThemes, paperIds, readerPapers, resolveAppTheme, resolveReadingTheme, saveThemePreference, themes, type AppThemeId, type ReaderPaper, type ThemeId, type ThemeMode, type ThemePreference, @@ -68,12 +69,13 @@ export function MediaThemeSettings({ scope, onReaderSettings }: { scope: string; 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); + // One notice for the panel: a run of taps updates it in place instead of stacking (ui.md §3.6). + const notice = useRef(Symbol('theme-notice')); + const report = (message: string, kind: 'success' | 'error') => notify(message, { kind }, notice.current); const following = preference.mode === 'system'; const resolved = resolveAppTheme(preference, systemDark); function save(patch: Partial) { - setError(''); setSaved(false); - try { saveThemePreference(scope, patch); setSaved(true); } catch (cause) { setError(cause instanceof Error ? cause.message : '主题保存失败'); } + try { saveThemePreference(scope, patch); report('主题已保存', 'success'); } catch (cause) { report(cause instanceof Error ? cause.message : '主题保存失败', 'error'); } } // Leaving follow-system keeps what is on screen now, so the switch never makes the page jump. const toggleFollow = () => save(following ? { mode: resolved.mode, theme: resolved.theme } : { mode: 'system' }); @@ -106,9 +108,8 @@ export function MediaThemeSettings({ scope, onReaderSettings }: { scope: string; {onReaderSettings && <>

阅读纸色

- { onReaderSettings(patch); setSaved(true); setError(''); }} /> + { onReaderSettings(patch); report('主题已保存', 'success'); }} />

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

} - {saved &&

主题已保存

}{error &&

{error}

} ; } diff --git a/web/src/features/media/styles/v2.css b/web/src/features/media/styles/v2.css index 401927b..21185ac 100644 --- a/web/src/features/media/styles/v2.css +++ b/web/src/features/media/styles/v2.css @@ -86,7 +86,7 @@ .media-screen .media-settings {max-width:760px;margin-inline:auto} .media-screen .media-settings-panel,.media-screen .media-form {max-width:760px;margin-inline:auto} .media-screen .media-setting-field,.media-screen .media-settings-check {min-height:56px;padding:12px 0;border-bottom:1px solid var(--border)} -/* Boolean preferences wear the same switch as the reader's sheets (.sheet .switch): one control for "on/off" app-wide. */ +/* Boolean preferences wear the same switch as the reader's sheets (.switch): one control for "on/off" app-wide. */ .media-screen .media-settings-check input[role=switch] {appearance:none;-webkit-appearance:none;position:relative;flex:0 0 auto;width:2.7rem;height:1.6rem;min-height:1.6rem;margin:0;padding:0;border:0;border-radius:var(--radius-pill);background:var(--color-line);cursor:pointer;transition:background var(--motion-duration) var(--motion-ease)} .media-screen .media-settings-check input[role=switch]::after {content:'';position:absolute;top:.2rem;inset-inline-start:.2rem;width:1.2rem;height:1.2rem;border-radius:50%;background:var(--color-surface);box-shadow:0 1px 2px rgb(0 0 0 / .25);transition:inset-inline-start var(--motion-duration) var(--motion-ease)} .media-screen .media-settings-check input[role=switch]:checked {background:var(--color-brand)} diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index 8f9e206..28959e1 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -1830,21 +1830,21 @@ book-content { margin-top: 0.85rem; } -/* ---- shelf settings sheet ---- */ +/* ---- switch rows (SwitchRow: sheets and the 偏好设置 page) ---- */ -.sheet .switch-field { +.switch-field { flex-direction: row; align-items: center; gap: 0.75rem; } -.sheet .switch-text { +.switch-text { flex: 1 1 auto; min-width: 0; font-size: var(--text-ui); } -.sheet .switch-text .muted { +.switch-text .muted { font-size: var(--text-meta); margin-top: 0.15rem; } @@ -1857,7 +1857,7 @@ book-content { * this on" — a separate class would be a second one, and the two would eventually * disagree. */ -.sheet .switch { +.switch { flex: 0 0 auto; position: relative; width: 2.7rem; @@ -1869,7 +1869,7 @@ book-content { transition: background var(--motion-duration) var(--motion-ease); } -.sheet .switch::after { +.switch::after { content: ''; position: absolute; top: 0.2rem; @@ -1882,11 +1882,11 @@ book-content { transition: inset-inline-start var(--motion-duration) var(--motion-ease); } -.sheet .switch[aria-pressed='true'] { +.switch[aria-pressed='true'] { background: var(--reader-fill); } -.sheet .switch[aria-pressed='true']::after { +.switch[aria-pressed='true']::after { inset-inline-start: 1.3rem; } @@ -1901,8 +1901,8 @@ book-content { animation: none; } - .sheet .switch, - .sheet .switch::after { + .switch, + .switch::after { transition: none; } diff --git a/web/src/styles/sheet.css b/web/src/styles/sheet.css index 1fc03c4..dfd1047 100644 --- a/web/src/styles/sheet.css +++ b/web/src/styles/sheet.css @@ -165,7 +165,7 @@ /* A switch row: label and hint take the width, the switch sits on the trailing edge. The row is the 44px hit target (ui.md §3.3); SwitchRow forwards a press on the words. */ -.sheet .switch-field { +.field.switch-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; diff --git a/web/test/theme.test.tsx b/web/test/theme.test.tsx index da206de..5cd1adc 100644 --- a/web/test/theme.test.tsx +++ b/web/test/theme.test.tsx @@ -7,6 +7,7 @@ import { resolveReaderSurface, resolveReadingTheme, saveThemePreference, themeSummary, } from '../src/shared/theme/theme.ts'; import { MediaThemeSettings } from '../src/features/media/components/theme-settings.tsx'; +import { noticeText } from './helpers/notices.ts'; const root = document.createElement('div'); document.body.append(root); @@ -135,7 +136,8 @@ describe('外观与主题', () => { await click('青绿'); expect(readThemePreference('a').system).toEqual({ light: 'green', dark: 'green' }); expect(pressed('青绿')).toBe('true'); - expect(root.querySelector('[role=status]')?.textContent).toBe('主题已保存'); + expect(root.querySelector('[role=status]')).toBeNull(); + expect(noticeText()).toBe('主题已保存'); }); it('leaving follow-system keeps what is on screen, then offers light or dark', async () => { @@ -164,7 +166,8 @@ describe('外观与主题', () => { await act(async () => render(, root)); vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('full'); }); await click('雾蓝'); - expect(root.querySelector('[role=alert]')?.textContent).toContain('无法保存主题'); + expect(root.querySelector('[role=alert]')).toBeNull(); + expect(document.querySelector('.notyf [role=alert]')?.textContent).toContain('无法保存主题'); expect(readThemePreference('a').system.light).toBe('warm'); }); }); diff --git a/web/tools/ui-review/react-app.mjs b/web/tools/ui-review/react-app.mjs index fcf7dfb..89f24fd 100644 --- a/web/tools/ui-review/react-app.mjs +++ b/web/tools/ui-review/react-app.mjs @@ -315,6 +315,7 @@ async function runMediaReview(browser, fixture, checks, errors) { ['media-folders', '/#/media/video/folders/review-lib', '文件夹浏览', '.media-folders'], // 我的: every preference is a section of one page; administration is one page per job. ['preferences', '/#/me/settings', '偏好设置', '.settings-section'], + ['preferences-shelf', '/#/me/settings?section=shelf', '偏好设置', '.settings-section .switch-field'], ['preferences-playback', '/#/me/settings?section=playback', '偏好设置', '.settings-section'], ['account', '/#/me/account', '账号与连接', '.media-account-panel'], ['admin-services', '/#/me/admin/services', '服务配置', '.business-settings'], @@ -333,6 +334,26 @@ async function runMediaReview(browser, fixture, checks, errors) { await capture(page, name, checks, errors); } + // 偏好设置 is a page, not a sheet: the shelf's switches must still be drawn there, and a + // theme change reports through the floating notice rather than a line in the page (§3.6). + await page.goto(`${origin}/#/me/settings?section=shelf`); + await waitMediaHeading(page, '偏好设置'); + const switches = await page.locator('.settings-section .switch').evaluateAll(nodes => nodes.map(node => { + const box = node.getBoundingClientRect(); + const style = getComputedStyle(node); + return { width: box.width, height: box.height, background: style.backgroundColor, radius: parseFloat(style.borderTopLeftRadius) }; + })); + assert.equal(switches.length, 2, 'shelf preferences should show 显示作者 and 显示阅读进度'); + for (const box of switches) { + assert.ok(box.width >= 40 && box.height >= 24 && box.height < 32 && box.radius >= box.height / 2, `settings switch is not the toolkit pill: ${JSON.stringify(box)}`); + assert.notEqual(box.background, 'rgba(0, 0, 0, 0)', 'settings switch has no track colour'); + } + await page.goto(`${origin}/#/me/settings`); + await waitMediaHeading(page, '偏好设置'); + await page.locator('.theme-grid [aria-pressed="false"]').first().click(); + await page.locator('.notyf .global-notice').filter({ hasText: '主题已保存' }).waitFor({ state: 'visible' }); + assert.equal(await page.locator('.settings-section [role=status]').count(), 0, 'theme feedback must not be inline'); + // The browse fixture includes one playable, partially watched video. Keep // this assertion close to the route sweep so the recent continuation card // cannot silently disappear when fixture data or rendering changes.