diff --git a/src/SEQTA.ts b/src/SEQTA.ts
index 6d4c5394..a7242c05 100644
--- a/src/SEQTA.ts
+++ b/src/SEQTA.ts
@@ -37,8 +37,6 @@ function registerFetchSeqtaAppLinkListener() {
});
}
-export let MenuOptionsOpen = false;
-
var IsSEQTAPage = false;
let hasSEQTAText = false;
diff --git a/src/css/injected.scss b/src/css/injected.scss
index d0efd9a8..dccad4a4 100644
--- a/src/css/injected.scss
+++ b/src/css/injected.scss
@@ -614,6 +614,154 @@ ul.magicDelete > li.deleting {
border-right: none;
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
+
+/* Hide native menu as soon as possible (during loading), before Svelte mounts.
+ Keep `#bsplus-sidebar-root` visible — themes target `#menu > ul > li.item` on it. */
+html.bsplus-custom-sidebar-pending #menu > ul:not(#bsplus-sidebar-root),
+html.bsplus-custom-sidebar-pending #menu > #userInfo,
+html.bsplus-custom-sidebar-pending #menu > .icon-cover,
+#menu.bsplus-custom-sidebar > ul:not(#bsplus-sidebar-root),
+#menu.bsplus-custom-sidebar > #userInfo,
+#menu.bsplus-custom-sidebar > .icon-cover {
+ position: absolute !important;
+ left: -10000px !important;
+ width: 1px !important;
+ height: 1px !important;
+ margin: 0 !important;
+ padding: 0 !important;
+ overflow: hidden !important;
+ opacity: 0 !important;
+ pointer-events: none !important;
+ clip: rect(0, 0, 0, 0) !important;
+}
+
+/* Custom Svelte sidebar: SEQTA-like `#menu > ul > li.item` / `.sub` for theme CSS. */
+#menu.bsplus-custom-sidebar {
+ /* SEQTA uses absolute top/bottom:0 — do not switch to relative or height collapses. */
+ position: absolute !important;
+ top: 0 !important;
+ bottom: 0 !important;
+ left: 0 !important;
+ display: flex !important;
+ flex-direction: column !important;
+ /* No padding-top: logo `ul::before` + theme `.sub { top: 71.5px }` match native. */
+ box-sizing: border-box !important;
+ height: auto !important;
+ max-height: none !important;
+ overflow: hidden !important;
+
+ > #bsplus-sidebar-root {
+ flex: 1 1 auto;
+ min-height: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 2;
+ position: relative;
+ }
+
+ /* Theme drill panels assume static hasChildren + absolute .sub filling #menu. */
+ li.hasChildren {
+ position: static !important;
+ }
+
+ /*
+ * Native SEQTA leaves `li.hasChildren.active` on the hidden list after click-through.
+ * Global `#menu:has(> ul > li.hasChildren.active) > ul > li:not(.hasChildren.active)`
+ * then sets pointer-events:none on our custom root rows. Override that here; drill
+ * locking only follows the custom list's own active folder.
+ */
+ > #bsplus-sidebar-root > li.item {
+ pointer-events: auto !important;
+ }
+
+ /* Only lock siblings while a drill `.sub` is open — not when a folder is
+ merely route-highlighted with `.active` at the root list. */
+ &:has(> #bsplus-sidebar-root > li.hasChildren.active > .sub)
+ > #bsplus-sidebar-root
+ > li:not(.hasChildren.active),
+ &:has(> #bsplus-sidebar-root > li.hasChildren.active > .sub)
+ > #bsplus-sidebar-root
+ > li:not(.hasChildren.active)
+ * {
+ pointer-events: none !important;
+ }
+
+ > #bsplus-sidebar-root .sub {
+ pointer-events: none;
+ }
+
+ > #bsplus-sidebar-root li.hasChildren.active > .sub {
+ pointer-events: auto;
+ /* Let the #menu chrome (gradient / theme) show through — themes often paint
+ `.sub` with flat `--background-primary` which reads as a white slab. */
+ background: transparent !important;
+ background-image: none !important;
+ animation: bsplus-submenu-enter 0.32s cubic-bezier(0.22, 1, 0.36, 1);
+ }
+
+ > #bsplus-sidebar-root li.hasChildren.active > .sub:has(.hasChildren.active) {
+ pointer-events: none !important;
+ animation: none;
+ }
+
+ > #bsplus-sidebar-root li.hasChildren.active .hasChildren.active > .sub {
+ pointer-events: auto !important;
+ background: transparent !important;
+ background-image: none !important;
+ animation: bsplus-submenu-enter 0.32s cubic-bezier(0.22, 1, 0.36, 1);
+ }
+
+ /* Keep folder + leaf rows on the same rhythm (themes often only style
+ `:not(.hasChildren)`, which left folders centred / differently spaced). */
+ > #bsplus-sidebar-root li.item {
+ width: auto !important;
+ max-width: none !important;
+ margin: 2px 6px !important;
+ box-sizing: border-box !important;
+ }
+
+ /* Folder label stays in flow so sidebar-animation translateX can run.
+ Visually covered by the absolute `.sub` panel. */
+ > #bsplus-sidebar-root > li.hasChildren.active > label {
+ pointer-events: none !important;
+ }
+
+ #bsplus-sidebar-root .sub .back {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 12px 16px 8px;
+ cursor: pointer;
+ font-weight: 700;
+ color: inherit;
+ }
+
+ #bsplus-sidebar-root .sub .back:hover {
+ opacity: 0.9;
+ }
+
+ #bsplus-sidebar-root .sub .backLabel {
+ font-size: 16px;
+ font-weight: 700;
+ }
+
+ #bsplus-sidebar-root .sub > ul {
+ list-style: none;
+ margin: 0;
+ padding: 0 0 16px;
+ }
+}
+
+@keyframes bsplus-submenu-enter {
+ from {
+ opacity: 0.35;
+ transform: translateX(28px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
#menu li > label > svg,
#menu section > label > svg {
margin: 0 10px 0 4px;
diff --git a/src/plugins/built-in/assessmentsOverview/index.ts b/src/plugins/built-in/assessmentsOverview/index.ts
index 68e2546e..0daf28d8 100644
--- a/src/plugins/built-in/assessmentsOverview/index.ts
+++ b/src/plugins/built-in/assessmentsOverview/index.ts
@@ -66,11 +66,16 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
label.textContent = "Overview";
gridItem.appendChild(label);
menu.insertBefore(gridItem, menu.firstChild);
+ window.dispatchEvent(new CustomEvent("bsplus-native-menu-updated"));
let loadRequestId = 0;
const menuObserver = new MutationObserver(() => {
+ const before = menu.firstElementChild;
ensureOverviewMenuPosition(menu, gridItem);
+ if (menu.firstElementChild !== before) {
+ window.dispatchEvent(new CustomEvent("bsplus-native-menu-updated"));
+ }
});
menuObserver.observe(menu, { childList: true });
diff --git a/src/plugins/built-in/gradeAnalytics/core/index.ts b/src/plugins/built-in/gradeAnalytics/core/index.ts
index 9d32c241..e69de29b 100644
--- a/src/plugins/built-in/gradeAnalytics/core/index.ts
+++ b/src/plugins/built-in/gradeAnalytics/core/index.ts
@@ -1,87 +0,0 @@
-import type { Plugin } from "@/plugins/core/types";
-import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
-import { waitForElm } from "@/seqta/utils/waitForElm";
-import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
-import {
- ensureAnalyticsMenuOrder,
- insertMenuItemAfterKey,
- processMenuItemNode,
-} from "@/seqta/utils/sidebarMenuIcons";
-import {
- ChangeMenuItemPositions,
- MenuOptionsOpen,
-} from "@/seqta/utils/Openers/OpenMenuOptions";
-import { settingsState } from "@/seqta/utils/listeners/SettingsState";
-import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility";
-import { loadAnalyticsPage } from "../loadAnalyticsPage";
-import styles from "../styles.css?inline";
-
-const ANALYTICS_MENU_ICON = MenuitemSVGKey.analytics;
-const ANALYTICS_MENU_CLASS = "betterseqta-grade-analytics-item";
-
-const gradeAnalyticsPlugin: Plugin<{}> = {
- id: "grade-analytics",
- name: "Grade Analytics",
- description:
- "Adds an analytics page with grade trends, distribution charts, and assessment history",
- version: "1.0.0",
- settings: {},
- disableToggle: true,
- styles,
-
- run: async () => {
- if (isSeqtaEngageExperience()) {
- return () => {};
- }
-
- const menuList = (await waitForElm("#menu > ul, #menu ul", true, 100, 60)) as HTMLElement;
-
- const analyticsItem = document.createElement("li");
- analyticsItem.className = "item";
- analyticsItem.classList.add(ANALYTICS_MENU_CLASS);
- analyticsItem.id = "analyticsbutton";
- analyticsItem.dataset.key = "analytics";
- analyticsItem.dataset.path = "/analytics";
- analyticsItem.dataset.betterseqta = "true";
- analyticsItem.innerHTML = ``;
-
- const syncAnalyticsMenu = () => {
- insertMenuItemAfterKey(menuList, analyticsItem, "courses");
- ensureAnalyticsMenuOrder();
- if (settingsState.menuorder.length > 0) {
- ChangeMenuItemPositions(settingsState.menuorder);
- }
- processMenuItemNode(analyticsItem);
- applyMenuItemVisibility();
- };
-
- syncAnalyticsMenu();
-
- const menuObserver = new MutationObserver(() => {
- if (MenuOptionsOpen || menuList.contains(analyticsItem)) return;
- syncAnalyticsMenu();
- });
- menuObserver.observe(menuList, { childList: true });
-
- analyticsItem.addEventListener("click", (e) => {
- const target = e.target as HTMLElement;
- if (
- MenuOptionsOpen ||
- analyticsItem.classList.contains("draggable") ||
- target.closest(".onoffswitch, .editmenuoption-container")
- ) {
- return;
- }
- e.preventDefault();
- window.history.pushState({}, "", "/#?page=/analytics");
- void loadAnalyticsPage();
- });
-
- return () => {
- menuObserver.disconnect();
- analyticsItem.remove();
- };
- },
-};
-
-export default gradeAnalyticsPlugin;
diff --git a/src/plugins/built-in/gradeAnalytics/injectAnalyticsMenuItem.ts b/src/plugins/built-in/gradeAnalytics/injectAnalyticsMenuItem.ts
new file mode 100644
index 00000000..152b80d3
--- /dev/null
+++ b/src/plugins/built-in/gradeAnalytics/injectAnalyticsMenuItem.ts
@@ -0,0 +1,125 @@
+import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
+import { waitForElm } from "@/seqta/utils/waitForElm";
+import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
+import {
+ ensureAnalyticsMenuOrder,
+ insertMenuItemAfterKey,
+} from "@/seqta/utils/Openers/analyticsMenuOrder";
+import { processMenuItemNode } from "@/seqta/utils/sidebarMenuIcons";
+import { ChangeMenuItemPositions } from "@/seqta/utils/Openers/menuOrder";
+import { isMenuOptionsOpen } from "@/seqta/utils/Openers/menuOptionsState";
+import { settingsState } from "@/seqta/utils/listeners/SettingsState";
+import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility";
+
+const ANALYTICS_MENU_ICON = MenuitemSVGKey.analytics;
+export const ANALYTICS_MENU_CLASS = "betterseqta-grade-analytics-item";
+export const ANALYTICS_MENU_KEY = "analytics";
+
+function getNativeMenuList(): HTMLElement | null {
+ const menu = document.getElementById("menu");
+ if (!menu) return null;
+ return menu.querySelector(
+ ":scope > ul:not(#bsplus-sidebar-root)",
+ ) as HTMLElement | null;
+}
+
+function loadAnalyticsPageLazy() {
+ void import("./loadAnalyticsPage").then((m) => m.loadAnalyticsPage());
+}
+
+/**
+ * Inject the Analytics sidebar row immediately (eager).
+ * The analytics page chunk stays lazy until click / direct navigation.
+ */
+export async function injectAnalyticsMenuItem(): Promise<() => void> {
+ if (isSeqtaEngageExperience()) {
+ return () => {};
+ }
+
+ document
+ .querySelectorAll(
+ `#menu .${ANALYTICS_MENU_CLASS}, #menu [data-key="${ANALYTICS_MENU_KEY}"]`,
+ )
+ .forEach((node) => {
+ // Only touch the native list copy — custom sidebar mirrors via sync.
+ if (node.closest("#bsplus-sidebar-root")) return;
+ node.remove();
+ });
+
+ await waitForElm(
+ "#menu > ul:not(#bsplus-sidebar-root), #menu > ul",
+ true,
+ 50,
+ 120,
+ );
+
+ let menuList = getNativeMenuList();
+ if (!menuList) {
+ menuList = (await waitForElm(
+ "#menu > ul:not(#bsplus-sidebar-root), #menu ul",
+ true,
+ 50,
+ 120,
+ )) as HTMLElement;
+ }
+
+ const analyticsItem = document.createElement("li");
+ analyticsItem.className = "item";
+ analyticsItem.classList.add(ANALYTICS_MENU_CLASS);
+ analyticsItem.id = "analyticsbutton";
+ analyticsItem.dataset.key = ANALYTICS_MENU_KEY;
+ analyticsItem.dataset.path = "/analytics";
+ analyticsItem.dataset.betterseqta = "true";
+ analyticsItem.innerHTML = ``;
+
+ const syncAnalyticsMenu = () => {
+ const list = getNativeMenuList() ?? menuList;
+ if (!list) return;
+
+ if (list.querySelector(':scope > [data-key="courses"]')) {
+ insertMenuItemAfterKey(list, analyticsItem, "courses");
+ } else if (list.querySelector(':scope > [data-key="home"]')) {
+ insertMenuItemAfterKey(list, analyticsItem, "home");
+ } else if (!list.contains(analyticsItem)) {
+ list.insertBefore(analyticsItem, list.firstChild);
+ }
+
+ ensureAnalyticsMenuOrder();
+ if (settingsState.menuorder.length > 0) {
+ ChangeMenuItemPositions(settingsState.menuorder);
+ }
+ processMenuItemNode(analyticsItem);
+ applyMenuItemVisibility();
+ window.dispatchEvent(new CustomEvent("bsplus-native-menu-updated"));
+ };
+
+ syncAnalyticsMenu();
+
+ const menuObserver = new MutationObserver(() => {
+ const list = getNativeMenuList() ?? menuList;
+ if (!list || isMenuOptionsOpen() || list.contains(analyticsItem)) return;
+ syncAnalyticsMenu();
+ });
+ menuObserver.observe(menuList, { childList: true });
+
+ const onClick = (e: MouseEvent) => {
+ const target = e.target as HTMLElement;
+ if (
+ isMenuOptionsOpen() ||
+ analyticsItem.classList.contains("draggable") ||
+ target.closest(".onoffswitch, .editmenuoption-container")
+ ) {
+ return;
+ }
+ e.preventDefault();
+ window.history.pushState({}, "", "/#?page=/analytics");
+ loadAnalyticsPageLazy();
+ };
+ analyticsItem.addEventListener("click", onClick);
+
+ return () => {
+ menuObserver.disconnect();
+ analyticsItem.removeEventListener("click", onClick);
+ analyticsItem.remove();
+ };
+}
diff --git a/src/plugins/built-in/gradeAnalytics/lazy.ts b/src/plugins/built-in/gradeAnalytics/lazy.ts
index 125db7d5..6e21f903 100644
--- a/src/plugins/built-in/gradeAnalytics/lazy.ts
+++ b/src/plugins/built-in/gradeAnalytics/lazy.ts
@@ -1,6 +1,7 @@
import { defineLazyPlugin } from "../../core/dynamicLoader";
import { defineSettings, numberSetting } from "../../core/settingsHelpers";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
+import { injectAnalyticsMenuItem } from "./injectAnalyticsMenuItem";
import styles from "./styles.css?inline";
const settings = defineSettings({
@@ -13,6 +14,10 @@ const settings = defineSettings({
}),
});
+/**
+ * Shell loads immediately so the sidebar Analytics icon can mount.
+ * The page UI (`loadAnalyticsPage` / charts) stays in a separate lazy chunk.
+ */
const gradeAnalyticsPluginLazy = defineLazyPlugin({
id: "grade-analytics",
name: "Grade Analytics",
@@ -23,16 +28,21 @@ const gradeAnalyticsPluginLazy = defineLazyPlugin({
disableToggle: true,
defaultEnabled: true,
styles,
+ // Kept for API compatibility; menu injection no longer waits on this chunk.
loader: () => import("./core/index"),
});
-const runGradeAnalytics = gradeAnalyticsPluginLazy.run!;
-
-gradeAnalyticsPluginLazy.run = async (api) => {
+gradeAnalyticsPluginLazy.run = async () => {
if (isSeqtaEngageExperience()) {
return () => {};
}
- return runGradeAnalytics(api);
+
+ // Eager: sidebar icon / native menu row only.
+ const cleanupMenu = await injectAnalyticsMenuItem();
+
+ return () => {
+ cleanupMenu();
+ };
};
export default gradeAnalyticsPluginLazy;
diff --git a/src/plugins/core/manager.ts b/src/plugins/core/manager.ts
index 5ea44033..0719975f 100644
--- a/src/plugins/core/manager.ts
+++ b/src/plugins/core/manager.ts
@@ -37,8 +37,10 @@ const PLUGIN_START_PHASES: readonly string[][] = [
"messageFolders",
"profile-picture",
"background-music",
+ // Menu icon is eager; page UI still loads on demand via dynamic import.
+ "grade-analytics",
],
- ["global-search", "grade-analytics"],
+ ["global-search"],
];
/**
diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts
index cd98fa70..5e12c330 100644
--- a/src/plugins/monofile.ts
+++ b/src/plugins/monofile.ts
@@ -188,6 +188,9 @@ function SortMessagePageItems(messagesParentElement: any) {
async function LoadPageElements(): Promise {
await AddBetterSEQTAElements();
+ void import("@/seqta/ui/sidebar/mountCustomSidebar").then((mod) => {
+ void mod.mountCustomSidebar();
+ });
const sublink: string | undefined = getEngageRoutePage();
if (isSeqtaEngageExperience() && !engageHashListenerAttached) {
@@ -676,6 +679,13 @@ export function init() {
}
document.querySelector(".legacy-root")?.classList.add("hidden");
+
+ // Hide native sidebar + mount Svelte replacement during the loading overlay.
+ document.documentElement.classList.add("bsplus-custom-sidebar-pending");
+ void import("@/seqta/ui/sidebar/mountCustomSidebar").then((mod) => {
+ mod.prepareCustomSidebarEarly();
+ });
+
void observeMenuItemPosition();
new StorageChangeHandler();
diff --git a/src/seqta/ui/AddBetterSEQTAElements.ts b/src/seqta/ui/AddBetterSEQTAElements.ts
index c4bf9959..b2774fd9 100644
--- a/src/seqta/ui/AddBetterSEQTAElements.ts
+++ b/src/seqta/ui/AddBetterSEQTAElements.ts
@@ -144,6 +144,7 @@ export async function AddBetterSEQTAElements() {
setupEventListeners();
await addDarkLightToggle();
customizeMenuToggle();
+ // Kept as fallback if the custom Svelte sidebar fails to mount.
setupSidebarAccessibility();
}
@@ -672,6 +673,20 @@ function updateSidebarAccessibility() {
const menu = document.getElementById("menu");
if (!menu) return;
+ // Custom Svelte sidebar owns its own a11y / drill UI — do not mark its
+ // `#bsplus-sidebar-root` items offscreen based on the hidden native list.
+ if (menu.classList.contains("bsplus-custom-sidebar")) {
+ const root = document.getElementById("bsplus-sidebar-root");
+ if (root) {
+ for (const entry of root.querySelectorAll(`.${BSPLUS_SIDEBAR_OFFSCREEN}`)) {
+ if (entry instanceof HTMLElement) {
+ entry.classList.remove(BSPLUS_SIDEBAR_OFFSCREEN);
+ }
+ }
+ }
+ return;
+ }
+
const visibleList = getVisibleSidebarList(menu);
const visibleEntries = new Set(
visibleList ? getDirectSidebarEntries(visibleList) : [],
@@ -717,7 +732,9 @@ function getDirectSidebarEntries(list: HTMLElement) {
}
function getVisibleSidebarList(menu: HTMLElement) {
- let currentList = menu.querySelector(":scope > ul") as HTMLElement | null;
+ let currentList = menu.querySelector(
+ ":scope > ul:not(#bsplus-sidebar-root)",
+ ) as HTMLElement | null;
while (currentList) {
const activeSubmenuParent = currentList.querySelector(
diff --git a/src/seqta/ui/sidebar/Sidebar.svelte b/src/seqta/ui/sidebar/Sidebar.svelte
new file mode 100644
index 00000000..bd91bf9a
--- /dev/null
+++ b/src/seqta/ui/sidebar/Sidebar.svelte
@@ -0,0 +1,371 @@
+
+
+
+
+
+{#snippet drillLevel(
+ frames: Array<{ frame: SidebarDrillFrame; folder: SidebarItemModel | null }>,
+ depth: number,
+)}
+ {@const current = frames[depth]}
+ {@const isLast = depth === frames.length - 1}
+ {@const nextKey = isLast ? null : frames[depth + 1].frame.key}
+ {@const folder = current.folder}
+
+{/snippet}
+
+
diff --git a/src/seqta/ui/sidebar/SidebarItem.svelte b/src/seqta/ui/sidebar/SidebarItem.svelte
new file mode 100644
index 00000000..4c978bcb
--- /dev/null
+++ b/src/seqta/ui/sidebar/SidebarItem.svelte
@@ -0,0 +1,236 @@
+
+
+
+
+
+
+
diff --git a/src/seqta/ui/sidebar/index.ts b/src/seqta/ui/sidebar/index.ts
new file mode 100644
index 00000000..a6dbcb86
--- /dev/null
+++ b/src/seqta/ui/sidebar/index.ts
@@ -0,0 +1,10 @@
+export {
+ prepareCustomSidebarEarly,
+ mountCustomSidebar,
+ unmountCustomSidebar,
+ openCustomSidebarEditor,
+ closeCustomSidebarEditor,
+ isCustomSidebarMounted,
+} from "./mountCustomSidebar";
+export { sidebarState } from "./sidebarState.svelte";
+export type { SidebarItem, SidebarDrillFrame } from "./types";
diff --git a/src/seqta/ui/sidebar/mountCustomSidebar.ts b/src/seqta/ui/sidebar/mountCustomSidebar.ts
new file mode 100644
index 00000000..a7483759
--- /dev/null
+++ b/src/seqta/ui/sidebar/mountCustomSidebar.ts
@@ -0,0 +1,284 @@
+import { mount, unmount } from "svelte";
+import type { SettingsState } from "@/types/storage";
+import { settingsState } from "@/seqta/utils/listeners/SettingsState";
+import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
+import { waitForElm } from "@/seqta/utils/waitForElm";
+import Sidebar from "./Sidebar.svelte";
+import { getNativeMenuList } from "./parseNativeMenu";
+import {
+ clearNativeDrillActive,
+ sidebarState,
+} from "./sidebarState.svelte";
+
+const ROOT_ID = "bsplus-sidebar-root";
+const MENU_CLASS = "bsplus-custom-sidebar";
+const PENDING_CLASS = "bsplus-custom-sidebar-pending";
+
+type ChangeListener = (newValue: unknown, oldValue: unknown) => void;
+
+let app: ReturnType | null = null;
+let menuEl: HTMLElement | null = null;
+let menuObserver: MutationObserver | null = null;
+let syncTimer: ReturnType | null = null;
+let hashListenerAttached = false;
+let earlyPrepareStarted = false;
+let catchupTimer: ReturnType | null = null;
+let nativeMenuListenerAttached = false;
+
+function onNativeMenuUpdated() {
+ if (menuEl) sidebarState.syncFromNative(menuEl);
+}
+
+const settingsListeners: Array<{
+ key: keyof SettingsState;
+ listener: ChangeListener;
+}> = [];
+
+function startCatchupSync() {
+ if (catchupTimer) clearInterval(catchupTimer);
+ let attempts = 0;
+ catchupTimer = setInterval(() => {
+ attempts += 1;
+ if (menuEl) sidebarState.syncFromNative(menuEl);
+ // Plugins (Analytics, Overview, icons) inject shortly after first paint.
+ if (attempts >= 60) {
+ if (catchupTimer) clearInterval(catchupTimer);
+ catchupTimer = null;
+ }
+ }, 50);
+}
+
+function scheduleSync() {
+ if (syncTimer) clearTimeout(syncTimer);
+ syncTimer = setTimeout(() => {
+ syncTimer = null;
+ if (menuEl) sidebarState.syncFromNative(menuEl);
+ }, 50);
+}
+
+function onHashChange() {
+ sidebarState.syncActiveFromLocation();
+ if (menuEl) clearNativeDrillActive(menuEl);
+
+ if (!sidebarState.isDrilling) return;
+
+ const path = sidebarState.activePath;
+ if (!path) return;
+
+ const openKey = sidebarState.drillStack[0]?.key;
+ const top = sidebarState.visibleRootItems.find(
+ (item) =>
+ item.key === openKey ||
+ item.path === path ||
+ (item.path != null && path.startsWith(`${item.path}/`)),
+ );
+
+ if (openKey && top?.key === openKey) return;
+ if (!path.includes("/")) sidebarState.resetDrill();
+}
+
+function ensureDefaultMenuOrder(menu: HTMLElement) {
+ const list = getNativeMenuList(menu);
+ if (!list) return;
+ const keys = [...list.children]
+ .map((node) => (node as HTMLElement).dataset.key)
+ .filter((key): key is string => !!key);
+
+ if (!settingsState.defaultmenuorder?.length) {
+ settingsState.defaultmenuorder = keys;
+ return;
+ }
+
+ for (const key of keys) {
+ if (!settingsState.defaultmenuorder.includes(key)) {
+ settingsState.defaultmenuorder = [
+ ...settingsState.defaultmenuorder,
+ key,
+ ];
+ }
+ }
+}
+
+function registerSetting(
+ key: keyof SettingsState,
+ listener: ChangeListener,
+) {
+ settingsState.register(key, listener);
+ settingsListeners.push({ key, listener });
+}
+
+function clearSettingListeners() {
+ for (const { key, listener } of settingsListeners) {
+ settingsState.unregister(key, listener);
+ }
+ settingsListeners.length = 0;
+}
+
+function clearPendingClass() {
+ document.documentElement.classList.remove(PENDING_CLASS);
+}
+
+/**
+ * Hide the native SEQTA menu immediately (even under the loading overlay)
+ * and begin mounting the Svelte sidebar as soon as `#menu` exists.
+ */
+export function prepareCustomSidebarEarly() {
+ if (isSeqtaEngageExperience()) return;
+ if (!settingsState.onoff) return;
+ if (earlyPrepareStarted) return;
+
+ earlyPrepareStarted = true;
+ document.documentElement.classList.add(PENDING_CLASS);
+ void mountCustomSidebar();
+}
+
+export function openCustomSidebarEditor() {
+ if (!app || !menuEl) return false;
+ sidebarState.setEditMode(true);
+ menuEl.classList.add("bsplus-sidebar-edit-mode");
+ return true;
+}
+
+export function closeCustomSidebarEditor() {
+ sidebarState.setEditMode(false);
+ menuEl?.classList.remove("bsplus-sidebar-edit-mode");
+ void import("@/seqta/utils/Openers/menuOptionsState").then((mod) => {
+ mod.setMenuOptionsOpen(false);
+ });
+}
+
+export function isCustomSidebarMounted(): boolean {
+ return app != null;
+}
+
+export async function mountCustomSidebar(): Promise {
+ if (isSeqtaEngageExperience()) return false;
+ if (!settingsState.onoff) return false;
+
+ // Already mounted — re-sync after Home/News/Analytics injections.
+ if (app && menuEl) {
+ ensureDefaultMenuOrder(menuEl);
+ sidebarState.syncSettings();
+ sidebarState.syncFromNative(menuEl);
+ startCatchupSync();
+ clearPendingClass();
+ return true;
+ }
+
+ document.documentElement.classList.add(PENDING_CLASS);
+
+ const menu = (await waitForElm("#menu", true, 50, 200)) as HTMLElement | null;
+ if (!menu) return false;
+
+ // Prefer a populated native list, but don't block forever during loading.
+ let list = getNativeMenuList(menu);
+ if (!list) {
+ await waitForElm("#menu > ul:not(#bsplus-sidebar-root)", true, 50, 120);
+ list = getNativeMenuList(menu);
+ }
+ if (!list) return false;
+
+ if (app) {
+ menuEl = menu;
+ ensureDefaultMenuOrder(menu);
+ sidebarState.syncFromNative(menu);
+ clearPendingClass();
+ return true;
+ }
+
+ menuEl = menu;
+ menu.classList.add(MENU_CLASS);
+
+ // Remove a stale root from a previous HMR / partial mount.
+ document.getElementById(ROOT_ID)?.remove();
+
+ ensureDefaultMenuOrder(menu);
+ sidebarState.syncSettings();
+ sidebarState.syncFromNative(menu);
+
+ // Mount as a direct child of `#menu` so root is `#menu > ul#bsplus-sidebar-root`.
+ app = mount(Sidebar, {
+ target: menu,
+ props: { menuEl: menu },
+ });
+
+ menu
+ .querySelectorAll(".bsplus-sidebar-offscreen")
+ .forEach((node) => node.classList.remove("bsplus-sidebar-offscreen"));
+
+ menuObserver?.disconnect();
+ menuObserver = new MutationObserver((mutations) => {
+ const ours = document.getElementById(ROOT_ID);
+ if (ours && mutations.every((m) => ours.contains(m.target))) return;
+ scheduleSync();
+ });
+ menuObserver.observe(menu, {
+ subtree: true,
+ childList: true,
+ attributes: true,
+ attributeFilter: ["class", "style", "data-key", "data-path", "data-colour"],
+ });
+
+ if (!hashListenerAttached) {
+ window.addEventListener("hashchange", onHashChange);
+ hashListenerAttached = true;
+ }
+
+ if (!nativeMenuListenerAttached) {
+ window.addEventListener("bsplus-native-menu-updated", onNativeMenuUpdated);
+ nativeMenuListenerAttached = true;
+ }
+
+ clearSettingListeners();
+ registerSetting("iconOnlySidebar", () => {
+ sidebarState.syncSettings();
+ });
+ registerSetting("menuorder", () => {
+ if (menuEl) sidebarState.syncFromNative(menuEl);
+ });
+ registerSetting("menuitems", () => {
+ if (menuEl) sidebarState.syncFromNative(menuEl);
+ });
+
+ startCatchupSync();
+ clearPendingClass();
+ return true;
+}
+
+export function unmountCustomSidebar() {
+ menuObserver?.disconnect();
+ menuObserver = null;
+ if (syncTimer) clearTimeout(syncTimer);
+ syncTimer = null;
+ if (catchupTimer) clearInterval(catchupTimer);
+ catchupTimer = null;
+
+ clearSettingListeners();
+
+ if (hashListenerAttached) {
+ window.removeEventListener("hashchange", onHashChange);
+ hashListenerAttached = false;
+ }
+
+ if (nativeMenuListenerAttached) {
+ window.removeEventListener(
+ "bsplus-native-menu-updated",
+ onNativeMenuUpdated,
+ );
+ nativeMenuListenerAttached = false;
+ }
+
+ if (app) {
+ unmount(app);
+ app = null;
+ }
+
+ document.getElementById(ROOT_ID)?.remove();
+ menuEl?.classList.remove(MENU_CLASS, "bsplus-sidebar-edit-mode");
+ menuEl = null;
+ sidebarState.resetDrill();
+ sidebarState.setEditMode(false);
+ sidebarState.ready = false;
+ earlyPrepareStarted = false;
+ clearPendingClass();
+}
diff --git a/src/seqta/ui/sidebar/parseNativeMenu.test.ts b/src/seqta/ui/sidebar/parseNativeMenu.test.ts
new file mode 100644
index 00000000..45f1ba3c
--- /dev/null
+++ b/src/seqta/ui/sidebar/parseNativeMenu.test.ts
@@ -0,0 +1,70 @@
+/**
+ * @jest-environment jsdom
+ */
+import { getPagePathFromHash, parseNativeMenu } from "./parseNativeMenu";
+
+describe("parseNativeMenu", () => {
+ it("parses top-level items and nested folders", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const menu = document.getElementById("menu")!;
+ const items = parseNativeMenu(menu);
+
+ expect(items.map((i) => i.key)).toEqual(["home", "assessments"]);
+ expect(items[0].betterseqta).toBe(true);
+ expect(items[1].hasChildren).toBe(true);
+ expect(items[1].children.map((c) => c.key)).toEqual([
+ "assessments-overview",
+ "upcoming",
+ ]);
+ expect(items[1].children[0].label).toBe("Overview");
+ });
+
+ it("reads page path from hash", () => {
+ expect(getPagePathFromHash("#?page=/timetable")).toBe("/timetable");
+ expect(getPagePathFromHash("#?page=/assessments/upcoming&x=1")).toBe(
+ "/assessments/upcoming",
+ );
+ });
+
+ it("ignores the custom Svelte sidebar list when parsing", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const menu = document.getElementById("menu")!;
+ const items = parseNativeMenu(menu);
+
+ expect(items.map((i) => i.key)).toEqual(["home"]);
+ });
+});
diff --git a/src/seqta/ui/sidebar/parseNativeMenu.ts b/src/seqta/ui/sidebar/parseNativeMenu.ts
new file mode 100644
index 00000000..76f4010b
--- /dev/null
+++ b/src/seqta/ui/sidebar/parseNativeMenu.ts
@@ -0,0 +1,140 @@
+import type { SidebarItem } from "./types";
+
+/** Native SEQTA list only — never the custom Svelte `#bsplus-sidebar-root`. */
+export function getNativeMenuList(menu: HTMLElement): HTMLElement | null {
+ return (
+ (menu.querySelector(
+ ":scope > ul:not(#bsplus-sidebar-root)",
+ ) as HTMLElement | null) ?? null
+ );
+}
+
+function readLabelText(label: HTMLElement | null): string {
+ if (!label) return "";
+ const clone = label.cloneNode(true) as HTMLElement;
+ clone.querySelectorAll("svg").forEach((svg) => svg.remove());
+ return (clone.textContent ?? "").replace(/\s+/g, " ").trim();
+}
+
+function readIconHtml(label: HTMLElement | null): string {
+ if (!label) return "";
+ const svg = label.querySelector(":scope > svg");
+ return svg instanceof SVGElement ? svg.outerHTML : "";
+}
+
+function parseEntry(entry: HTMLElement): SidebarItem | null {
+ if (!entry.matches("li.item, section.item, li, section")) return null;
+
+ const label = entry.querySelector(":scope > label") as HTMLElement | null;
+ const labelText = readLabelText(label);
+ let key =
+ entry.dataset.key ??
+ entry.id ??
+ labelText.toLowerCase().replace(/\s+/g, "-") ??
+ "";
+
+ // Stable key for BS+ Overview injection (no data-key on the native node).
+ if (
+ !entry.dataset.key &&
+ entry.classList.contains("betterseqta-assessments-overview-item")
+ ) {
+ key = "assessments-overview";
+ }
+
+ if (!key && !labelText) return null;
+
+ const childList = entry.querySelector(
+ ":scope > .sub > ul",
+ ) as HTMLElement | null;
+ const children = childList
+ ? [...childList.children]
+ .filter((node): node is HTMLElement => node instanceof HTMLElement)
+ .map(parseEntry)
+ .filter((item): item is SidebarItem => item != null)
+ : [];
+
+ return {
+ key: key || labelText,
+ path: entry.dataset.path ?? null,
+ id: entry.id || null,
+ label: labelText,
+ iconHtml: readIconHtml(label),
+ hasChildren: entry.classList.contains("hasChildren") || children.length > 0,
+ colour: entry.getAttribute("data-colour"),
+ itemColour: entry.style.getPropertyValue("--item-colour") || null,
+ betterseqta: entry.dataset.betterseqta === "true",
+ children,
+ };
+}
+
+/** Parse the native SEQTA `#menu` list into a plain tree for the Svelte sidebar. */
+export function parseNativeMenu(menu: HTMLElement): SidebarItem[] {
+ const list = getNativeMenuList(menu);
+ if (!list) return [];
+
+ return [...list.children]
+ .filter((node): node is HTMLElement => node instanceof HTMLElement)
+ .map(parseEntry)
+ .filter((item): item is SidebarItem => item != null);
+}
+
+export function findNativeMenuEntry(
+ menu: HTMLElement,
+ item: Pick,
+): HTMLElement | null {
+ const list = getNativeMenuList(menu);
+ if (!list) return null;
+
+ if (item.key === "assessments-overview") {
+ const overview = list.querySelector(
+ ".betterseqta-assessments-overview-item",
+ );
+ if (overview instanceof HTMLElement) return overview;
+ }
+
+ if (item.id) {
+ const byId = list.querySelector(`#${CSS.escape(item.id)}`);
+ if (byId instanceof HTMLElement) return byId;
+ }
+
+ if (item.key) {
+ const byKey = list.querySelector(
+ `li[data-key="${CSS.escape(item.key)}"], section[data-key="${CSS.escape(item.key)}"]`,
+ );
+ if (byKey instanceof HTMLElement) return byKey;
+ }
+
+ if (item.path) {
+ const byPath = list.querySelector(
+ `li[data-path="${CSS.escape(item.path)}"], section[data-path="${CSS.escape(item.path)}"]`,
+ );
+ if (byPath instanceof HTMLElement) return byPath;
+ }
+
+ if (item.label) {
+ const candidates = list.querySelectorAll(
+ "li.item, section.item",
+ );
+ for (const candidate of candidates) {
+ const label = candidate.querySelector(":scope > label");
+ if (
+ label instanceof HTMLElement &&
+ readLabelText(label) === item.label
+ ) {
+ return candidate;
+ }
+ }
+ }
+
+ return null;
+}
+
+export function getPagePathFromHash(hash = location.hash): string {
+ const match = hash.match(/[?&]page=([^&]*)/);
+ if (!match) return "";
+ try {
+ return decodeURIComponent(match[1]);
+ } catch {
+ return match[1];
+ }
+}
diff --git a/src/seqta/ui/sidebar/sidebarState.svelte.ts b/src/seqta/ui/sidebar/sidebarState.svelte.ts
new file mode 100644
index 00000000..97688147
--- /dev/null
+++ b/src/seqta/ui/sidebar/sidebarState.svelte.ts
@@ -0,0 +1,311 @@
+import { settingsState } from "@/seqta/utils/listeners/SettingsState";
+import {
+ findNativeMenuEntry,
+ getNativeMenuList,
+ getPagePathFromHash,
+ parseNativeMenu,
+} from "./parseNativeMenu";
+import type { SidebarDrillFrame, SidebarItem } from "./types";
+
+function orderItems(items: SidebarItem[], order: string[]): SidebarItem[] {
+ if (!order.length) return items;
+
+ const byKey = new Map(items.map((item) => [item.key, item]));
+ const seen = new Set();
+ const ordered: SidebarItem[] = [];
+
+ for (const key of order) {
+ const item = byKey.get(key);
+ if (!item) continue;
+ ordered.push(item);
+ seen.add(key);
+ }
+
+ for (const item of items) {
+ if (!seen.has(item.key)) ordered.push(item);
+ }
+
+ return ordered;
+}
+
+function filterVisible(items: SidebarItem[]): SidebarItem[] {
+ const menuItems = settingsState.menuitems as Record<
+ string,
+ { toggle?: boolean } | undefined
+ >;
+
+ return items.filter((item) => menuItems[item.key]?.toggle !== false);
+}
+
+/**
+ * SEQTA (and some themes) strip `.active` from `#menu li` after navigation.
+ * Theme decorations (e.g. beach palm/sand) and drill `.sub` chrome all depend
+ * on that class staying on our custom list — re-apply it from known state.
+ */
+export function restoreCustomMenuActive() {
+ const root = document.getElementById("bsplus-sidebar-root");
+ if (!root) return;
+
+ // Open drill folders (must keep `.active` for `.sub` layout + theme selectors).
+ for (const li of root.querySelectorAll("li.hasChildren")) {
+ if (!(li instanceof HTMLElement)) continue;
+ if (!li.querySelector(":scope > .sub")) continue;
+ if (!li.classList.contains("active")) li.classList.add("active");
+ }
+
+ // Route-active row — Svelte sets aria-current, but SEQTA often removes `.active`.
+ const activeKey = sidebarState.activeKey;
+ if (activeKey) {
+ const activeLi = root.querySelector(
+ `li.item[data-key="${CSS.escape(activeKey)}"]`,
+ );
+ if (
+ activeLi instanceof HTMLElement &&
+ !activeLi.classList.contains("active")
+ ) {
+ activeLi.classList.add("active");
+ }
+ }
+
+ for (const li of root.querySelectorAll('li.item[aria-current="page"]')) {
+ if (li instanceof HTMLElement && !li.classList.contains("active")) {
+ li.classList.add("active");
+ }
+ }
+}
+
+/** @deprecated Use restoreCustomMenuActive */
+export const restoreCustomDrillActive = restoreCustomMenuActive;
+
+/** Clear native drill state so it cannot steal pointer-events from the custom list. */
+export function clearNativeDrillActive(menu: HTMLElement) {
+ const nativeList = getNativeMenuList(menu);
+ nativeList
+ ?.querySelectorAll("li.hasChildren.active, section.hasChildren.active")
+ .forEach((node) => node.classList.remove("active"));
+}
+
+function findItemByPath(
+ items: SidebarItem[],
+ path: string,
+): SidebarItem | null {
+ for (const item of items) {
+ if (item.path === path) return item;
+ if (item.children.length) {
+ const nested = findItemByPath(item.children, path);
+ if (nested) return nested;
+ }
+ }
+ return null;
+}
+
+function findItemByKey(
+ items: SidebarItem[],
+ key: string,
+): SidebarItem | null {
+ for (const item of items) {
+ if (item.key === key) return item;
+ if (item.children.length) {
+ const nested = findItemByKey(item.children, key);
+ if (nested) return nested;
+ }
+ }
+ return null;
+}
+
+class SidebarState {
+ items = $state.raw([]);
+ drillStack = $state.raw([]);
+ activeKey = $state(null);
+ activePath = $state("");
+ editMode = $state(false);
+ iconOnly = $state(false);
+ ready = $state(false);
+
+ visibleRootItems = $derived(
+ filterVisible(orderItems(this.items, settingsState.menuorder ?? [])),
+ );
+
+ currentItems = $derived(
+ this.drillStack.length > 0
+ ? this.drillStack[this.drillStack.length - 1].items
+ : this.visibleRootItems,
+ );
+
+ currentTitle = $derived(
+ this.drillStack.length > 0
+ ? this.drillStack[this.drillStack.length - 1].label
+ : null,
+ );
+
+ isDrilling = $derived(this.drillStack.length > 0);
+
+ compact = $derived(this.iconOnly && !this.isDrilling && !this.editMode);
+
+ syncFromNative(menu: HTMLElement) {
+ this.items = parseNativeMenu(menu);
+ this.ready = this.items.length > 0;
+ this.syncActiveFromLocation();
+ this.pruneDrillStack();
+ }
+
+ syncSettings() {
+ this.iconOnly = settingsState.iconOnlySidebar === true;
+ }
+
+ syncActiveFromLocation() {
+ const path = getPagePathFromHash();
+ this.activePath = path;
+
+ if (!path) {
+ this.activeKey = null;
+ return;
+ }
+
+ const exact = findItemByPath(this.items, path);
+ if (exact) {
+ this.activeKey = exact.key;
+ return;
+ }
+
+ // Nested routes like /assessments/upcoming → assessments
+ const segments = path.split("/").filter(Boolean);
+ while (segments.length > 1) {
+ segments.pop();
+ const parentPath = `/${segments.join("/")}`;
+ const parent = findItemByPath(this.items, parentPath);
+ if (parent) {
+ this.activeKey = parent.key;
+ return;
+ }
+ }
+
+ this.activeKey = null;
+ }
+
+ pruneDrillStack() {
+ if (!this.drillStack.length) return;
+
+ const next: SidebarDrillFrame[] = [];
+ let cursor = this.visibleRootItems;
+
+ for (const frame of this.drillStack) {
+ const folder = cursor.find((item) => item.key === frame.key);
+ if (!folder?.hasChildren) break;
+ const children = filterVisible(folder.children);
+ next.push({ key: folder.key, label: folder.label, items: children });
+ cursor = children;
+ }
+
+ this.drillStack = next;
+ }
+
+ openFolder(item: SidebarItem) {
+ if (!item.hasChildren) return;
+ this.drillStack = [
+ ...this.drillStack,
+ {
+ key: item.key,
+ label: item.label,
+ items: filterVisible(item.children),
+ },
+ ];
+ }
+
+ goBack() {
+ if (!this.drillStack.length) return;
+ this.drillStack = this.drillStack.slice(0, -1);
+ }
+
+ resetDrill() {
+ this.drillStack = [];
+ }
+
+ setEditMode(enabled: boolean) {
+ this.editMode = enabled;
+ if (enabled) this.resetDrill();
+ }
+
+ reorderRoot(fromKey: string, toKey: string) {
+ if (fromKey === toKey) return;
+ const keys = this.visibleRootItems.map((item) => item.key);
+ const from = keys.indexOf(fromKey);
+ const to = keys.indexOf(toKey);
+ if (from < 0 || to < 0) return;
+
+ const next = [...keys];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ settingsState.menuorder = next;
+ }
+
+ setItemVisibility(key: string, visible: boolean) {
+ const current = {
+ ...(settingsState.menuitems as Record),
+ };
+ current[key] = { toggle: visible };
+ settingsState.menuitems = current as typeof settingsState.menuitems;
+ }
+
+ restoreDefaultOrder() {
+ const defaults = settingsState.defaultmenuorder;
+ if (defaults?.length) {
+ settingsState.menuorder = [...defaults];
+ }
+
+ const restored: Record = {};
+ for (const item of this.items) {
+ restored[item.key] = { toggle: true };
+ }
+ settingsState.menuitems = restored as typeof settingsState.menuitems;
+ }
+
+ activateItem(item: SidebarItem, menu: HTMLElement) {
+ if (this.editMode) return;
+
+ if (item.hasChildren) {
+ this.openFolder(item);
+ return;
+ }
+
+ this.activeKey = item.key;
+ if (item.path) this.activePath = item.path;
+
+ const native = findNativeMenuEntry(menu, item);
+ if (native) {
+ // Never strip `.active` from custom `#bsplus-sidebar-root` folders.
+ clearNativeDrillActive(menu);
+ native.click();
+ // SEQTA re-opens native drill and strips `.active` after click — undo both.
+ clearNativeDrillActive(menu);
+ restoreCustomMenuActive();
+ requestAnimationFrame(() => {
+ clearNativeDrillActive(menu);
+ restoreCustomMenuActive();
+ });
+ setTimeout(() => {
+ clearNativeDrillActive(menu);
+ restoreCustomMenuActive();
+ }, 0);
+ setTimeout(() => {
+ clearNativeDrillActive(menu);
+ restoreCustomMenuActive();
+ }, 50);
+ setTimeout(() => {
+ clearNativeDrillActive(menu);
+ restoreCustomMenuActive();
+ }, 100);
+ return;
+ }
+
+ if (item.path) {
+ location.hash = `?page=${item.path}`;
+ }
+ }
+
+ findByKey(key: string) {
+ return findItemByKey(this.items, key);
+ }
+}
+
+export const sidebarState = new SidebarState();
diff --git a/src/seqta/ui/sidebar/types.ts b/src/seqta/ui/sidebar/types.ts
new file mode 100644
index 00000000..2e89e10e
--- /dev/null
+++ b/src/seqta/ui/sidebar/types.ts
@@ -0,0 +1,19 @@
+export type SidebarItem = {
+ key: string;
+ path: string | null;
+ id: string | null;
+ label: string;
+ /** Outer HTML of the label's leading SVG (if any). */
+ iconHtml: string;
+ hasChildren: boolean;
+ colour: string | null;
+ itemColour: string | null;
+ betterseqta: boolean;
+ children: SidebarItem[];
+};
+
+export type SidebarDrillFrame = {
+ key: string;
+ label: string;
+ items: SidebarItem[];
+};
diff --git a/src/seqta/utils/Openers/OpenMenuOptions.ts b/src/seqta/utils/Openers/OpenMenuOptions.ts
index 794fcec2..7622fff5 100644
--- a/src/seqta/utils/Openers/OpenMenuOptions.ts
+++ b/src/seqta/utils/Openers/OpenMenuOptions.ts
@@ -1,11 +1,17 @@
import type { SettingsState } from "@/types/storage";
import { settingsState } from "../listeners/SettingsState";
import { applyMenuItemVisibility } from "../menuItemVisibility";
-import { ensureAnalyticsMenuOrder } from "@/seqta/utils/sidebarMenuIcons";
+import { ensureAnalyticsMenuOrder } from "@/seqta/utils/Openers/analyticsMenuOrder";
+import {
+ isMenuOptionsOpen,
+ setMenuOptionsOpen,
+} from "@/seqta/utils/Openers/menuOptionsState";
+import { ChangeMenuItemPositions } from "@/seqta/utils/Openers/menuOrder";
import stringToHTML from "../stringToHTML";
import Sortable from "sortablejs";
-export let MenuOptionsOpen = false;
+export { MenuOptionsOpen, setMenuOptionsOpen } from "@/seqta/utils/Openers/menuOptionsState";
+export { ChangeMenuItemPositions } from "@/seqta/utils/Openers/menuOrder";
function escapeHtmlAttr(value: string): string {
return value
@@ -190,14 +196,22 @@ function restoreMenuItemsFromEditMode(
}
export function OpenMenuOptions() {
- if (MenuOptionsOpen) return;
+ if (isMenuOptionsOpen()) return;
+
+ // Custom Svelte sidebar owns edit mode when mounted.
+ if (document.getElementById("bsplus-sidebar-root")) {
+ void import("@/seqta/ui/sidebar/mountCustomSidebar").then((mod) => {
+ if (mod.openCustomSidebarEditor()) setMenuOptionsOpen(true);
+ });
+ return;
+ }
const container = document.getElementById("container");
const menu = document.getElementById("menu");
if (!container || !menu) return;
syncDefaultMenuOrder(menu);
- MenuOptionsOpen = true;
+ setMenuOptionsOpen(true);
menu.classList.add("bsplus-sidebar-edit-mode");
const { cover, menusettings, defaultbutton, savebutton } =
@@ -220,7 +234,7 @@ export function OpenMenuOptions() {
const closeAll = () => {
menusettings.remove();
cover.remove();
- MenuOptionsOpen = false;
+ setMenuOptionsOpen(false);
menu.classList.remove("bsplus-sidebar-edit-mode");
menu.style.setProperty("--menuHidden", "none");
restoreMenuItemsFromEditMode(menu, listItems);
@@ -267,34 +281,3 @@ function cloneAttributes(target: any, source: any) {
target.setAttribute(attr.nodeName, attr.nodeValue);
});
}
-
-export function ChangeMenuItemPositions(menuorder: SettingsState["menuorder"]) {
- var menuList = document.querySelector("#menu")!.firstChild!.childNodes;
-
- let listorder = [];
- for (let i = 0; i < menuList.length; i++) {
- const menu = menuList[i] as HTMLElement;
-
- let a = menuorder.indexOf(menu.dataset.key);
-
- listorder.push(a);
- }
-
- var newArr = [];
- for (var i = 0; i < listorder.length; i++) {
- const index = listorder[i];
- if (index >= 0) {
- newArr[index] = menuList[i];
- }
- }
-
- let listItemsDOM = document.getElementById("menu")!.firstChild;
- for (let i = 0; i < newArr.length; i++) {
- const element = newArr[i];
- if (element) {
- const elem = element as HTMLElement;
- elem.setAttribute("data-checked", "true");
- listItemsDOM!.appendChild(element);
- }
- }
-}
diff --git a/src/seqta/utils/Openers/analyticsMenuOrder.ts b/src/seqta/utils/Openers/analyticsMenuOrder.ts
new file mode 100644
index 00000000..8ce46f8e
--- /dev/null
+++ b/src/seqta/utils/Openers/analyticsMenuOrder.ts
@@ -0,0 +1,44 @@
+import { settingsState } from "@/seqta/utils/listeners/SettingsState";
+
+/** Insert a menu DOM node after the top-level item with `afterKey`. */
+export function insertMenuItemAfterKey(
+ menuList: HTMLElement,
+ item: HTMLElement,
+ afterKey: string,
+): void {
+ const after = menuList.querySelector(
+ `:scope > li[data-key="${afterKey}"], :scope > section[data-key="${afterKey}"]`,
+ );
+ if (after instanceof HTMLElement) {
+ after.insertAdjacentElement("afterend", item);
+ } else {
+ menuList.appendChild(item);
+ }
+}
+
+export function insertKeyAfterInOrder(
+ order: string[],
+ key: string,
+ afterKey: string,
+): string[] {
+ if (order.includes(key)) return order;
+ const copy = [...order];
+ const afterIdx = copy.indexOf(afterKey);
+ if (afterIdx >= 0) {
+ copy.splice(afterIdx + 1, 0, key);
+ } else {
+ copy.push(key);
+ }
+ return copy;
+}
+
+/** Default Analytics immediately below Courses in saved menu order. */
+export function ensureAnalyticsMenuOrder(): void {
+ for (const key of ["defaultmenuorder", "menuorder"] as const) {
+ const order = settingsState[key];
+ if (key === "menuorder" && order.length === 0) continue;
+ if (!order.includes("analytics")) {
+ settingsState[key] = insertKeyAfterInOrder(order, "analytics", "courses");
+ }
+ }
+}
diff --git a/src/seqta/utils/Openers/menuOptionsState.ts b/src/seqta/utils/Openers/menuOptionsState.ts
new file mode 100644
index 00000000..ee79163d
--- /dev/null
+++ b/src/seqta/utils/Openers/menuOptionsState.ts
@@ -0,0 +1,13 @@
+/**
+ * Tiny flag module with no imports — breaks the OpenMenuOptions ↔ sidebarMenuIcons
+ * cycle that caused Vite HMR: "does not provide an export named 'MenuOptionsOpen'".
+ */
+export let MenuOptionsOpen = false;
+
+export function setMenuOptionsOpen(open: boolean) {
+ MenuOptionsOpen = open;
+}
+
+export function isMenuOptionsOpen(): boolean {
+ return MenuOptionsOpen;
+}
diff --git a/src/seqta/utils/Openers/menuOrder.ts b/src/seqta/utils/Openers/menuOrder.ts
new file mode 100644
index 00000000..09e66680
--- /dev/null
+++ b/src/seqta/utils/Openers/menuOrder.ts
@@ -0,0 +1,32 @@
+import type { SettingsState } from "@/types/storage";
+
+/** Reorder `#menu > ul` children to match a saved `menuorder` key list. */
+export function ChangeMenuItemPositions(menuorder: SettingsState["menuorder"]) {
+ const menuRoot = document.querySelector("#menu")?.firstChild;
+ if (!menuRoot) return;
+
+ const menuList = menuRoot.childNodes;
+ const listorder: number[] = [];
+
+ for (let i = 0; i < menuList.length; i++) {
+ const menu = menuList[i] as HTMLElement;
+ listorder.push(menuorder.indexOf(menu.dataset.key));
+ }
+
+ const newArr: ChildNode[] = [];
+ for (let i = 0; i < listorder.length; i++) {
+ const index = listorder[i];
+ if (index >= 0) {
+ newArr[index] = menuList[i];
+ }
+ }
+
+ for (let i = 0; i < newArr.length; i++) {
+ const element = newArr[i];
+ if (element) {
+ const elem = element as HTMLElement;
+ elem.setAttribute("data-checked", "true");
+ menuRoot.appendChild(element);
+ }
+ }
+}
diff --git a/src/seqta/utils/listeners/MessageListener.ts b/src/seqta/utils/listeners/MessageListener.ts
index c79e0ff8..73b6d21f 100644
--- a/src/seqta/utils/listeners/MessageListener.ts
+++ b/src/seqta/utils/listeners/MessageListener.ts
@@ -1,10 +1,8 @@
import browser from "webextension-polyfill";
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
-import {
- MenuOptionsOpen,
- OpenMenuOptions,
-} from "@/seqta/utils/Openers/OpenMenuOptions";
+import { OpenMenuOptions } from "@/seqta/utils/Openers/OpenMenuOptions";
+import { isMenuOptionsOpen } from "@/seqta/utils/Openers/menuOptionsState";
import sendThemeUpdate from "@/seqta/utils/sendThemeUpdate";
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent";
@@ -144,7 +142,7 @@ export class MessageHandler {
}
editSidebar() {
- if (!MenuOptionsOpen) {
+ if (!isMenuOptionsOpen()) {
OpenMenuOptions();
}
}
diff --git a/src/seqta/utils/listeners/StorageChanges.ts b/src/seqta/utils/listeners/StorageChanges.ts
index 74247b0c..720601ff 100644
--- a/src/seqta/utils/listeners/StorageChanges.ts
+++ b/src/seqta/utils/listeners/StorageChanges.ts
@@ -7,7 +7,7 @@ import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts";
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
import { registerHomeUpcomingSettingsListeners } from "@/seqta/utils/Loaders/LoadHomePage";
import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility";
-import { ChangeMenuItemPositions } from "@/seqta/utils/Openers/OpenMenuOptions";
+import { ChangeMenuItemPositions } from "@/seqta/utils/Openers/menuOrder";
import browser from "webextension-polyfill";
import type { CustomShortcut } from "@/types/storage";
diff --git a/src/seqta/utils/menuItemVisibility.ts b/src/seqta/utils/menuItemVisibility.ts
index 62cd0386..9a8f251f 100644
--- a/src/seqta/utils/menuItemVisibility.ts
+++ b/src/seqta/utils/menuItemVisibility.ts
@@ -10,7 +10,9 @@ export function applyMenuItemVisibility(): void {
let css = "";
for (const [menuItem, config] of Object.entries(settingsState.menuitems ?? {})) {
if (config && !config.toggle) {
- css += `li[data-key=${menuItem}],section[data-key=${menuItem}]{display:var(--menuHidden) !important;transition:1s;}`;
+ // Scope to the native SEQTA list only — the custom Svelte sidebar
+ // filters visibility in JS and must not be forced hidden by this rule.
+ css += `#menu > ul:not(#bsplus-sidebar-root) li[data-key=${menuItem}],#menu > ul:not(#bsplus-sidebar-root) section[data-key=${menuItem}]{display:var(--menuHidden) !important;transition:1s;}`;
}
}
diff --git a/src/seqta/utils/sidebarMenuIcons.ts b/src/seqta/utils/sidebarMenuIcons.ts
index cc0c01c0..0ece53fd 100644
--- a/src/seqta/utils/sidebarMenuIcons.ts
+++ b/src/seqta/utils/sidebarMenuIcons.ts
@@ -1,8 +1,6 @@
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
-import {
- ChangeMenuItemPositions,
- MenuOptionsOpen,
-} from "@/seqta/utils/Openers/OpenMenuOptions";
+import { ChangeMenuItemPositions } from "@/seqta/utils/Openers/menuOrder";
+import { isMenuOptionsOpen } from "@/seqta/utils/Openers/menuOptionsState";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForEngageMenuList } from "@/seqta/utils/waitForEngageMenuList";
@@ -12,48 +10,6 @@ import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
const BETTERSEQTA_ICON_ATTR = "data-betterseqta-icon";
-export function insertMenuItemAfterKey(
- menuList: HTMLElement,
- item: HTMLElement,
- afterKey: string,
-): void {
- const after = menuList.querySelector(
- `:scope > li[data-key="${afterKey}"], :scope > section[data-key="${afterKey}"]`,
- );
- if (after instanceof HTMLElement) {
- after.insertAdjacentElement("afterend", item);
- } else {
- menuList.appendChild(item);
- }
-}
-
-export function insertKeyAfterInOrder(
- order: string[],
- key: string,
- afterKey: string,
-): string[] {
- if (order.includes(key)) return order;
- const copy = [...order];
- const afterIdx = copy.indexOf(afterKey);
- if (afterIdx >= 0) {
- copy.splice(afterIdx + 1, 0, key);
- } else {
- copy.push(key);
- }
- return copy;
-}
-
-/** Default Analytics immediately below Courses in saved menu order. */
-export function ensureAnalyticsMenuOrder(): void {
- for (const key of ["defaultmenuorder", "menuorder"] as const) {
- const order = settingsState[key];
- if (key === "menuorder" && order.length === 0) continue;
- if (!order.includes("analytics")) {
- settingsState[key] = insertKeyAfterInOrder(order, "analytics", "courses");
- }
- }
-}
-
function getMenuLabel(element: HTMLElement): HTMLElement | null {
const label = element.querySelector(":scope > label");
return label instanceof HTMLElement ? label : null;
@@ -107,7 +63,7 @@ export function replaceMenuSVG(element: HTMLElement, svg: string) {
}
export function processMenuItemNode(node: HTMLElement) {
- if (!isTopLevelSidebarItem(node) || MenuOptionsOpen) return;
+ if (!isTopLevelSidebarItem(node) || isMenuOptionsOpen()) return;
const key = node.dataset.key as keyof typeof MenuitemSVGKey | undefined;
if (key && MenuitemSVGKey[key]) {
@@ -119,7 +75,7 @@ export function processMenuItemNode(node: HTMLElement) {
}
function processTopLevelMenuItems(reorder = !isSeqtaEngageExperience()) {
- if (MenuOptionsOpen) return;
+ if (isMenuOptionsOpen()) return;
const topList = getTopLevelMenuList();
if (!topList) return;
@@ -186,7 +142,7 @@ export async function observeMenuItemPosition() {
if (!isTopLevelSidebarItem(node)) return;
if ((element as any)[processedSymbol]) return;
- if (!MenuOptionsOpen) {
+ if (!isMenuOptionsOpen()) {
processMenuItemNode(node);
ChangeMenuItemPositions(settingsState.menuorder);
(element as any)[processedSymbol] = true;