/
vshmidt
/
streamlit
Обзор
Документация
Войти
/
vshmidt
/
streamlit
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
frontend/lib/src/theme/utils.ts
1 670 строк
53 KB
Lukas Masuch
Remove deprecated proto fields and unused theme creator code (#13842)
10 фев 2026, 01:13
Не верифицирован
10 фев 2026, 01:13
8722152
Код
Авторство
О чём код?
/** * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2026) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { darken, getLuminance, lighten, parseToRgba, toHex, transparentize, } from "color2k" import { cloneDeep, isObject, merge, mergeWith, once } from "lodash-es" import { getLogger } from "loglevel" import { CustomThemeConfig, ICustomThemeConfig } from "@streamlit/protobuf" import { localStorageAvailable, StreamlitConfig } from "@streamlit/utils" import { CircularBuffer } from "~lib/components/shared/Profiler/CircularBuffer" import { baseTheme, CachedTheme, darkTheme, EmotionTheme, lightTheme, ThemeConfig, ThemeSelection, ThemeSpacing, } from "~lib/theme" import { LocalStore } from "~lib/util/storageUtils" import { isDarkThemeInQueryParams, isLightThemeInQueryParams, notNullOrUndefined, } from "~lib/util/utils" import { createBaseUiTheme } from "./createBaseUiTheme" import { computeDerivedColors, createEmotionColors } from "./getColors" import { createShadows } from "./getShadows" import { fonts } from "./primitives/typography" import { DerivedColors, EmotionThemeColors } from "./types" export const AUTO_THEME_NAME = "Use system setting" export const CUSTOM_THEME_NAME = "Custom Theme" export const CUSTOM_THEME_LIGHT_NAME = "Custom Theme Light" export const CUSTOM_THEME_DARK_NAME = "Custom Theme Dark" export const CUSTOM_THEME_AUTO_NAME = "Custom Theme Auto" declare global { interface Window { __streamlit_profiles__?: Record< string, CircularBuffer<{ phase: "mount" | "update" | "nested-update" actualDuration: number baseDuration: number startTime: number commitTime: number }> > } } const LOG = getLogger("theme:utils") /** * Recursively sorts the theme input keys to ensure consistent theme hashing. * Used in App.tsx createThemeHash method. * * @param obj - The theme input object (or any nested value within it) * @returns The same structure with all object keys sorted alphabetically */ export function sortThemeInputKeys(obj: unknown): unknown { if (obj === null || obj === undefined) { return obj } // Handle arrays by recursively sorting their elements if (Array.isArray(obj)) { return obj.map(item => sortThemeInputKeys(item)) } // Handle objects (including nested theme sections) if (typeof obj === "object") { const sorted: Record<string, unknown> = {} Object.keys(obj) .sort() .forEach(key => { sorted[key] = sortThemeInputKeys((obj as Record<string, unknown>)[key]) }) return sorted } // Return primitives as-is return obj } function mergeTheme( theme: ThemeConfig, injectedTheme: ICustomThemeConfig | undefined ): ThemeConfig { // We confirm the injectedTheme is a valid object before merging it // since the type makes assumption about the implementation of the // injected object. if (injectedTheme && isObject(injectedTheme)) { const themeConfigProto = new CustomThemeConfig(injectedTheme) // eslint-disable-next-line @typescript-eslint/no-use-before-define return createTheme(theme.name, themeConfigProto, theme) } return theme } export const getMergedLightTheme = once(() => mergeTheme(lightTheme, StreamlitConfig.LIGHT_THEME) ) export const getMergedDarkTheme = once(() => mergeTheme(darkTheme, StreamlitConfig.DARK_THEME) ) export const getSystemThemePreference = (): "light" | "dark" => { return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" } export const getSystemTheme = (): ThemeConfig => { return getSystemThemePreference() === "dark" ? getMergedDarkTheme() : getMergedLightTheme() } export const createAutoTheme = (): ThemeConfig => ({ ...getSystemTheme(), name: AUTO_THEME_NAME, }) // Update auto theme in case it has changed export const createPresetThemes = (): ThemeConfig[] => [ createAutoTheme(), getMergedLightTheme(), getMergedDarkTheme(), ] export const isPresetTheme = (themeConfig: ThemeConfig): boolean => { const presetThemeNames = createPresetThemes().map((t: ThemeConfig) => t.name) return presetThemeNames.includes(themeConfig.name) } export const bgColorToBaseString = (bgColor?: string): string => bgColor === undefined || getLuminance(bgColor) > 0.5 ? "light" : "dark" export const isColor = (strColor: string): boolean => { const s = new Option().style s.color = strColor return s.color !== "" } /** * Helper function that rounds a font size (in rem) to the nearest eighth of a rem * This is used to keep configured font sizes to (generally) round values for dialogs. * See `convertFontSizes` in `StreamlitMarkdown/styled-components.ts` * (ex: 0.78 -> 0.75) */ export const roundFontSizeToNearestEighth = (remFontSize: number): number => { return Math.round(remFontSize * 8) / 8 } export const parseFont = ( font: string, // fallbackFont is the default streamlit font to use if the custom font fails to load fallbackFont: string = fonts.sansSerif ): string => { // Handle empty/whitespace-only input const trimmedFont = font.trim() if (!trimmedFont) { return fallbackFont } // Try to map a short font family to our default // font families const fontMap: Record<string, string> = { "sans-serif": fonts.sansSerif, serif: fonts.serif, monospace: fonts.monospace, } // The old font config supported "sans serif" as a font family, but this // isn't a valid font family, so we need to support it by converting it to // "sans-serif". const fontKey = trimmedFont.toLowerCase().replaceAll(" ", "-") if (fontKey in fontMap) { return fontMap[fontKey] } // Always append Streamlit's default font as the final fallback, ensuring // that if custom fonts fail to load (including any user-specified fallbacks), // Streamlit's default fonts are used instead of the browser's defaults. // Note: This may result in redundant generic families if the user's font string // already includes one (ex: "Arial, sans-serif, "Source Sans", sans-serif"). // This is intentional - it's valid CSS and ensures appropriate fallback behavior. return `${trimmedFont}, ${fallbackFont}` } /** * Helper function to parse/validate a config color value * @param color: a string - the color value passed in as a given config * @param configKey: a string - the config that the color value was passed for * @param inSidebar: boolean - whether this is for sidebar theming (for error messages) * @returns the validated color value or undefined if the color is invalid */ const parseColor = ( color: string, configKey: string, inSidebar: boolean = false ): string | undefined => { // First try the color as-is if (isColor(color)) { return color } // If that fails, try adding a # prefix if (isColor(`#${color}`)) { return `#${color}` } // If both fail and this is a color config, log a warning const isAColorConfig = configKey.toLowerCase().includes("color") if (isAColorConfig) { const themeSection = inSidebar ? "theme.sidebar" : "theme" LOG.warn( `Invalid color passed for ${configKey} in ${themeSection}: "${color}"` ) } return undefined } /** * Helper function for theme background colors * If the background color is configured, use it. * If the main color is configured, derive background color from it. * If neither is configured, fallback to default. */ const resolveBgColor = ( configBackgroundColor: string | undefined, configMainColor: string | undefined, defaultBackgroundColor: string, isLightTheme: boolean ): string => { if (configBackgroundColor) return configBackgroundColor if (configMainColor) { const transparency = isLightTheme ? 0.9 : 0.8 return transparentize(configMainColor, transparency) } return defaultBackgroundColor } /** * Helper function for theme text colors * If the text color is configured, use it. * If the main color is configured, derive text color from it. * If neither is configured, fallback to default. */ const resolveTextColor = ( configTextColor: string | undefined, configMainColor: string | undefined, defaultTextColor: string, isLightTheme: boolean ): string => { if (configTextColor) return configTextColor if (configMainColor) { const adjustmentAmount = 0.15 return isLightTheme ? darken(configMainColor, adjustmentAmount) : lighten(configMainColor, adjustmentAmount) } return defaultTextColor } /** * Applies background color overrides to theme colors using smart fallback logic. * For each background color: uses explicit config if provided, derives from main color if available, * or falls back to default. * @param existingColors - The existing emotion theme colors object * @param parsedColors - All parsed color configurations from user input * @returns Updated emotion theme colors object with background colors applied */ const setBackgroundColors = ( existingColors: EmotionThemeColors, parsedColors: Record<string, string | undefined> ): EmotionThemeColors => { const updatedColors = { ...existingColors, } const backgroundColorMap = { redBackgroundColor: { main: parsedColors.redColor, background: parsedColors.redBackgroundColor, }, orangeBackgroundColor: { main: parsedColors.orangeColor, background: parsedColors.orangeBackgroundColor, }, yellowBackgroundColor: { main: parsedColors.yellowColor, background: parsedColors.yellowBackgroundColor, }, blueBackgroundColor: { main: parsedColors.blueColor, background: parsedColors.blueBackgroundColor, }, greenBackgroundColor: { main: parsedColors.greenColor, background: parsedColors.greenBackgroundColor, }, violetBackgroundColor: { main: parsedColors.violetColor, background: parsedColors.violetBackgroundColor, }, grayBackgroundColor: { main: parsedColors.grayColor, background: parsedColors.grayBackgroundColor, }, } const isLightTheme = getLuminance(updatedColors.bgColor) > 0.5 Object.entries(backgroundColorMap).forEach(([key, { main, background }]) => { const typedKey = key as keyof typeof backgroundColorMap updatedColors[typedKey] = resolveBgColor( background, main, existingColors[typedKey], isLightTheme ) }) return updatedColors } /** * Applies text color overrides to theme colors using smart fallback logic. * For each text color: uses explicit config if provided, derives from main color if available, * or falls back to default. * @param existingColors - The existing emotion theme colors object * @param parsedColors - All parsed color configurations from user input * @returns Updated emotion theme colors object with text colors applied */ const setTextColors = ( existingColors: EmotionThemeColors, parsedColors: Record<string, string | undefined> ): EmotionThemeColors => { const updatedColors = { ...existingColors, } const textColorMap = { redTextColor: { main: parsedColors.redColor, text: parsedColors.redTextColor, }, orangeTextColor: { main: parsedColors.orangeColor, text: parsedColors.orangeTextColor, }, yellowTextColor: { main: parsedColors.yellowColor, text: parsedColors.yellowTextColor, }, blueTextColor: { main: parsedColors.blueColor, text: parsedColors.blueTextColor, }, greenTextColor: { main: parsedColors.greenColor, text: parsedColors.greenTextColor, }, violetTextColor: { main: parsedColors.violetColor, text: parsedColors.violetTextColor, }, grayTextColor: { main: parsedColors.grayColor, text: parsedColors.grayTextColor, }, } const isLightTheme = getLuminance(updatedColors.bgColor) > 0.5 Object.entries(textColorMap).forEach(([key, { main, text }]) => { const typedKey = key as keyof typeof textColorMap updatedColors[typedKey] = resolveTextColor( text, main, existingColors[typedKey], isLightTheme ) }) return updatedColors } /** * Helper function to parse the baseRadius & buttonRadius options which allow the same possible values * @param radius: a string - "none", "small", "medium", "large", "full", a number in pixels or rem * @returns radius value and css unit */ export const parseRadius = ( radius: string ): [number | undefined, "px" | "rem"] => { let cssUnit: "px" | "rem" = "rem" let radiusValue: number | undefined = undefined const processedRadius = radius.trim().toLowerCase() if (processedRadius === "none") { radiusValue = 0 } else if (processedRadius === "small") { radiusValue = 0.35 } else if (processedRadius === "medium") { radiusValue = 0.5 } else if (processedRadius === "large") { radiusValue = 1 } else if (processedRadius === "full") { radiusValue = 1.4 } else if (processedRadius.endsWith("rem")) { radiusValue = parseFloat(processedRadius) } else if (processedRadius.endsWith("px")) { radiusValue = parseFloat(processedRadius) cssUnit = "px" } else if (!isNaN(parseFloat(processedRadius))) { // Fallback: if the value can be parsed as a number, treat it as pixels radiusValue = parseFloat(processedRadius) cssUnit = "px" } return [radiusValue, cssUnit] } /** * Helper function to parse fontSize options which allow the same possible values * @param fontSize a string number in pixels or rem; handles number values as pixels * (e.g. "15px", "0.875rem", "15") * @returns font size in em (e.g. "0.875em") */ export const parseFontSize = ( configName: string, fontSize: string | number, inSidebar: boolean ): string | undefined => { const themeSection = inSidebar ? "theme.sidebar" : "theme" if (typeof fontSize === "string") { // If string, check its valid (ends with "rem" or "px") // and can be parsed as a number const processedFontSize = fontSize.trim().toLowerCase() const parsedFontSize = parseFloat(processedFontSize) if ( parsedFontSize && (processedFontSize.endsWith("rem") || processedFontSize.endsWith("px")) ) { return processedFontSize } // Fallback: if the value can be parsed as a number, treat it as pixels if (parsedFontSize.toString() === processedFontSize) { return `${processedFontSize}px` } } // If invalid, log warning and return undefined LOG.warn( `Invalid size passed for ${configName} in ${themeSection}: ${fontSize}. Falling back to default ${configName}.` ) } /** * Validate a font weight config */ const isValidFontWeight = ( weightConfigName: string, fontWeight: number | null | undefined, minWeight: number, maxWeight: number, inSidebar?: boolean ): boolean => { const themeSection = inSidebar ? "theme.sidebar" : "theme" // If the font weight config is set, validate it (log warning if invalid) if (notNullOrUndefined(fontWeight)) { const isInteger = Number.isInteger(fontWeight) const isIncrementOf100 = fontWeight % 100 === 0 const isInRange = fontWeight >= minWeight && fontWeight <= maxWeight if (!isInteger || !isIncrementOf100 || !isInRange) { LOG.warn( `Invalid ${weightConfigName}: ${fontWeight} in ${themeSection}. The ${weightConfigName} must be an integer ${minWeight}-${maxWeight}, and an increment of 100. Falling back to default font weight.` ) return false } return true } // If the font weight config is not set, return false return false } /** * Helper function to handle each heading font size in the headingFontSizes config * @param configName: the name of the config option * @param fontSize: the heading font size provided via theme config * @param baseFontSize: the base font size (default or provided via theme config) * @param inSidebar: whether this is the sidebar theme * @returns the heading font size in rem */ const convertHeadingFontSizeToRem = ( configName: string, fontSize: string, baseFontSize: number, inSidebar: boolean ): string | undefined => { // Validates the font size (logs warning & returns undefined if invalid) const validatedSize = parseFontSize(configName, fontSize, inSidebar) // Need each heading font size to be in rem if (validatedSize?.endsWith("rem")) { return validatedSize } else if (validatedSize?.endsWith("px")) { // Convert the font size to rem, and round to nearest 8th const remValue = parseFloat(validatedSize) / baseFontSize return `${remValue}rem` } // If invalid, return undefined return undefined } /** * Set the heading font sizes in the theme config * @param defaultFontSizes: the default theme font sizes * @param inSidebar: whether this is the sidebar theme * @param baseFontSize: the base font size (default or provided via theme config) * @param headingFontSizes: the h1-h6 heading font sizes provided via theme config * @returns an updated emotion theme font sizes object */ const setHeadingFontSizes = ( defaultFontSizes: EmotionTheme["fontSizes"], inSidebar: boolean, baseFontSize: number, headingFontSizes: string[] | null | undefined ): EmotionTheme["fontSizes"] => { const headingFontSizesOverrides = { ...defaultFontSizes, } if (headingFontSizes) { headingFontSizes.forEach((size, index) => { const headingFontSizeKey = `h${index + 1}FontSize` // Gets the heading font size in rem if not already & logs warning if invalid const convertedSize = convertHeadingFontSizeToRem( `${headingFontSizeKey} in headingFontSizes`, size, baseFontSize, inSidebar ) // If valid configured value, overwrite the default heading font size if (convertedSize) { // @ts-expect-error headingFontSizesOverrides[headingFontSizeKey] = convertedSize } }) } return headingFontSizesOverrides } /** * Helper function to set the normal, bold, and extrabold font weights based * on the baseFontWeight option * @param defaultFontWeights: the default theme font weights * @param inSidebar: whether the theme is in the sidebar * @param baseFontWeight: the base font weight provided via theme config * @param codeFontWeight: the code font weight provided via theme config * @param headingFontWeights: the h1-h6 heading font weights provided via theme config * @returns an updated emotion theme font weights object */ const setFontWeights = ( defaultFontWeights: EmotionTheme["fontWeights"], inSidebar: boolean, baseFontWeight: number | null | undefined, codeFontWeight: number | null | undefined, headingFontWeights: number[] | null | undefined ): EmotionTheme["fontWeights"] => { const fontWeightOverrides = { ...defaultFontWeights, // Override default h1FontWeight for sidebar to 600 // Default for main theme set in typography (700) h1FontWeight: inSidebar ? 600 : defaultFontWeights.h1FontWeight, } // Validate the baseFontWeight provided is an integer between 100 and 600 if ( baseFontWeight && isValidFontWeight("baseFontWeight", baseFontWeight, 100, 600) ) { // Set each of the font weights based on the base weight provided // The provided baseFontWeight sets the normal weight fontWeightOverrides.normal = baseFontWeight // The semiBold weight is set to the baseFontWeight + 100 fontWeightOverrides.semiBold = baseFontWeight + 100 // The bold weight is set to the baseFontWeight + 200 fontWeightOverrides.bold = baseFontWeight + 200 // The extrabold weight is set to the baseFontWeight + 300 fontWeightOverrides.extrabold = baseFontWeight + 300 // Set fallback for code font weights based on configured baseFontWeight fontWeightOverrides.code = baseFontWeight fontWeightOverrides.codeBold = baseFontWeight + 200 fontWeightOverrides.codeExtraBold = baseFontWeight + 300 } if ( codeFontWeight && isValidFontWeight("codeFontWeight", codeFontWeight, 100, 600) ) { // Set each of the code weights based on the base code font weight provided fontWeightOverrides.code = codeFontWeight // The bold weight is set to the codeFontWeight + 200 fontWeightOverrides.codeBold = codeFontWeight + 200 // The extrabold weight is set to the codeFontWeight + 300 fontWeightOverrides.codeExtraBold = codeFontWeight + 300 } if (headingFontWeights) { // Filling out the heading font weights array is handled in app_session.py headingFontWeights.forEach((weight, index) => { const headingFontWeightKey = `h${index + 1}FontWeight` if ( isValidFontWeight( `${headingFontWeightKey} in headingFontWeights`, weight, 100, 900, inSidebar ) ) { // @ts-expect-error fontWeightOverrides[headingFontWeightKey] = weight } }) } return fontWeightOverrides } /** * Helper function to validate each of the colors passed in the chart colors configs * @param configName: the name of the config ("chartCategoricalColors", "chartSequentialColors" or "chartDivergingColors") * @param colors: the colors config passed in (array of strings) * @returns the valid colors from the config */ const validateChartColors = ( configName: string, colors: string[] ): string[] => { return ( colors // parseColor returns undefined for invalid colors .map(color => parseColor(color, configName)) // Filter any invalid colors .filter((color): color is string => color !== undefined) ) } export const createEmotionTheme = ( themeInput: Partial<ICustomThemeConfig>, baseThemeConfig = baseTheme ): EmotionTheme => { const { colors, genericFonts, inSidebar } = baseThemeConfig.emotion const { baseFontSize, baseFontWeight, baseRadius, buttonRadius, codeFontSize, codeFontWeight, showWidgetBorder, headingFont, headingFontSizes, headingFontWeights, bodyFont, codeFont, showSidebarBorder, linkUnderline, // Since chart color configs passed as array, handle separate from parsedColors chartCategoricalColors, chartSequentialColors, chartDivergingColors, // Metric value styling metricValueFontSize, metricValueFontWeight, ...customColors } = themeInput const parsedColors = Object.entries(customColors).reduce( (colorsArg: Record<string, string>, [key, color]) => { // @ts-expect-error const validatedColor = parseColor(color, key, inSidebar) if (validatedColor) { colorsArg[key] = validatedColor } return colorsArg }, {} ) // Extract configured color values from parsedColors and map them to the correct theme color primitive const { secondaryBackgroundColor: secondaryBg, backgroundColor: bgColor, primaryColor: primary, textColor: bodyText, dataframeBorderColor, dataframeHeaderBackgroundColor, borderColor, linkColor, codeTextColor, codeBackgroundColor, redColor, orangeColor, yellowColor, blueColor, greenColor, violetColor, grayColor, } = parsedColors // Create a new generic colors object with configured colors, if they exist. // Fallback to the default colors if they are not set. Necessary as createEmotionColors // calculates some colors based on the generic colors. const newGenericColors = { ...colors, primary: primary ?? colors.primary, bodyText: bodyText ?? colors.bodyText, secondaryBg: secondaryBg ?? colors.secondaryBg, bgColor: bgColor ?? colors.bgColor, // Main theme colors redColor: redColor ?? colors.redColor, orangeColor: orangeColor ?? colors.orangeColor, yellowColor: yellowColor ?? colors.yellowColor, blueColor: blueColor ?? colors.blueColor, greenColor: greenColor ?? colors.greenColor, violetColor: violetColor ?? colors.violetColor, grayColor: grayColor ?? colors.grayColor, // Secondary color is not yet configurable. Set secondary color to primary color // by default for all custom themes. secondary: primary ?? colors.primary, } type ConditionalOverrides = { colors: ReturnType<typeof createEmotionColors> showSidebarBorder: boolean linkUnderline: boolean radii: EmotionTheme["radii"] fontSizes: EmotionTheme["fontSizes"] fontWeights: EmotionTheme["fontWeights"] } const conditionalOverrides: ConditionalOverrides = { colors: { ...createEmotionColors(newGenericColors), }, showSidebarBorder: showSidebarBorder ?? baseThemeConfig.emotion.showSidebarBorder, linkUnderline: linkUnderline ?? baseThemeConfig.emotion.linkUnderline, // Copy over the default values for the other configurable theme properties radii: { ...baseThemeConfig.emotion.radii }, fontSizes: { ...baseThemeConfig.emotion.fontSizes }, fontWeights: { ...baseThemeConfig.emotion.fontWeights }, } // Conditional Overrides - Colors conditionalOverrides.colors.codeBackgroundColor = codeBackgroundColor ?? colors.codeBackgroundColor conditionalOverrides.colors.dataframeHeaderBackgroundColor = dataframeHeaderBackgroundColor ?? colors.dataframeHeaderBackgroundColor if (notNullOrUndefined(borderColor)) { conditionalOverrides.colors.borderColor = borderColor const borderColorLight = transparentize(borderColor, 0.55) // Used for tabs border and expander when stale conditionalOverrides.colors.borderColorLight = borderColorLight // Set the fallback here for dataframe & table border color conditionalOverrides.colors.dataframeBorderColor = borderColorLight } if (notNullOrUndefined(dataframeBorderColor)) { // If dataframeBorderColor explicitly set, override borderColorLight fallback conditionalOverrides.colors.dataframeBorderColor = dataframeBorderColor } if (showWidgetBorder) { conditionalOverrides.colors.widgetBorderColor = conditionalOverrides.colors.borderColor } // Apply background color overrides based on configured background color or main color as fallback conditionalOverrides.colors = setBackgroundColors( conditionalOverrides.colors, parsedColors ) // Apply text color overrides based on configured text color or main color as fallback conditionalOverrides.colors = setTextColors( conditionalOverrides.colors, parsedColors ) // Link color should use the linkColor config if provided, otherwise // use blueTextColor (configured/derived or default) handled above conditionalOverrides.colors.link = linkColor ?? conditionalOverrides.colors.blueTextColor // Code text color should use the codeTextColor config if provided, otherwise // use the greenTextColor (configured/derived or default) handled above conditionalOverrides.colors.codeTextColor = codeTextColor ?? conditionalOverrides.colors.greenTextColor if ( notNullOrUndefined(chartCategoricalColors) && chartCategoricalColors.length > 0 ) { // Validate the categorical colors config const validatedCategoricalColors = validateChartColors( "chartCategoricalColors", chartCategoricalColors ) // Set the validated colors if non-empty array if (validatedCategoricalColors.length > 0) { conditionalOverrides.colors.chartCategoricalColors = validatedCategoricalColors } } if ( notNullOrUndefined(chartSequentialColors) && chartSequentialColors.length > 0 ) { // Validate the sequential colors config const validatedSequentialColors = validateChartColors( "chartSequentialColors", chartSequentialColors ) // Set the validated colors, sequential colors should be an array of length 10 // Also checked on BE, but check here again in case one of the entries is not a valid color if (validatedSequentialColors.length === 10) { conditionalOverrides.colors.chartSequentialColors = validatedSequentialColors } else { LOG.warn( `Invalid chartSequentialColors: ${chartSequentialColors.toString()}. Falling back to default chartSequentialColors.` ) } } if ( notNullOrUndefined(chartDivergingColors) && chartDivergingColors.length > 0 ) { // Validate the diverging colors config const validatedDivergingColors = validateChartColors( "chartDivergingColors", chartDivergingColors ) // Set the validated colors, diverging colors should be an array of length 10 // Also checked on BE, but check here again in case one of the entries is not a valid color if (validatedDivergingColors.length === 10) { conditionalOverrides.colors.chartDivergingColors = validatedDivergingColors } else { LOG.warn( `Invalid chartDivergingColors: ${chartDivergingColors.toString()}. Falling back to default chartDivergingColors.` ) } } // Conditional Overrides - Radii if (notNullOrUndefined(baseRadius)) { const [radiusValue, cssUnit] = parseRadius(baseRadius) if (notNullOrUndefined(radiusValue) && !isNaN(radiusValue)) { const radiusWithCssUnit = addCssUnit(radiusValue, cssUnit) conditionalOverrides.radii.default = radiusWithCssUnit // Set the fallback button radius if baseRadius is set conditionalOverrides.radii.button = radiusWithCssUnit // Adapt all the other radii sizes based on the base radii: // We make sure that the value is rounded to 2 decimal places to avoid // floating point precision issues. conditionalOverrides.radii.md = addCssUnit( roundToTwoDecimals(radiusValue * 0.5), cssUnit ) conditionalOverrides.radii.xl = addCssUnit( roundToTwoDecimals(radiusValue * 1.5), cssUnit ) conditionalOverrides.radii.xxl = addCssUnit( roundToTwoDecimals(radiusValue * 2), cssUnit ) } else { LOG.warn( `Invalid base radius: ${baseRadius}. Falling back to default base radius.` ) } } if (notNullOrUndefined(buttonRadius)) { const [radiusValue, cssUnit] = parseRadius(buttonRadius) if (notNullOrUndefined(radiusValue) && !isNaN(radiusValue)) { // If valid buttonRadius set, override baseRadius fallback conditionalOverrides.radii.button = addCssUnit(radiusValue, cssUnit) } else { LOG.warn( `Invalid button radius: ${buttonRadius}. Falling back to default button radius.` ) } } // Conditional Overrides - Font Sizes if (baseFontSize && baseFontSize > 0) { // Set the root font size to the configured value (used on global styles): conditionalOverrides.fontSizes.baseFontSize = baseFontSize } if (codeFontSize) { // Returns font size as a string, or undefined if invalid const parsedCodeFontSize = parseFontSize( "codeFontSize", codeFontSize, inSidebar ) if (parsedCodeFontSize) { conditionalOverrides.fontSizes.codeFontSize = parsedCodeFontSize } // codeFontSize default (fallback) set in typography primitives (0.875rem) // inlineCodeFontSize set in typography primitives (0.75em) } // Set the heading font sizes based on the heading font sizes config provided conditionalOverrides.fontSizes = setHeadingFontSizes( conditionalOverrides.fontSizes, inSidebar, conditionalOverrides.fontSizes.baseFontSize, headingFontSizes ) // Conditional Overrides - Font Weights // Set the font weights based on the font weight configs provided conditionalOverrides.fontWeights = setFontWeights( baseThemeConfig.emotion.fontWeights, inSidebar, baseFontWeight, codeFontWeight, headingFontWeights ) // Font Overrides // Type for font overrides - represents genericFonts properties that can be overridden type GenericFontOverride = { bodyFont: string codeFont: string headingFont: string // Not configurable through custom themes iconFont: string } const fontsOverride: GenericFontOverride = { // Default values for the generic fonts ...genericFonts, // Override properties if configured bodyFont: bodyFont ? parseFont(bodyFont, fonts.sansSerif) : genericFonts.bodyFont, codeFont: codeFont ? parseFont(codeFont, fonts.monospace) : genericFonts.codeFont, headingFont: headingFont ? parseFont(headingFont, fonts.sansSerif) : genericFonts.headingFont, } // Handle headingFont fallback if (bodyFont && !headingFont) { fontsOverride.headingFont = parseFont(bodyFont, fonts.sansSerif) } // Create shadows - auto-determines light/dark based on bgColor luminance const shadows = createShadows(conditionalOverrides.colors) return { ...baseThemeConfig.emotion, genericFonts: fontsOverride, ...conditionalOverrides, shadows, ...(() => { if (!metricValueFontSize) return {} // Use parseFontSize for format validation const parsedSize = parseFontSize( "metricValueFontSize", metricValueFontSize, inSidebar ) if (!parsedSize) return {} // Additional validation: must be greater than 0 const numericValue = parseFloat(parsedSize) if (numericValue <= 0) { LOG.warn( `Invalid metricValueFontSize: ${metricValueFontSize} in theme. The metricValueFontSize must be greater than 0. Falling back to default metricValueFontSize.` ) return {} } return { metricValueFontSize: parsedSize } })(), ...(metricValueFontWeight ? metricValueFontWeight >= 100 && metricValueFontWeight <= 900 ? { metricValueFontWeight } : (LOG.warn( `Invalid metricValueFontWeight: ${metricValueFontWeight}. Must be between 100 and 900.` ), {}) : {}), } } export const toThemeInput = ( theme: EmotionTheme ): Partial<CustomThemeConfig> => { const { colors } = theme return { primaryColor: colors.primary, backgroundColor: colors.bgColor, secondaryBackgroundColor: colors.secondaryBg, textColor: colors.bodyText, bodyFont: theme.genericFonts.bodyFont, } } export type ExportedTheme = { base: string primaryColor: string backgroundColor: string secondaryBackgroundColor: string textColor: string bodyFont: string } & DerivedColors export const toExportedTheme = (theme: EmotionTheme): ExportedTheme => { const { colors } = theme const themeInput = toThemeInput(theme) // At this point, we know that all of the fields of themeInput are populated // (since we went "backwards" from a theme -> themeInput), but typescript // doesn't know this, so we have to cast each field to string. return { primaryColor: themeInput.primaryColor as string, backgroundColor: themeInput.backgroundColor as string, secondaryBackgroundColor: themeInput.secondaryBackgroundColor as string, textColor: themeInput.textColor as string, bodyFont: themeInput.bodyFont as string, base: bgColorToBaseString(themeInput.backgroundColor), ...computeDerivedColors(colors), } } const completeThemeInput = ( partialInput: Partial<CustomThemeConfig>, baseThemeArg: ThemeConfig ): CustomThemeConfig => { return new CustomThemeConfig({ ...toThemeInput(baseThemeArg.emotion), ...partialInput, }) } export const createTheme = ( themeName: string, themeInput: Partial<CustomThemeConfig>, baseThemeConfig?: ThemeConfig, inSidebar = false ): ThemeConfig => { let completedThemeInput: CustomThemeConfig if (baseThemeConfig) { completedThemeInput = completeThemeInput(themeInput, baseThemeConfig) } else if (themeInput.base === CustomThemeConfig.BaseTheme.DARK) { completedThemeInput = completeThemeInput(themeInput, darkTheme) } else { completedThemeInput = completeThemeInput(themeInput, lightTheme) } // We use startingTheme to pick a set of "auxiliary colors" for widgets like // the success/info/warning/error boxes and others; these need to have their // colors tweaked to work well with the background. // // For our auxiliary colors, we pick colors that look reasonable based on the // theme's backgroundColor instead of picking them using themeInput.base. // This way, things will look good even if a user sets // themeInput.base === LIGHT and themeInput.backgroundColor === "black". const bgColor = completedThemeInput.backgroundColor const startingTheme = merge( cloneDeep( baseThemeConfig ? baseThemeConfig : getLuminance(bgColor) > 0.5 ? lightTheme : darkTheme ), { emotion: { inSidebar } } ) const emotion = createEmotionTheme(completedThemeInput, startingTheme) // We need to deep clone the theme object to prevent a bug in BaseWeb that causes // primitives to be modified globally. This cloning decouples our BaseWeb theme // object from the shared primitive objects and prevents unintended side effects. const basewebTheme = cloneDeep( createBaseUiTheme(emotion, startingTheme.primitives) ) return { ...startingTheme, name: themeName, emotion, basewebTheme, themeInput, } } const deleteOldCachedThemes = (): void => { const { CACHED_THEME_VERSION, CACHED_THEME_BASE_KEY } = LocalStore const { localStorage } = window // Pre-release versions of theming stored cached themes under the key // "stActiveTheme". localStorage.removeItem("stActiveTheme") // The first version of cached themes had keys of the form // `stActiveTheme-${window.location.pathname}` with no version number. localStorage.removeItem(CACHED_THEME_BASE_KEY) // Versions before the current schema stored full theme configs. Clear them. for (let i = 1; i < CACHED_THEME_VERSION; i++) { localStorage.removeItem(`${CACHED_THEME_BASE_KEY}-v${i}`) } } export const getThemeSelectionFromThemeConfig = ( themeConfig: ThemeConfig ): ThemeSelection => { if ( themeConfig.name === AUTO_THEME_NAME || themeConfig.name === CUSTOM_THEME_AUTO_NAME ) { return "System" } if ( themeConfig.name === lightTheme.name || themeConfig.name === CUSTOM_THEME_LIGHT_NAME ) { return "Light" } if ( themeConfig.name === darkTheme.name || themeConfig.name === CUSTOM_THEME_DARK_NAME ) { return "Dark" } // Single custom theme ("Custom Theme") has no light/dark distinction, // so we treat it as "System" for caching purposes. This ensures: // 1. No false mapping to preset themes // 2. Embed options can still override on subsequent visits if (themeConfig.name === CUSTOM_THEME_NAME) { return "System" } return "System" // This is reached for unrecognized theme names } const isThemeSelection = (value: unknown): value is ThemeSelection => value === "System" || value === "Light" || value === "Dark" export const getCachedThemeSelection = (): ThemeSelection | null => { if (!localStorageAvailable()) { return null } deleteOldCachedThemes() const cachedThemeStr = window.localStorage.getItem(LocalStore.ACTIVE_THEME) if (!cachedThemeStr) { return null } const cachedTheme: CachedTheme = JSON.parse(cachedThemeStr) return isThemeSelection(cachedTheme) ? cachedTheme : null } export const setCachedThemeSelection = (themeConfig: ThemeConfig): void => { if (!localStorageAvailable()) { return } deleteOldCachedThemes() // Do not set the theme if the app has a pre-defined theme from the embedder if (isLightThemeInQueryParams() || isDarkThemeInQueryParams()) { return } const cachedTheme: CachedTheme = getThemeSelectionFromThemeConfig(themeConfig) window.localStorage.setItem( LocalStore.ACTIVE_THEME, JSON.stringify(cachedTheme) ) } export const removeCachedTheme = (): void => { if (!localStorageAvailable()) { return } window.localStorage.removeItem(LocalStore.ACTIVE_THEME) } /** * Returns the theme specified by the host via query parameters, or null if no theme is specified. * This differs from getHostSpecifiedTheme() which falls back to auto theme. * * @returns ThemeConfig if host specified via query params (light_theme or dark_theme), null otherwise */ export const getHostSpecifiedThemeOnly = (): ThemeConfig | null => { if (isLightThemeInQueryParams()) { return getMergedLightTheme() } if (isDarkThemeInQueryParams()) { return getMergedDarkTheme() } return null } export const getHostSpecifiedTheme = (): ThemeConfig => { return getHostSpecifiedThemeOnly() ?? createAutoTheme() } export const getDefaultTheme = (): ThemeConfig => { // Priority for default theme const hostSpecified = getHostSpecifiedThemeOnly() if (hostSpecified) { return hostSpecified } const cachedSelection = getCachedThemeSelection() if (cachedSelection === "Light") { return getMergedLightTheme() } if (cachedSelection === "Dark") { return getMergedDarkTheme() } return createAutoTheme() } const whiteSpace = /\s+/ export function computeSpacingStyle( value: string, theme: EmotionTheme ): string { if (value === "") { return "" } return value .split(whiteSpace) .map(marginValue => { if (marginValue === "0") { return theme.spacing.none } if (!(marginValue in theme.spacing)) { LOG.error(`Invalid spacing value: ${marginValue}`) return theme.spacing.none } return theme.spacing[marginValue as ThemeSpacing] }) .join(" ") } export function addCssUnit(n: number, unit: "px" | "rem"): string { return `${n}${unit}` } function roundToTwoDecimals(n: number): number { return parseFloat(n.toFixed(2)) } export function blend(color: string, background: string | undefined): string { if (background === undefined) return color const [r, g, b, a] = parseToRgba(color) if (a === 1) return color const [br, bg, bb, ba] = parseToRgba(background) const ao = a + ba * (1 - a) // (xaA + xaB·(1−aA))/aR const ro = Math.round((a * r + ba * br * (1 - a)) / ao) const go = Math.round((a * g + ba * bg * (1 - a)) / ao) const bo = Math.round((a * b + ba * bb * (1 - a)) / ao) return toHex(`rgba(${ro}, ${go}, ${bo}, ${ao})`) } /** * Convert a SCSS rem value to pixels. * @param scssValue: a string containing a value in rem units with or without the "rem" unit suffix * @returns pixel value of the given rem value */ export const convertRemToPx = (scssValue: string): number => { const remValue = parseFloat(scssValue.replace(/rem$/, "")) return ( // TODO(lukasmasuch): We might want to somehow cache this value at some point. // However, I did experimented with the performance of calling this, and // it seems not like a big deal to call it many times. remValue * // We fallback to 16px if the fontSize is not defined (should only happen in tests) (parseFloat(getComputedStyle(document.documentElement).fontSize) || 16) ) } /** * Customizer function for lodash mergeWith that skips protobuf default values * (empty strings, null, empty arrays) to prevent them from overwriting valid values. * @returns objValue (keep existing value) if srcValue is a protobuf default, undefined otherwise */ const skipProtobufDefaults = ( objValue: unknown, srcValue: unknown ): unknown => { // Exclude empty strings, empty arrays, and null values if ( srcValue === "" || srcValue === null || (Array.isArray(srcValue) && srcValue.length === 0) ) { return objValue } // Let mergeWith handle all other cases normally return undefined } /** * Helper function to merge theme section configs (light/dark and light.sidebar/dark.sidebar) * into a consolidated theme input with proper inheritance. * Custom Light theme = uses streamlit base theme + [theme] configs + [theme.light] config overrides * Custom Dark theme = uses streamlit base theme + [theme] configs + [theme.dark] config overrides * Sidebar inherits from main theme with additional [theme.sidebar] overrides * Light sidebar = custom light theme + [theme.sidebar] + [theme.light.sidebar] overrides * Dark sidebar = custom dark theme + [theme.sidebar] + [theme.dark.sidebar] overrides * @param themeInput: the theme input (configs) to merge * @param variant: the theme variant to create ('light' or 'dark') */ export const handleSectionInheritance = ( themeInput: CustomThemeConfig, variant: "light" | "dark" ): CustomThemeConfig => { const isLightTheme = variant === "light" const base = isLightTheme ? CustomThemeConfig.BaseTheme.LIGHT : CustomThemeConfig.BaseTheme.DARK // Destructure to separate concerns: common theme properties, sidebar properties, // and light or dark variant sections const { light, dark, sidebar: baseSidebar, ...commonTheme } = themeInput const variantSection = isLightTheme ? light : dark const { sidebar: variantSidebar, ...variantTheme } = variantSection || {} // Merge common theme properties with variant overrides (excluding sidebars for now) // Note: base is set explicitly based on variant and is merged last to ensure it overrides // Use mergeWith with skipProtobufDefaults to prevent empty values from overwriting valid ones const result = mergeWith( {} as CustomThemeConfig, commonTheme, // Common theme properties from themeInput variantTheme, // Variant-specific theme overrides (without sidebar) { base }, // Set base last to override any base from commonTheme/variantTheme skipProtobufDefaults ) // Explicitly merge sidebars with correct precedence: baseSidebar < variantSidebar if (baseSidebar || variantSidebar) { // Use mergeWith with skipProtobufDefaults to prevent empty values from overwriting valid ones result.sidebar = mergeWith( {}, baseSidebar, variantSidebar, skipProtobufDefaults ) } return result } /** * Check if a theme section has any non-null/undefined values set. * Checks one level deep for nested objects (e.g., sidebar within light/dark sections). * Treats empty arrays as "no config" since they represent default values. * @param section: The theme section to check (e.g., themeInput.light or themeInput.dark) * @returns true if the section has any actual values set */ export const hasThemeSectionConfigs = ( section: ICustomThemeConfig | null | undefined ): boolean => { if (!section) { return false } // Helper to check if a value is non-empty (recursively checks one level deep) const isNonEmpty = (value: unknown): boolean => { if (value === null || value === undefined) { return false } // Empty arrays are treated as "no config" (they're default values) if (Array.isArray(value)) { return value.length > 0 } // Check nested objects one level deep (e.g., sidebar subsection) if (typeof value === "object") { return Object.values(value).some(isNonEmpty) } return true } return Object.values(section).some(isNonEmpty) } /** * Create custom themes from the theme input for main app * Function applies merge of sections/subsections and returns custom light/dark theme(s) * When no light/dark section configs are set, returns "Custom Theme" (1 theme) * When light and/or dark section configs are set, returns "Custom Theme Light", "Custom Theme Dark" * & auto based on system preference ("Use System Setting") (3 themes) * @param themeInput: the theme input (configs) with nested sections/subsections * @returns 1 or 3 custom themes */ export const createCustomThemes = ( themeInput: CustomThemeConfig ): ThemeConfig[] => { const hasLightConfigs = hasThemeSectionConfigs(themeInput.light) const hasDarkConfigs = hasThemeSectionConfigs(themeInput.dark) const customThemes: ThemeConfig[] = [] // When light or dark theme section configs are set, need to create a custom theme for each if (hasLightConfigs || hasDarkConfigs) { const lightThemeInput = handleSectionInheritance(themeInput, "light") const lightTheme = { ...createTheme(CUSTOM_THEME_LIGHT_NAME, lightThemeInput), displayName: "Light", } const darkThemeInput = handleSectionInheritance(themeInput, "dark") const darkTheme = { ...createTheme(CUSTOM_THEME_DARK_NAME, darkThemeInput), displayName: "Dark", } // Also add an auto custom theme based on the system preference const autoCustomTheme = getSystemThemePreference() === "dark" ? darkTheme : lightTheme const autoTheme = { ...autoCustomTheme, displayName: AUTO_THEME_NAME, name: CUSTOM_THEME_AUTO_NAME, } // Return light, dark, and auto custom themes customThemes.push(lightTheme, darkTheme, autoTheme) } else { // No light/dark section configs set - single "Custom Theme" returned const customTheme = createTheme(CUSTOM_THEME_NAME, themeInput) // Return the single custom theme customThemes.push(customTheme) } return customThemes } /** * Set the default heading font sizes for the sidebar. * @param configHeadingFontSizes: the heading font sizes provided via theme config * @returns the heading font sizes for the sidebar */ const setSidebarHeadingFontSizes = ( configHeadingFontSizes: string[] | null | undefined ): string[] => { // Default sidebar heading font sizes const defaultHeadingFontSizes = [ "1.5rem", "1.25rem", "1.125rem", "1rem", "0.875rem", "0.75rem", ] // Merge config overrides with sidebar defaults (for arrays, merge replaces at each index) return merge([], defaultHeadingFontSizes, configHeadingFontSizes || []) } /** * Create the sidebar's theme, including any sidebar custom theme configurations * Note: handleSectionInheritance has already handled section inheritance for sidebar * in generating the main theme. * @returns active theme to be used for the sidebar */ export const createSidebarTheme = (activeTheme: ThemeConfig): ThemeConfig => { const sidebarThemeInput = activeTheme.themeInput?.sidebar const { bgColor, secondaryBg } = activeTheme.emotion.colors // Either use the configured background color or secondary background from main theme: const sidebarBackground = sidebarThemeInput?.backgroundColor || secondaryBg // Either use the configured secondary background color or background from main theme: const secondaryBackgroundColor = sidebarThemeInput?.secondaryBackgroundColor || bgColor // Handle configured vs. default header font sizes for sidebar const headingFontSizes = setSidebarHeadingFontSizes( sidebarThemeInput?.headingFontSizes ) let baseTheme = getLuminance(sidebarBackground) > 0.5 ? CustomThemeConfig.BaseTheme.LIGHT : CustomThemeConfig.BaseTheme.DARK // If the active theme is a light/dark custom theme, use the expected base if (activeTheme.name === CUSTOM_THEME_LIGHT_NAME) { baseTheme = CustomThemeConfig.BaseTheme.LIGHT } else if (activeTheme.name === CUSTOM_THEME_DARK_NAME) { baseTheme = CustomThemeConfig.BaseTheme.DARK } // Use mergeWith & skipProtobufDefaults to prevent empty sidebar values from overwriting main theme configs const mergedSidebarThemeInput = mergeWith( {}, activeTheme.themeInput, // Use the theme props from the main theme as basis sidebarThemeInput, // Merge sidebar configs { // Explicit overrides that should always be applied base: baseTheme, backgroundColor: sidebarBackground, secondaryBackgroundColor: secondaryBackgroundColor, headingFontSizes: headingFontSizes, }, skipProtobufDefaults ) // Create the theme with overrides return createTheme( "Sidebar", mergedSidebarThemeInput, undefined, // Creating a new theme from scratch true // inSidebar ) } /** * Maps a user's cached theme selection to the best matching theme from available themes. * * @param cachedThemeSelection - The user's cached theme selection * @param availableThemes - The list of currently available themes * @returns The best matching theme, or null if no suitable match found * * @example * // When custom themes exist: * mapCachedThemeSelectionToAvailableTheme("Light", [customLight, customDark, customAuto]) * // Returns customLight (CUSTOM_THEME_LIGHT_NAME) * * @example * // When only preset themes: * mapCachedThemeSelectionToAvailableTheme("Light", [lightTheme, darkTheme]) * // Returns lightTheme ("Light") */ export const mapCachedThemeSelectionToAvailableTheme = ( cachedThemeSelection: ThemeSelection | null, availableThemes: ThemeConfig[] ): ThemeConfig | null => { if (!cachedThemeSelection) { return null } const mappedNames: Record<ThemeSelection, string[]> = { System: [CUSTOM_THEME_AUTO_NAME, AUTO_THEME_NAME, CUSTOM_THEME_NAME], Light: [CUSTOM_THEME_LIGHT_NAME, lightTheme.name], Dark: [CUSTOM_THEME_DARK_NAME, darkTheme.name], } for (const themeName of mappedNames[cachedThemeSelection]) { const match = availableThemes.find(theme => theme.name === themeName) if (match) { return match } } return null } /** * Gets the user's preferred theme from available themes. * Host-specified embed options take precedence over cached selections. * * @param availableThemes - The list of currently available themes * @returns The best matching theme, or null if no preference found */ export const getPreferredTheme = ( availableThemes: ThemeConfig[] ): ThemeConfig | null => { const hostSpecified = getHostSpecifiedThemeOnly() const hostSpecifiedSelection = hostSpecified ? getThemeSelectionFromThemeConfig(hostSpecified) : null const userPreferenceSelection = hostSpecifiedSelection ?? getCachedThemeSelection() return mapCachedThemeSelectionToAvailableTheme( userPreferenceSelection, availableThemes ) }