diff --git a/src/background.ts b/src/background.ts
index 60037577..dc23f115 100644
--- a/src/background.ts
+++ b/src/background.ts
@@ -306,6 +306,8 @@ function getDefaultValues(): SettingsState {
lettergrade: false,
newsSource: "australia",
iconOnlySidebar: false,
+ adaptiveThemeColour: false,
+ adaptiveThemeGradient: false,
};
}
diff --git a/src/css/injected.scss b/src/css/injected.scss
index cefcf34a..c429dc99 100644
--- a/src/css/injected.scss
+++ b/src/css/injected.scss
@@ -77,7 +77,6 @@ select {
border-radius: 8px !important;
}
#container {
- transition: 200ms;
background: var(--auto-background) !important;
}
:root * {
diff --git a/src/interface/components/Slider.svelte b/src/interface/components/Slider.svelte
index 3b3486b3..7af9a793 100644
--- a/src/interface/components/Slider.svelte
+++ b/src/interface/components/Slider.svelte
@@ -9,7 +9,7 @@
let percentage = $derived(((state - min) / (max - min)) * 100);
-
+
+
+
+
Adaptive Theme Colour
+
Change the theme colour based on the current class (e.g. when viewing a course or assessments page).
+
+
+ settingsState.adaptiveThemeColour = isOn}
+ />
+
+
+ {#if $settingsState.adaptiveThemeColour}
+
+
+
Soft Gradient
+
Use a soft gradient instead of a solid colour when viewing a class.
+
+
+ settingsState.adaptiveThemeGradient = isOn}
+ />
+
+
+ {/if}
+
+
{#each pluginSettings as plugin}
@@ -280,13 +309,15 @@
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
/>
{:else if setting.type === 'number'}
-
updatePluginSetting(plugin.pluginId, key, value)}
- min={setting.min}
- max={setting.max}
- step={setting.step}
- />
+
+ updatePluginSetting(plugin.pluginId, key, value)}
+ min={setting.min}
+ max={setting.max}
+ step={setting.step}
+ />
+
{:else if setting.type === 'string'}
{
+ if (settingsState.adaptiveThemeColour) void updateAllColors();
+ });
loading();
InjectCustomIcons();
HideMenuItems();
diff --git a/src/seqta/ui/colors/Manager.ts b/src/seqta/ui/colors/Manager.ts
index 318ceb64..209c74ba 100644
--- a/src/seqta/ui/colors/Manager.ts
+++ b/src/seqta/ui/colors/Manager.ts
@@ -1,8 +1,10 @@
import browser from "webextension-polyfill";
+import Color from "color";
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
import { lightenAndPaleColor } from "./lightenAndPaleColor";
import ColorLuminance from "./ColorLuminance";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
+import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
import darkLogo from "@/resources/icons/betterseqta-light-full.png";
import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
@@ -13,13 +15,7 @@ const setCSSVar = (varName: any, value: any) =>
const applyProperties = (props: any) =>
Object.entries(props).forEach(([key, value]) => setCSSVar(key, value));
-export function updateAllColors() {
- // Determine the color to use
- const selectedColor =
- settingsState.selectedColor !== ""
- ? settingsState.selectedColor
- : "#007bff";
-
+function applyColorsWith(selectedColor: string) {
if (settingsState.transparencyEffects) {
document.documentElement.classList.add("transparencyEffects");
}
@@ -28,7 +24,7 @@ export function updateAllColors() {
const commonProps = {
"--better-sub": "#161616",
"--better-alert-highlight": "#c61851",
- "--better-main": settingsState.selectedColor,
+ "--better-main": selectedColor,
};
// Mode-based properties, applied if storedSetting is provided
@@ -79,3 +75,29 @@ export function updateAllColors() {
}
}
}
+
+function toSoftGradient(hex: string): string {
+ const base = Color(hex);
+ const analogous = base.rotate(30).lighten(0.25).saturate(0.15);
+ const mid = base.mix(analogous, 0.5).hex();
+ return `linear-gradient(135deg, ${hex} 0%, ${mid} 50%, ${analogous.hex()} 100%)`;
+}
+
+export async function updateAllColors() {
+ let effectiveColor =
+ settingsState.selectedColor !== ""
+ ? settingsState.selectedColor
+ : "#007bff";
+
+ if (settingsState.adaptiveThemeColour) {
+ const adaptiveColor = await getAdaptiveColour();
+ if (adaptiveColor) {
+ effectiveColor =
+ settingsState.adaptiveThemeGradient
+ ? toSoftGradient(adaptiveColor)
+ : adaptiveColor;
+ }
+ }
+
+ applyColorsWith(effectiveColor);
+}
diff --git a/src/seqta/utils/adaptiveThemeColour.ts b/src/seqta/utils/adaptiveThemeColour.ts
new file mode 100644
index 00000000..7a5caafd
--- /dev/null
+++ b/src/seqta/utils/adaptiveThemeColour.ts
@@ -0,0 +1,114 @@
+import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
+
+/**
+ * Parses the current page from window.location.hash.
+ * Returns { programme, metaclass } for /courses/SEMESTER/X:Y or /assessments/SEMESTER/X:Y, or null.
+ * e.g. #?page=/courses/2023S/4804:11066 or #?page=/assessments/2023S/4621:10772
+ */
+function parsePageContext(): { programme: number; metaclass: number } | null {
+ const hash = window.location.hash || "";
+ const match = hash.match(/[?&]page=\/(courses|assessments)\/[^/]+\/(\d+):(\d+)/);
+ if (!match) return null;
+ const programme = parseInt(match[2], 10);
+ const metaclass = parseInt(match[3], 10);
+ if (isNaN(programme) || isNaN(metaclass)) return null;
+ return { programme, metaclass };
+}
+
+/**
+ * Fetches subjects and finds the subject matching programme:metaclass.
+ */
+async function getSubjectCode(
+ programme: number,
+ metaclass: number
+): Promise {
+ try {
+ const res = await fetch(`${location.origin}/seqta/student/load/subjects?`, {
+ method: "POST",
+ credentials: "include",
+ headers: { "Content-Type": "application/json; charset=utf-8" },
+ body: JSON.stringify({}),
+ });
+ const data = await res.json();
+ const payload = data?.payload;
+ if (!Array.isArray(payload)) return null;
+
+ for (const semester of payload) {
+ const subjects = semester?.subjects;
+ if (!Array.isArray(subjects)) continue;
+ const subject = subjects.find(
+ (s: any) =>
+ s &&
+ Number(s.programme) === programme &&
+ Number(s.metaclass) === metaclass
+ );
+ if (subject?.code) return subject.code;
+ }
+ return null;
+ } catch (error) {
+ console.warn("[BetterSEQTA+] Adaptive theme: failed to load subjects:", error);
+ return null;
+ }
+}
+
+/**
+ * Fetches user prefs and returns the colour for the given subject code.
+ */
+async function getSubjectColour(
+ subjectCode: string,
+ userId: number
+): Promise {
+ try {
+ const res = await fetch(`${location.origin}/seqta/student/load/prefs?`, {
+ method: "POST",
+ credentials: "include",
+ headers: { "Content-Type": "application/json; charset=utf-8" },
+ body: JSON.stringify({
+ request: "userPrefs",
+ asArray: true,
+ user: userId,
+ }),
+ });
+ const data = await res.json();
+ const payload = data?.payload;
+ if (!Array.isArray(payload)) return null;
+
+ const pref = payload.find(
+ (p: { name: string; value: string }) =>
+ p.name === `timetable.subject.colour.${subjectCode}`
+ );
+ return pref?.value ?? null;
+ } catch (error) {
+ console.warn("[BetterSEQTA+] Adaptive theme: failed to load prefs:", error);
+ return null;
+ }
+}
+
+/**
+ * Returns the adaptive theme colour for the current page context, or null.
+ * When viewing a course or assessments page, returns the subject's assigned colour.
+ */
+export async function getAdaptiveColour(): Promise {
+ const context = parsePageContext();
+ if (!context) return null;
+
+ const subjectCode = await getSubjectCode(context.programme, context.metaclass);
+ if (!subjectCode) return null;
+
+ let userId: number;
+ try {
+ const userInfo = await getUserInfo();
+ userId = userInfo?.id;
+ if (typeof userId !== "number") return null;
+ } catch {
+ return null;
+ }
+
+ const colour = await getSubjectColour(subjectCode, userId);
+ if (!colour || typeof colour !== "string") return null;
+
+ // Basic hex validation
+ if (/^#([0-9A-Fa-f]{3}){1,2}$/.test(colour)) return colour;
+ if (/^[0-9A-Fa-f]{6}$/.test(colour)) return `#${colour}`;
+ return null;
+}
diff --git a/src/seqta/utils/listeners/StorageChanges.ts b/src/seqta/utils/listeners/StorageChanges.ts
index d93edf80..bfa256d3 100644
--- a/src/seqta/utils/listeners/StorageChanges.ts
+++ b/src/seqta/utils/listeners/StorageChanges.ts
@@ -14,7 +14,9 @@ export class StorageChangeHandler {
}
private registerHandlers() {
- settingsState.register("selectedColor", updateAllColors.bind(this));
+ settingsState.register("selectedColor", () => void updateAllColors());
+ settingsState.register("adaptiveThemeColour", () => void updateAllColors());
+ settingsState.register("adaptiveThemeGradient", () => void updateAllColors());
settingsState.register("DarkMode", this.handleDarkModeChange.bind(this));
settingsState.register("onoff", this.handleOnOffChange.bind(this));
settingsState.register("shortcuts", this.handleShortcutsChange.bind(this));
@@ -45,7 +47,7 @@ export class StorageChangeHandler {
}
private handleDarkModeChange() {
- updateAllColors();
+ void updateAllColors();
}
private handleOnOffChange(newValue: boolean) {
diff --git a/src/types/storage.ts b/src/types/storage.ts
index 5b911ffc..dc683f2c 100644
--- a/src/types/storage.ts
+++ b/src/types/storage.ts
@@ -41,6 +41,8 @@ export interface SettingsState {
mockNotices?: boolean;
hideSensitiveContent?: boolean;
iconOnlySidebar?: boolean;
+ adaptiveThemeColour?: boolean;
+ adaptiveThemeGradient?: boolean;
// depreciated keys
animatedbk: boolean;