diff --git a/src/interface/pages/themeCreator.svelte b/src/interface/pages/themeCreator.svelte index ec6b74f5..d8da6b95 100644 --- a/src/interface/pages/themeCreator.svelte +++ b/src/interface/pages/themeCreator.svelte @@ -4,7 +4,10 @@ import { slide } from 'svelte/transition'; import { fade } from 'svelte/transition'; - import { type LoadedCustomTheme } from '@/types/CustomThemes' + import { + type LoadedCustomTheme, + shouldForceThemeAppearance, + } from '@/types/CustomThemes' import { settingsState } from '@/seqta/utils/listeners/SettingsState' @@ -40,7 +43,9 @@ coverImage: null, isEditable: true, hideThemeName: false, - forceDark: undefined + forceTheme: undefined, + forceDark: undefined, + adaptiveCssVariables: [], }) let closedAccordions = $state([]) let themeLoaded = $state(false); @@ -80,7 +85,13 @@ })) } - theme = loadedTheme + theme = { + ...loadedTheme, + adaptiveCssVariables: loadedTheme.adaptiveCssVariables ?? [], + forceTheme: + loadedTheme.forceTheme ?? + (loadedTheme.forceDark !== undefined ? true : undefined), + } themeLoaded = true } else { themeLoaded = true @@ -115,6 +126,13 @@ })) themeClone.coverImage = theme.coverImage + if (shouldForceThemeAppearance(themeClone)) { + themeClone.forceTheme = true; + } else { + themeClone.forceTheme = false; + themeClone.forceDark = undefined; + } + themeManager.clearPreview(); await themeManager.saveTheme(themeClone); await themeManager.setTheme(themeClone.id); @@ -317,6 +335,27 @@ +
+

Adaptive CSS variables

+

+ One per line, each must start with --. These receive the same colour as the adaptive accent when "Adaptive theme colour" is enabled in general settings. Use them in Custom CSS, e.g. border-color: var(--my-accent); +

