Files
openobserve/web/src/utils/theme.ts
T
Harsh Mahajanandgithub-actions[bot] 5f97a00d13 feat(announcements): banner editor with custom styles, link buttons and scheduling (#15194)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-13 12:20:41 +00:00

460 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <http://www.gnu.org/licenses/>.
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<void>;
};
};
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<string, string> => {
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 <html> for the O2 component library (Tailwind dark
// variant). `.dark` on <html> 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"));
};