diff --git a/README.md b/README.md index 610695a7..40a0855b 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,10 @@ artifacts. the fee at `0` to pass through every satoshi. - Populate `DEFAULT_RELAY_URLS_OVERRIDE` with WSS URLs to replace the bundled relay bootstrap list. Keep it empty to stick with the upstream defaults. + - Customize `THEME_ACCENT_OVERRIDES` with `#RRGGBB` hex strings when you want + light or dark mode to use different accent, accent-strong, or + accent-pressed colors. Leave the values `null` to inherit the defaults from + `css/tokens.css`. - **`js/config.js`**: - Re-exports `isDevMode` (derived from `IS_DEV_MODE`) for modules, publishes `window.__BITVID_DEV_MODE__`, and centralizes the global configuration diff --git a/config/instance-config.js b/config/instance-config.js index 680481a1..82e5f5d6 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -125,6 +125,28 @@ export const DEFAULT_WHITELIST_MODE_ENABLED = true; */ export const ALLOW_NSFW_CONTENT = false; +/** + * Optional theme accent overrides for light and dark mode. + * + * Operators can provide hex color strings (e.g., `"#2563eb"`) to customize the + * accent tokens without editing the generated CSS bundle. Leave values as + * `null` to keep the upstream defaults defined in `css/tokens.css`. Each theme + * accepts overrides for the base accent color as well as the stronger and + * pressed states used for hover/active presentations. + */ +export const THEME_ACCENT_OVERRIDES = Object.freeze({ + light: Object.freeze({ + accent: null, + accentStrong: null, + accentPressed: null, + }), + dark: Object.freeze({ + accent: null, + accentStrong: null, + accentPressed: null, + }), +}); + /** * Nostr kind used when persisting watch history events. * diff --git a/config/validate-config.js b/config/validate-config.js index 2a0f54d9..d94fda1d 100644 --- a/config/validate-config.js +++ b/config/validate-config.js @@ -7,6 +7,7 @@ import { ADMIN_SUPER_NPUB, PLATFORM_FEE_PERCENT, PLATFORM_LUD16_OVERRIDE, + THEME_ACCENT_OVERRIDES, } from "./instance-config.js"; function assertSuperAdminConfigured(value) { @@ -56,6 +57,50 @@ function getPlatformLud16Override(value) { return value.trim(); } +const ACCENT_HEX_PATTERN = /^#(?:[0-9a-fA-F]{6})$/; + +function assertThemeAccentOverrides(overrides) { + const source = + overrides && typeof overrides === "object" + ? overrides + : THEME_ACCENT_OVERRIDES; + + const allowedThemes = new Set(["light", "dark"]); + const allowedTokens = ["accent", "accentStrong", "accentPressed"]; + + Object.entries(source).forEach(([theme, themeOverrides]) => { + if (!allowedThemes.has(theme) || themeOverrides == null) { + return; + } + + if (typeof themeOverrides !== "object") { + throw new Error( + `THEME_ACCENT_OVERRIDES.${theme} must be an object with accent overrides.` + ); + } + + allowedTokens.forEach((token) => { + const rawValue = themeOverrides[token]; + if (rawValue == null || rawValue === "") { + return; + } + + if (typeof rawValue !== "string") { + throw new Error( + `THEME_ACCENT_OVERRIDES.${theme}.${token} must be a hex string (e.g., '#2563eb').` + ); + } + + const trimmed = rawValue.trim(); + if (!ACCENT_HEX_PATTERN.test(trimmed)) { + throw new Error( + `THEME_ACCENT_OVERRIDES.${theme}.${token} must be a #RRGGBB hex color.` + ); + } + }); + }); +} + function resolveConfig(overrides = {}) { return { adminSuperNpub: @@ -64,6 +109,8 @@ function resolveConfig(overrides = {}) { overrides.PLATFORM_FEE_PERCENT ?? PLATFORM_FEE_PERCENT, platformLud16Override: overrides.PLATFORM_LUD16_OVERRIDE ?? PLATFORM_LUD16_OVERRIDE, + themeAccentOverrides: + overrides.THEME_ACCENT_OVERRIDES ?? THEME_ACCENT_OVERRIDES, }; } @@ -82,4 +129,6 @@ export function validateInstanceConfig(overrides) { "PLATFORM_FEE_PERCENT is positive but PLATFORM_LUD16_OVERRIDE is empty. Set PLATFORM_LUD16_OVERRIDE to the Lightning address that should receive the platform's split, or publish a lud16 value on the Super Admin profile so bitvid can route platform fees." ); } + + assertThemeAccentOverrides(config.themeAccentOverrides); } diff --git a/js/themeController.js b/js/themeController.js index ddca025d..8d278a50 100644 --- a/js/themeController.js +++ b/js/themeController.js @@ -1,3 +1,4 @@ +import { THEME_ACCENT_OVERRIDES } from "../config/instance-config.js"; import { userLogger } from "./utils/logger.js"; const STORAGE_KEY = "bitvid:theme"; const FALLBACK_THEME = "dark"; @@ -13,6 +14,12 @@ const TOGGLE_SELECTORS = [ "#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; @@ -70,6 +77,34 @@ const getRootElement = () => { return null; }; +const normalizeAccentValue = (value) => + typeof value === "string" ? value.trim() : ""; + +const applyAccentOverrides = (root, theme) => { + if (!root) { + return; + } + + const themeOverrides = + THEME_ACCENT_OVERRIDES && + typeof THEME_ACCENT_OVERRIDES === "object" && + THEME_ACCENT_OVERRIDES[theme] && + typeof THEME_ACCENT_OVERRIDES[theme] === "object" + ? THEME_ACCENT_OVERRIDES[theme] + : null; + + 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); + } else { + root.style.removeProperty(cssVariable); + } + }); +}; + const updateThemeColorMeta = (theme) => { if (!isBrowser()) { return; @@ -202,6 +237,7 @@ const applyTheme = (theme, { persist = true } = {}) => { const root = getRootElement(); if (root) { root.dataset.theme = normalized; + applyAccentOverrides(root, normalized); } if (persist) {