feat: final bug fixes

This commit is contained in:
2026-07-26 16:31:06 +09:30
parent 318000009f
commit d5882e2d66
20 changed files with 534 additions and 114 deletions
+28 -9
View File
@@ -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` — Tailwinds 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;
+48
View File
@@ -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;
}
+1 -1
View File
@@ -23,7 +23,7 @@ html.transparencyEffects {
/* Blurs */
.search,
.document,
.border,
.bsplus-rounded,
.draggable,
.notice,
[class*="BasicPanel__BasicPanel___"],
@@ -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,
@@ -14,11 +14,15 @@
let outputUnit = $state<string>('');
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;
@@ -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(() => {
@@ -114,7 +114,7 @@
});
onMount(() => {
const itemsUpdatedHandler = (event: Event) => {
const applyItemsUpdate = (event: Event) => {
const detail = (event as CustomEvent<DynamicItemsUpdatedDetail>).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);
};
});
@@ -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);
},
@@ -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();
});
});
@@ -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<string, () => 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<string, () => 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<CalculatorMath> | null = null;
async function getCalculatorMath(): Promise<CalculatorMath> {
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<string, () => 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 {
/* ignore */
}
} catch {}
}
return null;
}
export function calculateExpression(input: string): CalculatorResult {
export async function calculateExpression(input: string): Promise<CalculatorResult> {
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: '' };
}
+17 -2
View File
@@ -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<void> {
// 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<void> {
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<void> {
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<void> {
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<void> {
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) => {
+1 -1
View File
@@ -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.).
+57 -18
View File
@@ -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<typeof setTimeout> | null = null;
let hashListenerAttached = false;
let sidebarCaptureAttached = false;
let earlyPrepareStarted = false;
let catchupTimer: ReturnType<typeof setInterval> | null = null;
let catchupTimers: ReturnType<typeof setTimeout>[] = [];
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;
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();
// Plugins (Analytics, Overview, icons) inject shortly after first paint.
if (attempts >= 60) {
clearInterval(catchupTimer!);
catchupTimer = null;
}, ms),
);
}
}, 50);
if (typeof requestIdleCallback === "function") {
requestIdleCallback(() => syncFromMenu(), { timeout: 1500 });
}
}
function onNativeMenuUpdated() {
syncFromMenu(true);
}
function scheduleSync() {
@@ -201,7 +234,12 @@ export async function mountCustomSidebar(): Promise<boolean> {
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<boolean> {
}
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<boolean> {
] 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;
}
+2 -2
View File
@@ -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<boolean> {
let title: HTMLElement;
try {
title = (await waitForElm("#title", true, 50, 200)) as HTMLElement;
title = (await waitForSeqtaTitle(50, 200)) as HTMLElement;
} catch {
return false;
}
@@ -311,10 +311,10 @@ 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 class="bsplus-rounded shortcut-container">
<div class="bsplus-rounded shortcuts" id="shortcuts"></div>
</div>
<div class="border timetable-container">
<div class="bsplus-rounded timetable-container">
<div class="home-subtitle">
<div class="engage-timetable-title-cluster">
<h2 id="engage-home-lesson-subtitle">Today's Lessons</h2>
@@ -331,7 +331,7 @@ export async function loadEngageHomePage(): Promise<void> {
</div>
<div class="day-container loading" id="engage-day-container"></div>
</div>
<div class="border notices-container">
<div class="bsplus-rounded notices-container">
<div style="display: flex; justify-content: space-between">
<h2 class="home-subtitle">Notices</h2>
<input type="date" id="engage-notices-date" />
+5 -5
View File
@@ -62,10 +62,10 @@ export async function loadHomePage() {
const skeletonStructure = stringToHTML(/* html */ `
<div class="home-container" id="home-container">
<div class="border shortcut-container">
<div class="border shortcuts" id="shortcuts"></div>
<div class="bsplus-rounded shortcut-container">
<div class="bsplus-rounded shortcuts" id="shortcuts"></div>
</div>
<div class="border timetable-container">
<div class="bsplus-rounded timetable-container">
<div class="home-subtitle">
<h2 id="home-lesson-subtitle">Today's Lessons</h2>
<div class="timetable-arrows">
@@ -80,7 +80,7 @@ export async function loadHomePage() {
<div class="day-container loading" id="day-container">
</div>
</div>
<div class="border upcoming-container">
<div class="bsplus-rounded upcoming-container">
<div class="upcoming-title">
<h2 class="home-subtitle">Upcoming Assessments</h2>
<div class="upcoming-filters" id="upcoming-filters"></div>
@@ -88,7 +88,7 @@ export async function loadHomePage() {
<div class="upcoming-items loading" id="upcoming-items">
</div>
</div>
<div class="border notices-container">
<div class="bsplus-rounded notices-container">
<div style="display: flex; justify-content: space-between">
<h2 class="home-subtitle">Notices</h2>
<input type="date" />
@@ -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<ThemeOfTheMonthEntry | null> {
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<void> {
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<ThemeOfTheMonthEntry | null> {
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();
}
}
@@ -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");
});
});
+1 -1
View File
@@ -42,7 +42,7 @@ export async function SendNewsPage() {
const html = stringToHTML(/* html */ `
<div class="home-root">
<div class="home-container" id="news-container">
<h1 class="border">Latest Headlines in ${displayCountry}</h1>
<h1 class="bsplus-rounded">Latest Headlines in ${displayCountry}</h1>
</div>
</div>`);
+48
View File
@@ -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<Element>((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]);
});
});
+55
View File
@@ -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<ShellKey, Promise<Element>>();
function sharedWait(
key: ShellKey,
selector: string,
interval: number,
maxIterations: number,
): Promise<Element> {
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<Element> {
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<Element> {
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();
}