diff --git a/package.json b/package.json
index 175c8755..d175d5ff 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "betterseqtaplus",
"version": "3.5.3",
"type": "module",
- "description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development add add heaps more features!",
+ "description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
"browserslist": "> 0.5%, last 2 versions, not dead",
"scripts": {
"autoaudit": "npm audit && npm audit fix && npm run build",
diff --git a/src/background.ts b/src/background.ts
index e5959f81..6a897ffb 100644
--- a/src/background.ts
+++ b/src/background.ts
@@ -331,6 +331,7 @@ function getDefaultValues(): SettingsState {
iconOnlySidebar: false,
adaptiveThemeColour: false,
adaptiveThemeGradient: false,
+ adaptiveThemeColourTransition: true,
};
}
diff --git a/src/interface/pages/settings/general.svelte b/src/interface/pages/settings/general.svelte
index 09e6d1cd..1e6e414f 100644
--- a/src/interface/pages/settings/general.svelte
+++ b/src/interface/pages/settings/general.svelte
@@ -252,6 +252,18 @@
/>
+
+
+
Smooth colour transition
+
Ease between class/subject colours when navigating instead of switching instantly
+
+
+ settingsState.adaptiveThemeColourTransition = isOn}
+ />
+
+
{/if}
diff --git a/src/seqta/ui/colors/Manager.ts b/src/seqta/ui/colors/Manager.ts
index 209c74ba..f035fe12 100644
--- a/src/seqta/ui/colors/Manager.ts
+++ b/src/seqta/ui/colors/Manager.ts
@@ -9,12 +9,80 @@ import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
import darkLogo from "@/resources/icons/betterseqta-light-full.png";
import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
+const ADAPTIVE_THEME_TRANSITION_MS = 400;
+
+let colorTransitionRafId: number | null = null;
+let lastInterpolatedHex: string | null = null;
+
// Helper functions
const setCSSVar = (varName: any, value: any) =>
document.documentElement.style.setProperty(varName, value);
const applyProperties = (props: any) =>
Object.entries(props).forEach(([key, value]) => setCSSVar(key, value));
+function easeInOutCubic(t: number): number {
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
+}
+
+/** Best-effort parse of a single sRGB hex from a colour string (hex, rgb, or gradient). */
+function parseRepresentativeHex(s: string): string | null {
+ if (!s || !s.trim()) return null;
+ const trimmed = s.trim();
+ try {
+ return Color(trimmed).hex();
+ } catch {
+ // continue
+ }
+ if (trimmed.includes("gradient")) {
+ const regex =
+ /#[0-9a-fA-F]{6}|rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)|rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)/gi;
+ const stops = trimmed.match(regex);
+ if (stops?.length) {
+ try {
+ return Color(stops[0]).hex();
+ } catch {
+ // continue
+ }
+ }
+ }
+ const hexMatch = trimmed.match(/#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})\b/);
+ if (hexMatch) {
+ try {
+ return Color(hexMatch[0]).hex();
+ } catch {
+ // continue
+ }
+ }
+ const rgbaMatch = trimmed.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
+ if (rgbaMatch) {
+ try {
+ return Color.rgb(
+ Number(rgbaMatch[1]),
+ Number(rgbaMatch[2]),
+ Number(rgbaMatch[3]),
+ ).hex();
+ } catch {
+ // continue
+ }
+ }
+ return null;
+}
+
+function getFromHex(): string | null {
+ const fromComputed = parseRepresentativeHex(
+ getComputedStyle(document.documentElement).getPropertyValue("--better-main").trim(),
+ );
+ if (fromComputed) return fromComputed;
+ return lastInterpolatedHex;
+}
+
+function cancelColorTransition() {
+ if (colorTransitionRafId !== null) {
+ cancelAnimationFrame(colorTransitionRafId);
+ colorTransitionRafId = null;
+ }
+}
+
function applyColorsWith(selectedColor: string) {
if (settingsState.transparencyEffects) {
document.documentElement.classList.add("transparencyEffects");
@@ -89,15 +157,71 @@ export async function updateAllColors() {
? settingsState.selectedColor
: "#007bff";
+ let adaptiveHex: string | null = null;
+
if (settingsState.adaptiveThemeColour) {
const adaptiveColor = await getAdaptiveColour();
if (adaptiveColor) {
- effectiveColor =
- settingsState.adaptiveThemeGradient
- ? toSoftGradient(adaptiveColor)
- : adaptiveColor;
+ adaptiveHex = adaptiveColor;
+ effectiveColor = settingsState.adaptiveThemeGradient
+ ? toSoftGradient(adaptiveColor)
+ : adaptiveColor;
}
}
- applyColorsWith(effectiveColor);
+ const baseSelected =
+ settingsState.selectedColor !== "" ? settingsState.selectedColor : "#007bff";
+ const toHex =
+ adaptiveHex ?? parseRepresentativeHex(baseSelected);
+
+ const shouldAnimate =
+ settingsState.adaptiveThemeColour &&
+ (settingsState.adaptiveThemeColourTransition ?? true) &&
+ !!toHex;
+
+ const applyImmediate = () => {
+ cancelColorTransition();
+ applyColorsWith(effectiveColor);
+ if (toHex) lastInterpolatedHex = toHex;
+ };
+
+ if (!shouldAnimate) {
+ applyImmediate();
+ return;
+ }
+
+ const fromHex = getFromHex();
+
+ if (!fromHex || !toHex || fromHex === toHex) {
+ applyImmediate();
+ return;
+ }
+
+ const useSoftGradientOnFrames =
+ !!adaptiveHex && !!settingsState.adaptiveThemeGradient;
+
+ cancelColorTransition();
+
+ const start = performance.now();
+
+ const step = (now: number) => {
+ const elapsed = now - start;
+ const t = Math.min(1, elapsed / ADAPTIVE_THEME_TRANSITION_MS);
+ const eased = easeInOutCubic(t);
+ const interpolatedHex = Color(fromHex).mix(Color(toHex), eased).hex();
+ const display = useSoftGradientOnFrames
+ ? toSoftGradient(interpolatedHex)
+ : interpolatedHex;
+ applyColorsWith(display);
+
+ if (t < 1) {
+ colorTransitionRafId = requestAnimationFrame(step);
+ } else {
+ colorTransitionRafId = null;
+ applyColorsWith(effectiveColor);
+ lastInterpolatedHex = toHex;
+ }
+ };
+
+ colorTransitionRafId = requestAnimationFrame(step);
}
diff --git a/src/seqta/utils/listeners/StorageChanges.ts b/src/seqta/utils/listeners/StorageChanges.ts
index 15d495c9..7e178379 100644
--- a/src/seqta/utils/listeners/StorageChanges.ts
+++ b/src/seqta/utils/listeners/StorageChanges.ts
@@ -17,6 +17,9 @@ export class StorageChangeHandler {
settingsState.register("selectedColor", () => void updateAllColors());
settingsState.register("adaptiveThemeColour", () => void updateAllColors());
settingsState.register("adaptiveThemeGradient", () => void updateAllColors());
+ settingsState.register("adaptiveThemeColourTransition", () =>
+ 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/storage.ts b/src/types/storage.ts
index dc683f2c..56dda410 100644
--- a/src/types/storage.ts
+++ b/src/types/storage.ts
@@ -43,6 +43,7 @@ export interface SettingsState {
iconOnlySidebar?: boolean;
adaptiveThemeColour?: boolean;
adaptiveThemeGradient?: boolean;
+ adaptiveThemeColourTransition?: boolean;
// depreciated keys
animatedbk: boolean;