// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
import { invalidateChartTheme } from "@/utils/chartTheme";
/**
* Run a light↔dark mode switch inside a View Transition so the whole page
* cross-fades as one frame. Without this, elements that carry their own CSS
* color transitions (inputs, selects, buttons) animate to the new theme a beat
* after everything else snaps, which reads as a flash.
*
* Every DOM write of the switch (the `.dark` class toggle, store dispatch,
* applyThemeColors) must happen inside `applyChanges` so the transition
* captures them together.
*
* Applies instantly (no fade) when:
* - the requested mode is already active — callers sync each other, and a
* second startViewTransition would cancel the fade already running;
* - the View Transitions API is unavailable;
* - the user prefers reduced motion.
*/
export const switchThemeMode = (mode: "light" | "dark", applyChanges: () => void): void => {
const root = document.documentElement;
const alreadyApplied = root.classList.contains("dark") === (mode === "dark");
const doc = document as Document & {
startViewTransition?: (callback: () => void) => {
finished: Promise;
};
};
if (alreadyApplied) {
applyChanges();
return;
}
// Freeze per-element CSS transitions (inputs, buttons carry their own
// `transition-colors`) while the switch is in flight, so every element snaps
// to its final color inside the sweep instead of animating a beat behind the
// rest of the page. The matching CSS lives in styles/tailwind.css.
const freeze = () => root.classList.add("theme-switching");
const unfreeze = () => root.classList.remove("theme-switching");
if (
typeof doc.startViewTransition !== "function" ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
freeze();
applyChanges();
// Two frames: let the new colors paint before transitions re-enable.
requestAnimationFrame(() => requestAnimationFrame(unfreeze));
return;
}
// The visual itself (soft top→bottom curtain sweep) is defined entirely in
// CSS on the ::view-transition pseudos in styles/tailwind.css.
const transition = doc.startViewTransition(() => {
freeze();
applyChanges();
});
transition.finished.then(unfreeze, unfreeze);
};
/**
* Resolve a design-token CSS custom property to its concrete value at runtime.
*
* Charts (ECharts options, SVG data-URI symbols, canvas) can't use CSS `var()` —
* they need a literal color string. This reads the token from the document root
* so the single source of truth stays the token layer (`lib/styles/tokens/*`),
* not a hardcoded hex. `fallback` is returned when the token is unset or the DOM
* is unavailable (SSR / unit tests), so callers still get a sensible value.
*
* @param token - Token name, with or without the leading `--` (e.g. "--color-indigo-500")
* @param fallback - Value to use if the token can't be read
*/
export const cssToken = (token: string, fallback: string): string => {
if (typeof document === "undefined" || !document.documentElement) {
return fallback;
}
const name = token.startsWith("--") ? token : `--${token}`;
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
};
/**
* Helper function to convert hex color to rgba
* @param hex - Hex color code (e.g., "#3F7994")
* @param opacity - Opacity value (1-10 scale, where 10 = fully opaque)
* @returns RGBA color string
*/
export const hexToRgba = (hex: string, opacity: number): string => {
hex = hex.replace("#", "");
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
const alpha = opacity / 10; // Convert 1-10 scale to 0.1-1.0
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
/**
* WCAG relative luminance of a hex color (sRGB, gamma-corrected).
*/
const relativeLuminance = (hex: string): number => {
hex = hex.replace("#", "");
const channel = (v: number) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
const r = channel(parseInt(hex.substring(0, 2), 16));
const g = channel(parseInt(hex.substring(2, 4), 16));
const b = channel(parseInt(hex.substring(4, 6), 16));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
/** WCAG contrast ratio (1–21) between two hex colors. */
const contrastRatio = (hexA: string, hexB: string): number => {
const a = relativeLuminance(hexA);
const b = relativeLuminance(hexB);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
};
/**
* Pick white or dark label text for a button whose background is theme-color
* derived. Predefined dark-mode theme colors are deliberately light/pastel (so
* they double as readable text/link accents on dark surfaces) — which can drop
* hardcoded white button-label text well below readable, e.g. O2 Signature's
* dark accent #8B8DF0 with white text lands at 2.93:1. Falls back to dark text
* only below a floor, not the strict 4.5:1 AA text bar: every saturated brand
* color (e.g. O2 Signature light #6B76E3, white text at 3.94:1) already ships
* with white and sits just under that bar, so comparing "whichever wins" flips
* text to dark across the whole app over a fractional AA shortfall.
*/
const pickButtonForeground = (bgHex: string): string => {
const darkText = cssToken("--color-grey-900", "#171717");
const white = "#FFFFFF";
const MIN_WHITE_CONTRAST = 3;
return contrastRatio(white, bgHex) >= MIN_WHITE_CONTRAST ? white : darkText;
};
/** Text for an arbitrary user-chosen background, always at least WCAG AA (4.5:1). */
export const pickReadableForeground = (bgHex: string): string => {
const darkText = cssToken("--color-grey-900", "#171717");
const white = "#FFFFFF";
const best = contrastRatio(white, bgHex) >= contrastRatio(darkText, bgHex) ? white : darkText;
// On mid-tones (luminance about 0.15-0.26) neither reaches 4.5, but pure black always does.
return contrastRatio(best, bgHex) >= 4.5 ? best : "#000000";
};
/**
* Mix two colors together (similar to CSS color-mix)
* @param color1 - First hex color code (e.g., "#3F7994")
* @param color2 - Second hex color code (e.g., "#FFFFFF")
* @param percentage - Percentage of color1 (0-100, where 100 = fully color1)
* @returns Hex color string of the mixed result
*/
export const mixColors = (color1: string, color2: string, percentage: number): string => {
color1 = color1.replace("#", "");
color2 = color2.replace("#", "");
const r1 = parseInt(color1.substring(0, 2), 16);
const g1 = parseInt(color1.substring(2, 4), 16);
const b1 = parseInt(color1.substring(4, 6), 16);
const r2 = parseInt(color2.substring(0, 2), 16);
const g2 = parseInt(color2.substring(2, 4), 16);
const b2 = parseInt(color2.substring(4, 6), 16);
const ratio = percentage / 100;
const r = Math.round(r1 * ratio + r2 * (1 - ratio));
const g = Math.round(g1 * ratio + g2 * (1 - ratio));
const b = Math.round(b1 * ratio + b2 * (1 - ratio));
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
};
export interface SemanticColors {
error: string; // coral hex, e.g. "#F45B49"
errorBg: string; // coral tint bg, e.g. "#FEF0EE"
errorText: string; // dark coral for text, e.g. "#C0392B"
success: string; // emerald hex, e.g. "#5ACA7A"
successBg: string; // emerald tint bg, e.g. "#EAF9EF"
successText: string; // dark emerald for text, e.g. "#208A3C"
secondaryBtnBg: string;
secondaryBtnText: string;
secondaryBtnBorder: string;
// Outline + ghost button tinting — makes Cancel, Builder/SQL, icon buttons use primary color
outlineText?: string;
outlineBorder?: string;
ghostText?: string;
}
/**
* Generate a full primary color palette from a single base hex color.
* The base color is treated as the 600 shade; lighter shades are mixed
* towards white, darker shades towards black.
*/
const generatePrimaryPalette = (baseHex: string): Record => {
return {
"50": mixColors(baseHex, "#ffffff", 5),
"100": mixColors(baseHex, "#ffffff", 15),
"200": mixColors(baseHex, "#ffffff", 30),
"300": mixColors(baseHex, "#ffffff", 50),
"400": mixColors(baseHex, "#ffffff", 70),
"500": mixColors(baseHex, "#ffffff", 85),
"600": baseHex,
"700": mixColors(baseHex, "#000000", 80),
"800": mixColors(baseHex, "#000000", 60),
"900": mixColors(baseHex, "#000000", 40),
// 950 continues the linear −20 step (700:80 → 800:60 → 900:40 → 950:20).
// dark.css consumes --color-primary-950 for two surfaces. For the default
// #3F7994 base, 20% reproduces the stock teal #0d181e (13/63 ≈ 0.20).
"950": mixColors(baseHex, "#000000", 20),
};
};
/**
* Sync the O2 component-library design tokens (--color-primary-*)
* with the current custom theme color so all O2 components pick it up.
*/
const syncO2LibraryTokens = (themeColor: string): void => {
const palette = generatePrimaryPalette(themeColor);
const root = document.documentElement;
for (const [shade, hex] of Object.entries(palette)) {
root.style.setProperty(`--color-primary-${shade}`, hex);
}
// The "primary" button variant's label text must stay readable against its
// resting background (the 600 shade) — see pickButtonForeground for why this
// can't be hardcoded to white.
root.style.setProperty("--color-button-primary-foreground", pickButtonForeground(palette["600"]));
};
/**
* Apply theme colors directly to CSS variables
* @param themeColor - Hex color code for the theme
* @param mode - Theme mode ("light" or "dark")
* @param isDefault - Whether this is the default theme
*/
export const applyThemeColors = (
themeColor: string,
mode: "light" | "dark",
isDefault: boolean = false,
semanticColors?: SemanticColors,
) => {
const darkModeActive = mode === "dark";
// Toggle .dark class on for the O2 component library (Tailwind dark
// variant). `.dark` on is the single dark-mode signal.
document.documentElement.classList.toggle("dark", darkModeActive);
// Sync O2 library tokens with the custom theme color.
// When using the default theme, clear any previously-set inline primary palette so
// the CSS token values in base.css take effect (inline styles beat stylesheets).
if (isDefault) {
const root = document.documentElement;
["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"].forEach(
(shade) => {
root.style.removeProperty(`--color-primary-${shade}`);
},
);
root.style.removeProperty("--color-button-primary-foreground");
} else {
syncO2LibraryTokens(themeColor);
}
if (darkModeActive) {
// Apply dark mode theme color
const rgbaColor = hexToRgba(themeColor, 10);
document.body.style.setProperty("--color-theme-accent", rgbaColor);
// Apply table header background color (80% theme color mixed with 20% black for dark mode)
const tableHeaderBg = mixColors(themeColor, "#000000", 40);
document.body.style.setProperty("--color-theme-table-header-bg", tableHeaderBg);
// Apply tab background colors for dark mode
// --color-theme-tab-bg: 20% white + 80% theme color (inverted from light mode)
const tabBg = hexToRgba(themeColor, 3); // 0.8 alpha (80% theme color)
document.body.style.setProperty("--color-theme-tab-bg", tabBg);
// --color-theme-tab-bg-inactive: 10% theme color mixed with dark background
const inactiveTabBg = hexToRgba(themeColor, 1); // 0.1 alpha (10% theme color)
document.body.style.setProperty("--color-theme-tab-bg-inactive", inactiveTabBg);
// // Apply header menu background color for dark mode (30% theme color)
// const headerMenuBg = hexToRgba(themeColor, 2); // 0.3 alpha (30% theme color)
// document.body.style.setProperty('--color-theme-header-menu-bg', headerMenuBg);
// Apply menu gradient colors
if (isDefault) {
// Use default menu gradient colors
document.body.style.setProperty(
"--color-theme-menu-gradient-start",
"rgba(89, 155, 174, 0.3)",
);
document.body.style.setProperty("--color-theme-menu-gradient-end", "rgba(48, 193, 233, 0.3)");
// Use default menu color for dark mode
document.body.style.setProperty("--color-theme-menu-color", "#FFFFFF");
} else {
// Calculate menu gradient from theme color
const menuGradientStart = hexToRgba(themeColor, 3); // 0.3 alpha (30%)
const menuGradientEnd = hexToRgba(themeColor, 3); // 0.3 alpha (30%)
document.body.style.setProperty("--color-theme-menu-gradient-start", menuGradientStart);
document.body.style.setProperty("--color-theme-menu-gradient-end", menuGradientEnd);
// Use theme color as menu color for dark mode
document.body.style.setProperty("--color-theme-menu-color", themeColor);
}
// Clear light mode variables
document.documentElement.style.removeProperty("--color-theme-accent");
document.documentElement.style.removeProperty("--color-theme-body-bg-primary");
document.documentElement.style.removeProperty("--color-theme-body-bg-secondary");
document.documentElement.style.removeProperty("--color-theme-table-header-bg");
document.documentElement.style.removeProperty("--color-theme-tab-bg");
document.documentElement.style.removeProperty("--color-theme-tab-bg-inactive");
document.documentElement.style.removeProperty("--color-theme-header-menu-bg");
document.documentElement.style.removeProperty("--color-theme-menu-gradient-start");
document.documentElement.style.removeProperty("--color-theme-menu-gradient-end");
document.documentElement.style.removeProperty("--color-theme-menu-color");
// Page background = a single near-black color with a subtle theme tint (no
// gradient), mirroring the light-mode single-tint treatment.
const darkBodyBg = mixColors(themeColor, "#000000", 8); // 8% theme + 92% black
document.body.style.setProperty("background", darkBodyBg, "important");
} else {
// Apply light mode theme color
const rgbaColor = hexToRgba(themeColor, 10);
document.documentElement.style.setProperty("--color-theme-accent", rgbaColor);
// Auto-calculate and apply background colors based on theme color.
// primaryBg (1%) is reused as a subtle surface tint by other components
// (QueryInspector, .bg-white) — keep it. secondaryBg is the far stop of the
// page-background gradient on alerts/AddDestination.vue + alerts/AddTemplate.vue.
const primaryBg = hexToRgba(themeColor, 0.1); // 0.01 alpha (1%)
const secondaryBg = hexToRgba(themeColor, 4); // 0.4 alpha (40%)
document.documentElement.style.setProperty("--color-theme-body-bg-primary", primaryBg);
document.documentElement.style.setProperty("--color-theme-body-bg-secondary", secondaryBg);
// Page background = a single, subtle primary tint (no gradient) so the muted
// area around the white content card reads as one calm color — matching the
// page chrome (surface-chrome = primary-100).
const bodyBg = hexToRgba(themeColor, 0.5); // ~0.05 alpha — a calm, barely-tinted backdrop
document.body.style.setProperty("background", bodyBg, "important");
// Apply table header background color (80% theme color mixed with 20% white)
const tableHeaderBg = mixColors(themeColor, "#FFFFFF", 30);
document.documentElement.style.setProperty("--color-theme-table-header-bg", tableHeaderBg);
// Apply tab background colors for light mode
// --color-theme-tab-bg: 20% theme color + 80% white
const tabBg = hexToRgba(themeColor, 2); // 0.2 alpha (20% theme color)
document.documentElement.style.setProperty("--color-theme-tab-bg", tabBg);
// --color-theme-tab-bg-inactive: 10% theme color + 90% white
const inactiveTabBg = hexToRgba(themeColor, 1); // 0.1 alpha (10% theme color)
document.documentElement.style.setProperty("--color-theme-tab-bg-inactive", inactiveTabBg);
// Apply menu gradient colors
if (isDefault) {
// Use default menu gradient colors
document.documentElement.style.setProperty(
"--color-theme-menu-gradient-start",
"rgba(89, 175, 199, 0.3)",
);
document.documentElement.style.setProperty(
"--color-theme-menu-gradient-end",
"rgba(48, 193, 233, 0.3)",
);
// Use default menu color for light mode
document.documentElement.style.setProperty("--color-theme-menu-color", "#3F7994");
} else {
// Calculate menu gradient from theme color
const menuGradientStart = hexToRgba(themeColor, 3); // 0.3 alpha (30%)
const menuGradientEnd = hexToRgba(themeColor, 3); // 0.3 alpha (30%)
document.documentElement.style.setProperty(
"--color-theme-menu-gradient-start",
menuGradientStart,
);
document.documentElement.style.setProperty(
"--color-theme-menu-gradient-end",
menuGradientEnd,
);
// Use theme color as menu color for light mode
document.documentElement.style.setProperty("--color-theme-menu-color", themeColor);
}
// Clear dark mode variables
document.body.style.removeProperty("--color-theme-accent");
document.body.style.removeProperty("--color-theme-table-header-bg");
document.body.style.removeProperty("--color-theme-tab-bg");
document.body.style.removeProperty("--color-theme-tab-bg-inactive");
document.body.style.removeProperty("--color-theme-header-menu-bg");
document.body.style.removeProperty("--color-theme-menu-gradient-start");
document.body.style.removeProperty("--color-theme-menu-gradient-end");
document.body.style.removeProperty("--color-theme-menu-color");
}
// Apply semantic colors (O2 Signature triadic theme and any future multi-color themes)
const semanticTokenNames = [
"--color-status-negative",
"--color-status-error-text",
"--color-status-error-bg",
"--color-status-positive",
"--color-status-success-text",
"--color-status-success-bg",
"--color-button-secondary",
"--color-button-secondary-foreground",
"--color-button-secondary-border",
"--color-button-outline-text",
"--color-button-outline-border",
"--color-button-ghost-text",
];
if (semanticColors) {
// Clear both targets first to avoid stale values from a prior mode switch
semanticTokenNames.forEach((t) => {
document.body.style.removeProperty(t);
document.documentElement.style.removeProperty(t);
});
const target = darkModeActive ? document.body : document.documentElement;
target.style.setProperty("--color-status-negative", semanticColors.error);
target.style.setProperty("--color-status-error-text", semanticColors.errorText);
target.style.setProperty("--color-status-error-bg", semanticColors.errorBg);
target.style.setProperty("--color-status-positive", semanticColors.success);
target.style.setProperty("--color-status-success-text", semanticColors.successText);
target.style.setProperty("--color-status-success-bg", semanticColors.successBg);
target.style.setProperty("--color-button-secondary", semanticColors.secondaryBtnBg);
target.style.setProperty(
"--color-button-secondary-foreground",
semanticColors.secondaryBtnText,
);
target.style.setProperty("--color-button-secondary-border", semanticColors.secondaryBtnBorder);
if (semanticColors.outlineText)
target.style.setProperty("--color-button-outline-text", semanticColors.outlineText);
if (semanticColors.outlineBorder)
target.style.setProperty("--color-button-outline-border", semanticColors.outlineBorder);
if (semanticColors.ghostText)
target.style.setProperty("--color-button-ghost-text", semanticColors.ghostText);
} else {
semanticTokenNames.forEach((t) => {
document.documentElement.style.removeProperty(t);
document.body.style.removeProperty(t);
});
}
// Invalidate the resolved-token cache so charts read fresh values on theme change.
invalidateChartTheme();
// Dispatch event to notify components (like SearchResult) to re-render
window.dispatchEvent(new CustomEvent("themeColorChanged"));
};