+ +
+ + + {#each [ { type: 'switch', @@ -332,21 +371,28 @@ title: 'Force Theme', description: 'Force users to use either dark or light mode', props: { - state: theme.forceDark !== undefined, - onChange: (value: boolean) => theme = { ...theme, forceDark: value ? false : undefined } + state: shouldForceThemeAppearance(theme), + onChange: (value: boolean) => { + if (value) { + theme = { ...theme, forceTheme: true, forceDark: false }; + } else { + theme = { ...theme, forceTheme: false, forceDark: undefined }; + } + } } }, { type: 'conditional', props: { - condition: theme.forceDark !== undefined, + condition: shouldForceThemeAppearance(theme), children: { type: 'lightDarkToggle', title: 'Mode', description: 'Choose whether to force light or dark mode', props: { state: theme.forceDark === true, - onChange: (value: boolean) => theme = { ...theme, forceDark: value } + onChange: (value: boolean) => + (theme = { ...theme, forceDark: value, forceTheme: true }) } } } diff --git a/src/plugins/built-in/themes/theme-manager.ts b/src/plugins/built-in/themes/theme-manager.ts index 488e376b..7e3b2192 100644 --- a/src/plugins/built-in/themes/theme-manager.ts +++ b/src/plugins/built-in/themes/theme-manager.ts @@ -1,8 +1,18 @@ import localforage from "localforage"; import browser from "webextension-polyfill"; -import type { CustomTheme, LoadedCustomTheme } from "@/types/CustomThemes"; +import { + type CustomTheme, + getForcedDarkMode, + type LoadedCustomTheme, + shouldForceThemeAppearance, +} from "@/types/CustomThemes"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import debounce from "@/seqta/utils/debounce"; +import { updateAllColors } from "@/seqta/ui/colors/Manager"; +import { + clearCustomThemeAdaptiveCssVariables, + setCustomThemeAdaptiveCssVariables, +} from "@/seqta/ui/colors/customThemeAdaptiveBindings"; type ThemeContent = { id: string; @@ -14,6 +24,8 @@ type ThemeContent = { CustomCSS?: string; hideThemeName?: boolean; forceDark?: boolean; + forceTheme?: boolean; + adaptiveCssVariables?: string[]; images: { id: string; variableName: string; data: string }[]; // data: base64 }; @@ -209,7 +221,7 @@ export class ThemeManager { console.debug("[ThemeManager] Storing original settings"); settingsState.originalSelectedColor = settingsState.selectedColor; - if (theme.forceDark) { + if (shouldForceThemeAppearance(theme)) { settingsState.originalDarkMode = settingsState.DarkMode; } } @@ -240,6 +252,7 @@ export class ThemeManager { this.currentTheme = theme; settingsState.selectedTheme = themeId; } + void updateAllColors(); } catch (error) { console.error("[ThemeManager] Error setting theme:", error); } @@ -270,9 +283,10 @@ export class ThemeManager { } // Apply theme settings - if (theme.forceDark !== undefined) { - console.debug("[ThemeManager] Setting dark mode:", theme.forceDark); - settingsState.DarkMode = theme.forceDark; + if (shouldForceThemeAppearance(theme)) { + const dark = getForcedDarkMode(theme); + console.debug("[ThemeManager] Setting dark mode:", dark); + settingsState.DarkMode = dark; } // Use the stored selected color if available, otherwise use the default @@ -289,6 +303,8 @@ export class ThemeManager { ); settingsState.selectedColor = theme.defaultColour; } + + setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []); } catch (error) { console.error("[ThemeManager] Error applying theme:", error); } @@ -373,6 +389,7 @@ export class ThemeManager { if (clearSelectedTheme) { settingsState.selectedTheme = ""; } + clearCustomThemeAdaptiveCssVariables(); } catch (error) { console.error("[ThemeManager] Error removing theme:", error); } @@ -585,6 +602,13 @@ export class ThemeManager { isEditable: false, hideThemeName: themeData.hideThemeName ?? false, forceDark: themeData.forceDark, + forceTheme: + themeData.forceTheme !== undefined + ? themeData.forceTheme + : themeData.forceDark !== undefined + ? true + : undefined, + adaptiveCssVariables: themeData.adaptiveCssVariables, }; await this.saveTheme(theme); @@ -651,7 +675,7 @@ export class ThemeManager { public async previewTheme(theme: LoadedCustomTheme): Promise { console.debug("[ThemeManager] Previewing theme:", theme.name); try { - const { CustomCSS, CustomImages, defaultColour, forceDark } = theme; + const { CustomCSS, CustomImages, defaultColour } = theme; // Store original settings only if this is a new theme if (!theme.webURL) { @@ -697,13 +721,16 @@ export class ThemeManager { this.previousImageVariableNames = newImageVariableNames; // Apply theme settings - if (forceDark !== undefined) { - settingsState.DarkMode = forceDark; + if (shouldForceThemeAppearance(theme)) { + settingsState.DarkMode = getForcedDarkMode(theme); } if (defaultColour) { settingsState.selectedColor = defaultColour; } + + setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []); + void updateAllColors(); } catch (error) { console.error("[ThemeManager] Error previewing theme:", error); } @@ -769,15 +796,18 @@ export class ThemeManager { this.previousImageVariableNames = newImageVariableNames; } - // Always apply dark mode setting - if (theme.forceDark !== undefined) { - settingsState.DarkMode = theme.forceDark; + // Always apply dark mode setting when theme forces appearance + if (shouldForceThemeAppearance(theme as CustomTheme)) { + settingsState.DarkMode = getForcedDarkMode(theme as CustomTheme); } // Only apply color if this is a new theme if (!theme.webURL && theme.defaultColour) { settingsState.selectedColor = theme.defaultColour; } + + setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []); + void updateAllColors(); } catch (error) { console.error("[ThemeManager] Error updating theme preview:", error); } @@ -815,6 +845,8 @@ export class ThemeManager { this.previewStyleElement = null; } + clearCustomThemeAdaptiveCssVariables(); + // Restore original settings const storedColor = localStorage.getItem("originalPreviewColor"); @@ -844,6 +876,8 @@ export class ThemeManager { settingsState.DarkMode = this.originalPreviewTheme; this.originalPreviewTheme = null; } + + void updateAllColors(); } catch (error) { console.error("[ThemeManager] Error clearing preview:", error); } diff --git a/src/seqta/ui/colors/Manager.ts b/src/seqta/ui/colors/Manager.ts index f035fe12..d72c8844 100644 --- a/src/seqta/ui/colors/Manager.ts +++ b/src/seqta/ui/colors/Manager.ts @@ -5,6 +5,7 @@ import { lightenAndPaleColor } from "./lightenAndPaleColor"; import ColorLuminance from "./ColorLuminance"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour"; +import { getCustomThemeAdaptiveCssVariables } from "@/seqta/ui/colors/customThemeAdaptiveBindings"; import darkLogo from "@/resources/icons/betterseqta-light-full.png"; import lightLogo from "@/resources/icons/betterseqta-dark-full.png"; @@ -127,6 +128,12 @@ function applyColorsWith(selectedColor: string) { // Apply all the properties applyProperties({ ...commonProps, ...modeProps, ...dynamicProps }); + if (settingsState.selectedTheme) { + for (const name of getCustomThemeAdaptiveCssVariables()) { + setCSSVar(name, selectedColor); + } + } + let alliframes = document.getElementsByTagName("iframe"); for (let i = 0; i < alliframes.length; i++) { diff --git a/src/seqta/ui/colors/customThemeAdaptiveBindings.ts b/src/seqta/ui/colors/customThemeAdaptiveBindings.ts new file mode 100644 index 00000000..dba35e3f --- /dev/null +++ b/src/seqta/ui/colors/customThemeAdaptiveBindings.ts @@ -0,0 +1,40 @@ +/** Tracks which author-declared CSS variables mirror the effective accent; not persisted in settings storage. */ + +const VALID_CUSTOM_PROP = /^--[a-zA-Z0-9_-]{1,120}$/; + +let boundNames: string[] = []; + +export function normalizeAdaptiveCssVariableNames( + names: string[] | undefined, +): string[] { + if (!names?.length) return []; + const out: string[] = []; + const seen = new Set(); + for (const raw of names) { + const s = raw.trim(); + if (!VALID_CUSTOM_PROP.test(s) || seen.has(s)) continue; + seen.add(s); + out.push(s); + } + return out; +} + +export function setCustomThemeAdaptiveCssVariables( + names: string[] | undefined, +): void { + for (const n of boundNames) { + document.documentElement.style.removeProperty(n); + } + boundNames = normalizeAdaptiveCssVariableNames(names); +} + +export function getCustomThemeAdaptiveCssVariables(): string[] { + return boundNames; +} + +export function clearCustomThemeAdaptiveCssVariables(): void { + for (const n of boundNames) { + document.documentElement.style.removeProperty(n); + } + boundNames = []; +} diff --git a/src/seqta/utils/listeners/StorageChanges.ts b/src/seqta/utils/listeners/StorageChanges.ts index 7e178379..2d9ccf06 100644 --- a/src/seqta/utils/listeners/StorageChanges.ts +++ b/src/seqta/utils/listeners/StorageChanges.ts @@ -20,6 +20,7 @@ export class StorageChangeHandler { settingsState.register("adaptiveThemeColourTransition", () => void updateAllColors(), ); + settingsState.register("selectedTheme", () => void updateAllColors()); settingsState.register("DarkMode", this.handleDarkModeChange.bind(this)); settingsState.register("onoff", this.handleOnOffChange.bind(this)); settingsState.register("shortcuts", this.handleShortcutsChange.bind(this)); diff --git a/src/types/CustomThemes.ts b/src/types/CustomThemes.ts index a8620db1..6ff7def4 100644 --- a/src/types/CustomThemes.ts +++ b/src/types/CustomThemes.ts @@ -12,7 +12,14 @@ export type CustomTheme = { hideThemeName: boolean; webURL?: string; selectedColor?: string; + /** + * When true, the theme forces light/dark via `forceDark` (`false` = light, `true` = dark). + * When false/omitted, use legacy rule: `forceDark !== undefined` still means "force" for old JSON. + */ + forceTheme?: boolean; forceDark?: boolean; + /** CSS custom property names (e.g. `--my-accent`) that receive the same value as `--better-main` when adaptive colours apply. */ + adaptiveCssVariables?: string[]; }; export type LoadedCustomTheme = CustomTheme & { @@ -37,3 +44,18 @@ export type ThemeList = { themes: CustomTheme[]; selectedTheme: string; }; + +/** Whether the theme forces appearance (light vs dark). */ +export function shouldForceThemeAppearance(theme: { + forceTheme?: boolean; + forceDark?: boolean; +}): boolean { + if (theme.forceTheme === true) return true; + if (theme.forceTheme === false) return false; + return theme.forceDark !== undefined; +} + +/** Resolved forced dark mode when forcing is active. */ +export function getForcedDarkMode(theme: { forceDark?: boolean }): boolean { + return theme.forceDark === true; +}