From 451a0e789706ed38107d6abc885df1fb120d51e7 Mon Sep 17 00:00:00 2001 From: Darkatse <39111369+Darkatse@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:20:17 +0800 Subject: [PATCH] feat(settings): add dynamic wallpaper switching to dynamic themes --- src-tauri/src/application/dto/settings_dto.rs | 9 ++ .../application/services/settings_service.rs | 26 ++++ src-tauri/src/domain/models/settings.rs | 9 ++ src/locales/en.json | 12 +- src/locales/zh-cn.json | 10 +- src/locales/zh-tw.json | 10 +- src/scripts/backgrounds.js | 37 +++++- .../setting/setting-panel/settings-app.css | 104 ++++++++++++++- .../setting/setting-panel/settings-patch.js | 20 ++- .../setting/setting-panel/settings-popup.js | 104 ++++++++++++++- .../setting/setting-panel/settings-state.js | 5 +- .../tauri/setting/settings-app/SettingsApp.js | 119 +++++++++++++++--- .../tauri/setting/settings-app/components.js | 37 ++++++ src/tauri/main/adapters/st/appearance.js | 57 +++++++++ .../main/services/dynamic-theme/install.js | 108 ++++++++++------ tests/dynamic-appearance-contract.test.mjs | 67 ++++++++++ tests/tauritavern-settings-patch.test.mjs | 34 +++++ ...tauritavern-settings-vue-contract.test.mjs | 11 ++ 18 files changed, 708 insertions(+), 71 deletions(-) create mode 100644 src/tauri/main/adapters/st/appearance.js create mode 100644 tests/dynamic-appearance-contract.test.mjs diff --git a/src-tauri/src/application/dto/settings_dto.rs b/src-tauri/src/application/dto/settings_dto.rs index b8482472..9817f69a 100644 --- a/src-tauri/src/application/dto/settings_dto.rs +++ b/src-tauri/src/application/dto/settings_dto.rs @@ -67,6 +67,9 @@ pub struct DynamicThemeSettingsDto { pub enabled: bool, pub day_theme: String, pub night_theme: String, + pub wallpaper_enabled: bool, + pub day_wallpaper: String, + pub night_wallpaper: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -74,6 +77,9 @@ pub struct UpdateDynamicThemeSettingsDto { pub enabled: Option, pub day_theme: Option, pub night_theme: Option, + pub wallpaper_enabled: Option, + pub day_wallpaper: Option, + pub night_wallpaper: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -221,6 +227,9 @@ impl From for DynamicThemeSettingsDto { enabled: settings.enabled, day_theme: settings.day_theme, night_theme: settings.night_theme, + wallpaper_enabled: settings.wallpaper_enabled, + day_wallpaper: settings.day_wallpaper, + night_wallpaper: settings.night_wallpaper, } } } diff --git a/src-tauri/src/application/services/settings_service.rs b/src-tauri/src/application/services/settings_service.rs index 8e667924..5b60ce54 100644 --- a/src-tauri/src/application/services/settings_service.rs +++ b/src-tauri/src/application/services/settings_service.rs @@ -161,6 +161,18 @@ impl SettingsService { settings.dynamic_theme.night_theme = night_theme; } + if let Some(wallpaper_enabled) = dynamic_theme.wallpaper_enabled { + settings.dynamic_theme.wallpaper_enabled = wallpaper_enabled; + } + + if let Some(day_wallpaper) = dynamic_theme.day_wallpaper { + settings.dynamic_theme.day_wallpaper = day_wallpaper; + } + + if let Some(night_wallpaper) = dynamic_theme.night_wallpaper { + settings.dynamic_theme.night_wallpaper = night_wallpaper; + } + if settings.dynamic_theme.enabled { if settings.dynamic_theme.day_theme.trim().is_empty() { return Err(ApplicationError::ValidationError( @@ -174,6 +186,20 @@ impl SettingsService { )); } } + + if settings.dynamic_theme.wallpaper_enabled { + if settings.dynamic_theme.day_wallpaper.trim().is_empty() { + return Err(ApplicationError::ValidationError( + "Dynamic wallpaper day wallpaper is required".to_string(), + )); + } + + if settings.dynamic_theme.night_wallpaper.trim().is_empty() { + return Err(ApplicationError::ValidationError( + "Dynamic wallpaper night wallpaper is required".to_string(), + )); + } + } } if let Some(models) = dto.models { diff --git a/src-tauri/src/domain/models/settings.rs b/src-tauri/src/domain/models/settings.rs index a1303033..5010d35a 100644 --- a/src-tauri/src/domain/models/settings.rs +++ b/src-tauri/src/domain/models/settings.rs @@ -103,6 +103,12 @@ pub struct DynamicThemeSettings { pub day_theme: String, #[serde(default)] pub night_theme: String, + #[serde(default)] + pub wallpaper_enabled: bool, + #[serde(default)] + pub day_wallpaper: String, + #[serde(default)] + pub night_wallpaper: String, } impl Default for DynamicThemeSettings { @@ -111,6 +117,9 @@ impl Default for DynamicThemeSettings { enabled: false, day_theme: String::new(), night_theme: String::new(), + wallpaper_enabled: false, + day_wallpaper: String::new(), + night_wallpaper: String::new(), } } } diff --git a/src/locales/en.json b/src/locales/en.json index 4f8f2c57..a0ab7a2f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -18,5 +18,15 @@ "tauritavern.error.network.body_interrupted": "The response was interrupted while it was being read.", "tauritavern.error.network.request_failed": "Network request failed.", "tauritavern.error.network.action": "Check your network, VPN, proxy, or custom endpoint address, then try again.", - "tauritavern.error.network.endpoint_label": "Endpoint" + "tauritavern.error.network.endpoint_label": "Endpoint", + "Dynamic Theme & Wallpaper": "Dynamic Theme & Wallpaper", + "Enable Theme Switching": "Enable Theme Switching", + "Enable Wallpaper Switching": "Enable Wallpaper Switching", + "Day Wallpaper": "Day Wallpaper", + "Night Wallpaper": "Night Wallpaper", + "Choose Wallpaper": "Choose Wallpaper", + "No backgrounds available": "No backgrounds available", + "Dynamic Theme & Wallpaper hint": "Automatically switch TauriTavern themes and global wallpapers with the system light/dark mode.", + "Dynamic Theme help: mapping": "Day = system light mode. Night = system dark mode.", + "Dynamic Theme help: behavior": "Theme switching behaves like changing the theme dropdown. Wallpaper switching updates the global SillyTavern background and respects chat-locked backgrounds." } diff --git a/src/locales/zh-cn.json b/src/locales/zh-cn.json index 0293a1f4..ee78929f 100644 --- a/src/locales/zh-cn.json +++ b/src/locales/zh-cn.json @@ -3891,11 +3891,19 @@ "Character/User avatar originals help: off": "关闭:应用会为角色头像/用户头像提供缓存/生成的缩略图。", "Dynamic Theme": "动态主题", "Enable Dynamic Theme": "启用动态主题", + "Dynamic Theme & Wallpaper": "动态主题与壁纸", + "Enable Theme Switching": "启用主题切换", + "Enable Wallpaper Switching": "启用壁纸切换", "Day Theme": "日间主题", "Night Theme": "夜间主题", + "Day Wallpaper": "日间壁纸", + "Night Wallpaper": "夜间壁纸", + "Choose Wallpaper": "选择壁纸", + "No backgrounds available": "暂无可用背景", "Dynamic Theme hint": "根据系统浅色/深色模式自动切换 TauriTavern 主题。", + "Dynamic Theme & Wallpaper hint": "根据系统浅色/深色模式自动切换 TauriTavern 主题与全局壁纸。", "Dynamic Theme help: mapping": "日间 = 系统浅色模式。夜间 = 系统深色模式。", - "Dynamic Theme help: behavior": "切换效果与手动切换主题相同。", + "Dynamic Theme help: behavior": "主题切换效果与手动切换主题相同。壁纸切换会更新 SillyTavern 全局背景,并尊重聊天锁定的背景。", "Prompt Cache": "提示词缓存", "Claude Prompt Cache": "Claude 提示词缓存", "5m (Default TTL)": "5m(默认 TTL)", diff --git a/src/locales/zh-tw.json b/src/locales/zh-tw.json index adb067c0..cf563b70 100644 --- a/src/locales/zh-tw.json +++ b/src/locales/zh-tw.json @@ -4407,11 +4407,19 @@ "Character/User avatar originals help: off": "關閉:應用會為角色頭像/使用者頭像提供快取/產生的縮圖。", "Dynamic Theme": "動態主題", "Enable Dynamic Theme": "啟用動態主題", + "Dynamic Theme & Wallpaper": "動態主題與壁紙", + "Enable Theme Switching": "啟用主題切換", + "Enable Wallpaper Switching": "啟用壁紙切換", "Day Theme": "日間主題", "Night Theme": "夜間主題", + "Day Wallpaper": "日間壁紙", + "Night Wallpaper": "夜間壁紙", + "Choose Wallpaper": "選擇壁紙", + "No backgrounds available": "暫無可用背景", "Dynamic Theme hint": "根據系統淺色/深色模式自動切換 TauriTavern 主題。", + "Dynamic Theme & Wallpaper hint": "根據系統淺色/深色模式自動切換 TauriTavern 主題與全域壁紙。", "Dynamic Theme help: mapping": "日間 = 系統淺色模式。夜間 = 系統深色模式。", - "Dynamic Theme help: behavior": "切換效果等同於手動更改主題下拉選單。", + "Dynamic Theme help: behavior": "主題切換效果等同於手動更改主題下拉選單。壁紙切換會更新 SillyTavern 全域背景,並尊重聊天鎖定的背景。", "Prompt Cache": "Prompt 快取", "Claude Prompt Cache": "Claude Prompt 快取", "5m (Default TTL)": "5m(預設 TTL)", diff --git a/src/scripts/backgrounds.js b/src/scripts/backgrounds.js index b56788ff..b903acc3 100644 --- a/src/scripts/backgrounds.js +++ b/src/scripts/backgrounds.js @@ -169,6 +169,24 @@ export let background_settings = { sortOrder: BG_SORT_OPTIONS.AZ, }; +export function getSystemBackgroundEntries() { + return cachedSystemBackgrounds.map(({ filename, isAnimated }) => ({ filename, isAnimated })); +} + +export function assertSystemBackgroundExists(bg) { + const filename = String(bg || ''); + if (!filename) { + throw new Error('Background filename is required'); + } + + const entry = cachedSystemBackgrounds.find((candidate) => candidate.filename === filename); + if (!entry) { + throw new Error(`Background not found: ${filename}`); + } + + return entry; +} + /** * Sorts an array of background entries based on the current sort order. * @param {Array} backgrounds - Array of background entries @@ -817,7 +835,7 @@ function renderChatBackgrounds(backgrounds) { activateLazyLoader(); } -export async function getBackgrounds() { +export async function refreshSystemBackgroundEntries() { const response = await fetch('/api/backgrounds/all', { method: 'POST', headers: getRequestHeaders(), @@ -843,6 +861,12 @@ export async function getBackgrounds() { } } + return getSystemBackgroundEntries(); +} + +export async function getBackgrounds() { + await refreshSystemBackgroundEntries(); + // Load folders first so getFilteredImages() works correctly in folder view await loadFolders(); @@ -1563,6 +1587,17 @@ async function setBackground(bg, url) { saveSettingsDebounced(); } +export async function applyGlobalBackground(bg) { + const entry = assertSystemBackgroundExists(bg); + const url = generateUrlParameter(entry.filename, false); + if (background_settings.name === entry.filename && background_settings.url === url) { + return; + } + + await setBackground(entry.filename, url); + highlightSelectedBackground(); +} + async function delBackground(bg) { const response = await fetch('/api/backgrounds/delete', { method: 'POST', diff --git a/src/scripts/tauri/setting/setting-panel/settings-app.css b/src/scripts/tauri/setting/setting-panel/settings-app.css index eaee1029..b99b9e97 100644 --- a/src/scripts/tauri/setting/setting-panel/settings-app.css +++ b/src/scripts/tauri/setting/setting-panel/settings-app.css @@ -151,7 +151,9 @@ .tt-settings-switch input:focus-visible + span, .tt-settings-icon-button:focus-visible, -.tt-settings-action-button:focus-visible { +.tt-settings-action-button:focus-visible, +.tt-settings-wallpaper-button:focus-visible, +.tt-wallpaper-picker-item:focus-visible { outline: 2px solid var(--SmartThemeQuoteColor); outline-offset: 2px; } @@ -260,6 +262,102 @@ margin: 0; } +.tt-settings-wallpaper-button { + display: grid; + grid-template-columns: 68px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + width: 100%; + min-height: 48px; + margin: 0; + padding: 4px 8px 4px 4px; + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 8px; + color: var(--SmartThemeBodyColor); + background: var(--black10a); + cursor: pointer; +} + +.tt-settings-wallpaper-button:hover:not(:disabled) { + background: var(--white20a); +} + +.tt-settings-wallpaper-button:disabled { + cursor: not-allowed; + opacity: 0.64; +} + +.tt-settings-wallpaper-swatch { + display: inline-flex; + align-items: center; + justify-content: center; + width: 68px; + aspect-ratio: 16 / 9; + overflow: hidden; + border-radius: 6px; + border: 1px solid color-mix(in srgb, var(--SmartThemeBorderColor) 80%, transparent); + background-color: var(--black30a); + background-position: center; + background-size: cover; +} + +.tt-settings-wallpaper-swatch i { + color: var(--SmartThemeQuoteColor); +} + +.tt-settings-wallpaper-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; +} + +.tt-wallpaper-picker { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); + gap: 10px; + width: min(100%, 760px); +} + +.tt-wallpaper-picker-item { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + margin: 0; + padding: 6px; + border: 1px solid color-mix(in srgb, var(--SmartThemeBorderColor) 76%, transparent); + border-radius: 8px; + color: var(--SmartThemeBodyColor); + background: var(--black10a); + cursor: pointer; +} + +.tt-wallpaper-picker-item:hover, +.tt-wallpaper-picker-item.is-selected { + border-color: var(--SmartThemeQuoteColor); + background: color-mix(in srgb, var(--SmartThemeQuoteColor) 14%, var(--black10a)); +} + +.tt-wallpaper-picker-thumb { + display: block; + width: 100%; + aspect-ratio: 16 / 9; + border-radius: 6px; + background-color: var(--black30a); + background-position: center; + background-size: cover; +} + +.tt-wallpaper-picker-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; +} + @media (max-width: 640px) { .tt-settings-row { grid-template-columns: 1fr; @@ -295,4 +393,8 @@ .tt-settings-action-button { width: 100%; } + + .tt-wallpaper-picker { + grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); + } } diff --git a/src/scripts/tauri/setting/setting-panel/settings-patch.js b/src/scripts/tauri/setting/setting-panel/settings-patch.js index 44bc93be..ca6e26cf 100644 --- a/src/scripts/tauri/setting/setting-panel/settings-patch.js +++ b/src/scripts/tauri/setting/setting-panel/settings-patch.js @@ -17,9 +17,12 @@ export function buildTauriTavernSettingsUpdate(initial, draft) { const nextChatHistoryMode = normalizeChatHistoryModeName(draft.chatHistoryMode); const nextCloseToTrayOnClose = Boolean(draft.closeToTrayOnClose); - const nextDynamicThemeEnabled = Boolean(draft.dynamicTheme?.enabled); + const nextDynamicThemeEnabled = Boolean(draft.dynamicTheme?.themeEnabled); const nextDynamicThemeDayTheme = String(draft.dynamicTheme?.dayTheme || '').trim(); const nextDynamicThemeNightTheme = String(draft.dynamicTheme?.nightTheme || '').trim(); + const nextDynamicThemeWallpaperEnabled = Boolean(draft.dynamicTheme?.wallpaperEnabled); + const nextDynamicThemeDayWallpaper = String(draft.dynamicTheme?.dayWallpaper || ''); + const nextDynamicThemeNightWallpaper = String(draft.dynamicTheme?.nightWallpaper || ''); const nextAllowKeysExposure = Boolean(draft.allowKeysExposure); const nextAvatarPersonaOriginalImagesEnabled = Boolean(draft.avatarPersonaOriginalImagesEnabled); @@ -41,9 +44,12 @@ export function buildTauriTavernSettingsUpdate(initial, draft) { && (nextEmbeddedRuntimeProfile !== initial.embeddedRuntimeProfile || requiresEmbeddedRuntimeMigration); const hasChatHistoryModeChange = nextChatHistoryMode !== initial.chatHistoryMode; const hasCloseToTrayOnCloseChange = nextCloseToTrayOnClose !== initial.closeToTrayOnClose; - const hasDynamicThemeChange = nextDynamicThemeEnabled !== initial.dynamicTheme.enabled + const hasDynamicThemeChange = nextDynamicThemeEnabled !== initial.dynamicTheme.themeEnabled || nextDynamicThemeDayTheme !== initial.dynamicTheme.dayTheme - || nextDynamicThemeNightTheme !== initial.dynamicTheme.nightTheme; + || nextDynamicThemeNightTheme !== initial.dynamicTheme.nightTheme + || nextDynamicThemeWallpaperEnabled !== initial.dynamicTheme.wallpaperEnabled + || nextDynamicThemeDayWallpaper !== initial.dynamicTheme.dayWallpaper + || nextDynamicThemeNightWallpaper !== initial.dynamicTheme.nightWallpaper; const hasAllowKeysExposureChange = nextAllowKeysExposure !== initial.allowKeysExposure; const hasAvatarPersonaOriginalImagesEnabledChange = nextAvatarPersonaOriginalImagesEnabled !== initial.avatarPersonaOriginalImagesEnabled; @@ -90,6 +96,9 @@ export function buildTauriTavernSettingsUpdate(initial, draft) { enabled: nextDynamicThemeEnabled, day_theme: nextDynamicThemeDayTheme, night_theme: nextDynamicThemeNightTheme, + wallpaper_enabled: nextDynamicThemeWallpaperEnabled, + day_wallpaper: nextDynamicThemeDayWallpaper, + night_wallpaper: nextDynamicThemeNightWallpaper, }; } if (hasAllowKeysExposureChange) { @@ -127,9 +136,12 @@ export function buildTauriTavernSettingsUpdate(initial, draft) { chatHistoryMode: nextChatHistoryMode, closeToTrayOnClose: nextCloseToTrayOnClose, dynamicTheme: { - enabled: nextDynamicThemeEnabled, + themeEnabled: nextDynamicThemeEnabled, dayTheme: nextDynamicThemeDayTheme, nightTheme: nextDynamicThemeNightTheme, + wallpaperEnabled: nextDynamicThemeWallpaperEnabled, + dayWallpaper: nextDynamicThemeDayWallpaper, + nightWallpaper: nextDynamicThemeNightWallpaper, }, allowKeysExposure: nextAllowKeysExposure, avatarPersonaOriginalImagesEnabled: nextAvatarPersonaOriginalImagesEnabled, diff --git a/src/scripts/tauri/setting/setting-panel/settings-popup.js b/src/scripts/tauri/setting/setting-panel/settings-popup.js index 1534e277..eb8513c3 100644 --- a/src/scripts/tauri/setting/setting-panel/settings-popup.js +++ b/src/scripts/tauri/setting/setting-panel/settings-popup.js @@ -67,7 +67,7 @@ const HELP_TOPICS = { ], }, dynamicTheme: { - title: 'Dynamic Theme', + title: 'Dynamic Theme & Wallpaper', lines: [ 'Dynamic Theme help: mapping', 'Dynamic Theme help: behavior', @@ -103,6 +103,33 @@ function readThemeOptions() { })); } +function getBackgroundThumbnailUrl(filename) { + if (typeof window.__TAURITAVERN_THUMBNAIL__ === 'function') { + return window.__TAURITAVERN_THUMBNAIL__('bg', filename); + } + + return `/thumbnail?type=bg&file=${encodeURIComponent(filename)}`; +} + +async function readBackgroundOptions() { + const { + background_settings, + refreshSystemBackgroundEntries, + } = await import('../../../backgrounds.js'); + + const backgroundEntries = await refreshSystemBackgroundEntries(); + + return { + currentBackground: String(background_settings.name || ''), + backgroundOptions: backgroundEntries.map((entry) => ({ + value: entry.filename, + label: entry.filename, + thumbnailUrl: getBackgroundThumbnailUrl(entry.filename), + isAnimated: entry.isAnimated, + })), + }; +} + function createPopupColumn() { const root = document.createElement('div'); root.className = 'flex-container flexFlowColumn'; @@ -110,6 +137,71 @@ function createPopupColumn() { return root; } +function setWallpaperPickerSelection(root, selectedValue) { + for (const item of root.querySelectorAll('.tt-wallpaper-picker-item')) { + item.classList.toggle('is-selected', item.getAttribute('data-value') === selectedValue); + } +} + +function createWallpaperPickerItem(option, selected, onSelect) { + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'tt-wallpaper-picker-item'; + item.dataset.value = option.value; + item.title = option.label; + + const thumbnail = document.createElement('span'); + thumbnail.className = 'tt-wallpaper-picker-thumb'; + thumbnail.style.backgroundImage = `url("${option.thumbnailUrl}")`; + + const label = document.createElement('span'); + label.className = 'tt-wallpaper-picker-label'; + label.textContent = option.label; + + item.append(thumbnail, label); + item.classList.toggle('is-selected', selected === option.value); + item.addEventListener('click', () => onSelect(option.value)); + + return item; +} + +async function chooseWallpaper(backgroundOptions, request = {}) { + if (backgroundOptions.length === 0) { + await callGenericPopup(translate('No backgrounds available'), POPUP_TYPE.TEXT, '', { + okButton: translate('OK'), + allowVerticalScrolling: true, + wide: false, + large: false, + }); + return null; + } + + const requestedValue = String(request?.currentValue || ''); + let selected = backgroundOptions.some((option) => option.value === requestedValue) + ? requestedValue + : backgroundOptions[0].value; + + const picker = document.createElement('div'); + picker.className = 'tt-wallpaper-picker'; + + for (const option of backgroundOptions) { + picker.appendChild(createWallpaperPickerItem(option, selected, (nextValue) => { + selected = nextValue; + setWallpaperPickerSelection(picker, selected); + })); + } + + const result = await callGenericPopup(picker, POPUP_TYPE.CONFIRM, '', { + okButton: translate('Select'), + cancelButton: translate('Cancel'), + allowVerticalScrolling: true, + wide: true, + large: false, + }); + + return result === POPUP_RESULT.AFFIRMATIVE ? selected : null; +} + async function showHelpTopic(topicId) { const topic = HELP_TOPICS[topicId]; if (!topic) { @@ -182,9 +274,10 @@ async function chooseDataRoot() { return normalized; } -function createSettingsActions() { +function createSettingsActions(backgroundOptions) { return { chooseDataRoot: () => runTaskOrPopup(chooseDataRoot), + chooseWallpaper: (request) => runTaskOrPopup(() => chooseWallpaper(backgroundOptions, request)), showHelp: (topicId) => runTaskOrPopup(() => showHelpTopic(topicId)), reloadFrontend: () => runTaskOrPopup(async () => { window.location.reload(); @@ -211,16 +304,19 @@ function createSettingsActions() { export async function openTauriTavernSettingsPopup() { ensureSettingsStyle(); - const [viewModel, bundle] = await Promise.all([ + const [viewModel, bundle, backgroundModel] = await Promise.all([ loadTauriTavernSettingsViewModel(), importSettingsBundle(), + readBackgroundOptions(), ]); const mount = document.createElement('div'); const appHandle = bundle.mountTauriTavernSettingsApp(mount, { viewModel, themeOptions: readThemeOptions(), - actions: createSettingsActions(), + backgroundOptions: backgroundModel.backgroundOptions, + currentBackground: backgroundModel.currentBackground, + actions: createSettingsActions(backgroundModel.backgroundOptions), tr: translate, }); diff --git a/src/scripts/tauri/setting/setting-panel/settings-state.js b/src/scripts/tauri/setting/setting-panel/settings-state.js index 0ad035af..fc69f5c8 100644 --- a/src/scripts/tauri/setting/setting-panel/settings-state.js +++ b/src/scripts/tauri/setting/setting-panel/settings-state.js @@ -96,9 +96,12 @@ export function createTauriTavernSettingsState(settings, options = {}) { ? options.nativeRegexBackendEnabled : readNativeRegexBackendEnabledFromSettings(settings), dynamicTheme: { - enabled: Boolean(dynamicTheme.enabled), + themeEnabled: Boolean(dynamicTheme.enabled), dayTheme: String(dynamicTheme.day_theme || '').trim(), nightTheme: String(dynamicTheme.night_theme || '').trim(), + wallpaperEnabled: Boolean(dynamicTheme.wallpaper_enabled), + dayWallpaper: String(dynamicTheme.day_wallpaper || ''), + nightWallpaper: String(dynamicTheme.night_wallpaper || ''), }, promptCacheTtl: PROMPT_CACHE_TTL_VALUES.includes(rawPromptCacheTtl) ? rawPromptCacheTtl : 'off', promptCacheTtlSource: rawPromptCacheTtl, diff --git a/src/scripts/tauri/setting/settings-app/SettingsApp.js b/src/scripts/tauri/setting/settings-app/SettingsApp.js index 76fcd067..6c6dec2b 100644 --- a/src/scripts/tauri/setting/settings-app/SettingsApp.js +++ b/src/scripts/tauri/setting/settings-app/SettingsApp.js @@ -4,6 +4,7 @@ import { SettingRow, SettingsSection, ToggleSwitch, + WallpaperField, } from './components.js'; const PANEL_RUNTIME_OPTIONS = [ @@ -32,8 +33,10 @@ const PROMPT_CACHE_OPTIONS = [ function cloneOptions(options) { return options.map((option) => ({ - value: String(option.value || '').trim(), - label: String(option.label || option.value || '').trim(), + value: String(option.value || ''), + label: String(option.label || option.value || ''), + thumbnailUrl: String(option.thumbnailUrl || ''), + isAnimated: Boolean(option.isAnimated), })); } @@ -44,8 +47,12 @@ function translateOptions(options, tr) { })); } -function cloneDraft(values, themeOptions) { +function cloneDraft(values, themeOptions, backgroundOptions, currentBackground) { const fallbackTheme = themeOptions[0]?.value || ''; + const normalizedCurrentBackground = String(currentBackground || ''); + const fallbackBackground = backgroundOptions.some((option) => option.value === normalizedCurrentBackground) + ? normalizedCurrentBackground + : backgroundOptions[0]?.value || ''; return { panelRuntimeProfile: values.panelRuntimeProfile, @@ -61,9 +68,12 @@ function cloneDraft(values, themeOptions) { avatarPersonaOriginalImagesEnabled: values.avatarPersonaOriginalImagesEnabled, nativeRegexBackendEnabled: values.nativeRegexBackendEnabled, dynamicTheme: { - enabled: values.dynamicTheme.enabled, + themeEnabled: values.dynamicTheme.themeEnabled, dayTheme: values.dynamicTheme.dayTheme || fallbackTheme, nightTheme: values.dynamicTheme.nightTheme || fallbackTheme, + wallpaperEnabled: values.dynamicTheme.wallpaperEnabled, + dayWallpaper: values.dynamicTheme.dayWallpaper || (values.dynamicTheme.wallpaperEnabled ? fallbackBackground : ''), + nightWallpaper: values.dynamicTheme.nightWallpaper || (values.dynamicTheme.wallpaperEnabled ? fallbackBackground : ''), }, promptCacheTtl: values.promptCacheTtl, }; @@ -96,8 +106,10 @@ export function createTauriTavernSettingsApp(options) { } const themeOptions = cloneOptions(options.themeOptions || []); + const backgroundOptions = cloneOptions(options.backgroundOptions || []); + const currentBackground = String(options.currentBackground || ''); const capabilities = { ...viewModel.capabilities }; - const initialDraft = cloneDraft(viewModel.values, themeOptions); + const initialDraft = cloneDraft(viewModel.values, themeOptions, backgroundOptions, currentBackground); const initialDataRoot = cloneDataRoot(viewModel.dataRoot); return { @@ -108,11 +120,13 @@ export function createTauriTavernSettingsApp(options) { SettingRow, SettingsSection, ToggleSwitch, + WallpaperField, }, data() { return { capabilities, themeOptions, + backgroundOptions, draft: initialDraft, dataRoot: initialDataRoot, details: { @@ -144,7 +158,7 @@ export function createTauriTavernSettingsApp(options) { requestProxySummary() { return this.tr(this.details.requestProxy ? 'Click to collapse' : 'Click to expand'); }, - dynamicThemeSummary() { + dynamicAppearanceSummary() { return this.tr(this.details.dynamicTheme ? 'Click to collapse' : 'Click to expand'); }, dataRootSummary() { @@ -194,6 +208,36 @@ export function createTauriTavernSettingsApp(options) { { value: normalized, label: normalized }, ]; }, + backgroundOptionsWithStored(storedValue) { + const normalized = String(storedValue || ''); + if (!normalized || this.backgroundOptions.some((option) => option.value === normalized)) { + return this.backgroundOptions; + } + + return [ + ...this.backgroundOptions, + { + value: normalized, + label: normalized, + thumbnailUrl: '', + isAnimated: false, + }, + ]; + }, + backgroundOption(storedValue) { + const normalized = String(storedValue || ''); + return this.backgroundOptionsWithStored(normalized) + .find((option) => option.value === normalized) || null; + }, + ensureWallpaperDefaults() { + const fallback = this.backgroundOptions[0]?.value || ''; + if (!this.draft.dynamicTheme.dayWallpaper) { + this.draft.dynamicTheme.dayWallpaper = fallback; + } + if (!this.draft.dynamicTheme.nightWallpaper) { + this.draft.dynamicTheme.nightWallpaper = fallback; + } + }, showHelp(topicId) { void requireAction(actions, 'showHelp')(topicId); }, @@ -226,14 +270,33 @@ export function createTauriTavernSettingsApp(options) { this.details.requestProxy = true; this.$nextTick(() => this.$refs.requestProxyUrl?.focus?.()); }, - setDynamicThemeEnabled(enabled) { - this.draft.dynamicTheme.enabled = enabled; + setThemeSwitchingEnabled(enabled) { + this.draft.dynamicTheme.themeEnabled = enabled; if (!enabled) { return; } this.details.dynamicTheme = true; this.$nextTick(() => this.$refs.dynamicThemeDay?.$el?.focus?.()); }, + setWallpaperSwitchingEnabled(enabled) { + this.draft.dynamicTheme.wallpaperEnabled = enabled; + if (!enabled) { + return; + } + + this.ensureWallpaperDefaults(); + this.details.dynamicTheme = true; + }, + async chooseWallpaper(targetKey) { + const selected = await requireAction(actions, 'chooseWallpaper')({ + currentValue: this.draft.dynamicTheme[targetKey], + }); + if (!selected) { + return; + } + + this.draft.dynamicTheme[targetKey] = selected; + }, getDraft() { return { panelRuntimeProfile: this.draft.panelRuntimeProfile, @@ -419,22 +482,22 @@ export function createTauriTavernSettingsApp(options) { @toggle="details.dynamicTheme = $event.currentTarget.open" > - {{ tr('Dynamic Theme') }} + {{ tr('Dynamic Theme & Wallpaper') }} - {{ dynamicThemeSummary }} + {{ dynamicAppearanceSummary }}
@@ -442,17 +505,41 @@ export function createTauriTavernSettingsApp(options) { ref="dynamicThemeDay" v-model="draft.dynamicTheme.dayTheme" :options="themeOptionsWithStored(draft.dynamicTheme.dayTheme)" - :disabled="!draft.dynamicTheme.enabled" + :disabled="!draft.dynamicTheme.themeEnabled" /> - {{ tr('Dynamic Theme hint') }} + + + + + + + + + + {{ tr('Dynamic Theme & Wallpaper hint') }}
diff --git a/src/scripts/tauri/setting/settings-app/components.js b/src/scripts/tauri/setting/settings-app/components.js index 9d7b1fe1..7ebb4faa 100644 --- a/src/scripts/tauri/setting/settings-app/components.js +++ b/src/scripts/tauri/setting/settings-app/components.js @@ -88,6 +88,43 @@ export const SelectField = { `, }; +export const WallpaperField = { + props: { + option: { type: Object, default: null }, + value: { type: String, default: '' }, + placeholder: { type: String, required: true }, + disabled: { type: Boolean, default: false }, + }, + emits: ['choose'], + computed: { + label() { + return this.option?.label || this.value || this.placeholder; + }, + swatchStyle() { + if (!this.option?.thumbnailUrl) { + return {}; + } + + return { backgroundImage: `url("${this.option.thumbnailUrl}")` }; + }, + }, + template: ` + + `, +}; + export const ActionButton = { props: { label: { type: String, required: true }, diff --git a/src/tauri/main/adapters/st/appearance.js b/src/tauri/main/adapters/st/appearance.js new file mode 100644 index 00000000..2aecf992 --- /dev/null +++ b/src/tauri/main/adapters/st/appearance.js @@ -0,0 +1,57 @@ +// @ts-check + +function getSillyTavernThemeSelector() { + const selector = document.getElementById('themes'); + if (!(selector instanceof HTMLSelectElement)) { + throw new Error('Dynamic appearance: SillyTavern theme selector not found'); + } + return selector; +} + +/** + * @param {string} themeName + */ +export function assertSillyTavernThemeAvailable(themeName) { + const targetTheme = String(themeName || '').trim(); + if (!targetTheme) { + throw new Error('Dynamic appearance theme target is empty'); + } + + const selector = getSillyTavernThemeSelector(); + const exists = Array.from(selector.options).some((option) => option.value === targetTheme); + if (!exists) { + throw new Error(`Dynamic appearance theme target not found: ${targetTheme}`); + } + + return targetTheme; +} + +/** + * @param {string} themeName + */ +export function applySillyTavernTheme(themeName) { + const targetTheme = assertSillyTavernThemeAvailable(themeName); + const selector = getSillyTavernThemeSelector(); + if (selector.value === targetTheme) { + return; + } + + selector.value = targetTheme; + selector.dispatchEvent(new Event('change', { bubbles: true })); +} + +/** + * @param {string} filename + */ +export async function assertSillyTavernGlobalBackgroundAvailable(filename) { + const { assertSystemBackgroundExists } = await import('../../../../scripts/backgrounds.js'); + return assertSystemBackgroundExists(filename).filename; +} + +/** + * @param {string} filename + */ +export async function applySillyTavernGlobalBackground(filename) { + const { applyGlobalBackground } = await import('../../../../scripts/backgrounds.js'); + await applyGlobalBackground(filename); +} diff --git a/src/tauri/main/services/dynamic-theme/install.js b/src/tauri/main/services/dynamic-theme/install.js index 7349202a..0e41cfd3 100644 --- a/src/tauri/main/services/dynamic-theme/install.js +++ b/src/tauri/main/services/dynamic-theme/install.js @@ -2,16 +2,14 @@ import { eventSource, event_types } from '../../../../scripts/events.js'; import { getTauriTavernSettings } from '../../../../tauri-bridge.js'; +import { + applySillyTavernGlobalBackground, + applySillyTavernTheme, + assertSillyTavernGlobalBackgroundAvailable, + assertSillyTavernThemeAvailable, +} from '../../adapters/st/appearance.js'; import { DYNAMIC_THEME_CHANGED_EVENT } from './constants.js'; -function getSillyTavernThemeSelector() { - const selector = document.getElementById('themes'); - if (!(selector instanceof HTMLSelectElement)) { - throw new Error('Dynamic theme: SillyTavern theme selector not found'); - } - return selector; -} - function readSystemThemeFromMedia() { const query = globalThis.matchMedia?.('(prefers-color-scheme: dark)'); if (!query) { @@ -40,29 +38,14 @@ function normalizeDynamicThemeSettings(payload) { const settings = /** @type {any} */ (payload); - const enabled = Boolean(settings.enabled); + const themeEnabled = Boolean(settings.enabled); const dayTheme = String(settings.day_theme || '').trim(); const nightTheme = String(settings.night_theme || '').trim(); + const wallpaperEnabled = Boolean(settings.wallpaper_enabled); + const dayWallpaper = String(settings.day_wallpaper || ''); + const nightWallpaper = String(settings.night_wallpaper || ''); - return { enabled, dayTheme, nightTheme }; -} - -/** - * @param {string} themeName - */ -function applySillyTavernTheme(themeName) { - const selector = getSillyTavernThemeSelector(); - if (selector.value === themeName) { - return; - } - - const exists = Array.from(selector.options).some((option) => option.value === themeName); - if (!exists) { - throw new Error(`Dynamic theme target not found: ${themeName}`); - } - - selector.value = themeName; - selector.dispatchEvent(new Event('change', { bubbles: true })); + return { themeEnabled, dayTheme, nightTheme, wallpaperEnabled, dayWallpaper, nightWallpaper }; } export function installDynamicTheme() { @@ -70,20 +53,51 @@ export function installDynamicTheme() { let dynamicTheme = normalizeDynamicThemeSettings(settings.dynamic_theme); const preferredColorSchemeQuery = getPreferredColorSchemeQuery(); let systemTheme = preferredColorSchemeQuery?.matches ? 'dark' : readSystemThemeFromMedia(); + let hasPendingVisibilitySync = false; /** @param {string} reason */ - const syncNow = (reason) => { - if (!dynamicTheme.enabled) { + const syncNow = async (reason) => { + if (!dynamicTheme.themeEnabled && !dynamicTheme.wallpaperEnabled) { return; } - const targetTheme = systemTheme === 'dark' ? dynamicTheme.nightTheme : dynamicTheme.dayTheme; - if (!targetTheme) { - throw new Error('Dynamic theme is enabled but the target theme is empty'); + /** @type {{ theme?: string, wallpaper?: string }} */ + const applied = {}; + let targetTheme = ''; + let targetWallpaper = ''; + + if (dynamicTheme.themeEnabled) { + targetTheme = systemTheme === 'dark' ? dynamicTheme.nightTheme : dynamicTheme.dayTheme; + if (!targetTheme) { + throw new Error('Dynamic theme is enabled but the target theme is empty'); + } } - applySillyTavernTheme(targetTheme); - console.debug('Dynamic theme applied', { reason, systemTheme, targetTheme }); + if (dynamicTheme.wallpaperEnabled) { + targetWallpaper = systemTheme === 'dark' ? dynamicTheme.nightWallpaper : dynamicTheme.dayWallpaper; + if (!targetWallpaper) { + throw new Error('Dynamic wallpaper is enabled but the target wallpaper is empty'); + } + } + + if (dynamicTheme.themeEnabled) { + assertSillyTavernThemeAvailable(targetTheme); + } + if (dynamicTheme.wallpaperEnabled) { + await assertSillyTavernGlobalBackgroundAvailable(targetWallpaper); + } + + if (dynamicTheme.themeEnabled) { + applySillyTavernTheme(targetTheme); + applied.theme = targetTheme; + } + + if (dynamicTheme.wallpaperEnabled) { + await applySillyTavernGlobalBackground(targetWallpaper); + applied.wallpaper = targetWallpaper; + } + + console.debug('Dynamic appearance applied', { reason, systemTheme, ...applied }); }; /** @@ -97,13 +111,15 @@ export function installDynamicTheme() { systemTheme = nextTheme; if (document.visibilityState === 'hidden') { + hasPendingVisibilitySync = true; return; } + hasPendingVisibilitySync = false; void Promise.resolve() .then(() => syncNow(reason)) .catch((error) => { - console.error('Dynamic theme sync failed after system theme change', error); + console.error('Dynamic appearance sync failed after system theme change', error); }); }; @@ -113,7 +129,7 @@ export function installDynamicTheme() { void Promise.resolve() .then(() => syncNow('config-changed')) .catch((error) => { - console.error('Dynamic theme sync failed after config change', error); + console.error('Dynamic appearance sync failed after config change', error); }); }; @@ -123,11 +139,11 @@ export function installDynamicTheme() { void Promise.resolve() .then(() => syncNow('startup')) .catch((error) => { - console.error('Dynamic theme initial sync failed', error); + console.error('Dynamic appearance initial sync failed', error); }); if (!preferredColorSchemeQuery) { - throw new Error('Dynamic theme: matchMedia is unavailable'); + throw new Error('Dynamic appearance: matchMedia is unavailable'); } const handlePreferredColorSchemeChange = (/** @type {any} */ event) => { @@ -140,12 +156,12 @@ export function installDynamicTheme() { } else if (typeof preferredColorSchemeQuery.addListener === 'function') { preferredColorSchemeQuery.addListener(handlePreferredColorSchemeChange); } else { - throw new Error('Dynamic theme: matchMedia change listener is unavailable'); + throw new Error('Dynamic appearance: matchMedia change listener is unavailable'); } const listen = window.__TAURI__?.event?.listen; if (typeof listen !== 'function') { - throw new Error('Dynamic theme: Tauri theme listener is unavailable'); + throw new Error('Dynamic appearance: Tauri theme listener is unavailable'); } void listen('tauri://theme-changed', (/** @type {any} */ event) => { @@ -159,6 +175,16 @@ export function installDynamicTheme() { } const nextTheme = preferredColorSchemeQuery.matches ? 'dark' : 'light'; + if (nextTheme === systemTheme && hasPendingVisibilitySync) { + hasPendingVisibilitySync = false; + void Promise.resolve() + .then(() => syncNow('visibilitychange')) + .catch((error) => { + console.error('Dynamic appearance sync failed after visibility change', error); + }); + return; + } + updateSystemThemeAndSync(nextTheme, 'visibilitychange'); }); }); diff --git a/tests/dynamic-appearance-contract.test.mjs b/tests/dynamic-appearance-contract.test.mjs new file mode 100644 index 00000000..24e27257 --- /dev/null +++ b/tests/dynamic-appearance-contract.test.mjs @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +async function readRepoFile(relativePath) { + return readFile(path.join(REPO_ROOT, relativePath), 'utf8'); +} + +test('dynamic appearance runtime applies SillyTavern appearance through the ST adapter', async () => { + const runtime = await readRepoFile('src/tauri/main/services/dynamic-theme/install.js'); + const adapter = await readRepoFile('src/tauri/main/adapters/st/appearance.js'); + + assert.match(runtime, /applySillyTavernTheme/); + assert.match(runtime, /applySillyTavernGlobalBackground/); + assert.doesNotMatch(runtime, /scripts\/backgrounds\.js/); + assert.match(adapter, /import\('\.\.\/\.\.\/\.\.\/\.\.\/scripts\/backgrounds\.js'\)/); +}); + +test('dynamic appearance runtime validates targets before applying appearance', async () => { + const runtime = await readRepoFile('src/tauri/main/services/dynamic-theme/install.js'); + const themeAssert = runtime.indexOf('assertSillyTavernThemeAvailable(targetTheme)'); + const wallpaperAssert = runtime.indexOf('await assertSillyTavernGlobalBackgroundAvailable(targetWallpaper)'); + const themeApply = runtime.indexOf('applySillyTavernTheme(targetTheme)'); + const wallpaperApply = runtime.indexOf('await applySillyTavernGlobalBackground(targetWallpaper)'); + + assert.ok(themeAssert > -1); + assert.ok(wallpaperAssert > -1); + assert.ok(themeApply > -1); + assert.ok(wallpaperApply > -1); + assert.ok(themeAssert < themeApply); + assert.ok(wallpaperAssert < wallpaperApply); +}); + +test('global background helper reuses background state instead of simulating UI clicks', async () => { + const source = await readRepoFile('src/scripts/backgrounds.js'); + const start = source.indexOf('export async function applyGlobalBackground'); + const end = source.indexOf('async function delBackground', start); + + assert.notEqual(start, -1); + assert.notEqual(end, -1); + const helper = source.slice(start, end); + assert.match(helper, /assertSystemBackgroundExists/); + assert.match(helper, /setBackground/); + assert.match(helper, /highlightSelectedBackground/); + assert.doesNotMatch(helper, /\.click\(/); +}); + +test('background option refresh is separated from drawer rendering', async () => { + const source = await readRepoFile('src/scripts/backgrounds.js'); + const refreshStart = source.indexOf('export async function refreshSystemBackgroundEntries'); + const getBackgroundsStart = source.indexOf('export async function getBackgrounds'); + const getBackgroundsEnd = source.indexOf('/**\n * Preloads all image metadata', getBackgroundsStart); + + assert.notEqual(refreshStart, -1); + assert.notEqual(getBackgroundsStart, -1); + assert.notEqual(getBackgroundsEnd, -1); + + const refresh = source.slice(refreshStart, getBackgroundsStart); + const getBackgrounds = source.slice(getBackgroundsStart, getBackgroundsEnd); + assert.doesNotMatch(refresh, /renderSystemBackgrounds|loadFolders|preloadImageMetadata/); + assert.match(getBackgrounds, /refreshSystemBackgroundEntries/); + assert.match(getBackgrounds, /renderSystemBackgrounds/); +}); diff --git a/tests/tauritavern-settings-patch.test.mjs b/tests/tauritavern-settings-patch.test.mjs index b38b30ae..d63251f5 100644 --- a/tests/tauritavern-settings-patch.test.mjs +++ b/tests/tauritavern-settings-patch.test.mjs @@ -22,6 +22,9 @@ function createSettings(overrides = {}) { enabled: false, day_theme: 'Default', night_theme: 'Dark', + wallpaper_enabled: false, + day_wallpaper: ' Day.png', + night_wallpaper: 'Night .png', }, models: { claude: { @@ -37,6 +40,9 @@ test('buildTauriTavernSettingsUpdate returns an empty patch for unchanged settin nativeRegexBackendEnabled: true, }); + assert.equal(initial.dynamicTheme.dayWallpaper, ' Day.png'); + assert.equal(initial.dynamicTheme.nightWallpaper, 'Night .png'); + const update = buildTauriTavernSettingsUpdate(initial, { ...initial, dynamicTheme: { ...initial.dynamicTheme }, @@ -81,6 +87,34 @@ test('buildTauriTavernSettingsUpdate preserves minimal nested patch semantics', }); }); +test('buildTauriTavernSettingsUpdate persists dynamic wallpaper settings with theme settings', () => { + const initial = createTauriTavernSettingsState(createSettings(), { + nativeRegexBackendEnabled: true, + }); + + const update = buildTauriTavernSettingsUpdate(initial, { + ...initial, + dynamicTheme: { + ...initial.dynamicTheme, + wallpaperEnabled: true, + dayWallpaper: ' Soft Morning.png', + nightWallpaper: 'Deep Night .webp', + }, + }); + + assert.equal(update.hasChanges, true); + assert.deepEqual(update.patch, { + dynamic_theme: { + enabled: false, + day_theme: 'Default', + night_theme: 'Dark', + wallpaper_enabled: true, + day_wallpaper: ' Soft Morning.png', + night_wallpaper: 'Deep Night .webp', + }, + }); +}); + test('buildTauriTavernSettingsUpdate persists embedded runtime legacy migration', () => { const initial = createTauriTavernSettingsState(createSettings({ embedded_runtime_profile: 'compat', diff --git a/tests/tauritavern-settings-vue-contract.test.mjs b/tests/tauritavern-settings-vue-contract.test.mjs index cf56ebf8..b69db277 100644 --- a/tests/tauritavern-settings-vue-contract.test.mjs +++ b/tests/tauritavern-settings-vue-contract.test.mjs @@ -44,6 +44,13 @@ test('TauriTavern Settings popup is a host wrapper around the Vue bundle', async assert.match(source, /applyTauriTavernSettingsUpdateEffects\(update,\s*updatedSettings\)/); }); +test('TauriTavern Settings wallpaper options use the no-render background refresh', async () => { + const source = await readRepoFile('src/scripts/tauri/setting/setting-panel/settings-popup.js'); + + assert.match(source, /refreshSystemBackgroundEntries/); + assert.doesNotMatch(source, /getBackgrounds/); +}); + test('webpack exposes a dedicated TauriTavern Settings Vue entry', async () => { const source = await readRepoFile('webpack.config.js'); @@ -76,6 +83,10 @@ test('TauriTavern Settings Vue app stays presentation-only', async () => { const entry = await readRepoFile('src/scripts/tauri/setting/settings-app/index.js'); assert.match(entry, /from\s+['"]vue\/dist\/vue\.esm-bundler\.js['"]/); assert.match(entry, /export\s+function\s+mountTauriTavernSettingsApp/); + + const app = await readRepoFile('src/scripts/tauri/setting/settings-app/SettingsApp.js'); + assert.match(app, /Dynamic Theme & Wallpaper/); + assert.match(app, /WallpaperField/); }); test('TauriTavern Settings keeps mobile toggle rows inline', async () => {