From 3a2c43822363d741fe45ee036a40771ab6caac4c Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Mon, 16 Mar 2026 15:40:16 +1030 Subject: [PATCH] feat: adaptive themeing --- src/background.ts | 2 + src/css/injected.scss | 1 - src/interface/components/Slider.svelte | 2 +- src/interface/pages/settings/general.svelte | 47 ++++++-- src/plugins/monofile.ts | 6 +- src/seqta/ui/colors/Manager.ts | 38 +++++-- src/seqta/utils/adaptiveThemeColour.ts | 114 ++++++++++++++++++++ src/seqta/utils/listeners/StorageChanges.ts | 6 +- src/types/storage.ts | 2 + 9 files changed, 197 insertions(+), 21 deletions(-) create mode 100644 src/seqta/utils/adaptiveThemeColour.ts 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;