diff --git a/src/css/injected.scss b/src/css/injected.scss index 7a9f2b1a..3b5e3169 100644 --- a/src/css/injected.scss +++ b/src/css/injected.scss @@ -481,7 +481,7 @@ ul.magicDelete > li.deleting { place-items: center; } #menu .sub { - top: 71.5px; + top: 71px; margin-top: -2px; } @@ -728,7 +728,7 @@ html.bsplus-custom-title-pending #title > :not(#bsplus-title-root), &::before { box-shadow: none !important; border-bottom: none !important; - height: calc(69.5px + 8px) !important; + height: calc(71px + 8px) !important; } } @@ -1123,7 +1123,7 @@ ol:has([class*="MessageList__avatar___"] svg) { } #container #content::before { content: ""; - height: 69.5px; + height: 71px; width: 100%; } .menuShown #container #content #title { @@ -1356,7 +1356,7 @@ body.student #menu > ul::before { position: sticky; top: 0; background-color: transparent; - height: 69.5px; + height: 71px; box-shadow: 0px 0px 4px 2px rgba(0, 0, 0, 0.2); } @@ -1605,9 +1605,14 @@ html.transparencyEffects color: var(--text-primary) !important; display: flex; justify-content: space-between; + align-items: center; + box-sizing: border-box; padding-right: 56px !important; - height: 54px; - min-height: 48px; + /* Must match logo/`#content` spacer (~71px). A 54px height only "looked" + taller in DEV (content-box); production content-script Tailwind forces + border-box and collapses the bar to 54px. */ + height: 71px; + min-height: 71px; box-shadow: rgba(0, 0, 0, 0.35) 0px 0px 10px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); font-weight: 500 !important; @@ -1620,12 +1625,13 @@ html.transparencyEffects #title::before { background-image: var(--betterseqta-logo); - background-position: left; + background-position: left center; background-repeat: no-repeat; background-size: auto 48px; height: 69.5px; content: ""; width: 184px; + flex-shrink: 0; } .rightElements { display: flex; @@ -1648,10 +1654,20 @@ html.transparencyEffects color: var(--text-color); } .timetable-arrows { + display: flex; + flex-direction: row; + align-items: center; + flex-shrink: 0; + gap: 4px; padding: 5px; } .timetable-arrows svg { - margin: 0 5px; + display: block; + flex-shrink: 0; + width: 24px; + height: 24px; + margin: 0; + position: static; cursor: pointer; } .timetable-arrows svg:hover { @@ -1801,9 +1817,12 @@ html.transparencyEffects -webkit-box-shadow: 0px 5px 16px 6px rgba(0, 0, 0, 0.3); box-shadow: 0px 5px 16px 6px rgba(0, 0, 0, 0.3); } -.border { +/* Not named `.border` — Tailwind’s utility of that name is injected into the + page via content-script CSS in production and would paint a 1px outline. */ +.bsplus-rounded { border-radius: 16px; overflow: clip; + border-width: 0; } .shortcut-container h2 { font-size: 20px; diff --git a/src/css/injected/sidebar-animation.scss b/src/css/injected/sidebar-animation.scss index f789b142..5718924c 100644 --- a/src/css/injected/sidebar-animation.scss +++ b/src/css/injected/sidebar-animation.scss @@ -20,6 +20,37 @@ animation: none !important; } +/* + * Custom sidebar: park sibling chrome while `.drilling` is set. + * SEQTA strips/restores `.active` on every nested leaf click; keying only off + * `:has(.active)` briefly undoes the translate and the 0.3s transform + * transition makes that look like the open animation flashing again. + * + * Scope carefully — only root siblings (and nested-folder siblings via + * `.sub:has(.sub)`), never the visible leaf rows in the current panel. + */ +#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling > li::before, +#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling > li::after, +#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling > li > label, +#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling > li > svg { + transform: translateX(-320px) !important; + animation: none !important; + transition: none !important; +} + +#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling .sub:has(.sub) > ul > li::before, +#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling .sub:has(.sub) > ul > li::after, +#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling .sub:has(.sub) > ul > li > label, +#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling .sub:has(.sub) > ul > li > svg { + transform: translateX(-320px) !important; + animation: none !important; + transition: none !important; +} + +#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling { + overflow: hidden !important; +} + #menu li.hasChildren:not(.active) li .sub { display: none; // improves performance } @@ -38,6 +69,13 @@ transition: transform 0.3s ease; } +#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling > li > label, +#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling > li > svg, +#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling .sub:has(.sub) > ul > li > label, +#menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling .sub:has(.sub) > ul > li > svg { + transition: none !important; +} + #menu .sub { transition: transform 0.3s ease, left 0.4s cubic-bezier(0.4, 0, 0.2, 1); } @@ -55,3 +93,13 @@ body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has( body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li > svg { transform: translateX(-70px); } + +body.icon-only-sidebar #menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling > li::before, +body.icon-only-sidebar #menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling > li > label, +body.icon-only-sidebar #menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling > li > svg, +body.icon-only-sidebar #menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling .sub:has(.sub) > ul > li::before, +body.icon-only-sidebar #menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling .sub:has(.sub) > ul > li > label, +body.icon-only-sidebar #menu.bsplus-custom-sidebar #bsplus-sidebar-root.drilling .sub:has(.sub) > ul > li > svg { + transform: translateX(-70px) !important; + transition: none !important; +} diff --git a/src/css/injected/transparency.scss b/src/css/injected/transparency.scss index d2dd8fed..c96c7642 100644 --- a/src/css/injected/transparency.scss +++ b/src/css/injected/transparency.scss @@ -23,7 +23,7 @@ html.transparencyEffects { /* Blurs */ .search, .document, - .border, + .bsplus-rounded, .draggable, .notice, [class*="BasicPanel__BasicPanel___"], diff --git a/src/plugins/built-in/animatedBackground/backgroundLayers.ts b/src/plugins/built-in/animatedBackground/backgroundLayers.ts index c5f8531d..ad8382c9 100644 --- a/src/plugins/built-in/animatedBackground/backgroundLayers.ts +++ b/src/plugins/built-in/animatedBackground/backgroundLayers.ts @@ -1,5 +1,6 @@ import type { PluginAPI } from "@/plugins/core/types"; import { waitForElm } from "@/seqta/utils/waitForElm"; +import { waitForSeqtaMenu } from "@/seqta/utils/waitForSeqtaShell"; export const ANIMATED_BG_MARKER = "bsplus-animated-bg"; @@ -51,7 +52,7 @@ export async function syncAnimatedBackground( try { const [container, menu] = await Promise.all([ waitForElm("#container", true), - waitForElm("#menu", true), + waitForSeqtaMenu(), ]); ensureAnimatedBackgroundLayers( container as HTMLElement, diff --git a/src/plugins/built-in/globalSearch/src/components/Calculator.svelte b/src/plugins/built-in/globalSearch/src/components/Calculator.svelte index 5a870a68..8ecccaed 100644 --- a/src/plugins/built-in/globalSearch/src/components/Calculator.svelte +++ b/src/plugins/built-in/globalSearch/src/components/Calculator.svelte @@ -14,12 +14,16 @@ let outputUnit = $state(''); let isPartial = $state(false); - const processInput = (input: string) => { + let calcGen = 0; + + const processInput = async (input: string) => { + const gen = ++calcGen; isCalculating = true; - + try { - const calcResult = calculateExpression(input); - + const calcResult = await calculateExpression(input); + if (gen !== calcGen) return; + if (calcResult.isValid) { result = calcResult.result; inputUnit = calcResult.inputUnit; @@ -33,19 +37,20 @@ isPartial = false; dispatch('hasResult', null); } - } catch (e) { + } catch { + if (gen !== calcGen) return; result = null; inputUnit = ''; outputUnit = ''; isPartial = false; dispatch('hasResult', null); } finally { - isCalculating = false; + if (gen === calcGen) isCalculating = false; } - } - + }; + $effect(() => { - processInput(searchTerm); + void processInput(searchTerm); }); onDestroy(() => { diff --git a/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte b/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte index b31d3f47..d358579f 100644 --- a/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte +++ b/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte @@ -114,7 +114,7 @@ }); onMount(() => { - const itemsUpdatedHandler = (event: Event) => { + const applyItemsUpdate = (event: Event) => { const detail = (event as CustomEvent).detail; if ( @@ -142,6 +142,21 @@ setupSearchIndexes(); performSearch(); }; + + // Trace showed ~18ms itemsUpdatedHandler spikes; coalesce bursts via rAF. + let itemsUpdateRaf = 0; + let pendingItemsEvent: Event | null = null; + const itemsUpdatedHandler = (event: Event) => { + pendingItemsEvent = event; + if (itemsUpdateRaf) return; + itemsUpdateRaf = requestAnimationFrame(() => { + itemsUpdateRaf = 0; + const next = pendingItemsEvent; + pendingItemsEvent = null; + if (next) applyItemsUpdate(next); + }); + }; + window.addEventListener('dynamic-items-updated', itemsUpdatedHandler); setupSearchIndexes(); @@ -153,6 +168,7 @@ }; return () => { + if (itemsUpdateRaf) cancelAnimationFrame(itemsUpdateRaf); window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler); }; }); diff --git a/src/plugins/built-in/globalSearch/src/core/index.ts b/src/plugins/built-in/globalSearch/src/core/index.ts index f4a7c9d8..b81cbdcb 100644 --- a/src/plugins/built-in/globalSearch/src/core/index.ts +++ b/src/plugins/built-in/globalSearch/src/core/index.ts @@ -1,7 +1,7 @@ import type { Plugin } from "@/plugins/core/types"; import { verboseDebug, verboseLog } from "@/utils/verboseLog"; import styles from "./styles.css?inline"; -import { waitForElm } from "@/seqta/utils/waitForElm"; +import { waitForSeqtaTitle } from "@/seqta/utils/waitForSeqtaShell"; import { runIndexing, ensureSchemaCurrent } from "../indexing/indexer"; import { installResetIndexMessageListener } from "../indexing/resetIndexes"; import { isIndexingPaused } from "../indexing/indexingPause"; @@ -13,6 +13,14 @@ import { installPassiveObserver, } from "../indexing/passiveObserver"; +function scheduleIdleIndexing(run: () => void): void { + if (typeof requestIdleCallback === "function") { + requestIdleCallback(() => run(), { timeout: 5000 }); + return; + } + setTimeout(run, 2000); +} + const globalSearchPlugin: Plugin<{}> = { id: "global-search", name: "Global Search", @@ -84,17 +92,12 @@ const globalSearchPlugin: Plugin<{}> = { } if (api.settings.runIndexingOnLoad && !isIndexingPaused()) { - setTimeout(async () => { - if (!isIndexingPaused()) await runIndexing(); - }, 2000); + scheduleIdleIndexing(() => { + if (!isIndexingPaused()) void runIndexing(); + }); } - const title = document.querySelector("#title"); - if (title) { - void mountSearchBar(title, api, appRef); - } else { - void mountSearchBar(await waitForElm("#title", true, 100, 60), api, appRef); - } + void mountSearchBar(await waitForSeqtaTitle(100, 60), api, appRef); return () => cleanupSearchBar(appRef); }, diff --git a/src/plugins/built-in/globalSearch/src/utils/calculator.test.ts b/src/plugins/built-in/globalSearch/src/utils/calculator.test.ts new file mode 100644 index 00000000..033bb71b --- /dev/null +++ b/src/plugins/built-in/globalSearch/src/utils/calculator.test.ts @@ -0,0 +1,23 @@ +import { + calculateExpression, + isLikelyMathExpression, +} from "./calculator"; + +describe("calculator", () => { + it("rejects non-math text without needing mathjs for validity", () => { + expect(isLikelyMathExpression("homework due tomorrow")).toBe(false); + expect(isLikelyMathExpression("2 + 2")).toBe(true); + }); + + it("evaluates expressions after dynamic mathjs load", async () => { + const result = await calculateExpression("2 + 2"); + expect(result.isValid).toBe(true); + expect(result.result).toBe("4"); + }); + + it("returns empty for plain search queries", async () => { + const result = await calculateExpression("assessments"); + expect(result.isValid).toBe(false); + expect(result.result).toBeNull(); + }); +}); diff --git a/src/plugins/built-in/globalSearch/src/utils/calculator.ts b/src/plugins/built-in/globalSearch/src/utils/calculator.ts index e4098e0c..45a6c15b 100644 --- a/src/plugins/built-in/globalSearch/src/utils/calculator.ts +++ b/src/plugins/built-in/globalSearch/src/utils/calculator.ts @@ -1,10 +1,3 @@ -import { - create, absDependencies, addDependencies, cosDependencies, divideDependencies, eDependencies, - evaluateDependencies, formatDependencies, log10Dependencies, logDependencies, modDependencies, - multiplyDependencies, piDependencies, powDependencies, sinDependencies, sqrtDependencies, - subtractDependencies, tanDependencies, toDependencies, typeOfDependencies, unaryMinusDependencies, - unaryPlusDependencies, unitDependencies, -} from 'mathjs'; import { unitFullNames } from './unitMap'; export interface CalculatorResult { @@ -18,45 +11,106 @@ export interface CalculatorResult { export const CALCULATOR_MAX_INPUT_LENGTH = 128; -const CALCULATOR_MATH_CONFIG = Object.assign( - {}, - evaluateDependencies, formatDependencies, unitDependencies, typeOfDependencies, - addDependencies, subtractDependencies, multiplyDependencies, divideDependencies, - powDependencies, modDependencies, unaryMinusDependencies, unaryPlusDependencies, - absDependencies, sqrtDependencies, logDependencies, log10Dependencies, - sinDependencies, cosDependencies, tanDependencies, toDependencies, - piDependencies, eDependencies, -); +type CalculatorMath = { + evaluate: (expr: string) => unknown; + format: (value: unknown, opts: object) => string; + typeOf: (value: unknown) => string; + unit: (expression: string) => { formatUnits: () => string } | null; + import: (fns: Record never>, opts: { override: boolean }) => void; +}; -const BLOCKED_MATH_FUNCTIONS = ['import', 'createUnit', 'random', 'pickRandom', 'chain', 'help'] as const; - -function createSandboxedMath() { - const sandbox = create(CALCULATOR_MATH_CONFIG); - const blockFn = () => { throw new Error('Function not allowed'); }; - const blocked: Record never> = {}; - for (const name of BLOCKED_MATH_FUNCTIONS) blocked[name] = blockFn; - sandbox.import(blocked, { override: true }); - return sandbox; -} - -const calculatorMath = createSandboxedMath(); const FORMAT_OPTS = { precision: 14, lowerExp: -15, upperExp: 15 } as const; const emptyResult = (error?: string): CalculatorResult => ({ result: null, isValid: false, isPartial: false, inputUnit: '', outputUnit: '', error, }); -function detectUnit(expression: string): string { +const BLOCKED_MATH_FUNCTIONS = ['import', 'createUnit', 'random', 'pickRandom', 'chain', 'help'] as const; + +let calculatorMathPromise: Promise | null = null; + +async function getCalculatorMath(): Promise { + if (!calculatorMathPromise) { + calculatorMathPromise = (async () => { + const { + create, + absDependencies, + addDependencies, + cosDependencies, + divideDependencies, + eDependencies, + evaluateDependencies, + formatDependencies, + log10Dependencies, + logDependencies, + modDependencies, + multiplyDependencies, + piDependencies, + powDependencies, + sinDependencies, + sqrtDependencies, + subtractDependencies, + tanDependencies, + toDependencies, + typeOfDependencies, + unaryMinusDependencies, + unaryPlusDependencies, + unitDependencies, + } = await import('mathjs'); + + const config = Object.assign( + {}, + evaluateDependencies, + formatDependencies, + unitDependencies, + typeOfDependencies, + addDependencies, + subtractDependencies, + multiplyDependencies, + divideDependencies, + powDependencies, + modDependencies, + unaryMinusDependencies, + unaryPlusDependencies, + absDependencies, + sqrtDependencies, + logDependencies, + log10Dependencies, + sinDependencies, + cosDependencies, + tanDependencies, + toDependencies, + piDependencies, + eDependencies, + ); + + const sandbox = create(config) as CalculatorMath; + const blockFn = () => { + throw new Error('Function not allowed'); + }; + const blocked: Record never> = {}; + for (const name of BLOCKED_MATH_FUNCTIONS) blocked[name] = blockFn; + sandbox.import(blocked, { override: true }); + return sandbox; + })(); + } + return calculatorMathPromise; +} + +function detectUnit(math: CalculatorMath, expression: string): string { try { - const unit = calculatorMath.unit(expression); + const unit = math.unit(expression); if (unit) { const unitStr = unit.formatUnits(); return unitFullNames[unitStr] || unitStr; } - } catch {} + } catch { + /* ignore */ + } return ''; } -function isLikelyMathExpression(input: string): boolean { +/** Cheap pre-check so SearchBar/Calculator never load mathjs for plain text. */ +export function isLikelyMathExpression(input: string): boolean { const trimmed = input.trim(); if (!/[\d+\-*/^()=.]/.test(trimmed)) return false; const nonMathWords = ['abs', 'function', 'class', 'const', 'let', 'var', 'if', 'else', 'while', 'for', 'return', 'import', 'export']; @@ -65,23 +119,25 @@ function isLikelyMathExpression(input: string): boolean { return /(\d+\.?\d*|\+|\-|\*|\/|\^|\(|\)|sin|cos|tan|log|sqrt|pi|e|=)/i.test(trimmed); } -function tryCompleteExpression(expression: string): string | null { +function tryCompleteExpression(math: CalculatorMath, expression: string): string | null { const trimmed = expression.trim(); for (const pattern of [/[\+\-\*\/\^]\s*$/, /\(\s*$/, /[\+\-\*\/\^]\s*\(/]) { if (!pattern.test(trimmed)) continue; const partial = trimmed.replace(/[\+\-\*\/\^]\s*$/, '').trim(); if (!partial || partial.match(/[\+\-\*\/\^]\s*$/)) continue; try { - const result = calculatorMath.evaluate(partial); + const result = math.evaluate(partial); if (typeof result === 'number' && !isNaN(result)) { - return calculatorMath.format(result, FORMAT_OPTS); + return math.format(result, FORMAT_OPTS); } - } catch {} + } catch { + /* ignore */ + } } return null; } -export function calculateExpression(input: string): CalculatorResult { +export async function calculateExpression(input: string): Promise { const trimmed = input.trim(); if (!trimmed || (trimmed.length <= 2 && !/\d/.test(trimmed))) return emptyResult(); if (trimmed.length > CALCULATOR_MAX_INPUT_LENGTH) { @@ -89,21 +145,23 @@ export function calculateExpression(input: string): CalculatorResult { } if (!isLikelyMathExpression(trimmed)) return emptyResult(); + const math = await getCalculatorMath(); + try { - const evaluated = calculatorMath.evaluate(trimmed.replace('**', '^')); + const evaluated = math.evaluate(trimmed.replace('**', '^')); if (evaluated !== undefined) { - const result = calculatorMath.format(evaluated, FORMAT_OPTS); - const isUnit = calculatorMath.typeOf(evaluated) === 'Unit'; + const result = math.format(evaluated, FORMAT_OPTS); + const isUnit = math.typeOf(evaluated) === 'Unit'; return { result, isValid: true, isPartial: false, - inputUnit: isUnit ? detectUnit(trimmed) : '', - outputUnit: isUnit ? detectUnit(result) : '', + inputUnit: isUnit ? detectUnit(math, trimmed) : '', + outputUnit: isUnit ? detectUnit(math, result) : '', }; } } catch (error) { - const partialResult = tryCompleteExpression(trimmed); + const partialResult = tryCompleteExpression(math, trimmed); if (partialResult) { return { result: partialResult, isValid: true, isPartial: true, inputUnit: '', outputUnit: '' }; } diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts index 7d2024b6..0a6af2de 100644 --- a/src/plugins/monofile.ts +++ b/src/plugins/monofile.ts @@ -1,6 +1,5 @@ // Third-party libraries import browser from "webextension-polyfill"; -import { animate, stagger } from "motion"; // Internal utilities and functions import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour"; @@ -48,6 +47,11 @@ import iframeCSS from "@/css/iframe.scss?raw"; import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility"; +/** Lazy-load Motion so first-paint shell (loading overlay / sidebar) stays free of it. */ +function loadMotion() { + return import("motion"); +} + export function hideSideBar() { const sidebar = document.getElementById("menu"); // The sidebar element to be closed const main = document.getElementById("main"); // The main content element that must be resized to fill the page @@ -250,6 +254,7 @@ async function handleNotices(node: Element): Promise { // get index of node in relation to parent const index = Array.from(node.parentElement!.children).indexOf(node); + const { animate } = await loadMotion(); animate( node, { opacity: [0, 1], y: [50, 0], scale: [0.99, 1] }, @@ -371,6 +376,7 @@ async function handleMessages(node: Element): Promise { const messages = Array.from( document.querySelectorAll("[data-message]"), ).slice(0, 35); + const { animate, stagger } = await loadMotion(); animate( messages, { opacity: [0, 1], y: [10, 0] }, @@ -397,6 +403,7 @@ async function handleDashboard(node: Element): Promise { try { const children = document.querySelectorAll(".dashboard > *"); if (children.length) { + const { animate, stagger } = await loadMotion(); animate( children, { opacity: [0, 1], y: [10, 0] }, @@ -422,6 +429,7 @@ async function handleDocuments(node: Element): Promise { try { const rows = document.querySelectorAll(".documents tbody tr.document"); if (rows.length) { + const { animate, stagger } = await loadMotion(); animate( rows, { opacity: [0, 1], y: [10, 0] }, @@ -445,6 +453,7 @@ async function handleReports(node: Element): Promise { try { const items = document.querySelectorAll(".reports .item"); if (items.length) { + const { animate, stagger } = await loadMotion(); animate( items, { opacity: [0, 1], y: [10, 0] }, @@ -647,8 +656,14 @@ export function showConflictPopup() { document.getElementById("container")?.append(background); + // CSS fade — avoid Motion on conflict overlay (not critical-path shell, but keeps Motion off this import path). if (settingsState.animations) { - animate([background as HTMLElement], { opacity: [0, 1] }); + const el = background as HTMLElement; + el.style.opacity = "0"; + el.style.transition = "opacity 200ms ease-in-out"; + requestAnimationFrame(() => { + el.style.opacity = "1"; + }); } background.addEventListener("click", (event) => { diff --git a/src/seqta/ui/sidebar/SidebarItem.svelte b/src/seqta/ui/sidebar/SidebarItem.svelte index 896c5ac8..d8beb056 100644 --- a/src/seqta/ui/sidebar/SidebarItem.svelte +++ b/src/seqta/ui/sidebar/SidebarItem.svelte @@ -48,7 +48,7 @@ tabindex={editMode ? -1 : 0} aria-label={item.label} aria-current={active ? "page" : undefined} - in:fly={{ x: drillEnter ? 24 : 0, duration: drillEnter ? 180 : 0 }} + in:fly={drillEnter ? { x: 24, duration: 180 } : undefined} onclick={(e) => { // Keep SEQTA's #menu handlers from seeing custom-list clicks — that fights // our drill UI and can freeze the tab (Goals / Folios / etc.). diff --git a/src/seqta/ui/sidebar/mountCustomSidebar.ts b/src/seqta/ui/sidebar/mountCustomSidebar.ts index ddfa8744..156e6e46 100644 --- a/src/seqta/ui/sidebar/mountCustomSidebar.ts +++ b/src/seqta/ui/sidebar/mountCustomSidebar.ts @@ -3,6 +3,7 @@ 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 { waitForSeqtaMenu } from "@/seqta/utils/waitForSeqtaShell"; import Sidebar from "./Sidebar.svelte"; import { getNativeMenuList } from "./parseNativeMenu"; import { @@ -28,30 +29,62 @@ let syncTimer: ReturnType | null = null; let hashListenerAttached = false; let sidebarCaptureAttached = false; let earlyPrepareStarted = false; -let catchupTimer: ReturnType | null = null; +let catchupTimers: ReturnType[] = []; let nativeMenuListenerAttached = false; +let lastMenuFingerprint = ""; const settingsListeners: Array<{ key: keyof SettingsState; listener: ChangeListener; }> = []; -function syncFromMenu() { - if (menuEl) sidebarState.syncFromNative(menuEl); +/** Stable fingerprint of native menu keys so unchanged DOM does not re-sync. */ +function menuFingerprint(menu: HTMLElement): string { + const list = getNativeMenuList(menu); + if (!list) return ""; + const keys: string[] = []; + for (const node of list.children) { + const el = node as HTMLElement; + if (el.id === ROOT_ID) continue; + const key = el.dataset.key ?? ""; + const path = el.dataset.path ?? ""; + const colour = el.dataset.colour ?? ""; + keys.push(`${key}|${path}|${colour}`); + } + return `${keys.length}:${keys.join(",")}`; } +function syncFromMenu(force = false) { + if (!menuEl) return; + const next = menuFingerprint(menuEl); + if (!force && next === lastMenuFingerprint) return; + lastMenuFingerprint = next; + sidebarState.syncFromNative(menuEl); +} + +/** + * Sparse catch-up after mount: plugins inject menu items shortly after first paint. + * MutationObserver + scheduleSync cover ongoing changes; this only covers a short window. + */ function startCatchupSync() { - if (catchupTimer) clearInterval(catchupTimer); - let attempts = 0; - catchupTimer = setInterval(() => { - attempts += 1; - syncFromMenu(); - // Plugins (Analytics, Overview, icons) inject shortly after first paint. - if (attempts >= 60) { - clearInterval(catchupTimer!); - catchupTimer = null; - } - }, 50); + for (const t of catchupTimers) clearTimeout(t); + catchupTimers = []; + // Immediate, then a few delayed passes (~1s total) instead of 50ms×60. + const delays = [0, 200, 500, 1000]; + for (const ms of delays) { + catchupTimers.push( + setTimeout(() => { + syncFromMenu(); + }, ms), + ); + } + if (typeof requestIdleCallback === "function") { + requestIdleCallback(() => syncFromMenu(), { timeout: 1500 }); + } +} + +function onNativeMenuUpdated() { + syncFromMenu(true); } function scheduleSync() { @@ -201,7 +234,12 @@ export async function mountCustomSidebar(): Promise { document.documentElement.classList.add(PENDING_CLASS); - const menu = (await waitForElm("#menu", true, 50, 200)) as HTMLElement | null; + let menu: HTMLElement | null = null; + try { + menu = (await waitForSeqtaMenu(50, 200)) as HTMLElement; + } catch { + return false; + } if (!menu) return false; // Prefer a populated native list, but don't block forever during loading. @@ -270,7 +308,7 @@ export async function mountCustomSidebar(): Promise { } if (!nativeMenuListenerAttached) { - window.addEventListener("bsplus-native-menu-updated", syncFromMenu); + window.addEventListener("bsplus-native-menu-updated", onNativeMenuUpdated); nativeMenuListenerAttached = true; } @@ -288,7 +326,7 @@ export async function mountCustomSidebar(): Promise { ] as const) { registerSetting(key, () => applySidebarLook(menuEl)); } - const resync = () => syncFromMenu(); + const resync = () => syncFromMenu(true); registerSetting("menuorder", resync); registerSetting("menuitems", resync); @@ -302,8 +340,9 @@ export function unmountCustomSidebar() { menuObserver = null; if (syncTimer) clearTimeout(syncTimer); syncTimer = null; - if (catchupTimer) clearInterval(catchupTimer); - catchupTimer = null; + for (const t of catchupTimers) clearTimeout(t); + catchupTimers = []; + lastMenuFingerprint = ""; clearSettingListeners(); @@ -318,7 +357,7 @@ export function unmountCustomSidebar() { } if (nativeMenuListenerAttached) { - window.removeEventListener("bsplus-native-menu-updated", syncFromMenu); + window.removeEventListener("bsplus-native-menu-updated", onNativeMenuUpdated); nativeMenuListenerAttached = false; } diff --git a/src/seqta/ui/titlebar/mountCustomTitleBar.ts b/src/seqta/ui/titlebar/mountCustomTitleBar.ts index 2ef3480e..060bc197 100644 --- a/src/seqta/ui/titlebar/mountCustomTitleBar.ts +++ b/src/seqta/ui/titlebar/mountCustomTitleBar.ts @@ -1,7 +1,7 @@ import { mount, unmount } from "svelte"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; -import { waitForElm } from "@/seqta/utils/waitForElm"; +import { waitForSeqtaTitle } from "@/seqta/utils/waitForSeqtaShell"; import TitleBar from "./TitleBar.svelte"; import { titleBarState } from "./titleBarState.svelte"; @@ -105,7 +105,7 @@ export async function mountCustomTitleBar(): Promise { let title: HTMLElement; try { - title = (await waitForElm("#title", true, 50, 200)) as HTMLElement; + title = (await waitForSeqtaTitle(50, 200)) as HTMLElement; } catch { return false; } diff --git a/src/seqta/utils/Loaders/LoadEngageHomePage.ts b/src/seqta/utils/Loaders/LoadEngageHomePage.ts index 1f91643a..db523db6 100644 --- a/src/seqta/utils/Loaders/LoadEngageHomePage.ts +++ b/src/seqta/utils/Loaders/LoadEngageHomePage.ts @@ -311,10 +311,10 @@ export async function loadEngageHomePage(): Promise { const engageHomeBody = stringToHTML(/* html */ `
-
-
+
+
-
+

