Adaptive custom themes (#416)

* feat: Smooth change in colour, no hard cut

Added option smoothing on colour change so there is no hard cut made when switching subjects

* feat: Themes can adapt to colour

* quick fix to forced mode as well

* [CodeFactor] Apply fixes

---------

Co-authored-by: codefactor-io <support@codefactor.io>
This commit is contained in:
Aden Lindsay
2026-04-06 17:21:38 +09:30
committed by GitHub
6 changed files with 168 additions and 18 deletions
+53 -7
View File
@@ -4,7 +4,10 @@
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { fade } 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' import { settingsState } from '@/seqta/utils/listeners/SettingsState'
@@ -40,7 +43,9 @@
coverImage: null, coverImage: null,
isEditable: true, isEditable: true,
hideThemeName: false, hideThemeName: false,
forceDark: undefined forceTheme: undefined,
forceDark: undefined,
adaptiveCssVariables: [],
}) })
let closedAccordions = $state<string[]>([]) let closedAccordions = $state<string[]>([])
let themeLoaded = $state(false); 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 themeLoaded = true
} else { } else {
themeLoaded = true themeLoaded = true
@@ -115,6 +126,13 @@
})) }))
themeClone.coverImage = theme.coverImage themeClone.coverImage = theme.coverImage
if (shouldForceThemeAppearance(themeClone)) {
themeClone.forceTheme = true;
} else {
themeClone.forceTheme = false;
themeClone.forceDark = undefined;
}
themeManager.clearPreview(); themeManager.clearPreview();
await themeManager.saveTheme(themeClone); await themeManager.saveTheme(themeClone);
await themeManager.setTheme(themeClone.id); await themeManager.setTheme(themeClone.id);
@@ -317,6 +335,27 @@
<Divider /> <Divider />
<div class="py-3">
<h2 class="text-sm font-bold">Adaptive CSS variables</h2>
<p class="text-xs text-zinc-600 dark:text-zinc-400">
One per line, each must start with <code class="text-xs">--</code>. These receive the same colour as the adaptive accent when &quot;Adaptive theme colour&quot; is enabled in general settings. Use them in Custom CSS, e.g. <code class="text-xs">border-color: var(--my-accent);</code>
</p>
<textarea
placeholder="--my-accent&#10;--class-banner"
value={theme.adaptiveCssVariables?.join('\n') ?? ''}
oninput={(e) => {
const lines = e.currentTarget.value
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
theme = { ...theme, adaptiveCssVariables: lines };
}}
class="p-2 mt-2 w-full min-h-[5rem] font-mono text-sm rounded-lg border-0 transition dark:placeholder-zinc-400 bg-zinc-200 dark:bg-zinc-700 focus:outline-none focus:ring-1 focus:ring-zinc-100 dark:focus:ring-zinc-700 focus:bg-zinc-300/50 dark:focus:bg-zinc-600"
></textarea>
</div>
<Divider />
{#each [ {#each [
{ {
type: 'switch', type: 'switch',
@@ -332,21 +371,28 @@
title: 'Force Theme', title: 'Force Theme',
description: 'Force users to use either dark or light mode', description: 'Force users to use either dark or light mode',
props: { props: {
state: theme.forceDark !== undefined, state: shouldForceThemeAppearance(theme),
onChange: (value: boolean) => theme = { ...theme, forceDark: value ? false : undefined } onChange: (value: boolean) => {
if (value) {
theme = { ...theme, forceTheme: true, forceDark: false };
} else {
theme = { ...theme, forceTheme: false, forceDark: undefined };
}
}
} }
}, },
{ {
type: 'conditional', type: 'conditional',
props: { props: {
condition: theme.forceDark !== undefined, condition: shouldForceThemeAppearance(theme),
children: { children: {
type: 'lightDarkToggle', type: 'lightDarkToggle',
title: 'Mode', title: 'Mode',
description: 'Choose whether to force light or dark mode', description: 'Choose whether to force light or dark mode',
props: { props: {
state: theme.forceDark === true, state: theme.forceDark === true,
onChange: (value: boolean) => theme = { ...theme, forceDark: value } onChange: (value: boolean) =>
(theme = { ...theme, forceDark: value, forceTheme: true })
} }
} }
} }
+45 -11
View File
@@ -1,8 +1,18 @@
import localforage from "localforage"; import localforage from "localforage";
import browser from "webextension-polyfill"; 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 { settingsState } from "@/seqta/utils/listeners/SettingsState";
import debounce from "@/seqta/utils/debounce"; import debounce from "@/seqta/utils/debounce";
import { updateAllColors } from "@/seqta/ui/colors/Manager";
import {
clearCustomThemeAdaptiveCssVariables,
setCustomThemeAdaptiveCssVariables,
} from "@/seqta/ui/colors/customThemeAdaptiveBindings";
type ThemeContent = { type ThemeContent = {
id: string; id: string;
@@ -14,6 +24,8 @@ type ThemeContent = {
CustomCSS?: string; CustomCSS?: string;
hideThemeName?: boolean; hideThemeName?: boolean;
forceDark?: boolean; forceDark?: boolean;
forceTheme?: boolean;
adaptiveCssVariables?: string[];
images: { id: string; variableName: string; data: string }[]; // data: base64 images: { id: string; variableName: string; data: string }[]; // data: base64
}; };
@@ -209,7 +221,7 @@ export class ThemeManager {
console.debug("[ThemeManager] Storing original settings"); console.debug("[ThemeManager] Storing original settings");
settingsState.originalSelectedColor = settingsState.selectedColor; settingsState.originalSelectedColor = settingsState.selectedColor;
if (theme.forceDark) { if (shouldForceThemeAppearance(theme)) {
settingsState.originalDarkMode = settingsState.DarkMode; settingsState.originalDarkMode = settingsState.DarkMode;
} }
} }
@@ -240,6 +252,7 @@ export class ThemeManager {
this.currentTheme = theme; this.currentTheme = theme;
settingsState.selectedTheme = themeId; settingsState.selectedTheme = themeId;
} }
void updateAllColors();
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error setting theme:", error); console.error("[ThemeManager] Error setting theme:", error);
} }
@@ -270,9 +283,10 @@ export class ThemeManager {
} }
// Apply theme settings // Apply theme settings
if (theme.forceDark !== undefined) { if (shouldForceThemeAppearance(theme)) {
console.debug("[ThemeManager] Setting dark mode:", theme.forceDark); const dark = getForcedDarkMode(theme);
settingsState.DarkMode = theme.forceDark; console.debug("[ThemeManager] Setting dark mode:", dark);
settingsState.DarkMode = dark;
} }
// Use the stored selected color if available, otherwise use the default // Use the stored selected color if available, otherwise use the default
@@ -289,6 +303,8 @@ export class ThemeManager {
); );
settingsState.selectedColor = theme.defaultColour; settingsState.selectedColor = theme.defaultColour;
} }
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error applying theme:", error); console.error("[ThemeManager] Error applying theme:", error);
} }
@@ -373,6 +389,7 @@ export class ThemeManager {
if (clearSelectedTheme) { if (clearSelectedTheme) {
settingsState.selectedTheme = ""; settingsState.selectedTheme = "";
} }
clearCustomThemeAdaptiveCssVariables();
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error removing theme:", error); console.error("[ThemeManager] Error removing theme:", error);
} }
@@ -585,6 +602,13 @@ export class ThemeManager {
isEditable: false, isEditable: false,
hideThemeName: themeData.hideThemeName ?? false, hideThemeName: themeData.hideThemeName ?? false,
forceDark: themeData.forceDark, forceDark: themeData.forceDark,
forceTheme:
themeData.forceTheme !== undefined
? themeData.forceTheme
: themeData.forceDark !== undefined
? true
: undefined,
adaptiveCssVariables: themeData.adaptiveCssVariables,
}; };
await this.saveTheme(theme); await this.saveTheme(theme);
@@ -651,7 +675,7 @@ export class ThemeManager {
public async previewTheme(theme: LoadedCustomTheme): Promise<void> { public async previewTheme(theme: LoadedCustomTheme): Promise<void> {
console.debug("[ThemeManager] Previewing theme:", theme.name); console.debug("[ThemeManager] Previewing theme:", theme.name);
try { try {
const { CustomCSS, CustomImages, defaultColour, forceDark } = theme; const { CustomCSS, CustomImages, defaultColour } = theme;
// Store original settings only if this is a new theme // Store original settings only if this is a new theme
if (!theme.webURL) { if (!theme.webURL) {
@@ -697,13 +721,16 @@ export class ThemeManager {
this.previousImageVariableNames = newImageVariableNames; this.previousImageVariableNames = newImageVariableNames;
// Apply theme settings // Apply theme settings
if (forceDark !== undefined) { if (shouldForceThemeAppearance(theme)) {
settingsState.DarkMode = forceDark; settingsState.DarkMode = getForcedDarkMode(theme);
} }
if (defaultColour) { if (defaultColour) {
settingsState.selectedColor = defaultColour; settingsState.selectedColor = defaultColour;
} }
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
void updateAllColors();
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error previewing theme:", error); console.error("[ThemeManager] Error previewing theme:", error);
} }
@@ -769,15 +796,18 @@ export class ThemeManager {
this.previousImageVariableNames = newImageVariableNames; this.previousImageVariableNames = newImageVariableNames;
} }
// Always apply dark mode setting // Always apply dark mode setting when theme forces appearance
if (theme.forceDark !== undefined) { if (shouldForceThemeAppearance(theme as CustomTheme)) {
settingsState.DarkMode = theme.forceDark; settingsState.DarkMode = getForcedDarkMode(theme as CustomTheme);
} }
// Only apply color if this is a new theme // Only apply color if this is a new theme
if (!theme.webURL && theme.defaultColour) { if (!theme.webURL && theme.defaultColour) {
settingsState.selectedColor = theme.defaultColour; settingsState.selectedColor = theme.defaultColour;
} }
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
void updateAllColors();
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error updating theme preview:", error); console.error("[ThemeManager] Error updating theme preview:", error);
} }
@@ -815,6 +845,8 @@ export class ThemeManager {
this.previewStyleElement = null; this.previewStyleElement = null;
} }
clearCustomThemeAdaptiveCssVariables();
// Restore original settings // Restore original settings
const storedColor = localStorage.getItem("originalPreviewColor"); const storedColor = localStorage.getItem("originalPreviewColor");
@@ -844,6 +876,8 @@ export class ThemeManager {
settingsState.DarkMode = this.originalPreviewTheme; settingsState.DarkMode = this.originalPreviewTheme;
this.originalPreviewTheme = null; this.originalPreviewTheme = null;
} }
void updateAllColors();
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error clearing preview:", error); console.error("[ThemeManager] Error clearing preview:", error);
} }
+7
View File
@@ -5,6 +5,7 @@ import { lightenAndPaleColor } from "./lightenAndPaleColor";
import ColorLuminance from "./ColorLuminance"; import ColorLuminance from "./ColorLuminance";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour"; import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
import { getCustomThemeAdaptiveCssVariables } from "@/seqta/ui/colors/customThemeAdaptiveBindings";
import darkLogo from "@/resources/icons/betterseqta-light-full.png"; import darkLogo from "@/resources/icons/betterseqta-light-full.png";
import lightLogo from "@/resources/icons/betterseqta-dark-full.png"; import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
@@ -127,6 +128,12 @@ function applyColorsWith(selectedColor: string) {
// Apply all the properties // Apply all the properties
applyProperties({ ...commonProps, ...modeProps, ...dynamicProps }); applyProperties({ ...commonProps, ...modeProps, ...dynamicProps });
if (settingsState.selectedTheme) {
for (const name of getCustomThemeAdaptiveCssVariables()) {
setCSSVar(name, selectedColor);
}
}
let alliframes = document.getElementsByTagName("iframe"); let alliframes = document.getElementsByTagName("iframe");
for (let i = 0; i < alliframes.length; i++) { for (let i = 0; i < alliframes.length; i++) {
@@ -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<string>();
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 = [];
}
@@ -20,6 +20,7 @@ export class StorageChangeHandler {
settingsState.register("adaptiveThemeColourTransition", () => settingsState.register("adaptiveThemeColourTransition", () =>
void updateAllColors(), void updateAllColors(),
); );
settingsState.register("selectedTheme", () => void updateAllColors());
settingsState.register("DarkMode", this.handleDarkModeChange.bind(this)); settingsState.register("DarkMode", this.handleDarkModeChange.bind(this));
settingsState.register("onoff", this.handleOnOffChange.bind(this)); settingsState.register("onoff", this.handleOnOffChange.bind(this));
settingsState.register("shortcuts", this.handleShortcutsChange.bind(this)); settingsState.register("shortcuts", this.handleShortcutsChange.bind(this));
+22
View File
@@ -12,7 +12,14 @@ export type CustomTheme = {
hideThemeName: boolean; hideThemeName: boolean;
webURL?: string; webURL?: string;
selectedColor?: 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; 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 & { export type LoadedCustomTheme = CustomTheme & {
@@ -37,3 +44,18 @@ export type ThemeList = {
themes: CustomTheme[]; themes: CustomTheme[];
selectedTheme: string; 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;
}