mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
460 lines
20 KiB
TypeScript
460 lines
20 KiB
TypeScript
// 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"));
|
||
};
|