Today's Lessons

@@ -331,7 +331,7 @@ export async function loadEngageHomePage(): Promise {
-
+

Notices

diff --git a/src/seqta/utils/Loaders/LoadHomePage.ts b/src/seqta/utils/Loaders/LoadHomePage.ts index 1c777b2b..f0de74d1 100644 --- a/src/seqta/utils/Loaders/LoadHomePage.ts +++ b/src/seqta/utils/Loaders/LoadHomePage.ts @@ -62,10 +62,10 @@ export async function loadHomePage() { const skeletonStructure = stringToHTML(/* html */ `
-
-
+
+
-
+

Today's Lessons

@@ -80,7 +80,7 @@ export async function loadHomePage() {
-
+

Upcoming Assessments

@@ -88,7 +88,7 @@ export async function loadHomePage() {
-
+

Notices

diff --git a/src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts b/src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts index 1a659b3f..221212be 100644 --- a/src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts +++ b/src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts @@ -24,20 +24,58 @@ export interface ThemeOfTheMonthEntry { updated_at: number; } +const TOTM_FETCH_TIMEOUT_MS = 800; +const TOTM_CACHE_KEY = "bsplus_theme_of_the_month_cache"; + +function isValidTotmEntry(data: unknown): data is ThemeOfTheMonthEntry { + return ( + !!data && + typeof data === "object" && + typeof (data as ThemeOfTheMonthEntry).id === "string" && + !!(data as ThemeOfTheMonthEntry).id + ); +} + +async function readCachedThemeOfTheMonth(): Promise { + try { + const stored = await browser.storage.local.get(TOTM_CACHE_KEY); + const cached = stored[TOTM_CACHE_KEY]; + return isValidTotmEntry(cached) ? cached : null; + } catch { + return null; + } +} + +async function writeCachedThemeOfTheMonth( + entry: ThemeOfTheMonthEntry, +): Promise { + try { + await browser.storage.local.set({ [TOTM_CACHE_KEY]: entry }); + } catch { + /* ignore cache write failures */ + } +} + +/** + * Fetches Theme of the Month with a hard timeout so a hung API cannot stall + * the startup popup queue. Falls back to last-good cached entry on timeout/error. + */ export async function fetchThemeOfTheMonth(): Promise { try { const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, { cache: "no-store", + signal: AbortSignal.timeout(TOTM_FETCH_TIMEOUT_MS), }); - if (!res.ok) return null; + if (!res.ok) return readCachedThemeOfTheMonth(); const text = await res.text(); - if (!text) return null; + if (!text) return readCachedThemeOfTheMonth(); const data = JSON.parse(text); - if (!data || typeof data !== "object" || !data.id) return null; - return data as ThemeOfTheMonthEntry; + if (!isValidTotmEntry(data)) return readCachedThemeOfTheMonth(); + void writeCachedThemeOfTheMonth(data); + return data; } catch (err) { console.warn("[ThemeOfTheMonth] Failed to fetch current entry:", err); - return null; + return readCachedThemeOfTheMonth(); } } diff --git a/src/seqta/utils/Openers/fetchThemeOfTheMonth.test.ts b/src/seqta/utils/Openers/fetchThemeOfTheMonth.test.ts new file mode 100644 index 00000000..b007fb15 --- /dev/null +++ b/src/seqta/utils/Openers/fetchThemeOfTheMonth.test.ts @@ -0,0 +1,52 @@ +import browser from "webextension-polyfill"; +import { fetchThemeOfTheMonth } from "./OpenThemeOfTheMonthPopup"; + +jest.mock("../DevApiBase", () => ({ + getApiBase: () => "https://example.test", +})); + +const CACHE_KEY = "bsplus_theme_of_the_month_cache"; + +const sampleEntry = { + id: "totm-1", + month: "2026-07", + title: "July Theme", + description: "desc", + cover_image: null, + theme_id: null, + theme: null, + created_at: 1, + updated_at: 1, +}; + +describe("fetchThemeOfTheMonth", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + jest.clearAllMocks(); + }); + + it("caches a successful response", async () => { + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: async () => JSON.stringify(sampleEntry), + }) as typeof fetch; + + const entry = await fetchThemeOfTheMonth(); + expect(entry?.id).toBe("totm-1"); + expect(browser.storage.local.set).toHaveBeenCalledWith({ + [CACHE_KEY]: sampleEntry, + }); + }); + + it("falls back to cache when fetch fails or times out", async () => { + await browser.storage.local.set({ [CACHE_KEY]: sampleEntry }); + globalThis.fetch = jest + .fn() + .mockRejectedValue(new DOMException("Aborted", "AbortError")); + + const entry = await fetchThemeOfTheMonth(); + expect(entry?.id).toBe("totm-1"); + }); +}); diff --git a/src/seqta/utils/SendNewsPage.ts b/src/seqta/utils/SendNewsPage.ts index 60cf8b5b..07f762a1 100644 --- a/src/seqta/utils/SendNewsPage.ts +++ b/src/seqta/utils/SendNewsPage.ts @@ -42,7 +42,7 @@ export async function SendNewsPage() { const html = stringToHTML(/* html */ `
-

Latest Headlines in ${displayCountry}

+

Latest Headlines in ${displayCountry}

`); diff --git a/src/seqta/utils/waitForSeqtaShell.test.ts b/src/seqta/utils/waitForSeqtaShell.test.ts new file mode 100644 index 00000000..27050c25 --- /dev/null +++ b/src/seqta/utils/waitForSeqtaShell.test.ts @@ -0,0 +1,48 @@ +/** + * @jest-environment jsdom + */ +import { waitForElm } from "@/seqta/utils/waitForElm"; +import { + resetSeqtaShellWaiters, + waitForSeqtaMenu, + waitForSeqtaTitle, +} from "./waitForSeqtaShell"; + +jest.mock("@/seqta/utils/waitForElm", () => ({ + waitForElm: jest.fn(), +})); + +describe("waitForSeqtaShell", () => { + beforeEach(() => { + resetSeqtaShellWaiters(); + document.body.innerHTML = ""; + jest.mocked(waitForElm).mockReset(); + }); + + it("returns existing #title without calling waitForElm", async () => { + const el = document.createElement("div"); + el.id = "title"; + document.body.append(el); + + await expect(waitForSeqtaTitle()).resolves.toBe(el); + expect(waitForElm).not.toHaveBeenCalled(); + }); + + it("shares one waitForElm promise for concurrent #menu waiters", async () => { + let resolveWait!: (el: Element) => void; + const pending = new Promise((resolve) => { + resolveWait = resolve; + }); + jest.mocked(waitForElm).mockReturnValue(pending); + + const a = waitForSeqtaMenu(); + const b = waitForSeqtaMenu(); + expect(waitForElm).toHaveBeenCalledTimes(1); + + const menu = document.createElement("div"); + menu.id = "menu"; + resolveWait(menu); + + await expect(Promise.all([a, b])).resolves.toEqual([menu, menu]); + }); +}); diff --git a/src/seqta/utils/waitForSeqtaShell.ts b/src/seqta/utils/waitForSeqtaShell.ts new file mode 100644 index 00000000..d8b5fe5f --- /dev/null +++ b/src/seqta/utils/waitForSeqtaShell.ts @@ -0,0 +1,55 @@ +import { waitForElm } from "@/seqta/utils/waitForElm"; + +/** + * Shared readiness promises for SEQTA chrome elements that titlebar, search, + * and sidebar all wait on. Avoids overlapping waitForElm observers/polls. + */ + +type ShellKey = "title" | "menu"; + +const inflight = new Map>(); + +function sharedWait( + key: ShellKey, + selector: string, + interval: number, + maxIterations: number, +): Promise { + const existing = inflight.get(key); + if (existing) return existing; + + const promise = waitForElm(selector, true, interval, maxIterations).then( + (el) => el, + (err) => { + inflight.delete(key); + throw err; + }, + ); + inflight.set(key, promise); + return promise; +} + +/** Resolves once `#title` exists (shared across titlebar + global search). */ +export function waitForSeqtaTitle( + interval = 50, + maxIterations = 200, +): Promise { + const immediate = document.querySelector("#title"); + if (immediate) return Promise.resolve(immediate); + return sharedWait("title", "#title", interval, maxIterations); +} + +/** Resolves once `#menu` exists (shared across sidebar + background layers). */ +export function waitForSeqtaMenu( + interval = 50, + maxIterations = 200, +): Promise { + const immediate = document.querySelector("#menu"); + if (immediate) return Promise.resolve(immediate); + return sharedWait("menu", "#menu", interval, maxIterations); +} + +/** Test/helpers: clear shared caches between mounts. */ +export function resetSeqtaShellWaiters(): void { + inflight.clear(); +}