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 { 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<string[]>([])
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 @@
<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 [
{
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 })
}
}
}
+45 -11
View File
@@ -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<void> {
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);
}
+7
View File
@@ -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++) {
@@ -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", () =>
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));
+22
View File
@@ -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;
}