This commit is contained in:
2026-06-01 19:53:58 +09:30
11 changed files with 1266 additions and 81 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "betterseqtaplus", "name": "betterseqtaplus",
"version": "3.6.5", "version": "3.6.6",
"type": "module", "type": "module",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!", "description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
"browserslist": "> 0.5%, last 2 versions, not dead", "browserslist": "> 0.5%, last 2 versions, not dead",
+72
View File
@@ -551,6 +551,76 @@ async function migrateGlobalSearchDefaultsFor365Upgrade(
} }
} }
/** One-time reset for 3.6.6: re-enable Theme of the Month for existing users. */
const THEME_OF_THE_MONTH_RESET_VERSION = "3.6.6";
async function resetThemeOfTheMonthDisabledFor366Upgrade(
previousVersion: string,
): Promise<void> {
try {
const currRaw = browser.runtime.getManifest().version;
const prev = semver.coerce(previousVersion);
const curr = semver.coerce(currRaw);
if (
prev == null ||
curr == null ||
semver.lt(curr, THEME_OF_THE_MONTH_RESET_VERSION) ||
!semver.lt(prev, THEME_OF_THE_MONTH_RESET_VERSION)
) {
return;
}
await browser.storage.local.set({
themeOfTheMonthDisabled: false,
themeOfTheMonthLastSeenId: undefined,
});
console.info(
`[BetterSEQTA+] Migration ${THEME_OF_THE_MONTH_RESET_VERSION}: Theme of the Month re-enabled (from ${previousVersion}).`,
);
} catch (e) {
console.warn(
"[BetterSEQTA+] Theme of the Month 3.6.6 reset migration failed:",
e,
);
}
}
/** 3.7.0: Close no longer marks entries seen — clear legacy dismissal keys. */
const THEME_OF_THE_MONTH_RELOAD_VERSION = "3.7.0";
async function resetThemeOfTheMonthDismissalFor370Upgrade(
previousVersion: string,
): Promise<void> {
try {
const currRaw = browser.runtime.getManifest().version;
const prev = semver.coerce(previousVersion);
const curr = semver.coerce(currRaw);
if (
prev == null ||
curr == null ||
semver.lt(curr, THEME_OF_THE_MONTH_RELOAD_VERSION) ||
!semver.lt(prev, THEME_OF_THE_MONTH_RELOAD_VERSION)
) {
return;
}
await browser.storage.local.set({
themeOfTheMonthLastSeenId: undefined,
themeOfTheMonthDismissedMonth: undefined,
});
console.info(
`[BetterSEQTA+] Migration ${THEME_OF_THE_MONTH_RELOAD_VERSION}: Theme of the Month shows again until dismissed for the month (from ${previousVersion}).`,
);
} catch (e) {
console.warn(
"[BetterSEQTA+] Theme of the Month 3.7.0 dismissal migration failed:",
e,
);
}
}
browser.runtime.onInstalled.addListener(function (event) { browser.runtime.onInstalled.addListener(function (event) {
browser.storage.local.remove(["justupdated"]); browser.storage.local.remove(["justupdated"]);
browser.storage.local.remove(["data"]); browser.storage.local.remove(["data"]);
@@ -561,6 +631,8 @@ browser.runtime.onInstalled.addListener(function (event) {
if (event.reason === "update" && event.previousVersion) { if (event.reason === "update" && event.previousVersion) {
void migrateGlobalSearchDefaultsFor365Upgrade(event.previousVersion); void migrateGlobalSearchDefaultsFor365Upgrade(event.previousVersion);
void resetThemeOfTheMonthDisabledFor366Upgrade(event.previousVersion);
void resetThemeOfTheMonthDismissalFor370Upgrade(event.previousVersion);
} }
}); });
+185 -23
View File
@@ -455,6 +455,58 @@ ul.magicDelete > li.deleting {
top: 71.5px; top: 71.5px;
margin-top: -2px; margin-top: -2px;
} }
/* Drill-in stack: only the current list + folder header stay clickable.
Class is toggled by updateSidebarAccessibility (never touches aria-hidden). */
#menu .bsplus-sidebar-offscreen,
#menu .bsplus-sidebar-offscreen * {
pointer-events: none !important;
user-select: none !important;
}
#menu > ul > .bsplus-sidebar-offscreen:not(.hasChildren.active) {
position: absolute !important;
left: -10000px !important;
width: 1px !important;
height: 1px !important;
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
opacity: 0 !important;
}
#menu .sub .bsplus-sidebar-offscreen:not(.hasChildren.active) {
visibility: hidden !important;
position: absolute !important;
left: -10000px !important;
width: 1px !important;
height: 1px !important;
margin: 0 !important;
padding: 0 !important;
opacity: 0 !important;
}
/* Only the frontmost open .sub panel receives pointer events */
#menu .sub {
pointer-events: none;
}
#menu li.hasChildren.active > .sub {
pointer-events: auto;
}
#menu li.hasChildren.active > .sub:has(.hasChildren.active) {
pointer-events: none !important;
}
#menu li.hasChildren.active .hasChildren.active > .sub {
pointer-events: auto !important;
}
#menu:has(> ul > li.hasChildren.active) > ul > li:not(.hasChildren.active) {
pointer-events: none !important;
}
#menu section > label { #menu section > label {
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
@@ -2326,6 +2378,10 @@ blurred {
height: 64px; height: 64px;
cursor: pointer; cursor: pointer;
} }
/* While a drill-in submenu is open, don't steal clicks meant for folder rows. */
#menu:has(li.hasChildren.active) > .icon-cover {
pointer-events: none;
}
.uiSlidePane > .pane > .header button { .uiSlidePane > .pane > .header button {
color: var(--text-color) !important; color: var(--text-color) !important;
} }
@@ -3753,20 +3809,77 @@ div.day-empty {
pointer-events: none; pointer-events: none;
animation: themeOfTheMonthCardOut 0.18s ease-in forwards; animation: themeOfTheMonthCardOut 0.18s ease-in forwards;
} }
.themeOfTheMonthCardClose { .themeOfTheMonthCardConfirm {
position: absolute !important; position: absolute;
top: 4px !important; inset: 0;
right: 4px !important; z-index: 4;
z-index: 2; display: flex;
width: 32px; align-items: center;
height: 32px; justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.22); padding: 16px;
border-radius: 16px !important; border-radius: inherit;
background: rgba(0, 0, 0, 0.42); background: color-mix(in srgb, var(--background-primary) 88%, transparent);
color: white; backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
opacity: 0;
pointer-events: none;
transition: opacity 0.16s ease;
}
.themeOfTheMonthCardConfirm[hidden] {
display: none;
}
.themeOfTheMonthCardConfirmVisible {
opacity: 1;
pointer-events: auto;
}
.themeOfTheMonthCardConfirmInner {
width: 100%;
text-align: center;
}
.themeOfTheMonthCardConfirmInner h3 {
margin: 0 0 6px;
font-size: 1rem;
line-height: 1.2;
}
.themeOfTheMonthCardConfirmInner p {
margin: 0 0 14px;
font-size: 0.86rem;
line-height: 1.4;
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
}
.themeOfTheMonthCardConfirmActions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.themeOfTheMonthCardConfirmCancel,
.themeOfTheMonthCardConfirmAccept {
appearance: none;
border: none;
cursor: pointer; cursor: pointer;
font-size: 1.35rem; border-radius: 9999px;
line-height: 1; padding: 0.5rem 0.85rem;
font-size: 0.84rem;
font-weight: 700;
transition: transform 0.15s ease, filter 0.15s ease, background 0.15s ease;
}
.themeOfTheMonthCardConfirmCancel {
background: color-mix(in srgb, var(--text-primary) 10%, transparent);
color: var(--text-primary);
}
.themeOfTheMonthCardConfirmAccept {
background: var(--better-pri, #6366f1);
color: white;
}
.themeOfTheMonthCardConfirmCancel:hover,
.themeOfTheMonthCardConfirmAccept:hover {
filter: brightness(1.08);
transform: translateY(-1px);
}
.themeOfTheMonthCardConfirmCancel:active,
.themeOfTheMonthCardConfirmAccept:active {
transform: translateY(0);
} }
.themeOfTheMonthCardImage { .themeOfTheMonthCardImage {
display: block; display: block;
@@ -3805,11 +3918,34 @@ div.day-empty {
.themeOfTheMonthCardActions { .themeOfTheMonthCardActions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; align-items: center;
gap: 8px; justify-content: space-between;
gap: 10px;
}
.themeOfTheMonthCardActionsStart {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.themeOfTheMonthCardActionsEnd {
display: inline-flex;
flex-wrap: nowrap;
align-items: stretch;
margin-left: auto;
padding: 3px;
gap: 0;
overflow: hidden;
border-radius: 9999px;
background: color-mix(
in srgb,
var(--background-secondary, var(--text-primary)) 28%,
var(--background-primary)
);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary) 8%, transparent);
} }
.themeOfTheMonthCardPrimary, .themeOfTheMonthCardPrimary,
.themeOfTheMonthCardSecondary { .themeOfTheMonthCardSecondary,
.themeOfTheMonthCardDontShow {
appearance: none; appearance: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
@@ -3817,23 +3953,49 @@ div.day-empty {
padding: 0.58rem 0.9rem; padding: 0.58rem 0.9rem;
font-size: 0.86rem; font-size: 0.86rem;
font-weight: 700; font-weight: 700;
transition: transform 0.15s ease, filter 0.15s ease, background 0.15s ease; transition: background 0.15s ease, color 0.15s ease;
} }
.themeOfTheMonthCardPrimary { .themeOfTheMonthCardPrimary {
background: var(--better-pri, #6366f1); background: var(--better-pri, #6366f1);
color: white; color: white;
} }
.themeOfTheMonthCardSecondary { #theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardSecondary,
background: color-mix(in srgb, var(--text-primary) 10%, transparent); #theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardDontShow {
padding: 0.5rem 0.8rem;
font-size: 0.8rem;
font-weight: 600;
border: none !important;
border-radius: 9999px !important;
background: transparent !important;
box-shadow: none !important;
filter: none !important;
transform: none !important;
}
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardSecondary {
color: var(--text-primary); color: var(--text-primary);
} }
.themeOfTheMonthCardPrimary:hover, #theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardDontShow {
.themeOfTheMonthCardSecondary:hover { color: color-mix(in srgb, var(--text-primary) 58%, transparent);
}
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardSecondary:hover,
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardSecondary:focus-visible,
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardDontShow:hover,
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardDontShow:focus-visible {
background: color-mix(in srgb, var(--text-primary) 10%, transparent) !important;
border-radius: 9999px !important;
filter: none !important;
transform: none !important;
}
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardSecondary:active,
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardDontShow:active {
background: color-mix(in srgb, var(--text-primary) 14%, transparent) !important;
border-radius: 9999px !important;
}
.themeOfTheMonthCardPrimary:hover {
filter: brightness(1.08); filter: brightness(1.08);
transform: translateY(-1px); transform: translateY(-1px);
} }
.themeOfTheMonthCardPrimary:active, .themeOfTheMonthCardPrimary:active {
.themeOfTheMonthCardSecondary:active {
transform: translateY(0); transform: translateY(0);
} }
@keyframes themeOfTheMonthCardIn { @keyframes themeOfTheMonthCardIn {
@@ -423,6 +423,18 @@
{/each} {/each}
{/if} {/if}
</div> </div>
{#if plugin.pluginId === 'global-search'}
{@render Setting({
title: "Theme of the Month",
description: "Show the monthly featured theme popup when a new entry is available",
id: 15,
Component: Switch,
props: {
state: !($settingsState.themeOfTheMonthDisabled ?? false),
onChange: (isOn: boolean) => settingsState.themeOfTheMonthDisabled = !isOn
}
})}
{/if}
</div> </div>
{/each} {/each}
+72 -2
View File
@@ -17,6 +17,15 @@ import {
clearCustomThemeAdaptiveCssVariables, clearCustomThemeAdaptiveCssVariables,
setCustomThemeAdaptiveCssVariables, setCustomThemeAdaptiveCssVariables,
} from "@/seqta/ui/colors/customThemeAdaptiveBindings"; } from "@/seqta/ui/colors/customThemeAdaptiveBindings";
import {
clearThemeRuntime,
injectThemeDom,
runThemeScript,
type ThemeDomSpec,
type ThemeScriptSpec,
validateThemeDom,
validateThemeScript,
} from "./theme-runtime";
type ThemeContent = { type ThemeContent = {
id: string; id: string;
@@ -31,6 +40,8 @@ type ThemeContent = {
forceDark?: boolean; forceDark?: boolean;
adaptiveCssVariables?: string[]; adaptiveCssVariables?: string[];
images?: { id: string; variableName: string; data: string }[]; // data: base64 images?: { id: string; variableName: string; data: string }[]; // data: base64
themeScript?: ThemeScriptSpec;
themeDom?: ThemeDomSpec;
}; };
export type InstallThemeMeta = { export type InstallThemeMeta = {
@@ -53,6 +64,7 @@ export class ThemeManager {
private imageUrlCache: Map<string, string> = new Map(); private imageUrlCache: Map<string, string> = new Map();
private lastTransitionPoint: { x: number; y: number } = { x: 0, y: 0 }; private lastTransitionPoint: { x: number; y: number } = { x: 0, y: 0 };
private storeUpdateCheckRunning = false; private storeUpdateCheckRunning = false;
private headObserver: MutationObserver | null = null;
private constructor() { private constructor() {
console.debug("[ThemeManager] Initializing..."); console.debug("[ThemeManager] Initializing...");
@@ -307,6 +319,13 @@ export class ThemeManager {
private async applyTheme(theme: CustomTheme): Promise<void> { private async applyTheme(theme: CustomTheme): Promise<void> {
console.debug("[ThemeManager] Applying theme:", theme.name); console.debug("[ThemeManager] Applying theme:", theme.name);
try { try {
// Run the theme script BEFORE injecting CustomCSS so any state the
// script publishes (e.g. `data-city-state` and `--city-sky-color` for
// Noir City) is already on <html> when the new CSS rules paint.
// Otherwise the CSS lands with var() unresolved and the page flashes
// its previous state before snapping to the right colour.
runThemeScript(theme.themeScript);
// Apply custom CSS // Apply custom CSS
if (theme.CustomCSS) { if (theme.CustomCSS) {
console.debug("[ThemeManager] Applying custom CSS"); console.debug("[ThemeManager] Applying custom CSS");
@@ -348,6 +367,8 @@ export class ThemeManager {
} }
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []); setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
injectThemeDom(theme.themeDom);
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error applying theme:", error); console.error("[ThemeManager] Error applying theme:", error);
} }
@@ -379,6 +400,13 @@ export class ThemeManager {
): Promise<void> { ): Promise<void> {
console.debug("[ThemeManager] Removing theme:", theme.name); console.debug("[ThemeManager] Removing theme:", theme.name);
try { try {
clearThemeRuntime();
// Disconnect the head observer BEFORE removing the style element,
// otherwise the removal fires the observer and it would no-op only
// because the style is already gone — wasted work, but harmless.
this.disconnectStyleObserver();
// Remove custom CSS // Remove custom CSS
if (this.styleElement) { if (this.styleElement) {
console.debug("[ThemeManager] Removing custom CSS"); console.debug("[ThemeManager] Removing custom CSS");
@@ -448,7 +476,13 @@ export class ThemeManager {
} }
/** /**
* Apply custom CSS to the document * Apply custom CSS to the document. The `<style>` element is always
* re-appended to the end of `<head>` so it wins specificity ties
* against any styles SEQTA's late-loading injected.scss adds in dev
* mode (where `import("@/css/injected.scss")` is fire-and-forget and
* can resolve after the theme has already been applied). The head
* observer below keeps us at the end if anything else gets appended
* later (Vite HMR, another script-injected stylesheet, etc.).
*/ */
private applyCustomCSS(css: string): void { private applyCustomCSS(css: string): void {
console.debug("[ThemeManager] Applying custom CSS"); console.debug("[ThemeManager] Applying custom CSS");
@@ -456,14 +490,39 @@ export class ThemeManager {
if (!this.styleElement) { if (!this.styleElement) {
this.styleElement = document.createElement("style"); this.styleElement = document.createElement("style");
this.styleElement.id = "custom-theme"; this.styleElement.id = "custom-theme";
document.head.appendChild(this.styleElement);
} }
this.styleElement.textContent = css; this.styleElement.textContent = css;
document.head.appendChild(this.styleElement);
this.ensureStyleStaysLast();
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error applying custom CSS:", error); console.error("[ThemeManager] Error applying custom CSS:", error);
} }
} }
/**
* Watch `<head>` for any child-list changes and re-append the theme
* style element if anything has been added after it. Idempotent: the
* observer's own re-append fires the callback again, but the early
* `lastElementChild === style` check short-circuits the second pass.
*/
private ensureStyleStaysLast(): void {
if (this.headObserver) return;
this.headObserver = new MutationObserver(() => {
const style = this.styleElement;
if (!style || !document.head.contains(style)) return;
if (document.head.lastElementChild === style) return;
document.head.appendChild(style);
});
this.headObserver.observe(document.head, { childList: true });
}
private disconnectStyleObserver(): void {
if (this.headObserver) {
this.headObserver.disconnect();
this.headObserver = null;
}
}
/** /**
* Get list of available themes * Get list of available themes
*/ */
@@ -637,6 +696,15 @@ export class ThemeManager {
throw new Error("Theme is missing required fields (id or name)"); throw new Error("Theme is missing required fields (id or name)");
} }
// Validate optional runtime hooks. Reject the install if either is
// malformed so a tampered theme cannot smuggle in unsafe values.
if (!validateThemeScript(themeData.themeScript)) {
throw new Error("Theme has invalid themeScript");
}
if (!validateThemeDom(themeData.themeDom)) {
throw new Error("Theme has invalid themeDom");
}
const fromStore = meta?.fromStore ?? false; const fromStore = meta?.fromStore ?? false;
const serverUpdatedAtSec = meta?.serverUpdatedAtSec; const serverUpdatedAtSec = meta?.serverUpdatedAtSec;
@@ -701,6 +769,8 @@ export class ThemeManager {
? true ? true
: undefined, : undefined,
adaptiveCssVariables: themeData.adaptiveCssVariables, adaptiveCssVariables: themeData.adaptiveCssVariables,
themeScript: themeData.themeScript,
themeDom: themeData.themeDom,
}; };
await this.saveTheme(theme); await this.saveTheme(theme);
@@ -0,0 +1,792 @@
/**
* Theme runtime: minimal, locked-down hooks for themes that need a clock or
* a few decorative DOM elements (e.g. Noir City's day/night cycle and
* animated cars).
*
* Hard constraints:
* - No `eval` and no execution of theme-author JS. Themes reference built-in
* functions by name from a fixed allowlist.
* - No network, storage, cookies, or SEQTA DOM access.
* - Only mutations allowed are: CSS custom properties on <html>, a single
* `data-city-state` attribute on <html>, and appending a fixed set of
* decorative elements into `#bsplus-theme-runtime-root`.
*/
export type ThemeScriptSpec = {
onLoad?: string;
interval?: number;
onInterval?: string;
};
export type ThemeDomSpec = {
roadStrip?: boolean;
cars?: number;
/** @deprecated Use cityLayers flicker overlays instead. */
flickers?: number;
cityLayers?: boolean;
};
const ROOT_ID = "bsplus-theme-runtime-root";
const WALLPAPER_ID = "bsplus-theme-wallpaper";
const MAX_CARS = 10;
const MAX_FLICKERS = 8;
const MIN_INTERVAL_MS = 60_000;
const DEFAULT_INTERVAL_MS = 600_000;
/**
* IDs of decorative city layers injected when `themeDom.cityLayers` is on.
* Order matters: earlier entries paint behind later ones. Buildings sit
* behind the lit batches (so windows draw over the silhouettes); flicker
* frames sit over the lit batches so blinking windows hide steady ones.
* Sun and moon are last so they paint over everything else in the
* wallpaper stack (still behind #main via z-index 0).
*/
/**
* `city-buildings` always paints the night panorama; `city-day` paints
* the day panorama on top with opacity controlled by `--city-day-
* opacity`. The two stack so we can CSS-transition opacity between
* them at the day boundary, instead of snapping background-image (which
* doesn't animate). */
const CITY_LAYER_IDS = [
"city-buildings",
"city-day",
"city-lights-1",
"city-lights-2",
"city-lights-3",
"city-flicker-a",
"city-flicker-b",
"city-sun",
"city-moon",
] as const;
// Built-in functions themes may reference by exact string match.
const BUILTINS: Record<string, () => void> = {
"setTimeState()": setTimeState,
"setCityTime()": setCityTime,
};
let intervalHandle: ReturnType<typeof setInterval> | null = null;
let lastTimeState: TimeState | null = null;
let lastDomSpec: ThemeDomSpec | null = null;
let bodyObserver: MutationObserver | null = null;
let pageshowListenerAttached = false;
let repaintScheduled = false;
type TimeState = "night" | "dawn" | "day" | "dusk" | "evening";
/**
* Sky colour anchors. Exactly one of these is published as
* `--city-sky-color` at any given moment, based on the discrete
* `timeStateForMinutes()` bucket. CSS handles the cross-fade between
* anchors via its own `transition: background-color` rule, so the sky
* is a flat colour during the whole duration of each phase and only
* animates on the boundary crossing itself.
*
* night near-black with a hint of blue
* dawn peachy sunrise brighter than the previous dusty rose so
* the lerp from night -> dawn -> day doesn't pass through
* desaturated grey-purple in the middle.
* day soft pastel "skyblue" (#87CEEB, the CSS named colour). Was
* previously a desaturated steel-blue that read as grey at
* mid-lightness.
* dusk warm orange sunset, matching dawn's brightness so the
* afternoon transition stays colourful.
* evening deep indigo, low chroma, settling toward night.
*/
const SKY_COLOR: Record<TimeState, string> = {
night: "#0d0d0f",
dawn: "#cc6a5a",
day: "#87ceeb",
dusk: "#d56a3a",
evening: "#111118",
};
/* ============================================================ *
* Dev-only time picker
*
* Flip this to `false` to remove the floating slider entirely
* (it's also automatically off in production builds via the
* `IS_DEV` check below).
* ============================================================ */
const DEV_TIME_PICKER_ENABLED = true;
const DEV_OVERRIDE_KEY = "bsplus-city-time-override";
const IS_DEV = import.meta.env.MODE === "development";
const DEV_PICKER_ID = "bsplus-city-dev-picker";
function readDevOverride(): number | null {
if (!IS_DEV || !DEV_TIME_PICKER_ENABLED) return null;
try {
const raw = localStorage.getItem(DEV_OVERRIDE_KEY);
if (raw === null) return null;
const n = Number(raw);
if (Number.isFinite(n) && n >= 0 && n < 24 * 60) return n;
} catch {
// localStorage may be unavailable; ignore.
}
return null;
}
/**
* "Balanced 1h wedges" schedule:
*
* 00:00 .. 05:30 night (dark)
* 05:30 .. 06:30 dawn (peachy sunrise, ramping up)
* 06:30 .. 18:00 day (pastel blue, the big stable block)
* 18:00 .. 19:00 dusk (warm sunset)
* 19:00 .. 21:00 evening (deep indigo, fading toward night)
* 21:00 .. 24:00 night
*
* The discrete bucket here drives `data-city-state` (used by the car
* sprite swap and the day panorama). The continuous sky-colour lerp in
* `TIME_BOUNDARIES` MUST use the same minute markers so the boundary
* the user sees in the dev slider matches the visible colour change.
*/
function timeStateForMinutes(minutes: number): TimeState {
if (minutes < 5 * 60 + 30) return "night";
if (minutes < 6 * 60 + 30) return "dawn";
if (minutes < 18 * 60) return "day";
if (minutes < 19 * 60) return "dusk";
if (minutes < 21 * 60) return "evening";
return "night";
}
function getMinutesOfDay(now: Date = new Date()): number {
const override = readDevOverride();
if (override !== null) return override;
return now.getHours() * 60 + now.getMinutes();
}
function getTimeState(now: Date = new Date()): TimeState {
return timeStateForMinutes(getMinutesOfDay(now));
}
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
/**
* Sky colour publishing is intentionally NOT time-interpolated. We
* publish exactly one of the five `SKY_COLOR` anchors based on the
* discrete state from `timeStateForMinutes()`, and let CSS handle the
* cross-fade via its own `transition: background-color` on #content.
*
* That way the sky is a flat colour for the entire duration of each
* phase, and only animates between two anchors AT THE MOMENT the state
* boundary is crossed. The animation duration is decoupled from how
* long the phase lasts.
*
* Removed: the previous `lerpColor` + `TIME_BOUNDARIES` minute table.
*/
function skyColorForState(state: TimeState): string {
return SKY_COLOR[state];
}
function darknessForMinutes(minutes: number): number {
const noon = 12 * 60;
let distance = Math.abs(minutes - noon);
if (distance > 12 * 60) distance = 24 * 60 - distance;
return clamp01(distance / (12 * 60));
}
/**
* Per-phase lit-window opacities. Like `SKY_COLOR`, this is a flat lookup
* by discrete state NOT a continuous function of minute-of-day. The
* existing 10s CSS `transition: opacity` on #city-lights-1/2/3 handles
* the cross-fade between phases when the discrete state changes.
*
* night everything lit peak window activity
* dawn batch 1 only a few early risers, lobby lights
* day all off
* dusk batch 1 fully on, batch 2 fading in
* evening batches 1 + 2 fully on, batch 3 partially peak evening
*
* Tweak these freely; they're purely cosmetic and don't have to add up
* to anything.
*/
const LIT_OPACITIES: Record<TimeState, [number, number, number]> = {
night: [1.0, 1.0, 1.0],
dawn: [1.0, 0.0, 0.0],
day: [0.0, 0.0, 0.0],
dusk: [1.0, 0.6, 0.0],
evening: [1.0, 1.0, 0.5],
};
function litOpacitiesForState(state: TimeState): [number, number, number] {
return LIT_OPACITIES[state];
}
function sunForMinutes(minutes: number): { t: number; opacity: number } {
const sunrise = 6 * 60;
const sunset = 19 * 60;
if (minutes < sunrise || minutes >= sunset) return { t: 0, opacity: 0 };
return {
t: (minutes - sunrise) / (sunset - sunrise),
opacity: 1,
};
}
function moonForMinutes(minutes: number): { t: number; opacity: number } {
const sun = sunForMinutes(minutes);
if (sun.opacity > 0) return { t: 0, opacity: 0 };
const dusk = 19 * 60 + 30;
const dawn = 5 * 60;
if (minutes >= dusk) {
return { t: clamp01((minutes - dusk) / (24 * 60 - dusk)), opacity: 1 };
}
if (minutes < dawn) {
return { t: clamp01((dawn - minutes) / dawn), opacity: 1 };
}
return { t: 0.5, opacity: 1 };
}
function formatMinutes(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`;
}
/**
* Read the clock and update `data-city-state` + sky colour on <html>. Only
* writes when the state actually changes, so CSS transitions are driven by
* real state changes rather than every tick.
*/
export function setTimeState(): void {
setCityTime();
}
/**
* Read the clock and publish continuous city variables plus the discrete
* `data-city-state` bucket used by CSS that still needs hard cuts (e.g. day
* panorama swap, night car sprites).
*/
export function setCityTime(): void {
const minutes = getMinutesOfDay();
const state = timeStateForMinutes(minutes);
const html = document.documentElement;
if (state !== lastTimeState) {
lastTimeState = state;
html.setAttribute("data-city-state", state);
html.style.setProperty("--city-time-state", state);
}
const darkness = darknessForMinutes(minutes);
const [lit1, lit2, lit3] = litOpacitiesForState(state);
const sun = sunForMinutes(minutes);
const moon = moonForMinutes(minutes);
html.style.setProperty("--city-sky-color", skyColorForState(state));
html.style.setProperty("--city-darkness", String(darkness));
// 1 only during the `day` phase; 0 in every other phase. CSS cross-
// fades the day panorama over the night panorama using this on a 10s
// opacity transition, which avoids the visual snap that
// `background-image` swaps cause (background-image doesn't animate).
html.style.setProperty("--city-day-opacity", state === "day" ? "1" : "0");
html.style.setProperty("--city-lit-1-opacity", String(lit1));
html.style.setProperty("--city-lit-2-opacity", String(lit2));
html.style.setProperty("--city-lit-3-opacity", String(lit3));
html.style.setProperty("--city-sun-t", String(sun.t));
html.style.setProperty("--city-sun-opacity", String(sun.opacity));
html.style.setProperty("--city-moon-t", String(moon.t));
html.style.setProperty("--city-moon-opacity", String(moon.opacity));
}
/**
* Validate the script spec strictly. Returns true if the spec is safe to run.
* Themes that include unknown calls or non-builtins are rejected wholesale.
*/
export function validateThemeScript(script: unknown): script is ThemeScriptSpec {
if (script == null) return true; // optional field
if (typeof script !== "object") return false;
const s = script as Record<string, unknown>;
for (const key of Object.keys(s)) {
if (key !== "onLoad" && key !== "interval" && key !== "onInterval") {
return false;
}
}
if (s.onLoad !== undefined && !isAllowedCall(s.onLoad)) return false;
if (s.onInterval !== undefined && !isAllowedCall(s.onInterval)) return false;
if (
s.interval !== undefined &&
(typeof s.interval !== "number" ||
!Number.isFinite(s.interval) ||
s.interval < MIN_INTERVAL_MS)
) {
return false;
}
return true;
}
function isAllowedCall(value: unknown): boolean {
return typeof value === "string" && value in BUILTINS;
}
/**
* Validate the DOM spec. Caps counts and rejects unknown keys.
*/
export function validateThemeDom(dom: unknown): dom is ThemeDomSpec {
if (dom == null) return true;
if (typeof dom !== "object") return false;
const d = dom as Record<string, unknown>;
for (const key of Object.keys(d)) {
if (key !== "roadStrip" && key !== "cars" && key !== "flickers" && key !== "cityLayers") {
return false;
}
}
if (d.cityLayers !== undefined && typeof d.cityLayers !== "boolean") return false;
if (d.roadStrip !== undefined && typeof d.roadStrip !== "boolean") return false;
if (
d.cars !== undefined &&
(typeof d.cars !== "number" ||
!Number.isInteger(d.cars) ||
d.cars < 0 ||
d.cars > MAX_CARS)
) {
return false;
}
if (
d.flickers !== undefined &&
(typeof d.flickers !== "number" ||
!Number.isInteger(d.flickers) ||
d.flickers < 0 ||
d.flickers > MAX_FLICKERS)
) {
return false;
}
return true;
}
function callBuiltin(name: string): void {
const fn = BUILTINS[name];
if (!fn) return;
try {
fn();
} catch (e) {
console.error("[ThemeRuntime] Built-in failed:", name, e);
}
}
/**
* Run a validated themeScript: invoke `onLoad` once and start an interval
* timer for `onInterval` if configured. Idempotent clears any prior
* interval before scheduling a new one so repeated calls (e.g. during
* theme re-apply races) don't leak timers.
*/
export function runThemeScript(script: ThemeScriptSpec | undefined): void {
if (intervalHandle !== null) {
clearInterval(intervalHandle);
intervalHandle = null;
}
if (!script || !validateThemeScript(script)) return;
if (script.onLoad) callBuiltin(script.onLoad);
if (script.onInterval) {
const period = Math.max(MIN_INTERVAL_MS, script.interval ?? DEFAULT_INTERVAL_MS);
intervalHandle = setInterval(() => callBuiltin(script.onInterval!), period);
}
}
function getOrCreateRoot(): HTMLElement {
let root = document.getElementById(ROOT_ID);
if (!root) {
root = document.createElement("div");
root.id = ROOT_ID;
root.setAttribute("aria-hidden", "true");
}
// Always parent under <body>: the runtime root uses `position: fixed`
// and needs to escape any stacking context SEQTA puts on `#content` so
// the sidebar can layer over it cleanly via z-index.
if (root.parentElement !== document.body) {
document.body.appendChild(root);
}
return root;
}
/**
* The wallpaper container is a *separate* sibling of the runtime root,
* never a child. We keep it independent so its existence is determined
* solely by `themeDom.cityLayers`, and so the road strip's tight 60px
* bottom geometry can't accidentally constrain the full-area city
* layers. All CSS positioning of cityscape/sun/moon happens via
* `position: fixed` on the layer divs themselves; this container only
* groups them for cheap teardown.
*/
function getOrCreateWallpaper(): HTMLElement {
let wallpaper = document.getElementById(WALLPAPER_ID);
if (!wallpaper) {
wallpaper = document.createElement("div");
wallpaper.id = WALLPAPER_ID;
wallpaper.setAttribute("aria-hidden", "true");
}
if (wallpaper.parentElement !== document.body) {
document.body.appendChild(wallpaper);
}
return wallpaper;
}
/** Content scripts run at `document_start`; defer until `<body>` exists. */
function whenBodyReady(run: () => void): void {
if (document.body) {
run();
return;
}
document.addEventListener("DOMContentLoaded", run, { once: true });
}
function runtimeRootNeedsContent(spec: ThemeDomSpec): boolean {
const root = document.getElementById(ROOT_ID);
if (!root) return true;
if (spec.roadStrip && !document.getElementById("city-road")) return true;
const carCount = Math.min(spec.cars ?? 0, MAX_CARS);
if (carCount > 0 && root.querySelector(".city-car") === null) return true;
return false;
}
function cityWallpaperNeedsLayers(): boolean {
if (!lastDomSpec?.cityLayers) return false;
const wallpaper = document.getElementById(WALLPAPER_ID);
if (!wallpaper) return true;
return wallpaper.querySelector("#city-buildings") === null;
}
function repaintThemeDomIfNeeded(): void {
if (!lastDomSpec || !document.body) return;
if (runtimeRootNeedsContent(lastDomSpec)) {
const root = getOrCreateRoot();
populateRoot(root, lastDomSpec);
}
if (cityWallpaperNeedsLayers()) {
const wallpaper = getOrCreateWallpaper();
populateWallpaper(wallpaper);
}
}
function scheduleRepaintThemeDomIfNeeded(): void {
if (repaintScheduled) return;
repaintScheduled = true;
requestAnimationFrame(() => {
repaintScheduled = false;
repaintThemeDomIfNeeded();
});
}
function themeDomNeedsRepaint(): boolean {
if (!lastDomSpec) return false;
if (!document.getElementById(ROOT_ID)) return true;
if (lastDomSpec.cityLayers && !document.getElementById(WALLPAPER_ID)) {
return true;
}
return runtimeRootNeedsContent(lastDomSpec) || cityWallpaperNeedsLayers();
}
function populateWallpaper(wallpaper: HTMLElement): void {
while (wallpaper.firstChild) wallpaper.removeChild(wallpaper.firstChild);
for (const id of CITY_LAYER_IDS) {
const layer = document.createElement("div");
layer.id = id;
// `class` lets the theme target all cityscape overlays with a single
// rule for the shared fixed-positioning + background-attachment block.
if (
id.startsWith("city-buildings") ||
id === "city-day" ||
id.startsWith("city-lights") ||
id.startsWith("city-flicker")
) {
layer.className = "city-cityscape-layer";
}
wallpaper.appendChild(layer);
}
}
const CAR_VARIANTS = ["car-sedan", "car-hatchback", "car-boxy"] as const;
/**
* Populate the runtime root with the decorative DOM described by `dom`.
* Always wipes the root's existing children first so repeated calls leave
* exactly one road / N cars, no duplicates. The runtime root contains
* ONLY the road strip and cars full-area city layers (buildings, lit
* batches, flicker, sun, moon) live in a separate `#bsplus-theme-wallpaper`
* container so the root's tight 60px bottom strip never grows.
*/
function populateRoot(root: HTMLElement, dom: ThemeDomSpec): void {
while (root.firstChild) root.removeChild(root.firstChild);
if (dom.roadStrip) {
const road = document.createElement("div");
road.id = "city-road";
root.appendChild(road);
}
const carCount = Math.min(dom.cars ?? 0, MAX_CARS);
for (let i = 1; i <= carCount; i++) {
const car = document.createElement("div");
const variant = CAR_VARIANTS[(i - 1) % CAR_VARIANTS.length];
car.className = `city-car city-car-${i} ${variant}`;
root.appendChild(car);
}
// Legacy scattered flicker dots. Themes that opt into `cityLayers` use
// the overlay-cross-fade flicker instead, so we skip these when the new
// system is active to avoid double flickering.
if (!dom.cityLayers) {
const flickerCount = Math.min(dom.flickers ?? 0, MAX_FLICKERS);
for (let i = 1; i <= flickerCount; i++) {
const flicker = document.createElement("div");
flicker.className = `city-win-flicker city-win-flicker-${i}`;
root.appendChild(flicker);
}
}
}
/**
* Watch `<body>` for the runtime root or wallpaper being removed (SPA
* re-renders, external code, etc.) and re-inject from the cached spec.
* Without this, intermittent reloads could leave the page with no road/
* cars or no cityscape layers even though the runtime believed they were
* mounted.
*/
function ensureBodyObserver(): void {
if (bodyObserver) return;
const attach = () => {
if (!document.body || bodyObserver) return;
bodyObserver = new MutationObserver(() => {
if (themeDomNeedsRepaint()) scheduleRepaintThemeDomIfNeeded();
});
// Only direct children of <body>: the theme nodes are appended there.
// Watching the full subtree fired on every SEQTA menu/content mutation and
// could repaint decorative layers mid-navigation (felt like a page reload).
bodyObserver.observe(document.body, { childList: true });
};
whenBodyReady(attach);
if (!pageshowListenerAttached && typeof window !== "undefined") {
pageshowListenerAttached = true;
window.addEventListener(
"pageshow",
(event) => {
if (event.persisted) repaintThemeDomIfNeeded();
},
{ capture: true },
);
}
}
/**
* Inject the decorative DOM scaffolding required by themes that opt in via
* `themeDom`. Idempotent: safe to call multiple times in a row without
* an intervening `clearThemeRuntime`. Everything is positioned/styled by
* theme CSS.
*/
export function injectThemeDom(dom: ThemeDomSpec | undefined): void {
if (!dom || !validateThemeDom(dom)) return;
lastDomSpec = dom;
ensureBodyObserver();
const mount = () => {
if (!lastDomSpec) return;
const spec = lastDomSpec;
if (runtimeRootNeedsContent(spec)) {
const root = getOrCreateRoot();
populateRoot(root, spec);
}
if (spec.cityLayers) {
if (cityWallpaperNeedsLayers()) {
const wallpaper = getOrCreateWallpaper();
populateWallpaper(wallpaper);
}
} else {
// Theme switched from a cityLayers theme to one without — tear down
// any leftover wallpaper so we don't paint stale buildings/sun.
document.getElementById(WALLPAPER_ID)?.remove();
}
// Suppress the slow `transition: background-color` for the very first
// frame after the theme CSS lands. Without this, the browser
// interpolates from SEQTA's pre-theme `background: unset` (light) to
// var(--city-sky-color) over 30s on every page load. Double rAF: the
// first runs after the next layout, the second after that frame has
// actually been painted with the attribute set, so we can safely
// remove it and let real state changes (night -> dawn etc.) animate.
const html = document.documentElement;
html.setAttribute("data-city-just-applied", "");
requestAnimationFrame(() => {
requestAnimationFrame(() => {
html.removeAttribute("data-city-just-applied");
});
});
mountDevTimePicker();
};
whenBodyReady(mount);
}
/**
* Dev-only floating time picker. Renders a slider (0..1440 minutes) plus a
* checkbox to toggle "use real clock". The override is persisted in
* `localStorage` so it survives reloads during a debugging session. The
* whole block is dead code in production builds (the `IS_DEV` check is
* static and Vite tree-shakes it out).
*/
function mountDevTimePicker(): void {
if (!IS_DEV || !DEV_TIME_PICKER_ENABLED) return;
if (document.getElementById(DEV_PICKER_ID)) return;
const initialOverride = readDevOverride();
const startMinutes =
initialOverride ?? new Date().getHours() * 60 + new Date().getMinutes();
const useReal = initialOverride === null;
const panel = document.createElement("div");
panel.id = DEV_PICKER_ID;
panel.setAttribute("aria-label", "City time override (dev mode)");
// Inline styles so the panel can't be styled away by the active theme.
Object.assign(panel.style, {
position: "fixed",
bottom: "12px",
right: "12px",
zIndex: "2147483647",
padding: "10px 12px",
background: "rgba(20, 20, 24, 0.92)",
color: "#e8e8f0",
font: "12px/1.4 system-ui, sans-serif",
borderRadius: "8px",
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.4)",
border: "1px solid rgba(255, 255, 255, 0.08)",
pointerEvents: "auto",
userSelect: "none",
minWidth: "220px",
} satisfies Partial<CSSStyleDeclaration>);
const header = document.createElement("div");
header.textContent = "City time (dev)";
Object.assign(header.style, {
fontWeight: "600",
marginBottom: "6px",
opacity: "0.85",
} satisfies Partial<CSSStyleDeclaration>);
panel.appendChild(header);
const realRow = document.createElement("label");
Object.assign(realRow.style, {
display: "flex",
alignItems: "center",
gap: "6px",
marginBottom: "8px",
cursor: "pointer",
} satisfies Partial<CSSStyleDeclaration>);
const realCheck = document.createElement("input");
realCheck.type = "checkbox";
realCheck.checked = useReal;
realRow.appendChild(realCheck);
const realLabel = document.createElement("span");
realLabel.textContent = "Use real clock";
realRow.appendChild(realLabel);
panel.appendChild(realRow);
const slider = document.createElement("input");
slider.type = "range";
slider.min = "0";
slider.max = String(24 * 60 - 1);
slider.step = "5";
slider.value = String(startMinutes);
slider.disabled = useReal;
Object.assign(slider.style, {
width: "100%",
accentColor: "#c8b97a",
} satisfies Partial<CSSStyleDeclaration>);
panel.appendChild(slider);
const readout = document.createElement("div");
Object.assign(readout.style, {
marginTop: "4px",
opacity: "0.8",
fontVariantNumeric: "tabular-nums",
} satisfies Partial<CSSStyleDeclaration>);
panel.appendChild(readout);
function paintReadout(): void {
const m = Number(slider.value);
readout.textContent = useReal
? "Real clock"
: `${formatMinutes(m)} · ${timeStateForMinutes(m)}`;
}
function applyOverride(): void {
try {
if (realCheck.checked) {
localStorage.removeItem(DEV_OVERRIDE_KEY);
} else {
localStorage.setItem(DEV_OVERRIDE_KEY, slider.value);
}
} catch (e) {
console.warn("[ThemeRuntime] Could not persist time override:", e);
}
// Force a full re-publish next tick by clearing the cached bucket;
// setCityTime() short-circuits the discrete state when unchanged but
// always re-writes the continuous vars.
lastTimeState = null;
setCityTime();
paintReadout();
}
realCheck.addEventListener("change", () => {
slider.disabled = realCheck.checked;
applyOverride();
});
slider.addEventListener("input", () => {
if (realCheck.checked) {
realCheck.checked = false;
slider.disabled = false;
}
applyOverride();
});
paintReadout();
document.body.appendChild(panel);
}
function unmountDevTimePicker(): void {
document.getElementById(DEV_PICKER_ID)?.remove();
}
/**
* Remove everything the runtime added: injected DOM, interval timer, custom
* properties, and the `data-city-state` attribute.
*/
export function clearThemeRuntime(): void {
if (intervalHandle !== null) {
clearInterval(intervalHandle);
intervalHandle = null;
}
// Disconnect the body observer BEFORE removing the root so the observer
// doesn't see its own teardown removal as "root went missing" and try to
// re-inject what we're trying to clear.
if (bodyObserver) {
bodyObserver.disconnect();
bodyObserver = null;
}
lastDomSpec = null;
lastTimeState = null;
const root = document.getElementById(ROOT_ID);
if (root) root.remove();
document.getElementById(WALLPAPER_ID)?.remove();
unmountDevTimePicker();
const html = document.documentElement;
html.removeAttribute("data-city-state");
html.removeAttribute("data-city-just-applied");
html.style.removeProperty("--city-time-state");
html.style.removeProperty("--city-sky-color");
html.style.removeProperty("--city-darkness");
html.style.removeProperty("--city-day-opacity");
html.style.removeProperty("--city-lit-1-opacity");
html.style.removeProperty("--city-lit-2-opacity");
html.style.removeProperty("--city-lit-3-opacity");
html.style.removeProperty("--city-sun-t");
html.style.removeProperty("--city-sun-opacity");
html.style.removeProperty("--city-moon-t");
html.style.removeProperty("--city-moon-opacity");
}
+58 -24
View File
@@ -22,6 +22,9 @@ let sidebarAccessibilityObserver: MutationObserver | null = null;
let sidebarTabOrderAnimationFrame: number | null = null; let sidebarTabOrderAnimationFrame: number | null = null;
let sidebarAccessibilityListenersAttached = false; let sidebarAccessibilityListenersAttached = false;
/** Marks menu rows that are off-screen in the drill stack (CSS blocks clicks). */
const BSPLUS_SIDEBAR_OFFSCREEN = "bsplus-sidebar-offscreen";
export async function getUserInfo() { export async function getUserInfo() {
if (cachedUserInfo) return cachedUserInfo; if (cachedUserInfo) return cachedUserInfo;
@@ -493,10 +496,16 @@ function scheduleSidebarAccessibilityUpdate() {
cancelAnimationFrame(sidebarTabOrderAnimationFrame); cancelAnimationFrame(sidebarTabOrderAnimationFrame);
} }
// Double rAF: SEQTA applies `.active` / updates `.sub` on the next frame
// after a click. Running earlier hid the submenu with `aria-hidden` while
// focus was still on a <label> inside it, which broke routing and sent
// the SPA back to home.
sidebarTabOrderAnimationFrame = requestAnimationFrame(() => { sidebarTabOrderAnimationFrame = requestAnimationFrame(() => {
requestAnimationFrame(() => {
sidebarTabOrderAnimationFrame = null; sidebarTabOrderAnimationFrame = null;
updateSidebarAccessibility(); updateSidebarAccessibility();
}); });
});
} }
function handleSidebarKeyboardActivation(event: KeyboardEvent) { function handleSidebarKeyboardActivation(event: KeyboardEvent) {
@@ -506,9 +515,10 @@ function handleSidebarKeyboardActivation(event: KeyboardEvent) {
const menuItem = target.closest("#menu li, #menu section") as const menuItem = target.closest("#menu li, #menu section") as
| HTMLElement | HTMLElement
| null; | null;
if (!menuItem || target !== menuItem) return; if (!menuItem) return;
if (event.key === "Tab") { if (event.key === "Tab") {
if (target !== menuItem) return;
const menu = document.getElementById("menu"); const menu = document.getElementById("menu");
if (!menu) return; if (!menu) return;
@@ -552,11 +562,52 @@ function handleSidebarKeyboardActivation(event: KeyboardEvent) {
} }
} }
/**
* Keyboard tab order for the drilled-in sidebar only.
* SEQTA already sets `aria-hidden` on off-screen menu rows; we must not
* override that or hide `.sub` ourselves doing so while a <label> inside
* the submenu still has focus breaks SEQTA's router and navigates to home.
*/
/** Every folder row on the path to the open list (e.g. Assessments → 2026_S1). */
function getDrillFolderChain(
menu: HTMLElement,
visibleList: HTMLElement | null,
): Set<HTMLElement> {
const chain = new Set<HTMLElement>();
let list: HTMLElement | null = visibleList;
while (list && menu.contains(list)) {
const folder = getSidebarListParentEntry(list);
if (!folder || !menu.contains(folder)) break;
chain.add(folder);
const containerUl = folder.parentElement;
if (!(containerUl instanceof HTMLElement)) break;
const parentSub = containerUl.closest(".sub");
if (!parentSub || !menu.contains(parentSub)) break;
const parentFolder = parentSub.parentElement;
if (!(parentFolder instanceof HTMLElement) || !menu.contains(parentFolder)) {
break;
}
chain.add(parentFolder);
list =
parentFolder.parentElement instanceof HTMLElement
? parentFolder.parentElement
: null;
}
return chain;
}
function updateSidebarAccessibility() { function updateSidebarAccessibility() {
const menu = document.getElementById("menu"); const menu = document.getElementById("menu");
if (!menu) return; if (!menu) return;
const visibleEntries = new Set(getVisibleSidebarEntries(menu)); const visibleList = getVisibleSidebarList(menu);
const visibleEntries = new Set(
visibleList ? getDirectSidebarEntries(visibleList) : [],
);
const drillFolders = getDrillFolderChain(menu, visibleList);
const menuEntries = menu.querySelectorAll("li.item, section.item, li, section"); const menuEntries = menu.querySelectorAll("li.item, section.item, li, section");
for (const entry of menuEntries) { for (const entry of menuEntries) {
@@ -565,28 +616,19 @@ function updateSidebarAccessibility() {
const label = entry.querySelector(":scope > label") as HTMLLabelElement | null; const label = entry.querySelector(":scope > label") as HTMLLabelElement | null;
if (!label) continue; if (!label) continue;
const childSubmenu = entry.querySelector(":scope > .sub") as HTMLElement | null; const interactive =
const isHidden = visibleEntries.has(entry) || drillFolders.has(entry);
entry.offsetParent === null ||
window.getComputedStyle(entry).display === "none" ||
window.getComputedStyle(label).display === "none" ||
!visibleEntries.has(entry);
if (isHidden) { if (!interactive) {
entry.classList.add(BSPLUS_SIDEBAR_OFFSCREEN);
entry.tabIndex = -1; entry.tabIndex = -1;
label.tabIndex = -1; label.tabIndex = -1;
entry.setAttribute("aria-hidden", "true");
label.setAttribute("aria-hidden", "true");
if (childSubmenu) {
childSubmenu.setAttribute("aria-hidden", "true");
}
continue; continue;
} }
entry.classList.remove(BSPLUS_SIDEBAR_OFFSCREEN);
entry.tabIndex = 0; entry.tabIndex = 0;
label.tabIndex = -1; label.tabIndex = -1;
entry.removeAttribute("aria-hidden");
label.removeAttribute("aria-hidden");
if (!entry.hasAttribute("role")) { if (!entry.hasAttribute("role")) {
entry.setAttribute("role", "button"); entry.setAttribute("role", "button");
@@ -596,14 +638,6 @@ function updateSidebarAccessibility() {
if (accessibleLabel) { if (accessibleLabel) {
entry.setAttribute("aria-label", accessibleLabel); entry.setAttribute("aria-label", accessibleLabel);
} }
if (childSubmenu) {
const isExpanded = entry.classList.contains("active");
entry.setAttribute("aria-expanded", String(isExpanded));
childSubmenu.setAttribute("aria-hidden", String(!isExpanded));
} else {
entry.removeAttribute("aria-expanded");
}
} }
} }
@@ -45,10 +45,10 @@ export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | nul
} }
} }
/** True when we have a new monthly entry the user hasn't dismissed yet. */ /** True when the current month's entry should appear in the startup queue. */
export function shouldShowThemeOfTheMonth(entry: ThemeOfTheMonthEntry | null): boolean { export function shouldShowThemeOfTheMonth(entry: ThemeOfTheMonthEntry | null): boolean {
if (!entry || settingsState.themeOfTheMonthDisabled) return false; if (!entry || settingsState.themeOfTheMonthDisabled) return false;
return settingsState.themeOfTheMonthLastSeenId !== entry.id; return settingsState.themeOfTheMonthDismissedMonth !== entry.month;
} }
function escapeHTML(str: string): string { function escapeHTML(str: string): string {
@@ -108,18 +108,9 @@ async function resolvePopupHeroImageUrl(entry: ThemeOfTheMonthEntry): Promise<st
return fallback || null; return fallback || null;
} }
function closeThemeOfTheMonthCard( function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void) {
card: HTMLElement,
onDismissed?: () => void,
markSeen = true,
) {
if (card.classList.contains("themeOfTheMonthCardClosing")) return; if (card.classList.contains("themeOfTheMonthCardClosing")) return;
if (markSeen) {
const entryId = card.dataset.entryId;
if (entryId) settingsState.themeOfTheMonthLastSeenId = entryId;
}
card.classList.add("themeOfTheMonthCardClosing"); card.classList.add("themeOfTheMonthCardClosing");
window.setTimeout(() => { window.setTimeout(() => {
card.remove(); card.remove();
@@ -143,7 +134,6 @@ export async function OpenThemeOfTheMonthPopup(
const card = stringToHTML(/* html */ ` const card = stringToHTML(/* html */ `
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard" role="dialog" aria-label="Theme of the Month"> <aside id="theme-of-the-month-card" class="themeOfTheMonthCard" role="dialog" aria-label="Theme of the Month">
<button type="button" class="themeOfTheMonthCardClose" aria-label="Close Theme of the Month">×</button>
${ ${
heroUrl heroUrl
? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />` ? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />`
@@ -154,39 +144,74 @@ export async function OpenThemeOfTheMonthPopup(
<h2>${escapeHTML(entry.title)}</h2> <h2>${escapeHTML(entry.title)}</h2>
<p class="themeOfTheMonthCardDescription">${description}</p> <p class="themeOfTheMonthCardDescription">${description}</p>
<div class="themeOfTheMonthCardActions"> <div class="themeOfTheMonthCardActions">
<div class="themeOfTheMonthCardActionsStart">
${ ${
linkedThemeId linkedThemeId
? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>` ? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>`
: "" : ""
} }
<button type="button" class="themeOfTheMonthCardSecondary">Don't show again</button> </div>
<div class="themeOfTheMonthCardActionsEnd">
<button type="button" class="themeOfTheMonthCardSecondary">Close</button>
<button type="button" class="themeOfTheMonthCardDontShow">Don't show again</button>
</div>
</div>
</div>
<div class="themeOfTheMonthCardConfirm" hidden>
<div class="themeOfTheMonthCardConfirmInner">
<h3>Don't show again?</h3>
<p>Theme of the Month popups will be turned off. You can turn them back on in BetterSEQTA+ settings.</p>
<div class="themeOfTheMonthCardConfirmActions">
<button type="button" class="themeOfTheMonthCardConfirmCancel">Cancel</button>
<button type="button" class="themeOfTheMonthCardConfirmAccept">Don't show again</button>
</div>
</div> </div>
</div> </div>
</aside> </aside>
`).firstChild as HTMLElement; `).firstChild as HTMLElement;
card.dataset.entryId = entry.id;
const autoCloseTimeout = window.setTimeout(() => { const autoCloseTimeout = window.setTimeout(() => {
closeThemeOfTheMonthCard(card, onDismissed); closeThemeOfTheMonthCard(card, onDismissed);
}, 12000); }, 30_000);
const dismiss = (markSeen = true) => { const dismiss = () => {
window.clearTimeout(autoCloseTimeout); window.clearTimeout(autoCloseTimeout);
closeThemeOfTheMonthCard(card, onDismissed, markSeen); closeThemeOfTheMonthCard(card, onDismissed);
}; };
card.addEventListener("mouseenter", () => window.clearTimeout(autoCloseTimeout), { once: true }); card.addEventListener("mouseenter", () => window.clearTimeout(autoCloseTimeout), { once: true });
card.querySelector(".themeOfTheMonthCardClose")?.addEventListener("click", () => { const confirmEl = card.querySelector<HTMLElement>(".themeOfTheMonthCardConfirm");
card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDismissedMonth = entry.month;
dismiss(); dismiss();
}); });
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => { card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDismissedMonth = entry.month;
dismiss(); dismiss();
openThemeStoreWithHighlight(linkedThemeId!); openThemeStoreWithHighlight(linkedThemeId!);
}); });
card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => { const openDontShowConfirm = () => {
window.clearTimeout(autoCloseTimeout);
if (!confirmEl) return;
confirmEl.hidden = false;
requestAnimationFrame(() => confirmEl.classList.add("themeOfTheMonthCardConfirmVisible"));
};
card.querySelector(".themeOfTheMonthCardDontShow")?.addEventListener("click", openDontShowConfirm);
card.querySelector(".themeOfTheMonthCardConfirmCancel")?.addEventListener("click", () => {
if (!confirmEl) return;
confirmEl.classList.remove("themeOfTheMonthCardConfirmVisible");
window.setTimeout(() => {
confirmEl.hidden = true;
}, 160);
});
card.querySelector(".themeOfTheMonthCardConfirmAccept")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDisabled = true; settingsState.themeOfTheMonthDisabled = true;
dismiss(); dismiss();
}); });
@@ -196,7 +221,7 @@ export async function OpenThemeOfTheMonthPopup(
/** /**
* Dev helper: fetch the current month's entry and show the popup immediately, * Dev helper: fetch the current month's entry and show the popup immediately,
* even if the user has already dismissed it this month. * even if the user dismissed it for this calendar month.
*/ */
export async function showThemeOfTheMonthPopupNow(): Promise<void> { export async function showThemeOfTheMonthPopupNow(): Promise<void> {
const entry = await fetchThemeOfTheMonth(); const entry = await fetchThemeOfTheMonth();
@@ -207,7 +232,7 @@ export async function showThemeOfTheMonthPopupNow(): Promise<void> {
return; return;
} }
settingsState.themeOfTheMonthLastSeenId = undefined; settingsState.themeOfTheMonthDismissedMonth = undefined;
if (document.getElementById("whatsnewbk")) { if (document.getElementById("whatsnewbk")) {
await closePopup(); await closePopup();
+1 -1
View File
@@ -15,7 +15,7 @@ type QueueStep = (goNext: () => void) => void;
/** /**
* Runs startup modals in order: What's New (if the extension just updated), * Runs startup modals in order: What's New (if the extension just updated),
* Theme of the Month (when a new monthly entry hasn't been seen), then shows * Theme of the Month (when the user hasn't dismissed this calendar month), then shows
* the SEQTA Engage toast (once, non-blocking). * the SEQTA Engage toast (once, non-blocking).
*/ */
export async function runStartupPopupQueue() { export async function runStartupPopupQueue() {
+13
View File
@@ -26,6 +26,19 @@ export type CustomTheme = {
userEdited?: boolean; userEdited?: boolean;
/** CSS custom property names (e.g. `--my-accent`) that receive the same value as `--better-main` when adaptive colours apply. */ /** CSS custom property names (e.g. `--my-accent`) that receive the same value as `--better-main` when adaptive colours apply. */
adaptiveCssVariables?: string[]; adaptiveCssVariables?: string[];
/** Optional runtime hook for themes that need clock-driven CSS variables. See `theme-runtime.ts`. */
themeScript?: {
onLoad?: string;
interval?: number;
onInterval?: string;
};
/** Optional decorative DOM injection (e.g. animated cars). See `theme-runtime.ts`. */
themeDom?: {
roadStrip?: boolean;
cars?: number;
flickers?: number;
cityLayers?: boolean;
};
}; };
export type LoadedCustomTheme = CustomTheme & { export type LoadedCustomTheme = CustomTheme & {
+6 -1
View File
@@ -36,7 +36,12 @@ export interface SettingsState {
engageParentsAnnouncementShown?: boolean; engageParentsAnnouncementShown?: boolean;
/** One-time announcement: BS Cloud automatic settings sync (last in startup popup queue). */ /** One-time announcement: BS Cloud automatic settings sync (last in startup popup queue). */
bsCloudAutoSyncAnnouncementShown?: boolean; bsCloudAutoSyncAnnouncementShown?: boolean;
/** ID of the last Theme of the Month entry shown to the user (shows once per new entry). */ /**
* Calendar month (`YYYY-MM`) for which the user closed the Theme of the Month popup.
* Cleared automatically when a new month's entry is fetched (different `month`).
*/
themeOfTheMonthDismissedMonth?: string;
/** @deprecated Migrated away; no longer read. */
themeOfTheMonthLastSeenId?: string; themeOfTheMonthLastSeenId?: string;
/** Permanently disables Theme of the Month startup prompts. */ /** Permanently disables Theme of the Month startup prompts. */
themeOfTheMonthDisabled?: boolean; themeOfTheMonthDisabled?: boolean;