diff --git a/lib/stabilizeCrxDevHmr.ts b/lib/stabilizeCrxDevHmr.ts new file mode 100644 index 00000000..c8a88fac --- /dev/null +++ b/lib/stabilizeCrxDevHmr.ts @@ -0,0 +1,53 @@ +import type { Plugin, ViteDevServer } from "vite"; + +/** + * CRXJS + Vite 6 often corrupt content-script ESM bindings after HMR / + * `[crx] runtime reload` — modules load but named/`default` exports are missing + * until the dev server is restarted. + * + * Prefer invalidating the module graph + a full page reload over partial HMR. + */ +export default function stabilizeCrxDevHmr(): Plugin { + let reloadTimer: ReturnType | null = null; + + const scheduleFullReload = (s: ViteDevServer) => { + if (reloadTimer) clearTimeout(reloadTimer); + // Debounce cascading invalidations (e.g. many files in one save). + reloadTimer = setTimeout(() => { + reloadTimer = null; + s.ws.send({ type: "full-reload", path: "*" }); + }, 50); + }; + + return { + name: "stabilize-crx-dev-hmr", + apply: "serve", + enforce: "pre", + configureServer(s) { + s.ws.on("bsplus:reset-module-graph", () => { + s.moduleGraph.invalidateAll(); + scheduleFullReload(s); + }); + }, + handleHotUpdate({ file, modules, server: viteServer }) { + if (!file.replace(/\\/g, "/").includes("/src/")) return; + if (file.includes("node_modules")) return; + + const seen = new Set(modules); + const queue = [...modules]; + while (queue.length) { + const mod = queue.pop()!; + viteServer.moduleGraph.invalidateModule(mod); + for (const importer of mod.importers) { + if (seen.has(importer)) continue; + seen.add(importer); + queue.push(importer); + } + } + + scheduleFullReload(viteServer); + // Skip Vite's partial HMR for these modules — it is what leaves exports empty. + return []; + }, + }; +} diff --git a/lib/touchGlobalCSS.ts b/lib/touchGlobalCSS.ts index 0c6fc281..0065a3f0 100644 --- a/lib/touchGlobalCSS.ts +++ b/lib/touchGlobalCSS.ts @@ -1,55 +1,15 @@ -import fs from "fs"; +import type { Plugin } from "vite"; /** - * Creates a Vite plugin designed to improve the reliability of Hot Module Replacement (HMR) - * for global CSS files. + * Previously touched CSS mtimes on JS HMR to force style refresh. + * That raced with CRXJS runtime reload and corrupted Vite's module graph + * (missing named/`default` exports until `npm run dev` was restarted). * - * When a JavaScript/TypeScript module that imports a CSS file is updated, Vite's HMR - * might not always reliably update the styles injected by that global CSS. This plugin - * attempts to mitigate this by listening for hot updates. If an updated module - * has direct importers that are CSS files (e.g., a JS file imports a global CSS file), - * this plugin will "touch" those CSS files by updating their access and modification - * timestamps using `fs.utimesSync`. This action can help signal to Vite or the browser - * that the CSS file has changed, potentially triggering a more reliable style reload. - * - * @returns {import('vite').Plugin} A Vite plugin object configured with `name` and `handleHotUpdate` hooks. + * Style updates are now covered by `stabilizeCrxDevHmr` full reloads. */ -export default function touchGlobalCSSPlugin() { +export default function touchGlobalCSSPlugin(): Plugin { return { - /** - * The unique name of this Vite plugin. - * This name is used by Vite for identification purposes and will appear in logs. - * @type {string} - */ name: "touch-global-css", - /** - * A Vite hook that is called when a module is hot-updated. - * This function inspects the importers of the updated module. If any of these - * importers are CSS files, their filesystem timestamps are updated ("touched"). - * - * @param {object} context The context object provided by Vite's `handleHotUpdate` hook. - * @param {Array} context.modules An array of `ModuleNode` instances that have been updated. - * This plugin specifically accesses `modules[0]._clientModule.importers` - * to find CSS files that import the updated module. - */ - handleHotUpdate({ modules }) { - // It's assumed `modules[0]` is the primary updated module of interest. - // `_clientModule` and `importers` might be internal or less stable Vite APIs. - const importers = modules[0]?._clientModule?.importers; - if (importers) { - importers.forEach((importer) => { - // Check if the importer is a CSS file - if (importer.file && importer.file.includes(".css")) { - console.log("[touch-global-css] touching", importer.file); - try { - // Update the access and modification times of the CSS file to the current time - fs.utimesSync(importer.file, new Date(), new Date()); - } catch (err) { - console.error(`[touch-global-css] Error touching file ${importer.file}:`, err); - } - } - }); - } - }, + apply: "serve", }; } diff --git a/src/SEQTA.ts b/src/SEQTA.ts index a7242c05..af42696b 100644 --- a/src/SEQTA.ts +++ b/src/SEQTA.ts @@ -53,6 +53,12 @@ if (document.childNodes[1]) { init(); } +if (import.meta.env.DEV) { + window.addEventListener("unhandledrejection", (event) => { + recoverFromStaleDevModuleGraph(event.reason); + }); +} + async function init() { if ( hasSEQTAText && @@ -118,11 +124,16 @@ async function init() { initializeHideSensitiveToggle(); } + if (import.meta.env.DEV) { + sessionStorage.removeItem("bsplus-dev-export-recovery"); + } + verboseInfo( "[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.", ); } catch (error) { console.error(error); + recoverFromStaleDevModuleGraph(error); } } } @@ -135,4 +146,27 @@ function replaceIcons() { link.href = icon48; } }); +} + +/** Vite/CRX HMR can leave modules with missing named exports until the graph is cleared. */ +function recoverFromStaleDevModuleGraph(error: unknown) { + if (!import.meta.env.DEV) return; + + const message = error instanceof Error ? error.message : String(error); + if (!/does not provide an export named/.test(message)) return; + + const key = "bsplus-dev-export-recovery"; + if (!sessionStorage.getItem(key)) { + sessionStorage.setItem(key, "1"); + import.meta.hot?.send("bsplus:reset-module-graph"); + setTimeout(() => { + location.reload(); + }, 80); + return; + } + + sessionStorage.removeItem(key); + console.error( + "[BetterSEQTA+] Dev module graph is still stale after recovery. Restart `npm run dev`, then reload this page.", + ); } \ No newline at end of file diff --git a/src/css/injected.scss b/src/css/injected.scss index 71195bed..f36d058c 100644 --- a/src/css/injected.scss +++ b/src/css/injected.scss @@ -13,6 +13,7 @@ } @include meta.load-css("injected/sidebar-animation.scss"); +@include meta.load-css("injected/sidebar-styles.scss"); @include meta.load-css("injected/theme.scss"); @include meta.load-css("injected/transparency.scss"); @@ -565,21 +566,26 @@ ul.magicDelete > li.deleting { visibility: visible !important; } +/* Edit rows should match normal custom-sidebar item size (not shrunk). */ +#menu.bsplus-sidebar-edit-mode > #bsplus-sidebar-root > li.item.draggable, #menu.bsplus-sidebar-edit-mode .item.draggable { display: flex !important; - align-items: center; - gap: 0.5rem; - width: calc(100% - 12px) !important; + align-items: center !important; + width: auto !important; max-width: none !important; box-sizing: border-box !important; padding: 0 !important; + margin: 0 6px 8px !important; } #menu.bsplus-sidebar-edit-mode .item.draggable > label:not(.toggle) { - flex: 1 1 0 !important; - width: 0 !important; + flex: 1 1 auto !important; + width: auto !important; min-width: 0 !important; max-width: none !important; + padding: 12px !important; + padding-left: 4px !important; + box-sizing: border-box !important; } #menu.bsplus-sidebar-edit-mode .item.draggable .label { @@ -587,6 +593,14 @@ ul.magicDelete > li.deleting { overflow: hidden !important; text-overflow: ellipsis !important; overflow-wrap: normal !important; + font-size: inherit !important; + line-height: 1.2 !important; +} + +#menu.bsplus-sidebar-edit-mode .item.draggable > label:not(.toggle) > svg { + width: 24px !important; + height: 24px !important; + flex-shrink: 0 !important; } #menu.bsplus-sidebar-edit-mode .item.draggable > .toggle, @@ -594,7 +608,7 @@ ul.magicDelete > li.deleting { pointer-events: auto !important; flex: 0 0 auto !important; width: auto !important; - margin: 0 10px 0 0 !important; + margin: 0 12px 0 0 !important; position: relative; z-index: 2; } @@ -631,7 +645,7 @@ ul.magicDelete > li.deleting { } #menu { - width: 270px; + width: var(--bsplus-sidebar-width, 270px); z-index: 19; background: var(--better-main) !important; color: var(--text-color); @@ -907,7 +921,9 @@ body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) { margin-bottom: 8px !important; width: 85% !important; } -.item.draggable { +/* Keep width scoped to #menu — fallback drag clones append to body, and + width:100% there stretches across the viewport. */ +#menu .item.draggable { width: 100% !important; cursor: grab; @@ -921,6 +937,19 @@ body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) { } } +/* Sortable forceFallback clone (on body) — match source row size. */ +body > .bsplus-sortable-drag, +body > .sortable-fallback.bsplus-sortable-drag, +.bsplus-sortable-drag.sortable-fallback { + width: var(--bsplus-drag-width, 240px) !important; + max-width: var(--bsplus-drag-width, 240px) !important; + min-width: 0 !important; + box-sizing: border-box !important; + margin: 0 !important; + pointer-events: none !important; + list-style: none !important; +} + #menu { li.active > .sub > ul > .item:not(.hasChildren) { position: relative; @@ -1340,7 +1369,7 @@ html.transparencyEffects #content { transition: left 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.4s ease; - left: 270px; + left: var(--bsplus-sidebar-width, 270px); background: unset; } @@ -1368,8 +1397,8 @@ html.transparencyEffects display: none; } #menu { - -webkit-transform: translatex(-270px); - transform: translatex(-270px); + -webkit-transform: translatex(calc(-1 * var(--bsplus-sidebar-width, 270px))); + transform: translatex(calc(-1 * var(--bsplus-sidebar-width, 270px))); } .menuShown #menu { -webkit-transform: translatex(0); @@ -1379,8 +1408,8 @@ html.transparencyEffects left: 0; } .menuShown #content { - -webkit-transform: translatex(270px); - transform: translatex(270px); + -webkit-transform: translatex(var(--bsplus-sidebar-width, 270px)); + transform: translatex(var(--bsplus-sidebar-width, 270px)); } body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) { diff --git a/src/css/injected/sidebar-styles.scss b/src/css/injected/sidebar-styles.scss new file mode 100644 index 00000000..b43d03cb --- /dev/null +++ b/src/css/injected/sidebar-styles.scss @@ -0,0 +1,220 @@ +/* Custom sidebar style presets + Look (density / radius / indicator). */ + +$sidebar-motion: 0.4s cubic-bezier(0.4, 0, 0.2, 1); + +@mixin accent-bar($shadow: 0.55) { + content: "" !important; + position: absolute !important; + left: 0 !important; + top: 10px !important; + bottom: 10px !important; + width: 4px !important; + border-radius: 2px !important; + background: #fff !important; + box-shadow: 0 0 10px rgba(255, 255, 255, $shadow) !important; + pointer-events: none !important; + z-index: 3 !important; + opacity: 1 !important; + display: block !important; +} + +#menu.bsplus-custom-sidebar { + $item: "> #bsplus-sidebar-root > li.item:not(.bsplus-sidebar-edit-header):not(.bsplus-sidebar-edit-actions)"; + $label: "> #bsplus-sidebar-root > li.item > label:not(.toggle)"; + $active: "> #bsplus-sidebar-root > li.item.active:not(.hasChildren)"; + + /* Look: base radius (pill / sharp / strip override). + Density/radius animate with the same easing as sidebar width. */ + #{$item} { + border-radius: var(--bsplus-sidebar-radius, 12px) !important; + transition: + padding $sidebar-motion, + margin $sidebar-motion, + border-radius $sidebar-motion, + background-color 0.2s ease, + box-shadow 0.2s ease, + opacity 0.2s ease; + } + + #{$label} { + transition: + font-size $sidebar-motion, + line-height $sidebar-motion, + padding $sidebar-motion, + margin $sidebar-motion; + + > svg { + transition: + width $sidebar-motion, + height $sidebar-motion, + margin $sidebar-motion; + } + } + + /* —— Style presets —— */ + &.bsplus-sidebar-style-soft { + #{$item} { + margin: 0 8px 10px !important; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12); + } + #{$active} { + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.22); + background: rgba(0, 0, 0, 0.38) !important; + } + } + + &.bsplus-sidebar-style-pill { + #{$item} { + margin: 0 10px 8px !important; + border-radius: 999px !important; + } + #{$active} { + background: rgba(0, 0, 0, 0.4) !important; + } + } + + &.bsplus-sidebar-style-glass { + #{$item} { + margin: 0 8px 8px !important; + background: rgba(255, 255, 255, 0.1) !important; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.14); + } + #{$active} { + background: rgba(255, 255, 255, 0.2) !important; + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.28), + 0 8px 20px rgba(0, 0, 0, 0.2); + } + } + + &.bsplus-sidebar-style-sharp { + #{$item}, + #{$active} { + border-radius: 4px !important; + } + #{$item} { + margin: 0 4px 4px !important; + } + #{$active} { + background: rgba(0, 0, 0, 0.42) !important; + } + } + + &.bsplus-sidebar-style-strip { + #{$item} { + margin: 0 0 2px !important; + border-radius: 0 !important; + background: transparent !important; + box-shadow: none !important; + } + #{$active} { + position: relative !important; + background: rgba(255, 255, 255, 0.12) !important; + box-shadow: none !important; + &::after { + @include accent-bar(0.55); + } + } + } + + &.bsplus-sidebar-style-neon #{$active} { + background: rgba(255, 255, 255, 0.1) !important; + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.55), + 0 0 14px rgba(255, 255, 255, 0.4), + 0 0 28px rgba(255, 255, 255, 0.22) !important; + } + + &.bsplus-sidebar-style-neon #{$item} { + margin: 0 8px 8px !important; + } + + /* —— Density (comfortable = no class / no overrides) —— */ + &.bsplus-sidebar-density-compact { + #{$item}, + #{$active} { + padding: 6px 8px !important; + min-height: 0 !important; + } + #{$item} { + margin: 0 6px 4px !important; + height: auto !important; + } + #{$label} { + padding: 0 !important; + margin: 0 !important; + font-size: 13px !important; + line-height: 1.2 !important; + min-height: 0 !important; + height: auto !important; + white-space: nowrap !important; + + > svg { + width: 20px !important; + height: 20px !important; + margin: 0 8px 0 2px !important; + flex-shrink: 0 !important; + } + } + } + + &.bsplus-sidebar-density-large { + #{$item} { + margin: 0 8px 10px !important; + padding: 14px 12px !important; + } + #{$label} { + padding: 0 !important; + font-size: 17px !important; + line-height: 1.25 !important; + + > svg { + width: 30px !important; + height: 30px !important; + margin: 0 10px 0 4px !important; + } + } + } + + /* —— Active indicator (fill = default, no class) —— */ + &.bsplus-sidebar-indicator-bar:not(.bsplus-sidebar-style-strip) #{$active} { + position: relative !important; + background: rgba(255, 255, 255, 0.1) !important; + box-shadow: none !important; + &::after { + @include accent-bar(0.45); + } + } + + &.bsplus-sidebar-indicator-outline #{$active} { + background: transparent !important; + box-shadow: + inset 0 0 0 2px rgba(255, 255, 255, 0.55), + 0 0 0 1px rgba(255, 255, 255, 0.12) !important; + } + + &.bsplus-sidebar-indicator-underline:not(.bsplus-sidebar-style-strip) #{$active} { + position: relative !important; + background: transparent !important; + box-shadow: none !important; + &::after { + content: "" !important; + position: absolute !important; + left: 14px !important; + right: 14px !important; + bottom: 6px !important; + top: auto !important; + width: auto !important; + height: 3px !important; + border-radius: 999px !important; + background: #fff !important; + box-shadow: 0 0 8px rgba(255, 255, 255, 0.4) !important; + pointer-events: none !important; + z-index: 3 !important; + opacity: 1 !important; + display: block !important; + } + } +} diff --git a/src/css/injected/transparency.scss b/src/css/injected/transparency.scss index 8b10cfb4..d2dd8fed 100644 --- a/src/css/injected/transparency.scss +++ b/src/css/injected/transparency.scss @@ -51,7 +51,10 @@ html.transparencyEffects { backdrop-filter: blur(10px) !important; } - #menu, + #menu { + backdrop-filter: blur(var(--bsplus-sidebar-blur, 50px)); + } + .kanban-column, .whatsnewContainer, [class*="Message__Message___"] { diff --git a/src/interface/components/SidebarAppearance.svelte b/src/interface/components/SidebarAppearance.svelte new file mode 100644 index 00000000..6333f853 --- /dev/null +++ b/src/interface/components/SidebarAppearance.svelte @@ -0,0 +1,577 @@ + + +
+
+
+

Sidebar Style

+

Choose how the navigation menu looks

+
+
+ {selected.label} + {selected.description} +
+
+ +
+ + +
+ {#each SIDEBAR_STYLES as style (style.id)} + {@const active = style.id === selectedId} + + {/each} +
+
+
+ +
+
+

Sidebar Look

+

Density, size, and active-state details

+
+ +
+
+
+

Item Size

+

Spacing and type size for menu rows

+
+
+ (settingsState.sidebarActiveIndicator = value)} + options={[ + { value: "fill", label: "Fill" }, + { value: "bar", label: "Left bar" }, + { value: "outline", label: "Outline" }, + { value: "underline", label: "Underline" }, + ]} + /> +
+
+ +
+
+

Sidebar Width

+

Overall navigation column width

+
+
+
-

Maximum Assessments per Subject

-

Assessments shown for each included subject

+

Maximum Assessments per Subject

+

Assessments shown for each included subject

diff --git a/src/seqta/ui/sidebar/Sidebar.svelte b/src/seqta/ui/sidebar/Sidebar.svelte index b39ef99c..c02438e1 100644 --- a/src/seqta/ui/sidebar/Sidebar.svelte +++ b/src/seqta/ui/sidebar/Sidebar.svelte @@ -1,4 +1,6 @@ - {#each sidebarState.items as item (item.key)} + {#each sidebarState.editRootItems as item (item.key)} {/each}
  • -