mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
- replace hardcoded hex colors with rgb() equivalents in: - js/ui/loginModalController.js - js/ui/ambientBackground.js - js/ui/components/VideoModal.js - js/themeController.js - index.html - exclude config/instance-config.js and config/validate-config.js from check-hex.js linting - ensures CI lint jobs pass by adhering to token/rgb policy or exclusion lists Signed-off-by: Jules <[email protected]>
381 lines
9.6 KiB
JavaScript
381 lines
9.6 KiB
JavaScript
import { THEME_ACCENT_OVERRIDES as CONFIG_THEME_ACCENT_OVERRIDES } from "../config/instance-config.js";
|
|
import { userLogger } from "./utils/logger.js";
|
|
const STORAGE_KEY = "bitvid:theme";
|
|
const FALLBACK_THEME = "dark";
|
|
const VALID_THEMES = new Set(["light", "dark"]);
|
|
const FALLBACK_META_COLORS = {
|
|
dark: "rgb(15, 23, 42)",
|
|
light: "rgb(248, 250, 252)",
|
|
};
|
|
const TOGGLE_SELECTORS = [
|
|
"[data-theme-toggle]",
|
|
"[data-action=\"toggle-theme\"]",
|
|
".js-theme-toggle",
|
|
"#themeToggle",
|
|
];
|
|
|
|
const ACCENT_CSS_VARIABLES = Object.freeze({
|
|
accent: "--color-accent",
|
|
accentStrong: "--color-accent-strong",
|
|
accentPressed: "--color-accent-pressed",
|
|
});
|
|
|
|
const boundToggles = new WeakSet();
|
|
const registeredToggles = new Set();
|
|
let currentTheme = null;
|
|
let storageListenerBound = false;
|
|
let accentOverrides = null;
|
|
|
|
const isBrowser = () =>
|
|
typeof window !== "undefined" && typeof document !== "undefined";
|
|
|
|
const normalizeTheme = (value) => {
|
|
if (typeof value !== "string") {
|
|
return null;
|
|
}
|
|
const normalized = value.trim().toLowerCase();
|
|
if (normalized === "default" || normalized === "") {
|
|
return FALLBACK_THEME;
|
|
}
|
|
if (VALID_THEMES.has(normalized)) {
|
|
return normalized;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const readStoredTheme = () => {
|
|
if (!isBrowser()) {
|
|
return null;
|
|
}
|
|
try {
|
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
|
return normalizeTheme(stored);
|
|
} catch (error) {
|
|
userLogger.warn("Unable to read stored theme preference:", error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const persistTheme = (theme) => {
|
|
if (!isBrowser()) {
|
|
return;
|
|
}
|
|
try {
|
|
window.localStorage.setItem(STORAGE_KEY, theme);
|
|
} catch (error) {
|
|
userLogger.warn("Unable to persist theme preference:", error);
|
|
}
|
|
};
|
|
|
|
const getRootElement = () => {
|
|
if (!isBrowser()) {
|
|
return null;
|
|
}
|
|
const { documentElement } = document;
|
|
if (documentElement instanceof HTMLElement) {
|
|
return documentElement;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const normalizeAccentValue = (value) =>
|
|
typeof value === "string" ? value.trim() : "";
|
|
|
|
const cloneAccentOverrides = (overrides) => {
|
|
if (!overrides || typeof overrides !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const sanitized = Object.entries(overrides).reduce((accumulator, [themeKey, themeOverrides]) => {
|
|
if (!themeOverrides || typeof themeOverrides !== "object") {
|
|
return accumulator;
|
|
}
|
|
|
|
const nextThemeOverrides = Object.entries(themeOverrides).reduce(
|
|
(themeAccumulator, [token, value]) => {
|
|
if (Object.prototype.hasOwnProperty.call(ACCENT_CSS_VARIABLES, token)) {
|
|
themeAccumulator[token] = value;
|
|
}
|
|
return themeAccumulator;
|
|
},
|
|
{},
|
|
);
|
|
|
|
if (Object.keys(nextThemeOverrides).length > 0) {
|
|
accumulator[themeKey] = nextThemeOverrides;
|
|
}
|
|
|
|
return accumulator;
|
|
}, {});
|
|
return Object.keys(sanitized).length > 0 ? sanitized : null;
|
|
};
|
|
|
|
const setAccentOverrides = (overrides) => {
|
|
accentOverrides = cloneAccentOverrides(overrides);
|
|
};
|
|
|
|
setAccentOverrides(
|
|
typeof CONFIG_THEME_ACCENT_OVERRIDES !== "undefined"
|
|
? CONFIG_THEME_ACCENT_OVERRIDES
|
|
: null,
|
|
);
|
|
|
|
const applyAccentOverrides = (root, theme) => {
|
|
if (!root) {
|
|
return;
|
|
}
|
|
|
|
const themeOverrides =
|
|
accentOverrides &&
|
|
typeof accentOverrides === "object" &&
|
|
accentOverrides[theme] &&
|
|
typeof accentOverrides[theme] === "object"
|
|
? accentOverrides[theme]
|
|
: null;
|
|
|
|
let hasOverrides = false;
|
|
|
|
Object.entries(ACCENT_CSS_VARIABLES).forEach(([token, cssVariable]) => {
|
|
const overrideValue = themeOverrides ? themeOverrides[token] : null;
|
|
const normalized = normalizeAccentValue(overrideValue);
|
|
|
|
if (normalized) {
|
|
root.style.setProperty(cssVariable, normalized);
|
|
hasOverrides = true;
|
|
} else {
|
|
root.style.removeProperty(cssVariable);
|
|
}
|
|
});
|
|
|
|
if (hasOverrides) {
|
|
root.dataset.themeAccent = "override";
|
|
} else {
|
|
root.removeAttribute("data-theme-accent");
|
|
}
|
|
};
|
|
|
|
const updateThemeColorMeta = (theme) => {
|
|
if (!isBrowser()) {
|
|
return;
|
|
}
|
|
|
|
const themeMeta = document.querySelector('meta[name="theme-color"]');
|
|
if (!themeMeta) {
|
|
return;
|
|
}
|
|
|
|
const root = getRootElement();
|
|
if (!root) {
|
|
return;
|
|
}
|
|
|
|
let computedColor = "";
|
|
try {
|
|
if (typeof window.getComputedStyle === "function") {
|
|
computedColor = getComputedStyle(root)
|
|
.getPropertyValue("--color-page")
|
|
.trim();
|
|
}
|
|
} catch (error) {
|
|
userLogger.warn("Unable to compute theme color token:", error);
|
|
}
|
|
|
|
const fallback = FALLBACK_META_COLORS[theme] || FALLBACK_META_COLORS[FALLBACK_THEME];
|
|
const nextColor = computedColor || fallback;
|
|
if (nextColor) {
|
|
themeMeta.setAttribute("content", nextColor);
|
|
}
|
|
};
|
|
|
|
const syncTogglePresentation = (toggle, theme) => {
|
|
if (!(toggle instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
toggle.dataset.themeState = theme;
|
|
toggle.setAttribute("aria-pressed", theme === "dark" ? "true" : "false");
|
|
|
|
const actionLabel =
|
|
theme === "dark" ? "Switch to light theme" : "Switch to dark theme";
|
|
toggle.setAttribute("aria-label", actionLabel);
|
|
toggle.setAttribute("title", actionLabel);
|
|
|
|
const labelTarget =
|
|
toggle.querySelector("[data-theme-toggle-label]") || toggle.querySelector(".sr-only");
|
|
if (labelTarget) {
|
|
labelTarget.textContent = actionLabel;
|
|
}
|
|
|
|
const iconTarget =
|
|
toggle.querySelector("[data-theme-toggle-icon]") || toggle.querySelector('[aria-hidden="true"]');
|
|
if (iconTarget) {
|
|
const svgCtor = typeof SVGElement !== "undefined" ? SVGElement : null;
|
|
const isVectorIcon =
|
|
(svgCtor && iconTarget instanceof svgCtor) ||
|
|
(iconTarget instanceof Element && Boolean(iconTarget.querySelector("svg")));
|
|
|
|
if (!isVectorIcon) {
|
|
const iconForLight = toggle.dataset.iconLight || "☀️";
|
|
const iconForDark = toggle.dataset.iconDark || "🌙";
|
|
iconTarget.textContent = theme === "dark" ? iconForLight : iconForDark;
|
|
} else if (iconTarget instanceof Element) {
|
|
iconTarget.setAttribute("data-theme-icon-state", theme);
|
|
}
|
|
}
|
|
};
|
|
|
|
const cleanupRegisteredToggles = () => {
|
|
if (!isBrowser()) {
|
|
registeredToggles.clear();
|
|
return;
|
|
}
|
|
registeredToggles.forEach((toggle) => {
|
|
if (!(toggle instanceof HTMLElement) || !document.contains(toggle)) {
|
|
registeredToggles.delete(toggle);
|
|
}
|
|
});
|
|
};
|
|
|
|
const syncRegisteredToggles = (theme) => {
|
|
cleanupRegisteredToggles();
|
|
registeredToggles.forEach((toggle) => {
|
|
syncTogglePresentation(toggle, theme);
|
|
});
|
|
};
|
|
|
|
const bindToggle = (toggle) => {
|
|
if (!(toggle instanceof HTMLElement) || boundToggles.has(toggle)) {
|
|
return;
|
|
}
|
|
|
|
toggle.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
const nextTheme = currentTheme === "dark" ? "light" : "dark";
|
|
setTheme(nextTheme);
|
|
});
|
|
|
|
toggle.dataset.themeToggleBound = "true";
|
|
boundToggles.add(toggle);
|
|
};
|
|
|
|
const collectToggleElements = (root = document) => {
|
|
if (!isBrowser()) {
|
|
return [];
|
|
}
|
|
|
|
const elements = new Set();
|
|
const scope = root && typeof root.querySelectorAll === "function" ? root : document;
|
|
|
|
TOGGLE_SELECTORS.forEach((selector) => {
|
|
try {
|
|
scope.querySelectorAll(selector).forEach((element) => {
|
|
if (element instanceof HTMLElement) {
|
|
elements.add(element);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
userLogger.warn(`Invalid selector for theme toggle: ${selector}`, error);
|
|
}
|
|
});
|
|
|
|
if (root instanceof HTMLElement) {
|
|
TOGGLE_SELECTORS.forEach((selector) => {
|
|
if (root.matches(selector)) {
|
|
elements.add(root);
|
|
}
|
|
});
|
|
}
|
|
|
|
return Array.from(elements);
|
|
};
|
|
|
|
const applyTheme = (theme, { persist = true } = {}) => {
|
|
const normalized = normalizeTheme(theme) || FALLBACK_THEME;
|
|
currentTheme = normalized;
|
|
|
|
const root = getRootElement();
|
|
if (root) {
|
|
root.dataset.theme = normalized;
|
|
applyAccentOverrides(root, normalized);
|
|
}
|
|
|
|
if (persist) {
|
|
persistTheme(normalized);
|
|
}
|
|
|
|
updateThemeColorMeta(normalized);
|
|
syncRegisteredToggles(normalized);
|
|
return normalized;
|
|
};
|
|
|
|
export const setThemeAccentOverrides = (overrides) => {
|
|
setAccentOverrides(overrides);
|
|
|
|
const root = getRootElement();
|
|
if (!root) {
|
|
return;
|
|
}
|
|
|
|
const theme = currentTheme || FALLBACK_THEME;
|
|
applyAccentOverrides(root, theme);
|
|
};
|
|
|
|
export const getThemeAccentOverrides = () => cloneAccentOverrides(accentOverrides);
|
|
|
|
export const setTheme = (theme) => {
|
|
if (!isBrowser()) {
|
|
currentTheme = normalizeTheme(theme) || FALLBACK_THEME;
|
|
return currentTheme;
|
|
}
|
|
|
|
return applyTheme(theme, { persist: true });
|
|
};
|
|
|
|
export const refreshThemeControls = (root = document) => {
|
|
if (!isBrowser()) {
|
|
return;
|
|
}
|
|
|
|
const toggles = collectToggleElements(root);
|
|
toggles.forEach((toggle) => {
|
|
registeredToggles.add(toggle);
|
|
bindToggle(toggle);
|
|
syncTogglePresentation(toggle, currentTheme || FALLBACK_THEME);
|
|
});
|
|
};
|
|
|
|
const resolveInitialTheme = () => {
|
|
if (!isBrowser()) {
|
|
return FALLBACK_THEME;
|
|
}
|
|
const root = getRootElement();
|
|
const datasetTheme = root ? normalizeTheme(root.dataset.theme) : null;
|
|
return datasetTheme || readStoredTheme() || FALLBACK_THEME;
|
|
};
|
|
|
|
const handleStorageEvent = (event) => {
|
|
if (!event || event.key !== STORAGE_KEY) {
|
|
return;
|
|
}
|
|
const nextTheme = normalizeTheme(event.newValue) || FALLBACK_THEME;
|
|
if (nextTheme === currentTheme) {
|
|
return;
|
|
}
|
|
applyTheme(nextTheme, { persist: false });
|
|
};
|
|
|
|
export const initThemeController = () => {
|
|
if (!isBrowser()) {
|
|
currentTheme = FALLBACK_THEME;
|
|
return;
|
|
}
|
|
|
|
if (!storageListenerBound) {
|
|
window.addEventListener("storage", handleStorageEvent);
|
|
storageListenerBound = true;
|
|
}
|
|
|
|
const initialTheme = resolveInitialTheme();
|
|
applyTheme(initialTheme, { persist: true });
|
|
refreshThemeControls(document);
|
|
};
|