Merge branch 'main' into improved-global-search

This commit is contained in:
StroepWafel
2026-05-25 13:13:57 +09:30
committed by GitHub
32 changed files with 2729 additions and 589 deletions
+1 -38
View File
@@ -1,3 +1,4 @@
import { waitForEngageMenuList } from "@/seqta/utils/waitForEngageMenuList";
import { addExtensionSettings } from "@/seqta/utils/Adders/AddExtensionSettings";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { loadEngageHomePage } from "@/seqta/utils/Loaders/LoadEngageHomePage";
@@ -287,44 +288,6 @@ async function createSettingsButton(parent?: Element) {
);
}
/** Engage mounts the sidebar inside batched React trees; EventManager-based waitForElm can miss `#menu`. Polling `waitForElm` matches the real DOM reliably. */
async function waitForEngageMenuList(): Promise<HTMLElement | null> {
const poll = true as const;
const interval = 100;
const trySelectors: { selector: string; maxIterations: number }[] = [
{ selector: "#menu > ul > li", maxIterations: 500 },
{ selector: "#menu ul", maxIterations: 350 },
{ selector: "#menu", maxIterations: 350 },
];
for (const { selector, maxIterations } of trySelectors) {
try {
await waitForElm(selector, poll, interval, maxIterations);
} catch {
continue;
}
if (selector === "#menu > ul > li") {
const ul = document.querySelector("#menu > ul") as HTMLElement | null;
if (ul) return ul;
} else if (selector === "#menu ul") {
const ul = document.querySelector("#menu ul") as HTMLElement | null;
if (ul) return ul;
} else {
const menu = document.getElementById("menu");
const ul =
(menu?.querySelector("ul") as HTMLElement | null) ??
(menu?.firstElementChild as HTMLElement | null);
if (ul) return ul;
}
}
console.warn(
"[BetterSEQTA+] Engage: could not find a menu list to inject the home button",
);
return null;
}
async function injectEngageHomeButton() {
if (document.getElementById("homebutton")) return;
+65
View File
@@ -0,0 +1,65 @@
import browser from "webextension-polyfill";
const DEFAULT_BASE = "https://betterseqta.org";
const KEY = "bsplus_dev_api_base";
/**
* Returns the current content-API base URL.
*
* Reads from `sessionStorage` so a developer can temporarily override the
* server for testing. The value is cleared when the browser session ends,
* leaving production traffic unaffected for normal users.
*/
export function getApiBase(): string {
try {
if (typeof sessionStorage === "undefined") return DEFAULT_BASE;
const v = sessionStorage.getItem(KEY);
if (v && /^https?:\/\//.test(v)) return v.replace(/\/$/, "");
} catch {
// sessionStorage may throw in some restricted contexts; fall back silently.
}
return DEFAULT_BASE;
}
/**
* Persist a session-scoped override and broadcast it to the background script
* so its `fetch` calls hit the same host.
*
* Pass `null` to clear the override.
*/
export function setApiBase(url: string | null): void {
try {
if (!url) {
sessionStorage.removeItem(KEY);
} else {
sessionStorage.setItem(KEY, url.replace(/\/$/, ""));
}
} catch {
// ignore
}
void browser.runtime
.sendMessage({ type: "setDevApiBase", url: url || null })
.catch(() => {});
}
/** Returns the override URL if one is currently set in this session. */
export function getStoredOverride(): string | null {
try {
if (typeof sessionStorage === "undefined") return null;
return sessionStorage.getItem(KEY);
} catch {
return null;
}
}
/**
* Send the current session override to the background script.
* Call this early in page load so the background context stays in sync after
* service-worker restarts.
*/
export function syncApiBaseToBackground(): void {
const override = getStoredOverride();
void browser.runtime
.sendMessage({ type: "setDevApiBase", url: override })
.catch(() => {});
}
@@ -8,6 +8,7 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts";
import {
type EngageParentChild,
type EngageParentTimetableItem,
@@ -753,6 +754,9 @@ export async function loadEngageHomePage(): Promise<void> {
const engageHomeBody = stringToHTML(/* html */ `
<div class="home-root" id="engage-home-root">
<div class="home-container" id="engage-home-container">
<div class="border shortcut-container">
<div class="border shortcuts" id="shortcuts"></div>
</div>
<div class="border timetable-container">
<div class="home-subtitle">
<div class="engage-timetable-title-cluster">
@@ -792,6 +796,7 @@ export async function loadEngageHomePage(): Promise<void> {
bindEngageTimetableUi();
setEngageTimetableSubtitle();
renderShortcuts();
const select = document.getElementById(
"engage-child-selector",
+1 -10
View File
@@ -113,16 +113,7 @@ export async function loadHomePage() {
callHomeTimetable(TodayFormatted, true);
const activeClass = classes.find((c: any) => c.hasOwnProperty("active"));
const activeYear = activeClass?.year;
const allSubjectsInYear = classes
.filter((c: any) => c.year === activeYear)
.flatMap((c: any) => c.subjects || []);
const seen = new Set<string>();
const activeSubjects = allSubjectsInYear.filter((s: any) => {
if (seen.has(s.code)) return false;
seen.add(s.code);
return true;
});
const activeSubjects = activeClass?.subjects || [];
const activeSubjectCodes = activeSubjects.map((s: any) => s.code);
const currentAssessments = assessments
.filter((a: any) => activeSubjectCodes.includes(a.code))
@@ -14,13 +14,14 @@ export function showEngageParentsToast() {
settingsState.engageParentsAnnouncementShown = true;
const toast = document.createElement("div");
toast.className = "bsplus-toast";
toast.className = "bsplus-toast engageParentsToast";
toast.innerHTML = /* html */ `
<button class="bsplus-toast-close" aria-label="Dismiss">&times;</button>
<div class="bsplus-toast-content">
<p class="bsplus-toast-eyebrow">SEQTA Engage support</p>
<strong>BetterSEQTA+ now supports <span class="seqtaEngageAccent">SEQTA Engage</span></strong>
<p>Buy your mum a BetterSEQTA Plus! Parents now get themes, a cleaner home page, and all the Plus polish on SEQTA Engage.</p>
</div>
<button class="bsplus-toast-close" aria-label="Dismiss">&times;</button>
`;
toast.style.opacity = "0";
@@ -0,0 +1,218 @@
import browser from "webextension-polyfill";
import stringToHTML from "../stringToHTML";
import { settingsState } from "../listeners/SettingsState";
import { closePopup } from "./PopupManager";
import { getApiBase } from "../DevApiBase";
import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight";
import { cloudAuth } from "../CloudAuth";
/**
* Server response shape from `/api/theme-of-the-month/current`.
* Hero image is resolved client-side via the theme store API when `theme_id` is set.
*/
export interface ThemeOfTheMonthEntry {
id: string;
month: string;
title: string;
description: string;
cover_image: string | null;
theme_id: string | null;
theme: { id: string; name: string; slug: string } | null;
created_at: number;
updated_at: number;
}
/**
* Fetches the current month's Theme of the Month entry from the API.
* Returns `null` when no entry is configured for this month, or when the
* request fails (we never want a network problem to block other startup
* popups).
*/
export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
try {
const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, {
cache: "no-store",
});
if (!res.ok) return null;
const text = await res.text();
if (!text) return null;
const data = JSON.parse(text);
if (!data || typeof data !== "object" || !data.id) return null;
return data as ThemeOfTheMonthEntry;
} catch (err) {
console.warn("[ThemeOfTheMonth] Failed to fetch current entry:", err);
return null;
}
}
/** True when we have a new monthly entry the user hasn't dismissed yet. */
export function shouldShowThemeOfTheMonth(entry: ThemeOfTheMonthEntry | null): boolean {
if (!entry || settingsState.themeOfTheMonthDisabled) return false;
return settingsState.themeOfTheMonthLastSeenId !== entry.id;
}
function escapeHTML(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function formatMonthLabel(month: string): string {
const [yyyy, mm] = month.split("-");
if (!yyyy || !mm) return month;
const date = new Date(parseInt(yyyy, 10), parseInt(mm, 10) - 1, 1);
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
}
/** Same priority as the theme store: marquee, then cover/banner. */
function heroUrlFromStoreTheme(theme: {
marqueeImage?: string | null;
coverImage?: string | null;
}): string | null {
const url = (theme.marqueeImage || theme.coverImage || "").trim();
return url || null;
}
/**
* Loads hero image for a store theme via the background script (same path as
* {@link ThemeSelector} / theme store detail fetches).
*/
export async function fetchThemeStoreHeroImage(themeId: string): Promise<string | null> {
try {
const token = await cloudAuth.getStoredToken();
const res = (await browser.runtime.sendMessage({
type: "fetchThemeDetails",
themeId,
token: token ?? undefined,
})) as { success?: boolean; data?: { theme?: { marqueeImage?: string; coverImage?: string } } };
if (!res?.success || !res?.data?.theme) return null;
return heroUrlFromStoreTheme(res.data.theme);
} catch (err) {
console.warn("[ThemeOfTheMonth] Failed to fetch theme store image:", err);
return null;
}
}
/** Linked theme store image, else optional admin-uploaded cover. */
async function resolvePopupHeroImageUrl(entry: ThemeOfTheMonthEntry): Promise<string | null> {
const themeId = entry.theme_id ?? entry.theme?.id;
if (themeId) {
const fromStore = await fetchThemeStoreHeroImage(themeId);
if (fromStore) return fromStore;
}
const fallback = entry.cover_image?.trim();
return fallback || null;
}
function closeThemeOfTheMonthCard(
card: HTMLElement,
onDismissed?: () => void,
markSeen = true,
) {
if (card.classList.contains("themeOfTheMonthCardClosing")) return;
if (markSeen) {
const entryId = card.dataset.entryId;
if (entryId) settingsState.themeOfTheMonthLastSeenId = entryId;
}
card.classList.add("themeOfTheMonthCardClosing");
window.setTimeout(() => {
card.remove();
onDismissed?.();
}, 180);
}
/**
* Renders the Theme of the Month announcement card.
*/
export async function OpenThemeOfTheMonthPopup(
entry: ThemeOfTheMonthEntry,
onDismissed?: () => void,
) {
document.getElementById("theme-of-the-month-card")?.remove();
const monthLabel = formatMonthLabel(entry.month);
const heroUrl = await resolvePopupHeroImageUrl(entry);
const description = escapeHTML(entry.description).replace(/\n/g, " ");
const linkedThemeId = entry.theme_id ?? entry.theme?.id;
const card = stringToHTML(/* html */ `
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard" role="dialog" aria-label="Theme of the Month">
<button type="button" class="themeOfTheMonthCardClose" aria-label="Close Theme of the Month">×</button>
${
heroUrl
? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />`
: ""
}
<div class="themeOfTheMonthCardBody">
<p class="themeOfTheMonthCardEyebrow">Theme of the Month · ${escapeHTML(monthLabel)}</p>
<h2>${escapeHTML(entry.title)}</h2>
<p class="themeOfTheMonthCardDescription">${description}</p>
<div class="themeOfTheMonthCardActions">
${
linkedThemeId
? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>`
: ""
}
<button type="button" class="themeOfTheMonthCardSecondary">Don't show again</button>
</div>
</div>
</aside>
`).firstChild as HTMLElement;
card.dataset.entryId = entry.id;
const autoCloseTimeout = window.setTimeout(() => {
closeThemeOfTheMonthCard(card, onDismissed);
}, 12000);
const dismiss = (markSeen = true) => {
window.clearTimeout(autoCloseTimeout);
closeThemeOfTheMonthCard(card, onDismissed, markSeen);
};
card.addEventListener("mouseenter", () => window.clearTimeout(autoCloseTimeout), { once: true });
card.querySelector(".themeOfTheMonthCardClose")?.addEventListener("click", () => {
dismiss();
});
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
dismiss();
openThemeStoreWithHighlight(linkedThemeId!);
});
card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDisabled = true;
dismiss();
});
document.body.appendChild(card);
}
/**
* Dev helper: fetch the current month's entry and show the popup immediately,
* even if the user has already dismissed it this month.
*/
export async function showThemeOfTheMonthPopupNow(): Promise<void> {
const entry = await fetchThemeOfTheMonth();
if (!entry) {
alert(
"No Theme of the Month entry for the current month (UTC). Create one in the website admin, or check your dev API base URL.",
);
return;
}
settingsState.themeOfTheMonthLastSeenId = undefined;
if (document.getElementById("whatsnewbk")) {
await closePopup();
await new Promise((resolve) => setTimeout(resolve, 150));
}
await OpenThemeOfTheMonthPopup(entry);
}
+2 -4
View File
@@ -34,7 +34,6 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
const text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
<h1>3.6.6 Global Search improvements!</h1>
<li>Tuned hybrid search and indexing reliability.</li>
<li>Clearer progress UI and green “Done!” when a pass finishes.</li>
@@ -50,13 +49,12 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
<li>Fixed assessment averages treating N/A weightings incorrectly in subject average calculations.</li>
<li>Fixed the display of weightings that could not automatically be discovered.</li>
<li>Fixed the formatting of the weighting tag that was broken due to a SEQTA update.</li>
<h1>3.6.4 - DM Folders, Theme flavours and fixes, Upcoming Assements improvement</h1>
<h1>3.6.4 - Theme flavours and fixes, Upcoming Assements improvement</h1>
<li>Added advanced colour adjustments variables for theme customisation.</li>
<li>Improved logic for upcoming assements dashlet to improve compatibility.</li>
<li>BS Cloud can now automatically download themes from other devices.</li>
<li>Added theme flavours for multiple colour variations of the same theme.</li>
<li>Added custom message folder, customizable in settings.</li>
<h1>3.6.3 - Assessment overview fix</h1>
<li>Fixed assessments overview failing to load.</li>
+22 -2
View File
@@ -4,20 +4,40 @@ import {
shouldShowEngageParentsAnnouncement,
showEngageParentsToast,
} from "./OpenEngageParentsAnnouncement";
import {
fetchThemeOfTheMonth,
OpenThemeOfTheMonthPopup,
shouldShowThemeOfTheMonth,
} from "./OpenThemeOfTheMonthPopup";
import { syncApiBaseToBackground } from "../DevApiBase";
type QueueStep = (goNext: () => void) => void;
/**
* Runs startup modals in order: What's New (if the extension just updated),
* then shows the SEQTA Engage toast (once, non-blocking).
* Theme of the Month (when a new monthly entry hasn't been seen), then shows
* the SEQTA Engage toast (once, non-blocking).
*/
export function runStartupPopupQueue() {
export async function runStartupPopupQueue() {
// Make sure the background script knows about any dev-mode API override
// before we start firing requests.
syncApiBaseToBackground();
const steps: QueueStep[] = [];
if (settingsState.justupdated) {
steps.push((goNext) => OpenWhatsNewPopup(goNext));
}
// Fetch the Theme of the Month before queueing so we don't show an empty
// popup if the network or server is unavailable.
const themeOfTheMonth = await fetchThemeOfTheMonth();
if (shouldShowThemeOfTheMonth(themeOfTheMonth)) {
steps.push((goNext) => {
void OpenThemeOfTheMonthPopup(themeOfTheMonth!, goNext);
});
}
function runNext() {
const step = steps.shift();
if (step) step(runNext);
@@ -0,0 +1,30 @@
export const ENGAGE_STUDENT_STORAGE_KEY = () =>
`bsplus.engageTimetable.student.${location.origin}`;
/** Engage assessments URLs: /#?page=/assessments/{studentId}/{programme}:{metaclass}:{studentId} */
export function getEngageAssessmentStudentId(): string | null {
const hashMatch = window.location.hash.match(/\/assessments\/(\d+)/);
if (hashMatch?.[1]) return hashMatch[1];
return localStorage.getItem(ENGAGE_STUDENT_STORAGE_KEY());
}
export function buildEngageAssessmentPagePath(
studentId: string | number,
programmeId: string | number,
metaclassId: string | number,
assessmentId?: string | number,
): string {
const base = `#?page=/assessments/${studentId}/${programmeId}:${metaclassId}:${studentId}`;
return assessmentId != null ? `${base}&item=${assessmentId}` : base;
}
export function buildEngageAssessmentOverviewPath(
studentId: string | number,
): string {
return `#?page=/assessments/${studentId}/overview`;
}
export function isEngageAssessmentOverviewRoute(hash = window.location.hash): boolean {
return /\/assessments\/\d+\/overview/.test(hash);
}
@@ -0,0 +1,39 @@
import { OpenStorePage } from "@/seqta/ui/renderStore";
/**
* Module-level handoff for "open the theme store and highlight this theme".
*
* The store page is mounted lazily inside a Shadow DOM the first time it
* opens, so a `CustomEvent` listener would have to be wired up before mount
* (causing a race). Using a shared cell keeps the producer (popup button) and
* consumer (store `onMount`) decoupled without that timing constraint.
*
* The store reads & clears this on mount via {@link consumePendingHighlightThemeId}.
*/
let pendingHighlightThemeId: string | null = null;
/** Read and clear the pending theme id (called by the store on mount). */
export function consumePendingHighlightThemeId(): string | null {
const id = pendingHighlightThemeId;
pendingHighlightThemeId = null;
return id;
}
/**
* Opens the theme store and asks it to focus / highlight the given theme.
* If the store is already mounted we dispatch a DOM event so it can react
* without remounting; otherwise the store consumes the pending id on mount.
*/
export function openThemeStoreWithHighlight(themeId: string): void {
pendingHighlightThemeId = themeId;
const existing = document.getElementById("store");
if (existing) {
window.dispatchEvent(
new CustomEvent("bsplus:highlight-theme", { detail: { themeId } }),
);
return;
}
OpenStorePage();
}
+153
View File
@@ -0,0 +1,153 @@
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
import {
ChangeMenuItemPositions,
MenuOptionsOpen,
} from "@/seqta/utils/Openers/OpenMenuOptions";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForEngageMenuList } from "@/seqta/utils/waitForEngageMenuList";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { eventManager } from "@/seqta/utils/listeners/EventManager";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
const BETTERSEQTA_ICON_ATTR = "data-betterseqta-icon";
function getMenuLabel(element: HTMLElement): HTMLElement | null {
const label = element.querySelector(":scope > label");
return label instanceof HTMLElement ? label : null;
}
function getTopLevelMenuList(menu = document.getElementById("menu")): HTMLElement | null {
if (!menu) return null;
return (
(menu.querySelector(":scope > ul") as HTMLElement | null) ??
(menu.querySelector("ul") as HTMLElement | null)
);
}
export function isTopLevelSidebarItem(node: HTMLElement): boolean {
if (!node.classList.contains("item")) return false;
if (node.nodeName !== "LI" && node.nodeName !== "SECTION") return false;
const topList = getTopLevelMenuList();
return !!topList && node.parentElement === topList;
}
function wrapMenuLabelText(label: HTMLElement) {
const textNode = label.lastChild;
if (
textNode?.nodeType === 3 &&
textNode.parentNode &&
textNode.parentNode.nodeName !== "SPAN"
) {
const span = document.createElement("span");
span.textContent = textNode.nodeValue;
label.replaceChild(span, textNode);
}
}
export function replaceMenuSVG(element: HTMLElement, svg: string) {
const label = getMenuLabel(element);
if (!label?.firstChild) return;
if (label.firstElementChild?.getAttribute(BETTERSEQTA_ICON_ATTR) === "true") {
return;
}
label.firstChild.remove();
label.innerHTML = `<span>${label.innerHTML}</span>`;
const newSvg = stringToHTML(svg).firstChild;
if (!(newSvg instanceof Element)) return;
newSvg.setAttribute(BETTERSEQTA_ICON_ATTR, "true");
label.insertBefore(newSvg, label.firstChild);
}
export function processMenuItemNode(node: HTMLElement) {
if (!isTopLevelSidebarItem(node) || MenuOptionsOpen) return;
const key = node.dataset.key as keyof typeof MenuitemSVGKey | undefined;
if (key && MenuitemSVGKey[key]) {
replaceMenuSVG(node, MenuitemSVGKey[key]);
} else {
const label = getMenuLabel(node);
if (label) wrapMenuLabelText(label);
}
}
function processTopLevelMenuItems(reorder = !isSeqtaEngageExperience()) {
if (MenuOptionsOpen) return;
const topList = getTopLevelMenuList();
if (!topList) return;
for (const child of topList.children) {
if (child instanceof HTMLElement) {
processMenuItemNode(child);
}
}
if (reorder) {
ChangeMenuItemPositions(settingsState.menuorder);
}
}
let engageMenuIconObserver: MutationObserver | null = null;
let engageMenuIconFrame: number | null = null;
function scheduleEngageMenuIconPass() {
if (engageMenuIconFrame !== null) return;
engageMenuIconFrame = window.requestAnimationFrame(() => {
engageMenuIconFrame = null;
processTopLevelMenuItems(false);
});
}
async function observeEngageMenuIcons() {
const menuList = await waitForEngageMenuList();
const menu = document.getElementById("menu");
if (!menu || !menuList) return;
processTopLevelMenuItems(false);
engageMenuIconObserver?.disconnect();
engageMenuIconObserver = new MutationObserver(() => {
scheduleEngageMenuIconPass();
});
engageMenuIconObserver.observe(menu, {
childList: true,
subtree: true,
});
}
const processedSymbol = Symbol("processed");
export async function observeMenuItemPosition() {
if (isSeqtaEngageExperience()) {
await observeEngageMenuIcons();
return;
}
await waitForElm("#menu > ul > li");
eventManager.register(
"menuList",
{
parentElement: document.querySelector("#menu")!.firstChild as Element,
},
(element: Element) => {
const node = element as HTMLElement;
if (!isTopLevelSidebarItem(node)) return;
if ((element as any)[processedSymbol]) return;
if (!MenuOptionsOpen) {
processMenuItemNode(node);
ChangeMenuItemPositions(settingsState.menuorder);
(element as any)[processedSymbol] = true;
}
},
);
}
+39
View File
@@ -0,0 +1,39 @@
import { waitForElm } from "@/seqta/utils/waitForElm";
/** Engage mounts the sidebar inside batched React trees; polling `waitForElm` matches the real DOM reliably. */
export async function waitForEngageMenuList(): Promise<HTMLElement | null> {
const poll = true as const;
const interval = 100;
const trySelectors: { selector: string; maxIterations: number }[] = [
{ selector: "#menu > ul > li", maxIterations: 500 },
{ selector: "#menu ul", maxIterations: 350 },
{ selector: "#menu", maxIterations: 350 },
];
for (const { selector, maxIterations } of trySelectors) {
try {
await waitForElm(selector, poll, interval, maxIterations);
} catch {
continue;
}
if (selector === "#menu > ul > li") {
const ul = document.querySelector("#menu > ul") as HTMLElement | null;
if (ul) return ul;
} else if (selector === "#menu ul") {
const ul = document.querySelector("#menu ul") as HTMLElement | null;
if (ul) return ul;
} else {
const menu = document.getElementById("menu");
const ul =
(menu?.querySelector("ul") as HTMLElement | null) ??
(menu?.firstElementChild as HTMLElement | null);
if (ul) return ul;
}
}
console.warn(
"[BetterSEQTA+] Engage: could not find a menu list to inject the home button",
);
return null;
}