From 531a4e621afa96146e53ced05519399f706a5c5d Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 15 Jan 2026 13:20:23 +0000 Subject: [PATCH 01/36] feat: integrate AppThemeProvider for theme management and update color references - Added AppThemeProvider to _app.tsx for centralized theme management. - Updated color references across various components to use the new brand color scheme. - Introduced ClickStack and HyperDX themes with corresponding color definitions. - Enhanced UserPreferencesModal to allow theme selection and added brand theme options for development mode. - Updated stylesheets to support new theme colors and ensure consistent branding. --- packages/app/pages/_app.tsx | 35 +- packages/app/src/DBDashboardImportPage.tsx | 2 +- packages/app/src/DBSearchPage.tsx | 2 +- packages/app/src/OnboardingChecklist.tsx | 4 +- packages/app/src/PlaybarSlider.tsx | 2 +- packages/app/src/UserPreferencesModal.tsx | 52 ++- .../src/components/InputLanguageSwitch.tsx | 4 +- .../src/components/TimePicker/TimePicker.tsx | 2 +- packages/app/src/theme/ThemeProvider.tsx | 140 +++++++ packages/app/src/theme/index.ts | 113 ++++++ .../app/src/theme/themes/clickstack/Icon.tsx | 46 +++ .../app/src/theme/themes/clickstack/Logo.tsx | 52 +++ .../app/src/theme/themes/clickstack/index.ts | 14 + .../theme/themes/clickstack/mantineTheme.ts | 371 ++++++++++++++++++ .../app/src/theme/themes/hyperdx/Icon.tsx | 27 ++ .../app/src/theme/themes/hyperdx/Logo.tsx | 55 +++ .../app/src/theme/themes/hyperdx/index.ts | 14 + .../src/theme/themes/hyperdx/mantineTheme.ts | 363 +++++++++++++++++ packages/app/src/theme/types.ts | 12 + .../styles/SourceSelectControlled.module.scss | 4 +- packages/app/styles/_semantic-colors.scss | 9 + .../app/styles/themes/_clickstack-colors.scss | 176 +++++++++ .../app/styles/themes/_hyperdx-colors.scss | 142 +++++++ 23 files changed, 1612 insertions(+), 29 deletions(-) create mode 100644 packages/app/src/theme/ThemeProvider.tsx create mode 100644 packages/app/src/theme/index.ts create mode 100644 packages/app/src/theme/themes/clickstack/Icon.tsx create mode 100644 packages/app/src/theme/themes/clickstack/Logo.tsx create mode 100644 packages/app/src/theme/themes/clickstack/index.ts create mode 100644 packages/app/src/theme/themes/clickstack/mantineTheme.ts create mode 100644 packages/app/src/theme/themes/hyperdx/Icon.tsx create mode 100644 packages/app/src/theme/themes/hyperdx/Logo.tsx create mode 100644 packages/app/src/theme/themes/hyperdx/index.ts create mode 100644 packages/app/src/theme/themes/hyperdx/mantineTheme.ts create mode 100644 packages/app/src/theme/types.ts create mode 100644 packages/app/styles/themes/_clickstack-colors.scss create mode 100644 packages/app/styles/themes/_hyperdx-colors.scss diff --git a/packages/app/pages/_app.tsx b/packages/app/pages/_app.tsx index 1a1c162e10..7784f40c85 100644 --- a/packages/app/pages/_app.tsx +++ b/packages/app/pages/_app.tsx @@ -24,6 +24,7 @@ import { MANTINE_FONT_MAP, } from '@/config/fonts'; import { ibmPlexMono, inter, roboto, robotoMono } from '@/fonts'; +import { AppThemeProvider } from '@/theme/ThemeProvider'; import { ThemeWrapper } from '@/ThemeWrapper'; import { useConfirmModal } from '@/useConfirm'; import { QueryParamProvider as HDXQueryParamProvider } from '@/useQueryParam'; @@ -147,21 +148,25 @@ export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { /> - - - - - {getLayout()} - {confirmModal} - - - {background} - - - + + + + + + {getLayout()} + {confirmModal} + + + {background} + + + + ); } diff --git a/packages/app/src/DBDashboardImportPage.tsx b/packages/app/src/DBDashboardImportPage.tsx index f8f0777276..faaffb504f 100644 --- a/packages/app/src/DBDashboardImportPage.tsx +++ b/packages/app/src/DBDashboardImportPage.tsx @@ -110,7 +110,7 @@ function FileSelection({ diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 11df33c021..d9c587b7e0 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -297,7 +297,7 @@ function SearchSubmitButton({ data-testid="search-submit-button" variant="outline" type="submit" - color={isFormStateDirty ? 'var(--color-text-success)' : 'gray'} + color={isFormStateDirty ? 'var(--color-text-brand)' : 'gray'} > diff --git a/packages/app/src/OnboardingChecklist.tsx b/packages/app/src/OnboardingChecklist.tsx index cb7aa81399..331d1c3559 100644 --- a/packages/app/src/OnboardingChecklist.tsx +++ b/packages/app/src/OnboardingChecklist.tsx @@ -193,13 +193,13 @@ const OnboardingChecklist = ({ alignItems: 'center', justifyContent: 'center', border: step.isComplete - ? '1px solid var(--color-text-success)' + ? '1px solid var(--color-text-brand)' : '1px solid var(--color-border)', backgroundColor: step.isComplete ? 'transparent' : 'var(--color-bg-muted)', color: step.isComplete - ? 'var(--color-text-success)' + ? 'var(--color-text-brand)' : 'var(--color-text)', flexShrink: 0, }} diff --git a/packages/app/src/PlaybarSlider.tsx b/packages/app/src/PlaybarSlider.tsx index 9fd01f558b..d3762b786a 100644 --- a/packages/app/src/PlaybarSlider.tsx +++ b/packages/app/src/PlaybarSlider.tsx @@ -61,7 +61,7 @@ export const PlaybarSlider = ({ className={styles.markerDot} style={{ backgroundColor: mark.isSuccess - ? 'var(--color-text-success)' + ? 'var(--color-text-brand)' : mark.isError ? 'var(--color-text-danger)' : 'var(--color-text-muted)', diff --git a/packages/app/src/UserPreferencesModal.tsx b/packages/app/src/UserPreferencesModal.tsx index 66982c608c..b81f3f399c 100644 --- a/packages/app/src/UserPreferencesModal.tsx +++ b/packages/app/src/UserPreferencesModal.tsx @@ -12,17 +12,27 @@ import { Stack, Switch, Text, + Tooltip, } from '@mantine/core'; -import { IconWorld } from '@tabler/icons-react'; +import { IconFlask, IconWorld } from '@tabler/icons-react'; import { OPTIONS_FONTS } from './config/fonts'; +import { useAppTheme } from './theme/ThemeProvider'; +import { ThemeName } from './theme/types'; +import { themes } from './theme'; import { UserPreferences, useUserPreferences } from './useUserPreferences'; -const OPTIONS_THEMES = [ +const OPTIONS_COLOR_MODE = [ { label: 'Dark', value: 'dark' }, { label: 'Light', value: 'light' }, ]; +// Brand theme options (generated from theme registry) +const OPTIONS_BRAND_THEMES = Object.values(themes).map(t => ({ + label: t.displayName, + value: t.name, +})); + const OPTIONS_MIX_BLEND_MODE = [ 'normal', 'multiply', @@ -76,6 +86,7 @@ export const UserPreferencesModal = ({ onClose: () => void; }) => { const { userPreferences, setUserPreference } = useUserPreferences(); + const { themeName, setTheme, isDev } = useAppTheme(); return ( value && setTheme(value as ThemeName)} + data={OPTIONS_BRAND_THEMES} + allowDeselect={false} + /> + + )} + )} onLanguageChange('sql')} size="xs" role="button" @@ -37,7 +37,7 @@ export default function InputLanguageSwitch({ size="xs" role="button" fw={500} - c={language === 'lucene' ? 'var(--color-text-success)' : 'gray'} + c={language === 'lucene' ? 'var(--color-text-brand)' : 'gray'} onClick={() => onLanguageChange('lucene')} > Lucene diff --git a/packages/app/src/components/TimePicker/TimePicker.tsx b/packages/app/src/components/TimePicker/TimePicker.tsx index f5d4086c47..7bb5a326d7 100644 --- a/packages/app/src/components/TimePicker/TimePicker.tsx +++ b/packages/app/src/components/TimePicker/TimePicker.tsx @@ -231,7 +231,7 @@ const TimePickerComponent = ({ styles={{ input: { color: isLiveMode - ? 'var(--color-text-success)' + ? 'var(--color-text-brand)' : 'var(--color-text)', }, }} diff --git a/packages/app/src/theme/ThemeProvider.tsx b/packages/app/src/theme/ThemeProvider.tsx new file mode 100644 index 0000000000..f0ccf3cf6f --- /dev/null +++ b/packages/app/src/theme/ThemeProvider.tsx @@ -0,0 +1,140 @@ +import React, { createContext, useContext, useEffect, useMemo } from 'react'; + +import { + clearDevTheme, + getCurrentTheme, + getCurrentThemeName, + getTheme, + setDevTheme, + themes, + toggleDevTheme, +} from './index'; +import { ThemeConfig, ThemeName } from './types'; + +const IS_DEV = + process.env.NODE_ENV === 'development' || + process.env.NEXT_PUBLIC_IS_LOCAL_MODE === 'true'; + +interface ThemeContextValue { + theme: ThemeConfig; + themeName: ThemeName; + availableThemes: ThemeName[]; + // Dev-only functions + setTheme: (name: ThemeName) => void; + toggleTheme: () => void; + clearThemeOverride: () => void; + isDev: boolean; +} + +const ThemeContext = createContext(null); + +export function AppThemeProvider({ + themeName, + children, +}: { + themeName?: ThemeName; + children: React.ReactNode; +}) { + const theme = useMemo(() => { + return themeName ? getTheme(themeName) : getCurrentTheme(); + }, [themeName]); + + const contextValue = useMemo( + () => ({ + theme, + themeName: theme.name, + availableThemes: Object.keys(themes) as ThemeName[], + setTheme: setDevTheme, + toggleTheme: toggleDevTheme, + clearThemeOverride: clearDevTheme, + isDev: IS_DEV, + }), + [theme], + ); + + // Apply theme CSS class to document + useEffect(() => { + if (typeof document !== 'undefined') { + // Remove all theme classes + Object.values(themes).forEach(t => { + document.documentElement.classList.remove(t.cssClass); + }); + // Add current theme class + document.documentElement.classList.add(theme.cssClass); + } + }, [theme]); + + // Dev mode: keyboard shortcut to toggle theme (Ctrl+Shift+T) + useEffect(() => { + if (!IS_DEV || typeof window === 'undefined') return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.ctrlKey && e.shiftKey && e.key === 'T') { + e.preventDefault(); + toggleDevTheme(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + + // Dev mode: log theme info to console + useEffect(() => { + if (IS_DEV && typeof window !== 'undefined') { + // eslint-disable-next-line no-console + console.info( + `🎨 Theme: ${theme.displayName} (${theme.name})`, + '\n Toggle: Ctrl+Shift+T', + '\n Set via URL: ?theme=clickstack', + '\n Set via console: window.__setTheme("clickstack")', + ); + + // Expose helper functions to window for console access + (window as any).__setTheme = setDevTheme; + (window as any).__toggleTheme = toggleDevTheme; + (window as any).__clearTheme = clearDevTheme; + (window as any).__currentTheme = theme.name; + } + }, [theme]); + + return ( + + {children} + + ); +} + +export function useAppTheme(): ThemeContextValue { + const context = useContext(ThemeContext); + if (!context) { + // Fallback for when used outside provider + const theme = getCurrentTheme(); + return { + theme, + themeName: theme.name, + availableThemes: Object.keys(themes) as ThemeName[], + setTheme: setDevTheme, + toggleTheme: toggleDevTheme, + clearThemeOverride: clearDevTheme, + isDev: IS_DEV, + }; + } + return context; +} + +// Convenience hooks +export function useLogo() { + const { theme } = useAppTheme(); + return theme.Logo; +} + +export function useIcon() { + const { theme } = useAppTheme(); + return theme.Icon; +} + +// Hook to get current theme name (useful for conditional rendering) +export function useThemeName(): ThemeName { + return getCurrentThemeName(); +} diff --git a/packages/app/src/theme/index.ts b/packages/app/src/theme/index.ts new file mode 100644 index 0000000000..254918a32a --- /dev/null +++ b/packages/app/src/theme/index.ts @@ -0,0 +1,113 @@ +import { clickstackTheme } from './themes/clickstack'; +import { hyperdxTheme } from './themes/hyperdx'; +import { ThemeConfig, ThemeName } from './types'; + +// Theme registry +export const themes: Record = { + hyperdx: hyperdxTheme, + clickstack: clickstackTheme, +}; + +// Check if we're in development/local mode +const IS_DEV = + process.env.NODE_ENV === 'development' || + process.env.NEXT_PUBLIC_IS_LOCAL_MODE === 'true'; + +// LocalStorage key for dev theme override +const THEME_STORAGE_KEY = 'hdx-dev-theme'; + +// Default theme (can be overridden via environment variable) +export const DEFAULT_THEME: ThemeName = + (process.env.NEXT_PUBLIC_THEME as ThemeName) || 'hyperdx'; + +/** + * Get the theme name from various sources (dev mode only): + * 1. URL query param: ?theme=clickstack + * 2. localStorage: hdx-dev-theme + * 3. Environment variable: NEXT_PUBLIC_THEME + * 4. Default: hyperdx + */ +function getDevThemeName(): ThemeName { + if (typeof window === 'undefined') { + return DEFAULT_THEME; + } + + // Check URL query param first (highest priority for testing) + const urlParams = new URLSearchParams(window.location.search); + const urlTheme = urlParams.get('theme') as ThemeName | null; + if (urlTheme && themes[urlTheme]) { + // Persist to localStorage when set via URL + localStorage.setItem(THEME_STORAGE_KEY, urlTheme); + return urlTheme; + } + + // Check localStorage + const storedTheme = localStorage.getItem( + THEME_STORAGE_KEY, + ) as ThemeName | null; + if (storedTheme && themes[storedTheme]) { + return storedTheme; + } + + return DEFAULT_THEME; +} + +/** + * Set the dev theme (persists to localStorage) + * Only works in development mode + */ +export function setDevTheme(name: ThemeName): void { + if (!IS_DEV) { + console.warn('setDevTheme only works in development mode'); + return; + } + if (typeof window !== 'undefined' && themes[name]) { + localStorage.setItem(THEME_STORAGE_KEY, name); + // Reload to apply theme + window.location.reload(); + } +} + +/** + * Clear the dev theme override (reverts to default) + */ +export function clearDevTheme(): void { + if (typeof window !== 'undefined') { + localStorage.removeItem(THEME_STORAGE_KEY); + window.location.reload(); + } +} + +/** + * Toggle between themes (useful for keyboard shortcut) + */ +export function toggleDevTheme(): void { + if (!IS_DEV) return; + const current = getDevThemeName(); + const themeNames = Object.keys(themes) as ThemeName[]; + const currentIndex = themeNames.indexOf(current); + const nextIndex = (currentIndex + 1) % themeNames.length; + setDevTheme(themeNames[nextIndex]); +} + +// Get theme configuration +export function getTheme(name: ThemeName = DEFAULT_THEME): ThemeConfig { + return themes[name] || themes.hyperdx; +} + +// Get current theme based on environment and dev overrides +export function getCurrentTheme(): ThemeConfig { + const themeName = IS_DEV ? getDevThemeName() : DEFAULT_THEME; + return getTheme(themeName); +} + +// Get current theme name +export function getCurrentThemeName(): ThemeName { + return IS_DEV ? getDevThemeName() : DEFAULT_THEME; +} + +// Re-export types +export type { ThemeConfig, ThemeName } from './types'; + +// Re-export for backwards compatibility +export { makeTheme, theme } from './themes/hyperdx/mantineTheme'; diff --git a/packages/app/src/theme/themes/clickstack/Icon.tsx b/packages/app/src/theme/themes/clickstack/Icon.tsx new file mode 100644 index 0000000000..409f3c3b03 --- /dev/null +++ b/packages/app/src/theme/themes/clickstack/Icon.tsx @@ -0,0 +1,46 @@ +/** + * ClickStack Icon + * A stylized database/stack icon with the brand yellow color + */ +export default function Icon({ size = 16 }: { size?: number }) { + return ( + + {/* Stack of database layers */} + + + + + {/* Click/cursor arrow overlay */} + + + ); +} diff --git a/packages/app/src/theme/themes/clickstack/Logo.tsx b/packages/app/src/theme/themes/clickstack/Logo.tsx new file mode 100644 index 0000000000..8e519b134b --- /dev/null +++ b/packages/app/src/theme/themes/clickstack/Logo.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import Icon from './Icon'; + +export default function Logo({ + size = 'sm', +}: { + size?: 'sm' | 'md' | 'lg' | 'xl'; +}) { + const configs = { + sm: { + fontSize: 14, + iconSize: 14, + }, + md: { + fontSize: 16, + iconSize: 16, + }, + lg: { + fontSize: 18, + iconSize: 18, + }, + xl: { + fontSize: 22, + iconSize: 22, + }, + }; + + return ( +
+
+ +
+ + + ClickStack + +
+ ); +} diff --git a/packages/app/src/theme/themes/clickstack/index.ts b/packages/app/src/theme/themes/clickstack/index.ts new file mode 100644 index 0000000000..bc940f3d8d --- /dev/null +++ b/packages/app/src/theme/themes/clickstack/index.ts @@ -0,0 +1,14 @@ +import { ThemeConfig } from '../../types'; + +import Icon from './Icon'; +import Logo from './Logo'; +import { theme } from './mantineTheme'; + +export const clickstackTheme: ThemeConfig = { + name: 'clickstack', + displayName: 'ClickStack', + mantineTheme: theme, + Logo, + Icon, + cssClass: 'theme-clickstack', +}; diff --git a/packages/app/src/theme/themes/clickstack/mantineTheme.ts b/packages/app/src/theme/themes/clickstack/mantineTheme.ts new file mode 100644 index 0000000000..95051abd34 --- /dev/null +++ b/packages/app/src/theme/themes/clickstack/mantineTheme.ts @@ -0,0 +1,371 @@ +import { + ActionIcon, + Button, + MantineTheme, + MantineThemeOverride, + rem, + Select, + Text, + Tooltip, +} from '@mantine/core'; + +/** + * ClickStack Theme + * + * A distinct visual identity for ClickStack branding. + * Primary color: Yellow/Gold accent + * Style: Modern, professional + */ +export const makeTheme = ({ + fontFamily = '"Inter", sans-serif', +}: { + fontFamily?: string; +}): MantineThemeOverride => ({ + cursorType: 'pointer', + fontFamily, + primaryColor: 'yellow', + primaryShade: 6, + autoContrast: true, + white: '#fff', + fontSizes: { + xxs: '11px', + xs: '12px', + sm: '13px', + md: '15px', + lg: '16px', + xl: '18px', + }, + spacing: { + xxxs: 'calc(0.375rem * var(--mantine-scale))', + xxs: 'calc(0.5rem * var(--mantine-scale))', + xs: 'calc(0.625rem * var(--mantine-scale))', + sm: 'calc(0.75rem * var(--mantine-scale))', + md: 'calc(1rem * var(--mantine-scale))', + lg: 'calc(1.25rem * var(--mantine-scale))', + xl: 'calc(2rem * var(--mantine-scale))', + }, + colors: { + // ClickStack yellow/gold palette + yellow: [ + '#fff9e6', + '#fff3cc', + '#ffe699', + '#ffd966', + '#ffcc33', + '#f5b800', + '#cc9900', + '#997300', + '#664d00', + '#332600', + ], + gray: [ + '#f8f9fa', + '#f1f3f5', + '#e9ecef', + '#dee2e6', + '#ced4da', + '#adb5bd', + '#868e96', + '#495057', + '#343a40', + '#212529', + ], + dark: [ + '#d5d7e0', + '#acaebf', + '#8c8fa3', + '#666980', + '#4d4f66', + '#34354a', + '#2b2c3d', + '#1d1e30', + '#0c0d21', + '#01010a', + ], + }, + headings: { + fontFamily, + }, + components: { + Tooltip: Tooltip.extend({ + styles: () => ({ + tooltip: { + fontFamily: 'var(--mantine-font-family)', + }, + }), + }), + Modal: { + styles: { + header: { + fontFamily, + fontWeight: 'bold', + }, + }, + }, + InputWrapper: { + styles: { + label: { + marginBottom: 4, + }, + description: { + marginBottom: 8, + lineHeight: 1.3, + }, + }, + }, + Select: Select.extend({ + styles: { + input: { + border: '1px solid var(--color-border)', + }, + }, + }), + Input: { + styles: { + input: { + backgroundColor: 'var(--color-bg-field)', + border: '1px solid var(--color-border)', + }, + }, + }, + Card: { + styles: (_theme: MantineTheme, props: { variant?: string }) => { + if (props.variant === 'muted') { + return { + root: { + backgroundColor: 'var(--color-bg-muted)', + border: '1px solid var(--color-border)', + }, + }; + } + return { + root: { + backgroundColor: 'var(--color-bg-body)', + }, + }; + }, + }, + Divider: { + styles: { + root: { + borderColor: 'var(--color-border)', + borderTopColor: 'var(--color-border)', + '--divider-color': 'var(--color-border)', + '--item-border-color': 'var(--color-border)', + }, + }, + }, + Accordion: { + styles: (_theme: MantineTheme, props: { variant?: string }) => { + const base = { + control: { + '--item-border-color': 'var(--color-border)', + }, + item: { + borderColor: 'var(--color-border)', + }, + }; + if (props.variant === 'noPadding') { + return { + ...base, + content: { + paddingInline: 0, + }, + control: { + paddingInlineStart: 0, + }, + }; + } + return base; + }, + }, + UnstyledButton: { + styles: { + root: { + '--item-border-color': 'var(--color-border)', + }, + }, + }, + Paper: { + classNames: (_theme: MantineTheme, props: { variant?: string }) => { + if (props.variant === 'muted') { + return { + root: 'paper-muted', + }; + } + return {}; + }, + styles: (_theme: MantineTheme, props: { variant?: string }) => { + if (props.variant === 'muted') { + return { + root: { + backgroundColor: 'var(--color-bg-muted)', + border: '1px solid var(--color-border)', + }, + }; + } + return { + root: { + border: '1px solid var(--color-border)', + }, + }; + }, + }, + Text: Text.extend({ + styles: (theme, props) => { + if (props.variant === 'danger') { + return { + root: { + color: 'var(--color-text-danger)', + }, + }; + } + return {}; + }, + }), + Button: Button.extend({ + vars: (theme, props) => { + if (props.size === 'xxs') { + return { + root: { + '--button-height': rem(22), + '--button-padding-x': rem(4), + '--button-fz': rem(12), + }, + }; + } + + return { root: {} }; + }, + styles: (_theme, props) => { + if (props.variant === 'primary') { + return { + root: { + backgroundColor: 'var(--mantine-color-yellow-6)', + color: 'var(--mantine-color-dark-9)', + '&:hover': { + backgroundColor: 'var(--mantine-color-yellow-5)', + }, + }, + }; + } + + if (props.variant === 'secondary') { + return { + root: { + backgroundColor: 'var(--color-bg-body)', + color: 'var(--color-text)', + border: '1px solid var(--color-border)', + '&:hover': { + backgroundColor: 'var(--color-bg-hover)', + }, + }, + }; + } + + if (props.variant === 'danger') { + return { + root: { + backgroundColor: 'var(--mantine-color-red-light)', + color: 'var(--mantine-color-red-light-color)', + '&:hover': { + backgroundColor: 'var(--mantine-color-red-light-hover)', + }, + }, + }; + } + + return {}; + }, + }), + SegmentedControl: { + styles: { + root: { + background: 'var(--color-bg-field)', + }, + indicator: { + background: 'var(--color-bg-field-highlighted)', + }, + }, + }, + ActionIcon: ActionIcon.extend({ + defaultProps: { + variant: 'subtle', + color: 'gray', + }, + styles: (_theme, props) => { + if (props.variant === 'subtle') { + return { + root: { + backgroundColor: 'transparent', + color: 'var(--color-text)', + '&:hover': { + backgroundColor: 'var(--color-bg-hover)', + }, + '&:active': { + backgroundColor: 'var(--color-bg-muted)', + }, + }, + }; + } + + if (props.variant === 'default') { + return { + root: { + backgroundColor: 'var(--color-bg-hover)', + color: 'var(--color-text)', + border: 'none', + '&:hover': { + backgroundColor: 'var(--color-bg-muted)', + }, + '&:active': { + backgroundColor: 'var(--color-bg-muted)', + }, + }, + }; + } + + if (props.variant === 'primary') { + return { + root: { + backgroundColor: 'var(--mantine-color-yellow-6)', + color: 'var(--mantine-color-dark-9)', + '&:hover': { + backgroundColor: 'var(--mantine-color-yellow-5)', + }, + }, + }; + } + + if (props.variant === 'secondary') { + return { + root: { + backgroundColor: 'var(--color-bg-surface)', + color: 'var(--color-text)', + border: '1px solid var(--color-border)', + '&:hover': { + backgroundColor: 'var(--color-bg-hover)', + }, + }, + }; + } + + if (props.variant === 'danger') { + return { + root: { + backgroundColor: 'var(--mantine-color-red-light)', + color: 'var(--mantine-color-red-light-color)', + '&:hover': { + backgroundColor: 'var(--mantine-color-red-light-hover)', + }, + }, + }; + } + + return {}; + }, + }), + }, +}); + +export const theme = makeTheme({}); diff --git a/packages/app/src/theme/themes/hyperdx/Icon.tsx b/packages/app/src/theme/themes/hyperdx/Icon.tsx new file mode 100644 index 0000000000..36fc35ee93 --- /dev/null +++ b/packages/app/src/theme/themes/hyperdx/Icon.tsx @@ -0,0 +1,27 @@ +export default function Icon({ size = 16 }: { size?: number }) { + return ( + + + + + + + + + + + + ); +} diff --git a/packages/app/src/theme/themes/hyperdx/Logo.tsx b/packages/app/src/theme/themes/hyperdx/Logo.tsx new file mode 100644 index 0000000000..ed2e755f5a --- /dev/null +++ b/packages/app/src/theme/themes/hyperdx/Logo.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import Icon from './Icon'; + +export default function Logo({ + size = 'sm', +}: { + size?: 'sm' | 'md' | 'lg' | 'xl'; +}) { + const configs = { + sm: { + fontSize: 14, + iconSize: 14, + iconMarginBottom: 3, + }, + md: { + fontSize: 16, + iconSize: 16, + iconMarginBottom: 3, + }, + lg: { + fontSize: 18, + iconSize: 18, + iconMarginBottom: 3, + }, + xl: { + fontSize: 22, + iconSize: 22, + iconMarginBottom: 3, + }, + }; + + return ( +
+
+ +
+ + + HyperDX + +
+ ); +} diff --git a/packages/app/src/theme/themes/hyperdx/index.ts b/packages/app/src/theme/themes/hyperdx/index.ts new file mode 100644 index 0000000000..a088c0bc0e --- /dev/null +++ b/packages/app/src/theme/themes/hyperdx/index.ts @@ -0,0 +1,14 @@ +import { ThemeConfig } from '../../types'; + +import Icon from './Icon'; +import Logo from './Logo'; +import { theme } from './mantineTheme'; + +export const hyperdxTheme: ThemeConfig = { + name: 'hyperdx', + displayName: 'HyperDX', + mantineTheme: theme, + Logo, + Icon, + cssClass: 'theme-hyperdx', +}; diff --git a/packages/app/src/theme/themes/hyperdx/mantineTheme.ts b/packages/app/src/theme/themes/hyperdx/mantineTheme.ts new file mode 100644 index 0000000000..e8a6ee4680 --- /dev/null +++ b/packages/app/src/theme/themes/hyperdx/mantineTheme.ts @@ -0,0 +1,363 @@ +import { + ActionIcon, + Button, + MantineTheme, + MantineThemeOverride, + rem, + Select, + Text, + Tooltip, +} from '@mantine/core'; + +export const makeTheme = ({ + fontFamily = '"IBM Plex Sans", monospace', +}: { + fontFamily?: string; +}): MantineThemeOverride => ({ + cursorType: 'pointer', + fontFamily, + primaryColor: 'green', + primaryShade: 8, + autoContrast: true, + white: '#fff', + fontSizes: { + xxs: '11px', + xs: '12px', + sm: '13px', + md: '15px', + lg: '16px', + xl: '18px', + }, + spacing: { + xxxs: 'calc(0.375rem * var(--mantine-scale))', + xxs: 'calc(0.5rem * var(--mantine-scale))', + xs: 'calc(0.625rem * var(--mantine-scale))', + sm: 'calc(0.75rem * var(--mantine-scale))', + md: 'calc(1rem * var(--mantine-scale))', + lg: 'calc(1.25rem * var(--mantine-scale))', + xl: 'calc(2rem * var(--mantine-scale))', + }, + colors: { + green: [ + '#eafff6', + '#cdfee7', + '#a0fad5', + '#63f2bf', + '#25e2a5', + '#00c28a', + '#00a475', + '#008362', + '#00674e', + '#005542', + ], + gray: [ + '#FAFAFA', + '#e6e6ee', + '#D7D8DB', + '#aeaeb7', + '#A1A1AA', + '#868691', + '#7e7e8b', + '#6c6c79', + '#5f5f6e', + '#515264', + ], + dark: [ + '#C1C2C5', + '#A6A7AB', + '#909296', + '#5C5F66', + '#373A40', + '#2C2E33', + '#25262B', + '#1A1B1E', + '#141517', + '#101113', + ], + }, + headings: { + fontFamily, + }, + components: { + Tooltip: Tooltip.extend({ + styles: () => ({ + tooltip: { + fontFamily: 'var(--mantine-font-family)', + }, + }), + }), + Modal: { + styles: { + header: { + fontFamily, + fontWeight: 'bold', + }, + }, + }, + InputWrapper: { + styles: { + label: { + marginBottom: 4, + }, + description: { + marginBottom: 8, + lineHeight: 1.3, + }, + }, + }, + Select: Select.extend({ + styles: { + input: { + border: '1px solid var(--color-border)', + }, + }, + }), + Input: { + styles: { + input: { + backgroundColor: 'var(--color-bg-field)', + border: '1px solid var(--color-border)', + }, + }, + }, + Card: { + styles: (_theme: MantineTheme, props: { variant?: string }) => { + if (props.variant === 'muted') { + return { + root: { + backgroundColor: 'var(--color-bg-muted)', + border: '1px solid var(--color-border)', + }, + }; + } + return { + root: { + backgroundColor: 'var(--color-bg-body)', + }, + }; + }, + }, + Divider: { + styles: { + root: { + borderColor: 'var(--color-border)', + borderTopColor: 'var(--color-border)', + '--divider-color': 'var(--color-border)', + '--item-border-color': 'var(--color-border)', + }, + }, + }, + Accordion: { + styles: (_theme: MantineTheme, props: { variant?: string }) => { + const base = { + control: { + '--item-border-color': 'var(--color-border)', + }, + item: { + borderColor: 'var(--color-border)', + }, + }; + if (props.variant === 'noPadding') { + return { + ...base, + content: { + paddingInline: 0, + }, + control: { + paddingInlineStart: 0, + }, + }; + } + return base; + }, + }, + UnstyledButton: { + styles: { + root: { + '--item-border-color': 'var(--color-border)', + }, + }, + }, + Paper: { + classNames: (_theme: MantineTheme, props: { variant?: string }) => { + if (props.variant === 'muted') { + return { + root: 'paper-muted', + }; + } + return {}; + }, + styles: (_theme: MantineTheme, props: { variant?: string }) => { + if (props.variant === 'muted') { + return { + root: { + backgroundColor: 'var(--color-bg-muted)', + border: '1px solid var(--color-border)', + }, + }; + } + return { + root: { + border: '1px solid var(--color-border)', + }, + }; + }, + }, + Text: Text.extend({ + styles: (theme, props) => { + if (props.variant === 'danger') { + return { + root: { + color: 'var(--color-text-danger)', + }, + }; + } + return {}; + }, + }), + Button: Button.extend({ + vars: (theme, props) => { + if (props.size === 'xxs') { + return { + root: { + '--button-height': rem(22), + '--button-padding-x': rem(4), + '--button-fz': rem(12), + }, + }; + } + + return { root: {} }; + }, + styles: (_theme, props) => { + if (props.variant === 'primary') { + return { + root: { + backgroundColor: 'var(--mantine-color-green-light)', + color: 'var(--mantine-color-green-light-color)', + '&:hover': { + backgroundColor: 'var(--mantine-color-green-light-hover)', + }, + }, + }; + } + + if (props.variant === 'secondary') { + return { + root: { + backgroundColor: 'var(--color-bg-body)', + color: 'var(--color-text)', + border: '1px solid var(--color-border)', + '&:hover': { + backgroundColor: 'var(--color-bg-hover)', + }, + }, + }; + } + + if (props.variant === 'danger') { + return { + root: { + backgroundColor: 'var(--mantine-color-red-light)', + color: 'var(--mantine-color-red-light-color)', + '&:hover': { + backgroundColor: 'var(--mantine-color-red-light-hover)', + }, + }, + }; + } + + return {}; + }, + }), + SegmentedControl: { + styles: { + root: { + background: 'var(--color-bg-field)', + }, + indicator: { + background: 'var(--color-bg-field-highlighted)', + }, + }, + }, + ActionIcon: ActionIcon.extend({ + defaultProps: { + variant: 'subtle', + color: 'gray', + }, + styles: (_theme, props) => { + if (props.variant === 'subtle') { + return { + root: { + backgroundColor: 'transparent', + color: 'var(--color-text)', + '&:hover': { + backgroundColor: 'var(--color-bg-hover)', + }, + '&:active': { + backgroundColor: 'var(--color-bg-muted)', + }, + }, + }; + } + + if (props.variant === 'default') { + return { + root: { + backgroundColor: 'var(--color-bg-hover)', + color: 'var(--color-text)', + border: 'none', + '&:hover': { + backgroundColor: 'var(--color-bg-muted)', + }, + '&:active': { + backgroundColor: 'var(--color-bg-muted)', + }, + }, + }; + } + + if (props.variant === 'primary') { + return { + root: { + backgroundColor: 'var(--mantine-color-green-light)', + color: 'var(--mantine-color-green-light-color)', + '&:hover': { + backgroundColor: 'var(--mantine-color-green-light-hover)', + }, + }, + }; + } + + if (props.variant === 'secondary') { + return { + root: { + backgroundColor: 'var(--color-bg-surface)', + color: 'var(--color-text)', + border: '1px solid var(--color-border)', + '&:hover': { + backgroundColor: 'var(--color-bg-hover)', + }, + }, + }; + } + + if (props.variant === 'danger') { + return { + root: { + backgroundColor: 'var(--mantine-color-red-light)', + color: 'var(--mantine-color-red-light-color)', + '&:hover': { + backgroundColor: 'var(--mantine-color-red-light-hover)', + }, + }, + }; + } + + return {}; + }, + }), + }, +}); + +export const theme = makeTheme({}); diff --git a/packages/app/src/theme/types.ts b/packages/app/src/theme/types.ts new file mode 100644 index 0000000000..1d78582df5 --- /dev/null +++ b/packages/app/src/theme/types.ts @@ -0,0 +1,12 @@ +import { MantineThemeOverride } from '@mantine/core'; + +export type ThemeName = 'hyperdx' | 'clickstack'; + +export interface ThemeConfig { + name: ThemeName; + displayName: string; + mantineTheme: MantineThemeOverride; + Logo: React.ComponentType<{ size?: 'sm' | 'md' | 'lg' | 'xl' }>; + Icon: React.ComponentType<{ size?: number }>; + cssClass: string; // Applied to html element for CSS variable scoping +} diff --git a/packages/app/styles/SourceSelectControlled.module.scss b/packages/app/styles/SourceSelectControlled.module.scss index efc8e37e6c..ce21864671 100644 --- a/packages/app/styles/SourceSelectControlled.module.scss +++ b/packages/app/styles/SourceSelectControlled.module.scss @@ -1,12 +1,12 @@ .sourceSchemaPreviewButton { pointer-events: all; font-size: var(--mantine-font-size-xxs); - color: var(--color-text-success); + color: var(--color-text-brand); padding-inline: var(--mantine-spacing-xxxs); border-radius: var(--mantine-radius-xs); &:hover { - color: var(--color-text-success-hover); + color: var(--color-text-brand-hover); cursor: pointer; } } diff --git a/packages/app/styles/_semantic-colors.scss b/packages/app/styles/_semantic-colors.scss index 69977f9e29..89e49a3f4e 100644 --- a/packages/app/styles/_semantic-colors.scss +++ b/packages/app/styles/_semantic-colors.scss @@ -1,3 +1,12 @@ +/* Theme-specific color imports */ +@use './themes/hyperdx-colors'; +@use './themes/clickstack-colors'; + +/* + * Fallback colors for when no theme class is applied + * Uses HyperDX theme as default + */ + /* Dark Mode (default) */ [data-mantine-color-scheme='dark'] { /* Backgrounds */ diff --git a/packages/app/styles/themes/_clickstack-colors.scss b/packages/app/styles/themes/_clickstack-colors.scss new file mode 100644 index 0000000000..461279bef4 --- /dev/null +++ b/packages/app/styles/themes/_clickstack-colors.scss @@ -0,0 +1,176 @@ +/* ClickStack Theme Colors */ + +/* Yellow/Gold accent theme for ClickStack branding */ + +/* Uses Click UI design tokens */ + +/* Dark Mode */ +.theme-clickstack[data-mantine-color-scheme='dark'] { + /* Click UI Global Tokens - Dark Mode Values */ + --click-global-color-stroke-default: #323232; + --click-global-color-accent-default: #faff69; + --click-global-color-background-default: #1f1f1c; + --click-global-color-background-muted: #282828; + --click-global-color-text-default: #fff; + --click-global-color-text-muted: #b3b6bd; + --click-global-color-text-disabled: #808080; + --click-global-color-text-link-default: #faff69; + --click-global-color-text-link-hover: #feffc2; + --click-global-color-text-danger: #ffbaba; + --click-global-color-title-default: rgb(97.5% 97.5% 97.5%); + --click-global-color-title-muted: #b3b6bd; + --click-global-color-outline-default: #faff69; + --click-field-color-background-default: #2d2d2d; + + /* Backgrounds - Using Click UI tokens */ + --color-bg-body: var(--click-global-color-background-default); + --color-bg-surface: #34354a; + --color-bg-inverted: #d5d7e0; + --color-bg-muted: var(--click-global-color-background-muted); + --color-bg-highlighted: rgb(75 78 102 / 70%); + --color-bg-sidebar: var(--click-global-color-background-default); + --color-bg-header: var(--click-global-color-background-default); + --color-bg-hover: #2b2c3d; + --color-bg-active: #34354a; + --color-bg-field: var(--click-field-color-background-default); + --color-bg-field-highlighted: #4d4f66; + --color-bg-neutral: #4d4f66; + --color-bg-success: #22c55e; + --color-bg-danger: #ef4444; + --color-bg-warning: #f59e0b; + --color-bg-brand: var(--click-global-color-accent-default); + + /* Borders & Dividers */ + --color-border: var(--click-global-color-stroke-default); + --color-border-muted: var(--click-global-color-stroke-default); + + /* Text - Using Click UI tokens */ + --color-text: var(--click-global-color-text-default); + --color-text-inverted: var(--click-global-color-background-default); + --color-text-primary: var(--click-global-color-accent-default); + --color-text-primary-hover: var(--click-global-color-text-link-hover); + --color-text-secondary: var(--click-global-color-text-muted); + --color-text-muted: var(--click-global-color-text-muted); + --color-text-muted-hover: var(--click-global-color-title-muted); + --color-text-brand: var(--click-global-color-accent-default); + --color-text-brand-hover: var(--click-global-color-text-link-hover); + --color-text-success: #22c55e; + --color-text-success-hover: #4ade80; + --color-text-danger: var(--click-global-color-text-danger); + + /* Icons */ + --color-icon-primary: #d5d7e0; + --color-icon-muted: #666980; + + /* Overlay & Backdrop */ + --color-overlay: rgb(0 0 0 / 60%); + --color-backdrop: rgb(0 0 0 / 40%); + + /* States */ + --color-state-hover: #2b2c3d; + --color-state-selected: #34354a; + --color-state-focus: #4d4f66; + + /* Code / Misc UI */ + --color-bg-code: #1d1e30; + --color-border-code: #4d4f66; + --color-bg-kbd: var(--click-global-color-background-default); + + /* JSON Syntax Highlighting - Yellow/Gold palette */ + --color-json-string: #ffd966; + --color-json-number: #f59e0b; + --color-json-boolean: #f59e0b; + --color-json-key: #a78bfa; + --color-json-object: #ffd966; + --color-json-array: #ffd966; + --color-json-punctuation: #666980; + + /* Mantine Overrides */ + --mantine-color-body: var(--color-bg-body) !important; +} + +/* Light Mode */ +.theme-clickstack[data-mantine-color-scheme='light'] { + /* Click UI Global Tokens - Light Mode Values */ + --click-global-color-stroke-default: #e6e7e9; + --click-global-color-accent-default: #151515; + --click-global-color-background-default: #fff; + --click-global-color-background-muted: #f6f7fa; + --click-global-color-text-default: #161517; + --click-global-color-text-muted: #696e79; + --click-global-color-text-disabled: #a0a0a0; + --click-global-color-text-link-default: #437eef; + --click-global-color-text-link-hover: #104ec6; + --click-global-color-text-danger: #c10000; + --click-global-color-title-default: lch(11.126% 1.374 305.43deg); + --click-global-color-title-muted: #696e79; + --click-global-color-outline-default: #437eef; + --click-field-color-background-default: #fbfcff; + + /* Backgrounds - Using Click UI tokens */ + --color-bg-body: var(--click-global-color-background-default); + --color-bg-surface: var(--click-global-color-background-default); + --color-bg-inverted: #343a40; + --color-bg-muted: var(--click-global-color-background-muted); + --color-bg-highlighted: rgb(241 243 245 / 80%); + --color-bg-sidebar: var(--click-global-color-background-default); + --color-bg-header: var(--click-global-color-background-muted); + --color-bg-modal: var(--click-global-color-background-default); + --color-bg-hover: #e9ecef; + --color-bg-active: #dee2e6; + --color-bg-field: var(--click-field-color-background-default); + --color-bg-field-highlighted: var(--click-global-color-background-default); + --color-bg-neutral: #adb5bd; + --color-bg-success: #16a34a; + --color-bg-danger: #dc2626; + --color-bg-warning: #d97706; + --color-bg-brand: var(--click-global-color-accent-default); + + /* Borders & Dividers */ + --color-border: var(--click-global-color-stroke-default); + --color-border-muted: rgb(0 0 0 / 8%); + + /* Text - Using Click UI tokens */ + --color-text: var(--click-global-color-text-default); + --color-text-inverted: var(--click-global-color-background-default); + --color-text-primary: var(--click-global-color-text-link-default); + --color-text-primary-hover: var(--click-global-color-text-link-hover); + --color-text-secondary: var(--click-global-color-text-muted); + --color-text-muted: var(--click-global-color-text-muted); + --color-text-muted-hover: var(--click-global-color-title-default); + --color-text-brand: var(--click-global-color-accent-default); + --color-text-brand-hover: var(--click-global-color-text-link-hover); + --color-text-success: #16a34a; + --color-text-success-hover: #15803d; + --color-text-danger: var(--click-global-color-text-danger); + + /* Icons */ + --color-icon-primary: #343a40; + --color-icon-muted: #868e96; + + /* Overlay & Backdrop */ + --color-overlay: rgb(0 0 0 / 30%); + --color-backdrop: rgb(0 0 0 / 15%); + + /* States */ + --color-state-hover: #e9ecef; + --color-state-selected: #dee2e6; + --color-state-focus: #ced4da; + + /* Code / Misc UI */ + --color-bg-code: var(--click-global-color-background-muted); + --color-border-code: #dee2e6; + --color-bg-kbd: var(--click-global-color-background-muted); + + /* JSON Syntax Highlighting */ + --color-json-string: #997300; + --color-json-number: #d97706; + --color-json-boolean: #d97706; + --color-json-key: #7c3aed; + --color-json-object: #997300; + --color-json-array: #997300; + --color-json-punctuation: #868e96; + + /* Mantine Overrides */ + --mantine-color-body: var(--color-bg-body); +} diff --git a/packages/app/styles/themes/_hyperdx-colors.scss b/packages/app/styles/themes/_hyperdx-colors.scss new file mode 100644 index 0000000000..f463d7270a --- /dev/null +++ b/packages/app/styles/themes/_hyperdx-colors.scss @@ -0,0 +1,142 @@ +/* HyperDX Theme Colors */ + +/* Default theme with green accent */ + +/* Dark Mode */ +.theme-hyperdx[data-mantine-color-scheme='dark'] { + /* Backgrounds */ + --color-bg-body: var(--mantine-color-dark-9); + --color-bg-surface: var(--mantine-color-dark-5); + --color-bg-inverted: var(--mantine-color-dark-1); + --color-bg-muted: var(--mantine-color-dark-7); + --color-bg-highlighted: rgb(55 58 64 / 70%); + --color-bg-sidebar: var(--mantine-color-dark-9); + --color-bg-header: var(--mantine-color-dark-9); + --color-bg-hover: var(--mantine-color-dark-6); + --color-bg-active: var(--mantine-color-dark-5); + --color-bg-field: var(--mantine-color-dark-6); + --color-bg-field-highlighted: var(--mantine-color-dark-3); + --color-bg-neutral: var(--mantine-color-dark-4); + --color-bg-success: var(--mantine-color-green-5); + --color-bg-danger: var(--mantine-color-red-4); + --color-bg-warning: var(--mantine-color-orange-5); + --color-bg-brand: #4ffa7a; + + /* Borders & Dividers */ + --color-border: var(--mantine-color-dark-5); + --color-border-muted: rgb(255 255 255 / 8%); + + /* Text */ + --color-text: var(--mantine-color-dark-0); + --color-text-inverted: var(--mantine-color-dark-9); + --color-text-primary: var(--mantine-color-green-4); + --color-text-primary-hover: var(--mantine-color-green-3); + --color-text-secondary: var(--mantine-color-dark-2); + --color-text-muted: var(--mantine-color-dark-2); + --color-text-muted-hover: var(--mantine-color-dark-1); + --color-text-brand: var(--mantine-color-green-4); + --color-text-brand-hover: var(--mantine-color-green-3); + --color-text-success: var(--mantine-color-green-4); + --color-text-success-hover: var(--mantine-color-green-3); + --color-text-danger: var(--mantine-color-red-3); + + /* Icons */ + --color-icon-primary: var(--mantine-color-dark-1); + --color-icon-muted: var(--mantine-color-dark-4); + + /* Overlay & Backdrop */ + --color-overlay: rgb(0 0 0 / 50%); + --color-backdrop: rgb(0 0 0 / 30%); + + /* States */ + --color-state-hover: var(--mantine-color-dark-6); + --color-state-selected: var(--mantine-color-dark-5); + --color-state-focus: var(--mantine-color-dark-4); + + /* Code / Misc UI */ + --color-bg-code: var(--mantine-color-dark-8); + --color-border-code: var(--mantine-color-dark-6); + --color-bg-kbd: var(--mantine-color-dark-9); + + /* JSON Syntax Highlighting */ + --color-json-string: var(--mantine-color-green-3); + --color-json-number: var(--mantine-color-orange-3); + --color-json-boolean: var(--mantine-color-orange-3); + --color-json-key: var(--mantine-color-violet-3); + --color-json-object: var(--mantine-color-green-3); + --color-json-array: var(--mantine-color-green-3); + --color-json-punctuation: var(--mantine-color-dark-4); + + /* Mantine Overrides */ + --mantine-color-body: var(--color-bg-body) !important; +} + +/* Light Mode */ +.theme-hyperdx[data-mantine-color-scheme='light'] { + /* Mantine Overrides */ + --mantine-color-body: var(--color-bg-body); + + /* Backgrounds - inverted (dark becomes light) */ + --color-bg-body: var(--mantine-color-white); + --color-bg-surface: var(--mantine-color-white); + --color-bg-inverted: var(--mantine-color-dark-3); + --color-bg-muted: #f6f6fa; + --color-bg-highlighted: rgb(230 232 237 / 70%); + --color-bg-sidebar: var(--mantine-color-white); + --color-bg-header: var(--mantine-color-gray-1); + --color-bg-modal: var(--mantine-color-white); + --color-bg-hover: var(--mantine-color-gray-3); + --color-bg-active: var(--mantine-color-gray-4); + --color-bg-field: #ececf5; + --color-bg-field-highlighted: var(--mantine-color-white); + --color-bg-neutral: var(--mantine-color-gray-5); + --color-bg-success: var(--mantine-color-green-8); + --color-bg-danger: var(--mantine-color-red-8); + --color-bg-warning: var(--mantine-color-orange-8); + --color-bg-brand: var(--mantine-color-green-8); + + /* Borders & Dividers - inverted */ + --color-border: var(--mantine-color-gray-1); + --color-border-muted: rgb(0 0 0 / 5%); + + /* Text - inverted */ + --color-text: var(--mantine-color-dark-9); + --color-text-inverted: var(--mantine-color-white); + --color-text-primary: var(--mantine-color-green-8); + --color-text-primary-hover: var(--mantine-color-green-9); + --color-text-secondary: var(--mantine-color-gray-5); + --color-text-muted: var(--mantine-color-dark-6); + --color-text-muted-hover: var(--mantine-color-dark-9); + --color-text-brand: var(--mantine-color-green-8); + --color-text-brand-hover: var(--mantine-color-green-9); + --color-text-success: var(--mantine-color-green-8); + --color-text-success-hover: var(--mantine-color-green-9); + --color-text-danger: var(--mantine-color-red-8); + + /* Icons - inverted */ + --color-icon-primary: var(--mantine-color-dark-8); + --color-icon-muted: var(--mantine-color-dark-5); + + /* Overlay & Backdrop */ + --color-overlay: rgb(0 0 0 / 30%); + --color-backdrop: rgb(0 0 0 / 10%); + + /* States - inverted */ + --color-state-hover: var(--mantine-color-dark-3); + --color-state-selected: var(--mantine-color-dark-4); + --color-state-focus: var(--mantine-color-dark-5); + + /* Code / Misc UI - inverted */ + --color-bg-code: var(--mantine-color-dark-1); + --color-border-code: var(--mantine-color-dark-3); + --color-bg-kbd: var(--mantine-color-dark-0); + + /* JSON Syntax Highlighting */ + --color-json-string: var(--mantine-color-green-9); + --color-json-number: var(--mantine-color-orange-7); + --color-json-boolean: var(--mantine-color-orange-7); + --color-json-key: var(--mantine-color-violet-9); + --color-json-object: var(--mantine-color-green-7); + --color-json-array: var(--mantine-color-green-7); + --color-json-punctuation: var(--mantine-color-dark-5); +} From a25e0c6e2098fcf1dc3801925e6887fc4b8e598b Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 15 Jan 2026 14:25:20 +0000 Subject: [PATCH 02/36] refactor: update components to use theme-based Logo and Icon - Replaced direct imports of Logo and Icon with theme hooks in AppNav, LandingHeader, OnboardingChecklist, and Spotlights components. - Introduced ThemedLogo component in nextra.config.tsx for consistent logo rendering. - Updated styles in AppNav.module.scss to reflect new brand color scheme. - Modified Icon component to use a new SVG design for improved aesthetics. --- packages/app/src/AppNav.tsx | 6 +- packages/app/src/LandingHeader.tsx | 3 +- packages/app/src/OnboardingChecklist.tsx | 3 +- packages/app/src/Spotlights.tsx | 7 +- packages/app/src/nextra.config.tsx | 9 ++- .../app/src/theme/themes/clickstack/Icon.tsx | 78 ++++++++++--------- .../app/src/theme/themes/clickstack/Logo.tsx | 69 +++++++++++----- packages/app/styles/AppNav.module.scss | 10 +-- 8 files changed, 116 insertions(+), 69 deletions(-) diff --git a/packages/app/src/AppNav.tsx b/packages/app/src/AppNav.tsx index 5659ff5834..f2bc08b970 100644 --- a/packages/app/src/AppNav.tsx +++ b/packages/app/src/AppNav.tsx @@ -54,6 +54,7 @@ import packageJson from '../package.json'; const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? packageJson.version ?? 'dev'; +import { useIcon, useLogo } from './theme/ThemeProvider'; import api from './api'; import { AppNavCloudBanner, @@ -63,9 +64,7 @@ import { AppNavUserMenu, } from './AppNav.components'; import { IS_K8S_DASHBOARD_ENABLED, IS_LOCAL_MODE } from './config'; -import Icon from './Icon'; import InstallInstructionModal from './InstallInstructionsModal'; -import Logo from './Logo'; import OnboardingChecklist from './OnboardingChecklist'; import { useSavedSearches, useUpdateSavedSearch } from './savedSearch'; import type { SavedSearch, ServerDashboard } from './types'; @@ -356,6 +355,9 @@ function useSearchableList({ } export default function AppNav({ fixed = false }: { fixed?: boolean }) { + const Logo = useLogo(); + const Icon = useIcon(); + useEffect(() => { let redirectUrl; try { diff --git a/packages/app/src/LandingHeader.tsx b/packages/app/src/LandingHeader.tsx index c77abbc450..18d7cddc77 100644 --- a/packages/app/src/LandingHeader.tsx +++ b/packages/app/src/LandingHeader.tsx @@ -2,8 +2,8 @@ import Link from 'next/link'; import { Anchor, Burger, Button, Container, Group } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; +import { useLogo } from './theme/ThemeProvider'; import api from './api'; -import Logo from './Logo'; export default function LandingHeader({ activeKey, @@ -12,6 +12,7 @@ export default function LandingHeader({ activeKey: string; fixed?: boolean; }) { + const Logo = useLogo(); const { data: me } = api.useMe(); const isLoggedIn = Boolean(me); diff --git a/packages/app/src/OnboardingChecklist.tsx b/packages/app/src/OnboardingChecklist.tsx index 331d1c3559..a7953391cb 100644 --- a/packages/app/src/OnboardingChecklist.tsx +++ b/packages/app/src/OnboardingChecklist.tsx @@ -20,9 +20,9 @@ import { } from '@tabler/icons-react'; import { useQueriedChartConfig } from './hooks/useChartConfig'; +import { useIcon } from './theme/ThemeProvider'; import api from './api'; import { useConnections } from './connection'; -import Icon from './Icon'; import { useSources } from './source'; import { useLocalStorage } from './utils'; @@ -41,6 +41,7 @@ const OnboardingChecklist = ({ }: { onAddDataClick?: () => void; }) => { + const Icon = useIcon(); const [isCollapsed, setIsCollapsed] = useLocalStorage( 'onboardingChecklistCollapsed', false, diff --git a/packages/app/src/Spotlights.tsx b/packages/app/src/Spotlights.tsx index cef8392ce5..8310f2609f 100644 --- a/packages/app/src/Spotlights.tsx +++ b/packages/app/src/Spotlights.tsx @@ -14,14 +14,15 @@ import { IconSettings, } from '@tabler/icons-react'; +import { useIcon } from './theme/ThemeProvider'; import api from './api'; -import Logo from './Icon'; import { useSavedSearches } from './savedSearch'; import '@mantine/spotlight/styles.css'; export const useSpotlightActions = () => { const router = useRouter(); + const Icon = useIcon(); const { data: logViewsData } = useSavedSearches(); const { data: dashboardsData } = api.useDashboards(); @@ -150,7 +151,7 @@ export const useSpotlightActions = () => { { id: 'cloud', group: 'Menu', - leftSection: , + leftSection: , label: 'HyperDX Cloud', description: 'Ready to use HyperDX Cloud? Get started for free.', keywords: ['account', 'profile'], @@ -161,7 +162,7 @@ export const useSpotlightActions = () => { ); return logViewActions; - }, [logViewsData, dashboardsData, router]); + }, [Icon, logViewsData, dashboardsData, router]); return { actions }; }; diff --git a/packages/app/src/nextra.config.tsx b/packages/app/src/nextra.config.tsx index 103c171fe8..6db0224cef 100644 --- a/packages/app/src/nextra.config.tsx +++ b/packages/app/src/nextra.config.tsx @@ -1,12 +1,17 @@ import React from 'react'; -import Logo from './Logo'; +import { useLogo } from './theme/ThemeProvider'; // import NextraMain from './NextraMain'; import useNextraSeoProps from './useNextraSeoProps'; +function ThemedLogo() { + const Logo = useLogo(); + return ; +} + const theme = { useNextSeoProps: useNextraSeoProps, - logo: , + logo: , footer: { text: 'Made with ♥ in San Francisco, © 2024 DeploySentinel, Inc.', }, diff --git a/packages/app/src/theme/themes/clickstack/Icon.tsx b/packages/app/src/theme/themes/clickstack/Icon.tsx index 409f3c3b03..b4451df8f0 100644 --- a/packages/app/src/theme/themes/clickstack/Icon.tsx +++ b/packages/app/src/theme/themes/clickstack/Icon.tsx @@ -4,43 +4,47 @@ */ export default function Icon({ size = 16 }: { size?: number }) { return ( - - {/* Stack of database layers */} - - - - - {/* Click/cursor arrow overlay */} - + + + + + + ); } diff --git a/packages/app/src/theme/themes/clickstack/Logo.tsx b/packages/app/src/theme/themes/clickstack/Logo.tsx index 8e519b134b..e3b9fff46f 100644 --- a/packages/app/src/theme/themes/clickstack/Logo.tsx +++ b/packages/app/src/theme/themes/clickstack/Logo.tsx @@ -28,25 +28,58 @@ export default function Logo({ return (
-
- -
- - - ClickStack - + + + + + + +
); } diff --git a/packages/app/styles/AppNav.module.scss b/packages/app/styles/AppNav.module.scss index 6d2f338975..0b42f08997 100644 --- a/packages/app/styles/AppNav.module.scss +++ b/packages/app/styles/AppNav.module.scss @@ -61,7 +61,7 @@ gap: 10px; &:hover { - color: var(--color-text-success); + color: var(--color-text-brand); } &:focus-visible { @@ -78,7 +78,7 @@ transition: all 0.2s ease-in-out; &:hover { - color: var(--color-text-success); + color: var(--color-text-brand); } } @@ -103,7 +103,7 @@ white-space: nowrap; &:hover { - color: var(--color-text-success); + color: var(--color-text-brand); } &:focus-visible { @@ -117,12 +117,12 @@ } .nestedLinkActive { - color: var(--color-text-success); + color: var(--color-text-brand); font-weight: 500; } .listLinkActive { - color: var(--color-text-success); + color: var(--color-text-brand); font-weight: 500; } From 74b40116f55a92b0fd38dc9c4208d8c835f67a2d Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 15 Jan 2026 14:30:14 +0000 Subject: [PATCH 03/36] fix: update AppNav styles to use brand color scheme - Changed the class name for the search link in AppNav from 'text-success' to 'text-brand' to align with the new branding guidelines. --- packages/app/src/AppNav.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/AppNav.tsx b/packages/app/src/AppNav.tsx index f2bc08b970..626aac835d 100644 --- a/packages/app/src/AppNav.tsx +++ b/packages/app/src/AppNav.tsx @@ -683,7 +683,7 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) { icon={} href="/search" className={cx({ - 'text-success fw-600': + 'text-brand fw-600': pathname.includes('/search') && query.savedSearchId == null, 'fw-600': pathname.includes('/search') && query.savedSearchId != null, From 333354ade4361415c962ad13c1253758d644000d Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 15 Jan 2026 16:01:03 +0000 Subject: [PATCH 04/36] refactor: update components to use brand color scheme - Changed instances of 'text-success' to 'text-brand' in AuthPage, ExpandableRowTable, TimePicker, and DisplaySwitcher components to align with the new branding guidelines. - Added new utility classes for brand text styling in _utilities.scss. - Introduced base design tokens for themes in _base-tokens.scss, and created theme-specific tokens for ClickStack and HyperDX. - Updated app.scss to use the new theme tokens for consistent styling across the application. --- packages/app/src/AuthPage.tsx | 2 +- .../app/src/components/ExpandableRowTable.tsx | 2 +- .../src/components/TimePicker/TimePicker.tsx | 2 +- .../src/components/charts/DisplaySwitcher.tsx | 2 +- packages/app/src/theme/ThemeProvider.tsx | 51 ++++++++++++++++--- .../theme/themes/_base-tokens.scss} | 13 ++--- .../theme/themes/clickstack/_tokens.scss} | 2 +- .../theme/themes/hyperdx/_tokens.scss} | 2 +- packages/app/styles/_utilities.scss | 13 +++++ packages/app/styles/app.scss | 2 +- 10 files changed, 68 insertions(+), 23 deletions(-) rename packages/app/{styles/_semantic-colors.scss => src/theme/themes/_base-tokens.scss} (96%) rename packages/app/{styles/themes/_clickstack-colors.scss => src/theme/themes/clickstack/_tokens.scss} (99%) rename packages/app/{styles/themes/_hyperdx-colors.scss => src/theme/themes/hyperdx/_tokens.scss} (99%) diff --git a/packages/app/src/AuthPage.tsx b/packages/app/src/AuthPage.tsx index e821ce3940..abd4106e4f 100644 --- a/packages/app/src/AuthPage.tsx +++ b/packages/app/src/AuthPage.tsx @@ -138,7 +138,7 @@ export default function AuthPage({ action }: { action: 'register' | 'login' }) { : isRegister ? 'Register for ' : 'Login to '} - HyperDX + HyperDX {action === 'login' && (
Welcome back!
diff --git a/packages/app/src/components/ExpandableRowTable.tsx b/packages/app/src/components/ExpandableRowTable.tsx index 1eab24b2a9..1e23cec37b 100644 --- a/packages/app/src/components/ExpandableRowTable.tsx +++ b/packages/app/src/components/ExpandableRowTable.tsx @@ -152,7 +152,7 @@ const ExpandButton = memo( type="button" className={cx(styles.expandButton, { [styles.expanded]: isExpanded, - 'text-success': highlightedLineId === rowId, + 'text-brand': highlightedLineId === rowId, 'text-muted': highlightedLineId !== rowId, })} onClick={e => { diff --git a/packages/app/src/components/TimePicker/TimePicker.tsx b/packages/app/src/components/TimePicker/TimePicker.tsx index 7bb5a326d7..4e05da2526 100644 --- a/packages/app/src/components/TimePicker/TimePicker.tsx +++ b/packages/app/src/components/TimePicker/TimePicker.tsx @@ -223,7 +223,7 @@ const TimePickerComponent = ({ data-testid="time-picker-input" leftSection={ isLiveMode ? ( - + ) : ( ) diff --git a/packages/app/src/components/charts/DisplaySwitcher.tsx b/packages/app/src/components/charts/DisplaySwitcher.tsx index b75acf8c77..7b59e58267 100644 --- a/packages/app/src/components/charts/DisplaySwitcher.tsx +++ b/packages/app/src/components/charts/DisplaySwitcher.tsx @@ -30,7 +30,7 @@ function DisplaySwitcher({ size="xs" me={2} className={cx({ - 'text-success': value === optionValue, + 'text-brand': value === optionValue, 'text-muted-hover': value !== optionValue, })} disabled={disabled} diff --git a/packages/app/src/theme/ThemeProvider.tsx b/packages/app/src/theme/ThemeProvider.tsx index f0ccf3cf6f..6c9be0cbde 100644 --- a/packages/app/src/theme/ThemeProvider.tsx +++ b/packages/app/src/theme/ThemeProvider.tsx @@ -1,9 +1,14 @@ -import React, { createContext, useContext, useEffect, useMemo } from 'react'; +import React, { + createContext, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; import { clearDevTheme, - getCurrentTheme, - getCurrentThemeName, + DEFAULT_THEME, getTheme, setDevTheme, themes, @@ -15,6 +20,8 @@ const IS_DEV = process.env.NODE_ENV === 'development' || process.env.NEXT_PUBLIC_IS_LOCAL_MODE === 'true'; +const THEME_STORAGE_KEY = 'hdx-dev-theme'; + interface ThemeContextValue { theme: ThemeConfig; themeName: ThemeName; @@ -35,10 +42,37 @@ export function AppThemeProvider({ themeName?: ThemeName; children: React.ReactNode; }) { - const theme = useMemo(() => { - return themeName ? getTheme(themeName) : getCurrentTheme(); + // Start with default theme to match server render and avoid hydration mismatch + const [resolvedThemeName, setResolvedThemeName] = useState( + themeName ?? DEFAULT_THEME, + ); + + // After hydration, read from localStorage/URL in dev mode + useEffect(() => { + if (themeName || !IS_DEV) return; + + // Check URL query param first + const urlParams = new URLSearchParams(window.location.search); + const urlTheme = urlParams.get('theme') as ThemeName | null; + if (urlTheme && themes[urlTheme]) { + localStorage.setItem(THEME_STORAGE_KEY, urlTheme); + setResolvedThemeName(urlTheme); + return; + } + + // Check localStorage + const storedTheme = localStorage.getItem( + THEME_STORAGE_KEY, + ) as ThemeName | null; + if (storedTheme && themes[storedTheme]) { + setResolvedThemeName(storedTheme); + } }, [themeName]); + const theme = useMemo(() => { + return getTheme(resolvedThemeName); + }, [resolvedThemeName]); + const contextValue = useMemo( () => ({ theme, @@ -108,8 +142,8 @@ export function AppThemeProvider({ export function useAppTheme(): ThemeContextValue { const context = useContext(ThemeContext); if (!context) { - // Fallback for when used outside provider - const theme = getCurrentTheme(); + // Fallback for when used outside provider - always use default to avoid hydration issues + const theme = getTheme(DEFAULT_THEME); return { theme, themeName: theme.name, @@ -136,5 +170,6 @@ export function useIcon() { // Hook to get current theme name (useful for conditional rendering) export function useThemeName(): ThemeName { - return getCurrentThemeName(); + const { themeName } = useAppTheme(); + return themeName; } diff --git a/packages/app/styles/_semantic-colors.scss b/packages/app/src/theme/themes/_base-tokens.scss similarity index 96% rename from packages/app/styles/_semantic-colors.scss rename to packages/app/src/theme/themes/_base-tokens.scss index 89e49a3f4e..23ffbe7014 100644 --- a/packages/app/styles/_semantic-colors.scss +++ b/packages/app/src/theme/themes/_base-tokens.scss @@ -1,11 +1,8 @@ -/* Theme-specific color imports */ -@use './themes/hyperdx-colors'; -@use './themes/clickstack-colors'; - -/* - * Fallback colors for when no theme class is applied - * Uses HyperDX theme as default - */ +/* Base Design Tokens - Fallback values when no theme class is applied */ +/* Uses HyperDX theme as default */ + +@use './hyperdx/tokens' as hyperdx-tokens; +@use './clickstack/tokens' as clickstack-tokens; /* Dark Mode (default) */ [data-mantine-color-scheme='dark'] { diff --git a/packages/app/styles/themes/_clickstack-colors.scss b/packages/app/src/theme/themes/clickstack/_tokens.scss similarity index 99% rename from packages/app/styles/themes/_clickstack-colors.scss rename to packages/app/src/theme/themes/clickstack/_tokens.scss index 461279bef4..1a34c87c43 100644 --- a/packages/app/styles/themes/_clickstack-colors.scss +++ b/packages/app/src/theme/themes/clickstack/_tokens.scss @@ -1,4 +1,4 @@ -/* ClickStack Theme Colors */ +/* ClickStack Theme Design Tokens */ /* Yellow/Gold accent theme for ClickStack branding */ diff --git a/packages/app/styles/themes/_hyperdx-colors.scss b/packages/app/src/theme/themes/hyperdx/_tokens.scss similarity index 99% rename from packages/app/styles/themes/_hyperdx-colors.scss rename to packages/app/src/theme/themes/hyperdx/_tokens.scss index f463d7270a..ebe86ce455 100644 --- a/packages/app/styles/themes/_hyperdx-colors.scss +++ b/packages/app/src/theme/themes/hyperdx/_tokens.scss @@ -1,4 +1,4 @@ -/* HyperDX Theme Colors */ +/* HyperDX Theme Design Tokens */ /* Default theme with green accent */ diff --git a/packages/app/styles/_utilities.scss b/packages/app/styles/_utilities.scss index 65e2e7130c..9a39acb348 100644 --- a/packages/app/styles/_utilities.scss +++ b/packages/app/styles/_utilities.scss @@ -116,6 +116,19 @@ } } +.text-brand { + color: var(--color-text-brand) !important; +} + +.text-brand-hover { + color: var(--color-text-brand-hover); + transition: color 0.2s ease; + + &:hover { + color: var(--color-text-brand-hover); + } +} + .text-link { color: var(--color-text-primary) !important; } diff --git a/packages/app/styles/app.scss b/packages/app/styles/app.scss index 3edede13b2..af32c8b1e4 100644 --- a/packages/app/styles/app.scss +++ b/packages/app/styles/app.scss @@ -1,7 +1,7 @@ /* stylelint-disable */ // stylelint adds '}}' to end of file.. not sure why. Disabled for now. -@use './_semantic-colors'; +@use '../src/theme/themes/base-tokens'; // Bootstrap utilities only (d-flex, flex-column, m-*, p-*, etc.) @use './_bootstrap-utilities'; From 076f061245b99387316f9ac3c97fc9391370ae8a Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 15 Jan 2026 17:39:11 +0000 Subject: [PATCH 05/36] refactor: streamline component styles and theme integration - Simplified button styles in DBDashboardPage by removing redundant props. - Removed the 'color' prop from the Slider component in DBServiceMapPage for consistency. - Updated ThemeWrapper to utilize the app theme for MantineProvider, enhancing theme management. - Added comprehensive brand and neutral color palettes in _tokens.scss for both dark and light modes. - Refactored Logo components in ClickStack and HyperDX themes to remove unnecessary size props, standardizing their implementation. --- packages/app/src/DBDashboardPage.tsx | 7 +- packages/app/src/DBServiceMapPage.tsx | 1 - packages/app/src/ThemeWrapper.tsx | 31 ++++- .../app/src/theme/themes/clickstack/Logo.tsx | 27 +--- .../src/theme/themes/clickstack/_tokens.scss | 129 ++++++++++++++++++ .../theme/themes/clickstack/mantineTheme.ts | 48 ++----- .../app/src/theme/themes/hyperdx/Logo.tsx | 33 +---- 7 files changed, 171 insertions(+), 105 deletions(-) diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 58391e41bf..238bbdfbff 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -934,12 +934,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { This is a temporary dashboard and can not be saved. - diff --git a/packages/app/src/DBServiceMapPage.tsx b/packages/app/src/DBServiceMapPage.tsx index f7f2b9fa7d..ff0cb37312 100644 --- a/packages/app/src/DBServiceMapPage.tsx +++ b/packages/app/src/DBServiceMapPage.tsx @@ -115,7 +115,6 @@ function DBServiceMapPage() {
{ - const theme = React.useMemo( - () => (fontFamily ? makeTheme({ fontFamily }) : defaultTheme), - [fontFamily], - ); + const { theme: appTheme } = useAppTheme(); + + const mantineTheme = React.useMemo(() => { + // Start with the current theme's Mantine theme + const baseTheme = appTheme.mantineTheme; + + // Override font family if provided + if (fontFamily) { + return { + ...baseTheme, + fontFamily, + headings: { + ...baseTheme.headings, + fontFamily, + }, + }; + } + + return baseTheme; + }, [appTheme.mantineTheme, fontFamily]); + return ( - + {children} diff --git a/packages/app/src/theme/themes/clickstack/Logo.tsx b/packages/app/src/theme/themes/clickstack/Logo.tsx index e3b9fff46f..99069461f8 100644 --- a/packages/app/src/theme/themes/clickstack/Logo.tsx +++ b/packages/app/src/theme/themes/clickstack/Logo.tsx @@ -1,31 +1,6 @@ import React from 'react'; -import Icon from './Icon'; - -export default function Logo({ - size = 'sm', -}: { - size?: 'sm' | 'md' | 'lg' | 'xl'; -}) { - const configs = { - sm: { - fontSize: 14, - iconSize: 14, - }, - md: { - fontSize: 16, - iconSize: 16, - }, - lg: { - fontSize: 18, - iconSize: 18, - }, - xl: { - fontSize: 22, - iconSize: 22, - }, - }; - +export default function Logo() { return (
- +
HyperDX From fa2efc943520ec721d933bc11c7de51e64f8ba11 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Fri, 16 Jan 2026 17:51:19 +0000 Subject: [PATCH 06/36] chore: Add fallback comment for base design tokens in SCSS file --- packages/app/src/theme/themes/_base-tokens.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/theme/themes/_base-tokens.scss b/packages/app/src/theme/themes/_base-tokens.scss index 23ffbe7014..232f1dae70 100644 --- a/packages/app/src/theme/themes/_base-tokens.scss +++ b/packages/app/src/theme/themes/_base-tokens.scss @@ -1,4 +1,5 @@ /* Base Design Tokens - Fallback values when no theme class is applied */ + /* Uses HyperDX theme as default */ @use './hyperdx/tokens' as hyperdx-tokens; From c6e41ca63ea1eb826fe8d5ff371555010becf408 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Fri, 16 Jan 2026 18:31:25 +0000 Subject: [PATCH 07/36] fix: Update button styles in Clickstack theme - Adjusted primary button background and text colors to use new SCSS token values. - Modified hover state colors for primary buttons to enhance visual consistency across the theme. --- packages/app/src/theme/themes/clickstack/_tokens.scss | 8 +++++++- packages/app/src/theme/themes/clickstack/mantineTheme.ts | 6 +++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/app/src/theme/themes/clickstack/_tokens.scss b/packages/app/src/theme/themes/clickstack/_tokens.scss index 7bc492a852..0211af034f 100644 --- a/packages/app/src/theme/themes/clickstack/_tokens.scss +++ b/packages/app/src/theme/themes/clickstack/_tokens.scss @@ -8,7 +8,7 @@ .theme-clickstack[data-mantine-color-scheme='dark'] { /* Brand Palette - Yellow/Gold */ --palette-brand-50: #ffffe8; - --palette-brand-100: #feffc2; + --palette-brand-100: #feffba; --palette-brand-200: #fdffa3; --palette-brand-300: #faff69; --palette-brand-400: #eef400; @@ -105,6 +105,9 @@ --color-bg-danger: #ef4444; --color-bg-warning: #f59e0b; --color-bg-brand: var(--click-global-color-accent-default); + --color-primary-button-background: var(--palette-brand-300); + --color-primary-button-background-hover: var(--color-brand-200); + --color-primary-button-text: var(--color-text-inverted); /* Borders & Dividers */ --color-border: var(--click-global-color-stroke-default); @@ -254,6 +257,9 @@ --color-bg-danger: #dc2626; --color-bg-warning: #d97706; --color-bg-brand: var(--click-global-color-accent-default); + --color-primary-button-background: var(--palette-brand-900); + --color-primary-button-background-hover: var(--color-brand-800); + --color-primary-button-text: var(--color-text-inverted); /* Borders & Dividers */ --color-border: var(--click-global-color-stroke-default); diff --git a/packages/app/src/theme/themes/clickstack/mantineTheme.ts b/packages/app/src/theme/themes/clickstack/mantineTheme.ts index 02a0059e0a..876a989561 100644 --- a/packages/app/src/theme/themes/clickstack/mantineTheme.ts +++ b/packages/app/src/theme/themes/clickstack/mantineTheme.ts @@ -219,10 +219,10 @@ export const makeTheme = ({ if (props.variant === 'primary') { return { root: { - backgroundColor: 'var(--mantine-color-yellow-6)', - color: 'var(--mantine-color-dark-9)', + backgroundColor: 'var(--color-primary-button-background)', + color: 'var(--color-primary-button-text)', '&:hover': { - backgroundColor: 'var(--mantine-color-yellow-5)', + backgroundColor: 'var(--color-primary-button-background-hover)', }, }, }; From 5a7c7bc0a4882595d174a835ef946a73b6835913 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Fri, 16 Jan 2026 19:19:09 +0000 Subject: [PATCH 08/36] refactor: Update button variable names and styles in Clickstack and HyperDX themes - Renamed primary button background and hover variables for clarity. - Consolidated button style definitions to utilize base variables for improved maintainability. - Ensured consistent button styling across both Clickstack and HyperDX themes. --- .../src/theme/themes/clickstack/_tokens.scss | 12 ++-- .../theme/themes/clickstack/mantineTheme.ts | 58 ++++++------------- .../app/src/theme/themes/hyperdx/_tokens.scss | 5 ++ .../src/theme/themes/hyperdx/mantineTheme.ts | 58 ++++++------------- 4 files changed, 49 insertions(+), 84 deletions(-) diff --git a/packages/app/src/theme/themes/clickstack/_tokens.scss b/packages/app/src/theme/themes/clickstack/_tokens.scss index 0211af034f..0bc98f03a9 100644 --- a/packages/app/src/theme/themes/clickstack/_tokens.scss +++ b/packages/app/src/theme/themes/clickstack/_tokens.scss @@ -105,8 +105,10 @@ --color-bg-danger: #ef4444; --color-bg-warning: #f59e0b; --color-bg-brand: var(--click-global-color-accent-default); - --color-primary-button-background: var(--palette-brand-300); - --color-primary-button-background-hover: var(--color-brand-200); + + /* Primary Button */ + --color-primary-button-bg: var(--palette-brand-300); + --color-primary-button-bg-hover: var(--color-brand-200); --color-primary-button-text: var(--color-text-inverted); /* Borders & Dividers */ @@ -257,8 +259,10 @@ --color-bg-danger: #dc2626; --color-bg-warning: #d97706; --color-bg-brand: var(--click-global-color-accent-default); - --color-primary-button-background: var(--palette-brand-900); - --color-primary-button-background-hover: var(--color-brand-800); + + /* Primary Button */ + --color-primary-button-bg: var(--palette-slate-800); + --color-primary-button-bg-hover: #42464b; --color-primary-button-text: var(--color-text-inverted); /* Borders & Dividers */ diff --git a/packages/app/src/theme/themes/clickstack/mantineTheme.ts b/packages/app/src/theme/themes/clickstack/mantineTheme.ts index 876a989561..6e52518405 100644 --- a/packages/app/src/theme/themes/clickstack/mantineTheme.ts +++ b/packages/app/src/theme/themes/clickstack/mantineTheme.ts @@ -202,58 +202,36 @@ export const makeTheme = ({ }, }), Button: Button.extend({ - vars: (theme, props) => { + vars: (_theme, props) => { + const baseVars: Record = {}; + if (props.size === 'xxs') { - return { - root: { - '--button-height': rem(22), - '--button-padding-x': rem(4), - '--button-fz': rem(12), - }, - }; + baseVars['--button-height'] = rem(22); + baseVars['--button-padding-x'] = rem(4); + baseVars['--button-fz'] = rem(12); } - return { root: {} }; - }, - styles: (_theme, props) => { + // Use Mantine's built-in CSS vars for hover support if (props.variant === 'primary') { - return { - root: { - backgroundColor: 'var(--color-primary-button-background)', - color: 'var(--color-primary-button-text)', - '&:hover': { - backgroundColor: 'var(--color-primary-button-background-hover)', - }, - }, - }; + baseVars['--button-bg'] = 'var(--color-primary-button-bg)'; + baseVars['--button-hover'] = 'var(--color-primary-button-bg-hover)'; + baseVars['--button-color'] = 'var(--color-primary-button-text)'; } if (props.variant === 'secondary') { - return { - root: { - backgroundColor: 'var(--color-bg-body)', - color: 'var(--color-text)', - border: '1px solid var(--color-border)', - '&:hover': { - backgroundColor: 'var(--color-bg-hover)', - }, - }, - }; + baseVars['--button-bg'] = 'var(--color-bg-body)'; + baseVars['--button-hover'] = 'var(--color-bg-hover)'; + baseVars['--button-color'] = 'var(--color-text)'; + baseVars['--button-bd'] = '1px solid var(--color-border)'; } if (props.variant === 'danger') { - return { - root: { - backgroundColor: 'var(--mantine-color-red-light)', - color: 'var(--mantine-color-red-light-color)', - '&:hover': { - backgroundColor: 'var(--mantine-color-red-light-hover)', - }, - }, - }; + baseVars['--button-bg'] = 'var(--mantine-color-red-light)'; + baseVars['--button-hover'] = 'var(--mantine-color-red-light-hover)'; + baseVars['--button-color'] = 'var(--mantine-color-red-light-color)'; } - return {}; + return { root: baseVars }; }, }), SegmentedControl: { diff --git a/packages/app/src/theme/themes/hyperdx/_tokens.scss b/packages/app/src/theme/themes/hyperdx/_tokens.scss index ebe86ce455..e9edb3ae02 100644 --- a/packages/app/src/theme/themes/hyperdx/_tokens.scss +++ b/packages/app/src/theme/themes/hyperdx/_tokens.scss @@ -22,6 +22,11 @@ --color-bg-warning: var(--mantine-color-orange-5); --color-bg-brand: #4ffa7a; + /* Primary Button */ + --color-primary-button-bg: var(--palette-brand-300); + --color-primary-button-bg-hover: var(--color-brand-200); + --color-primary-button-text: var(--color-text-inverted); + /* Borders & Dividers */ --color-border: var(--mantine-color-dark-5); --color-border-muted: rgb(255 255 255 / 8%); diff --git a/packages/app/src/theme/themes/hyperdx/mantineTheme.ts b/packages/app/src/theme/themes/hyperdx/mantineTheme.ts index e8a6ee4680..c973a5bb91 100644 --- a/packages/app/src/theme/themes/hyperdx/mantineTheme.ts +++ b/packages/app/src/theme/themes/hyperdx/mantineTheme.ts @@ -216,58 +216,36 @@ export const makeTheme = ({ }, }), Button: Button.extend({ - vars: (theme, props) => { + vars: (_theme, props) => { + const baseVars: Record = {}; + if (props.size === 'xxs') { - return { - root: { - '--button-height': rem(22), - '--button-padding-x': rem(4), - '--button-fz': rem(12), - }, - }; + baseVars['--button-height'] = rem(22); + baseVars['--button-padding-x'] = rem(4); + baseVars['--button-fz'] = rem(12); } - return { root: {} }; - }, - styles: (_theme, props) => { + // Use Mantine's built-in CSS vars for hover support if (props.variant === 'primary') { - return { - root: { - backgroundColor: 'var(--mantine-color-green-light)', - color: 'var(--mantine-color-green-light-color)', - '&:hover': { - backgroundColor: 'var(--mantine-color-green-light-hover)', - }, - }, - }; + baseVars['--button-bg'] = 'var(--mantine-color-green-light)'; + baseVars['--button-hover'] = 'var(--mantine-color-green-light-hover)'; + baseVars['--button-color'] = 'var(--mantine-color-green-light-color)'; } if (props.variant === 'secondary') { - return { - root: { - backgroundColor: 'var(--color-bg-body)', - color: 'var(--color-text)', - border: '1px solid var(--color-border)', - '&:hover': { - backgroundColor: 'var(--color-bg-hover)', - }, - }, - }; + baseVars['--button-bg'] = 'var(--color-bg-body)'; + baseVars['--button-hover'] = 'var(--color-bg-hover)'; + baseVars['--button-color'] = 'var(--color-text)'; + baseVars['--button-bd'] = '1px solid var(--color-border)'; } if (props.variant === 'danger') { - return { - root: { - backgroundColor: 'var(--mantine-color-red-light)', - color: 'var(--mantine-color-red-light-color)', - '&:hover': { - backgroundColor: 'var(--mantine-color-red-light-hover)', - }, - }, - }; + baseVars['--button-bg'] = 'var(--mantine-color-red-light)'; + baseVars['--button-hover'] = 'var(--mantine-color-red-light-hover)'; + baseVars['--button-color'] = 'var(--mantine-color-red-light-color)'; } - return {}; + return { root: baseVars }; }, }), SegmentedControl: { From 811dba1f4d45324363fa113d4b5b43e0b70f99a0 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Fri, 16 Jan 2026 19:23:43 +0000 Subject: [PATCH 09/36] refactor: Simplify font selection logic in MyApp component - Removed unused default font variable import for cleaner code. - Updated font selection logic to only override the default font if a user preference is explicitly set, improving clarity and maintainability. --- packages/app/pages/_app.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/app/pages/_app.tsx b/packages/app/pages/_app.tsx index 7784f40c85..3625fc7854 100644 --- a/packages/app/pages/_app.tsx +++ b/packages/app/pages/_app.tsx @@ -19,7 +19,6 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { IS_LOCAL_MODE } from '@/config'; import { DEFAULT_FONT_VAR, - DEFAULT_MANTINE_FONT, FONT_VAR_MAP, MANTINE_FONT_MAP, } from '@/config/fonts'; @@ -69,8 +68,11 @@ export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { const confirmModal = useConfirmModal(); const background = useBackground(userPreferences); - const selectedMantineFont = - MANTINE_FONT_MAP[userPreferences.font] || DEFAULT_MANTINE_FONT; + // Only override font if user has explicitly set a preference + // Otherwise, let the theme use its default font + const selectedMantineFont = userPreferences.font + ? MANTINE_FONT_MAP[userPreferences.font] + : undefined; // port to react query ? (needs to wrap with QueryClientProvider) useEffect(() => { From 15dab75aa4ae674a8fcfcec8753eea03272fd650 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Mon, 19 Jan 2026 12:47:37 +0000 Subject: [PATCH 10/36] feat: Extend Tabs component in Clickstack theme with custom variables - Added custom CSS variables for the Tabs component to enhance styling capabilities. - Improved theme customization by allowing dynamic color adjustments for the Tabs root element. --- packages/app/src/theme/themes/clickstack/mantineTheme.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/app/src/theme/themes/clickstack/mantineTheme.ts b/packages/app/src/theme/themes/clickstack/mantineTheme.ts index 6e52518405..43130ea688 100644 --- a/packages/app/src/theme/themes/clickstack/mantineTheme.ts +++ b/packages/app/src/theme/themes/clickstack/mantineTheme.ts @@ -5,6 +5,7 @@ import { MantineThemeOverride, rem, Select, + Tabs, Text, Tooltip, } from '@mantine/core'; @@ -244,6 +245,13 @@ export const makeTheme = ({ }, }, }, + Tabs: Tabs.extend({ + vars: () => ({ + root: { + '--tabs-color': 'var(--color-text-brand)', + }, + }), + }), ActionIcon: ActionIcon.extend({ defaultProps: { variant: 'subtle', From 9cd1ec8074ce860c21046bf815fc454661489ea4 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Tue, 20 Jan 2026 13:11:56 +0000 Subject: [PATCH 11/36] style: Update sidenav link colors for improved hover and active states - Changed background colors for sidenav links to use active variants on hover and focus-visible states. - Updated SCSS variables for sidenav link colors across multiple themes to enhance consistency and visual feedback. --- .../src/components/AppNav/AppNav.module.scss | 22 +++++++++---------- .../app/src/theme/themes/_base-tokens.scss | 8 +++---- .../src/theme/themes/clickstack/_tokens.scss | 12 ++++++++-- .../app/src/theme/themes/hyperdx/_tokens.scss | 12 ++++++++-- 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/packages/app/src/components/AppNav/AppNav.module.scss b/packages/app/src/components/AppNav/AppNav.module.scss index 26188011b0..764328df47 100644 --- a/packages/app/src/components/AppNav/AppNav.module.scss +++ b/packages/app/src/components/AppNav/AppNav.module.scss @@ -131,17 +131,17 @@ $transition-slow: 0.2s ease; &:hover { color: var(--color-text-sidenav-link-active); - background: var(--color-bg-sidenav-link); + background: var(--color-bg-sidenav-link-active); } &:focus-visible { outline: none; - background: var(--color-bg-sidenav-link); + background: var(--color-bg-sidenav-link-active); } &Active { color: var(--color-text-sidenav-link-active); - background: var(--color-bg-sidenav-link); + background: var(--color-bg-sidenav-link-active); } } @@ -178,7 +178,7 @@ $transition-slow: 0.2s ease; transform $transition-fast; &:hover { - background: var(--color-bg-sidenav-link-hover); + background: var(--color-bg-sidenav-link-active-hover); color: var(--color-text-sidenav-link-active); transform: scale(1.1); } @@ -213,12 +213,12 @@ $transition-slow: 0.2s ease; &:hover { color: var(--color-text-sidenav-link-active); - background: var(--color-bg-sidenav-link); + background: var(--color-bg-sidenav-link-active); } &:focus-visible { outline: none; - background: var(--color-bg-sidenav-link); + background: var(--color-bg-sidenav-link-active); } i { @@ -227,7 +227,7 @@ $transition-slow: 0.2s ease; &Active { color: var(--color-text-sidenav-link-active); - background: var(--color-bg-sidenav-link); + background: var(--color-bg-sidenav-link-active); } } @@ -308,7 +308,7 @@ $transition-slow: 0.2s ease; overflow: hidden; &:hover { - background: var(--color-bg-sidenav-link); + background: var(--color-bg-sidenav-link-active); } @include interactive-press; @@ -321,7 +321,7 @@ $transition-slow: 0.2s ease; width: fit-content; &:hover { - background: var(--color-bg-sidenav-link); + background: var(--color-bg-sidenav-link-active); } @include interactive-press; @@ -365,7 +365,7 @@ $transition-slow: 0.2s ease; border-color $transition-slow; &:hover { - background: var(--color-bg-sidenav-link); + background: var(--color-bg-sidenav-link-active); border-color: var(--color-border); } @@ -377,7 +377,7 @@ $transition-slow: 0.2s ease; background: transparent; &:hover { - background: var(--color-bg-sidenav-link); + background: var(--color-bg-sidenav-link-active); } @include interactive-press; diff --git a/packages/app/src/theme/themes/_base-tokens.scss b/packages/app/src/theme/themes/_base-tokens.scss index b38d485623..7821a4d469 100644 --- a/packages/app/src/theme/themes/_base-tokens.scss +++ b/packages/app/src/theme/themes/_base-tokens.scss @@ -14,8 +14,8 @@ --color-bg-muted: var(--mantine-color-dark-7); --color-bg-highlighted: rgb(55 58 64 / 70%); --color-bg-sidenav: var(--mantine-color-dark-9); - --color-bg-sidenav-link: var(--mantine-color-dark-6); - --color-bg-sidenav-link-hover: var(--mantine-color-dark-7); + --color-bg-sidenav-link-active: var(--mantine-color-dark-6); + --color-bg-sidenav-link-active-hover: var(--mantine-color-dark-7); --color-bg-header: var(--mantine-color-dark-9); --color-bg-hover: var(--mantine-color-dark-6); --color-bg-active: var(--mantine-color-dark-5); @@ -88,8 +88,8 @@ --color-bg-muted: #f6f6fa; --color-bg-highlighted: rgb(230 232 237 / 70%); --color-bg-sidenav: var(--mantine-color-white); - --color-bg-sidenav-link: #f6f6fa; - --color-bg-sidenav-link-hover: var(--mantine-color-gray-2); + --color-bg-sidenav-link-active: #f6f6fa; + --color-bg-sidenav-link-active-hover: var(--mantine-color-gray-2); --color-bg-header: var(--mantine-color-gray-1); --color-bg-modal: var(--mantine-color-white); --color-bg-hover: var(--mantine-color-gray-3); diff --git a/packages/app/src/theme/themes/clickstack/_tokens.scss b/packages/app/src/theme/themes/clickstack/_tokens.scss index 0bc98f03a9..80b6ab557e 100644 --- a/packages/app/src/theme/themes/clickstack/_tokens.scss +++ b/packages/app/src/theme/themes/clickstack/_tokens.scss @@ -94,7 +94,9 @@ --color-bg-inverted: #d5d7e0; --color-bg-muted: var(--click-global-color-background-muted); --color-bg-highlighted: rgb(75 78 102 / 70%); - --color-bg-sidebar: var(--click-global-color-background-default); + --color-bg-sidenav: var(--click-global-color-background-default); + --color-bg-sidenav-link-active: var(--click-global-color-background-muted); + --color-bg-sidenav-link-active-hover: var(--mantine-color-gray-7); --color-bg-header: var(--click-global-color-background-default); --color-bg-hover: #2b2c3d; --color-bg-active: #34354a; @@ -128,6 +130,8 @@ --color-text-success: #22c55e; --color-text-success-hover: #4ade80; --color-text-danger: var(--click-global-color-text-danger); + --color-text-sidenav-link: var(--click-global-color-text-default); + --color-text-sidenav-link-active: var(--color-text-primary); /* Icons */ --color-icon-primary: #d5d7e0; @@ -247,7 +251,9 @@ --color-bg-inverted: #343a40; --color-bg-muted: var(--click-global-color-background-muted); --color-bg-highlighted: rgb(241 243 245 / 80%); - --color-bg-sidebar: var(--click-global-color-background-default); + --color-bg-sidenav: var(--click-global-color-background-default); + --color-bg-sidenav-link-active: var(--click-global-color-stroke-default); + --color-bg-sidenav-link-active-hover: var(--mantine-color-gray-3); --color-bg-header: var(--click-global-color-background-muted); --color-bg-modal: var(--click-global-color-background-default); --color-bg-hover: #e9ecef; @@ -282,6 +288,8 @@ --color-text-success: #16a34a; --color-text-success-hover: #15803d; --color-text-danger: var(--click-global-color-text-danger); + --color-text-sidenav-link: var(--click-global-color-text-default); + --color-text-sidenav-link-active: var(--click-global-color-accent-default); /* Icons */ --color-icon-primary: #343a40; diff --git a/packages/app/src/theme/themes/hyperdx/_tokens.scss b/packages/app/src/theme/themes/hyperdx/_tokens.scss index e9edb3ae02..c55825821a 100644 --- a/packages/app/src/theme/themes/hyperdx/_tokens.scss +++ b/packages/app/src/theme/themes/hyperdx/_tokens.scss @@ -10,7 +10,9 @@ --color-bg-inverted: var(--mantine-color-dark-1); --color-bg-muted: var(--mantine-color-dark-7); --color-bg-highlighted: rgb(55 58 64 / 70%); - --color-bg-sidebar: var(--mantine-color-dark-9); + --color-bg-sidenav: var(--mantine-color-dark-9); + --color-bg-sidenav-link-active: var(--mantine-color-dark-6); + --color-bg-sidenav-link-active-hover: var(--mantine-color-dark-7); --color-bg-header: var(--mantine-color-dark-9); --color-bg-hover: var(--mantine-color-dark-6); --color-bg-active: var(--mantine-color-dark-5); @@ -44,6 +46,8 @@ --color-text-success: var(--mantine-color-green-4); --color-text-success-hover: var(--mantine-color-green-3); --color-text-danger: var(--mantine-color-red-3); + --color-text-sidenav-link: var(--mantine-color-dark-0); + --color-text-sidenav-link-active: var(--color-text-primary); /* Icons */ --color-icon-primary: var(--mantine-color-dark-1); @@ -87,7 +91,9 @@ --color-bg-inverted: var(--mantine-color-dark-3); --color-bg-muted: #f6f6fa; --color-bg-highlighted: rgb(230 232 237 / 70%); - --color-bg-sidebar: var(--mantine-color-white); + --color-bg-sidenav: var(--mantine-color-white); + --color-bg-sidenav-link-active: #f6f6fa; + --color-bg-sidenav-link-active-hover: var(--mantine-color-gray-2); --color-bg-header: var(--mantine-color-gray-1); --color-bg-modal: var(--mantine-color-white); --color-bg-hover: var(--mantine-color-gray-3); @@ -117,6 +123,8 @@ --color-text-success: var(--mantine-color-green-8); --color-text-success-hover: var(--mantine-color-green-9); --color-text-danger: var(--mantine-color-red-8); + --color-text-sidenav-link: var(--mantine-color-dark-6); + --color-text-sidenav-link-active: var(--mantine-color-green-9); /* Icons - inverted */ --color-icon-primary: var(--mantine-color-dark-8); From 64382d2d4db41e2b641bcf54d60962ae78143f89 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Tue, 20 Jan 2026 13:23:14 +0000 Subject: [PATCH 12/36] refactor(theme): streamline theme management and enhance developer experience - Exported THEME_STORAGE_KEY for use in ThemeProvider. - Consolidated theme retrieval logic into a single source of truth with getDevThemeName(). - Introduced theme control functions (setTheme, toggleTheme, clearThemeOverride) to manage theme state without page reloads. - Updated AppThemeProvider to handle theme resolution more effectively during SSR and hydration. - Exposed theme management API to the window for easier access in development mode. --- packages/app/src/theme/ThemeProvider.tsx | 153 ++++++++++++++++------- packages/app/src/theme/index.ts | 53 ++------ 2 files changed, 115 insertions(+), 91 deletions(-) diff --git a/packages/app/src/theme/ThemeProvider.tsx b/packages/app/src/theme/ThemeProvider.tsx index 6c9be0cbde..f9430e3dc5 100644 --- a/packages/app/src/theme/ThemeProvider.tsx +++ b/packages/app/src/theme/ThemeProvider.tsx @@ -1,5 +1,6 @@ import React, { createContext, + useCallback, useContext, useEffect, useMemo, @@ -7,12 +8,11 @@ import React, { } from 'react'; import { - clearDevTheme, DEFAULT_THEME, + getDevThemeName, getTheme, - setDevTheme, + THEME_STORAGE_KEY, themes, - toggleDevTheme, } from './index'; import { ThemeConfig, ThemeName } from './types'; @@ -20,7 +20,17 @@ const IS_DEV = process.env.NODE_ENV === 'development' || process.env.NEXT_PUBLIC_IS_LOCAL_MODE === 'true'; -const THEME_STORAGE_KEY = 'hdx-dev-theme'; +// Type declaration for window namespace (avoids conflicts) +declare global { + interface Window { + __HDX_THEME?: { + current: ThemeName; + set: (name: ThemeName) => void; + toggle: () => void; + clear: () => void; + }; + } +} interface ThemeContextValue { theme: ThemeConfig; @@ -36,54 +46,80 @@ interface ThemeContextValue { const ThemeContext = createContext(null); export function AppThemeProvider({ - themeName, + themeName: propsThemeName, children, }: { themeName?: ThemeName; children: React.ReactNode; }) { - // Start with default theme to match server render and avoid hydration mismatch + // SSR/initial render: Always use props or DEFAULT_THEME for hydration consistency + // The server cannot read localStorage/URL, so we must start with a deterministic value const [resolvedThemeName, setResolvedThemeName] = useState( - themeName ?? DEFAULT_THEME, + () => propsThemeName ?? DEFAULT_THEME, ); // After hydration, read from localStorage/URL in dev mode + // Uses consolidated getDevThemeName() from index.ts as single source of truth useEffect(() => { - if (themeName || !IS_DEV) return; - - // Check URL query param first - const urlParams = new URLSearchParams(window.location.search); - const urlTheme = urlParams.get('theme') as ThemeName | null; - if (urlTheme && themes[urlTheme]) { - localStorage.setItem(THEME_STORAGE_KEY, urlTheme); - setResolvedThemeName(urlTheme); + // If theme is explicitly passed via props, use that (no dev override) + if (propsThemeName) { + setResolvedThemeName(propsThemeName); return; } - // Check localStorage - const storedTheme = localStorage.getItem( - THEME_STORAGE_KEY, - ) as ThemeName | null; - if (storedTheme && themes[storedTheme]) { - setResolvedThemeName(storedTheme); + // In dev mode, allow URL/localStorage overrides + if (IS_DEV) { + const devTheme = getDevThemeName(); + setResolvedThemeName(devTheme); } - }, [themeName]); + }, [propsThemeName]); const theme = useMemo(() => { return getTheme(resolvedThemeName); }, [resolvedThemeName]); + // Theme control functions - update state without page reload + const setTheme = useCallback((name: ThemeName) => { + if (!IS_DEV) { + console.warn('setTheme only works in development mode'); + return; + } + if (themes[name]) { + localStorage.setItem(THEME_STORAGE_KEY, name); + setResolvedThemeName(name); + } + }, []); + + const toggleTheme = useCallback(() => { + if (!IS_DEV) return; + const themeNames = Object.keys(themes) as ThemeName[]; + setResolvedThemeName(current => { + const currentIndex = themeNames.indexOf(current); + const nextIndex = (currentIndex + 1) % themeNames.length; + const nextTheme = themeNames[nextIndex]; + localStorage.setItem(THEME_STORAGE_KEY, nextTheme); + return nextTheme; + }); + }, []); + + const clearThemeOverride = useCallback(() => { + if (typeof window !== 'undefined') { + localStorage.removeItem(THEME_STORAGE_KEY); + setResolvedThemeName(propsThemeName ?? DEFAULT_THEME); + } + }, [propsThemeName]); + const contextValue = useMemo( () => ({ theme, themeName: theme.name, availableThemes: Object.keys(themes) as ThemeName[], - setTheme: setDevTheme, - toggleTheme: toggleDevTheme, - clearThemeOverride: clearDevTheme, + setTheme, + toggleTheme, + clearThemeOverride, isDev: IS_DEV, }), - [theme], + [theme, setTheme, toggleTheme, clearThemeOverride], ); // Apply theme CSS class to document @@ -105,32 +141,40 @@ export function AppThemeProvider({ const handleKeyDown = (e: KeyboardEvent) => { if (e.ctrlKey && e.shiftKey && e.key === 'T') { e.preventDefault(); - toggleDevTheme(); + e.stopPropagation(); + toggleTheme(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, []); + }, [toggleTheme]); - // Dev mode: log theme info to console + // Dev mode: expose theme API to window (namespaced to avoid global pollution) useEffect(() => { - if (IS_DEV && typeof window !== 'undefined') { - // eslint-disable-next-line no-console - console.info( - `🎨 Theme: ${theme.displayName} (${theme.name})`, - '\n Toggle: Ctrl+Shift+T', - '\n Set via URL: ?theme=clickstack', - '\n Set via console: window.__setTheme("clickstack")', - ); - - // Expose helper functions to window for console access - (window as any).__setTheme = setDevTheme; - (window as any).__toggleTheme = toggleDevTheme; - (window as any).__clearTheme = clearDevTheme; - (window as any).__currentTheme = theme.name; - } - }, [theme]); + if (!IS_DEV || typeof window === 'undefined') return; + + // eslint-disable-next-line no-console + console.info( + `🎨 Theme: ${theme.displayName} (${theme.name})`, + '\n Toggle: Ctrl+Shift+T', + '\n Set via URL: ?theme=clickstack', + '\n Set via console: window.__HDX_THEME.set("clickstack")', + ); + + // Expose namespaced helper object to window for console access + window.__HDX_THEME = { + current: theme.name, + set: setTheme, + toggle: toggleTheme, + clear: clearThemeOverride, + }; + + // Cleanup on unmount + return () => { + delete window.__HDX_THEME; + }; + }, [theme, setTheme, toggleTheme, clearThemeOverride]); return ( @@ -148,9 +192,22 @@ export function useAppTheme(): ThemeContextValue { theme, themeName: theme.name, availableThemes: Object.keys(themes) as ThemeName[], - setTheme: setDevTheme, - toggleTheme: toggleDevTheme, - clearThemeOverride: clearDevTheme, + // No-op functions when outside provider context + setTheme: () => { + console.warn( + 'useAppTheme: setTheme called outside of AppThemeProvider', + ); + }, + toggleTheme: () => { + console.warn( + 'useAppTheme: toggleTheme called outside of AppThemeProvider', + ); + }, + clearThemeOverride: () => { + console.warn( + 'useAppTheme: clearThemeOverride called outside of AppThemeProvider', + ); + }, isDev: IS_DEV, }; } diff --git a/packages/app/src/theme/index.ts b/packages/app/src/theme/index.ts index 254918a32a..2cd9a060d0 100644 --- a/packages/app/src/theme/index.ts +++ b/packages/app/src/theme/index.ts @@ -13,21 +13,26 @@ const IS_DEV = process.env.NODE_ENV === 'development' || process.env.NEXT_PUBLIC_IS_LOCAL_MODE === 'true'; -// LocalStorage key for dev theme override -const THEME_STORAGE_KEY = 'hdx-dev-theme'; +// LocalStorage key for dev theme override (exported for ThemeProvider) +export const THEME_STORAGE_KEY = 'hdx-dev-theme'; // Default theme (can be overridden via environment variable) export const DEFAULT_THEME: ThemeName = (process.env.NEXT_PUBLIC_THEME as ThemeName) || 'hyperdx'; /** - * Get the theme name from various sources (dev mode only): + * Get the theme name from various sources (dev mode only). + * This is the single source of truth for resolving dev theme names. + * + * Priority: * 1. URL query param: ?theme=clickstack * 2. localStorage: hdx-dev-theme * 3. Environment variable: NEXT_PUBLIC_THEME * 4. Default: hyperdx + * + * Note: URL params are persisted to localStorage when detected. */ -function getDevThemeName(): ThemeName { +export function getDevThemeName(): ThemeName { if (typeof window === 'undefined') { return DEFAULT_THEME; } @@ -52,45 +57,7 @@ function getDevThemeName(): ThemeName { return DEFAULT_THEME; } -/** - * Set the dev theme (persists to localStorage) - * Only works in development mode - */ -export function setDevTheme(name: ThemeName): void { - if (!IS_DEV) { - console.warn('setDevTheme only works in development mode'); - return; - } - if (typeof window !== 'undefined' && themes[name]) { - localStorage.setItem(THEME_STORAGE_KEY, name); - // Reload to apply theme - window.location.reload(); - } -} - -/** - * Clear the dev theme override (reverts to default) - */ -export function clearDevTheme(): void { - if (typeof window !== 'undefined') { - localStorage.removeItem(THEME_STORAGE_KEY); - window.location.reload(); - } -} - -/** - * Toggle between themes (useful for keyboard shortcut) - */ -export function toggleDevTheme(): void { - if (!IS_DEV) return; - const current = getDevThemeName(); - const themeNames = Object.keys(themes) as ThemeName[]; - const currentIndex = themeNames.indexOf(current); - const nextIndex = (currentIndex + 1) % themeNames.length; - setDevTheme(themeNames[nextIndex]); -} - -// Get theme configuration +// Get theme configuration by name export function getTheme(name: ThemeName = DEFAULT_THEME): ThemeConfig { return themes[name] || themes.hyperdx; } From 0e867fd77dd56c5d8dac9c298f797393572d1dbe Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Tue, 20 Jan 2026 13:39:09 +0000 Subject: [PATCH 13/36] style(theme): update color tokens and button styles for consistency - Replaced hardcoded background colors with SCSS variables for better theme integration. - Updated primary button hover states to use new variable references. - Added a new color token for improved palette management in the Clickstack theme. --- .../src/theme/themes/clickstack/_tokens.scss | 11 +-- .../theme/themes/clickstack/mantineTheme.ts | 76 +++++-------------- .../src/theme/themes/hyperdx/mantineTheme.ts | 76 +++++-------------- 3 files changed, 48 insertions(+), 115 deletions(-) diff --git a/packages/app/src/theme/themes/clickstack/_tokens.scss b/packages/app/src/theme/themes/clickstack/_tokens.scss index 80b6ab557e..9de1456c0c 100644 --- a/packages/app/src/theme/themes/clickstack/_tokens.scss +++ b/packages/app/src/theme/themes/clickstack/_tokens.scss @@ -90,7 +90,7 @@ /* Backgrounds - Using Click UI tokens */ --color-bg-body: var(--click-global-color-background-default); - --color-bg-surface: #34354a; + --color-bg-surface: var(--click-global-color-background-default); --color-bg-inverted: #d5d7e0; --color-bg-muted: var(--click-global-color-background-muted); --color-bg-highlighted: rgb(75 78 102 / 70%); @@ -98,8 +98,8 @@ --color-bg-sidenav-link-active: var(--click-global-color-background-muted); --color-bg-sidenav-link-active-hover: var(--mantine-color-gray-7); --color-bg-header: var(--click-global-color-background-default); - --color-bg-hover: #2b2c3d; - --color-bg-active: #34354a; + --color-bg-hover: var(--palette-neutral-725); + --color-bg-active: var(--palette-neutral-800); --color-bg-field: var(--click-field-color-background-default); --color-bg-field-highlighted: #4d4f66; --color-bg-neutral: #4d4f66; @@ -110,7 +110,7 @@ /* Primary Button */ --color-primary-button-bg: var(--palette-brand-300); - --color-primary-button-bg-hover: var(--color-brand-200); + --color-primary-button-bg-hover: var(--palette-brand-200); --color-primary-button-text: var(--color-text-inverted); /* Borders & Dividers */ @@ -189,6 +189,7 @@ --palette-slate-500: #808691; --palette-slate-600: #696e79; --palette-slate-700: #53575f; + --palette-slate-725: #42464b; --palette-slate-800: #302e32; --palette-slate-900: #161517; --palette-slate-base: #373439; @@ -268,7 +269,7 @@ /* Primary Button */ --color-primary-button-bg: var(--palette-slate-800); - --color-primary-button-bg-hover: #42464b; + --color-primary-button-bg-hover: var(--palette-slate-725); --color-primary-button-text: var(--color-text-inverted); /* Borders & Dividers */ diff --git a/packages/app/src/theme/themes/clickstack/mantineTheme.ts b/packages/app/src/theme/themes/clickstack/mantineTheme.ts index 43130ea688..ccace4524b 100644 --- a/packages/app/src/theme/themes/clickstack/mantineTheme.ts +++ b/packages/app/src/theme/themes/clickstack/mantineTheme.ts @@ -257,76 +257,42 @@ export const makeTheme = ({ variant: 'subtle', color: 'gray', }, - styles: (_theme, props) => { + vars: (_theme, props) => { + const baseVars: Record = {}; + if (props.variant === 'subtle') { - return { - root: { - backgroundColor: 'transparent', - color: 'var(--color-text)', - '&:hover': { - backgroundColor: 'var(--color-bg-hover)', - }, - '&:active': { - backgroundColor: 'var(--color-bg-muted)', - }, - }, - }; + baseVars['--ai-bg'] = 'transparent'; + baseVars['--ai-hover'] = 'var(--color-bg-hover)'; + baseVars['--ai-color'] = 'var(--color-text)'; } if (props.variant === 'default') { - return { - root: { - backgroundColor: 'var(--color-bg-hover)', - color: 'var(--color-text)', - border: 'none', - '&:hover': { - backgroundColor: 'var(--color-bg-muted)', - }, - '&:active': { - backgroundColor: 'var(--color-bg-muted)', - }, - }, - }; + baseVars['--ai-bg'] = 'var(--color-bg-hover)'; + baseVars['--ai-hover'] = 'var(--color-bg-muted)'; + baseVars['--ai-color'] = 'var(--color-text)'; + baseVars['--ai-bd'] = 'none'; } if (props.variant === 'primary') { - return { - root: { - backgroundColor: 'var(--mantine-color-yellow-6)', - color: 'var(--mantine-color-dark-9)', - '&:hover': { - backgroundColor: 'var(--mantine-color-yellow-5)', - }, - }, - }; + baseVars['--ai-bg'] = 'var(--color-primary-button-bg)'; + baseVars['--ai-hover'] = 'var(--color-primary-button-bg-hover)'; + baseVars['--ai-color'] = 'var(--color-primary-button-text)'; } if (props.variant === 'secondary') { - return { - root: { - backgroundColor: 'var(--color-bg-surface)', - color: 'var(--color-text)', - border: '1px solid var(--color-border)', - '&:hover': { - backgroundColor: 'var(--color-bg-hover)', - }, - }, - }; + baseVars['--ai-bg'] = 'var(--color-bg-surface)'; + baseVars['--ai-hover'] = 'var(--color-bg-hover)'; + baseVars['--ai-color'] = 'var(--color-text)'; + baseVars['--ai-bd'] = '1px solid var(--color-border)'; } if (props.variant === 'danger') { - return { - root: { - backgroundColor: 'var(--mantine-color-red-light)', - color: 'var(--mantine-color-red-light-color)', - '&:hover': { - backgroundColor: 'var(--mantine-color-red-light-hover)', - }, - }, - }; + baseVars['--ai-bg'] = 'var(--mantine-color-red-light)'; + baseVars['--ai-hover'] = 'var(--mantine-color-red-light-hover)'; + baseVars['--ai-color'] = 'var(--mantine-color-red-light-color)'; } - return {}; + return { root: baseVars }; }, }), }, diff --git a/packages/app/src/theme/themes/hyperdx/mantineTheme.ts b/packages/app/src/theme/themes/hyperdx/mantineTheme.ts index c973a5bb91..b47dcb276e 100644 --- a/packages/app/src/theme/themes/hyperdx/mantineTheme.ts +++ b/packages/app/src/theme/themes/hyperdx/mantineTheme.ts @@ -263,76 +263,42 @@ export const makeTheme = ({ variant: 'subtle', color: 'gray', }, - styles: (_theme, props) => { + vars: (_theme, props) => { + const baseVars: Record = {}; + if (props.variant === 'subtle') { - return { - root: { - backgroundColor: 'transparent', - color: 'var(--color-text)', - '&:hover': { - backgroundColor: 'var(--color-bg-hover)', - }, - '&:active': { - backgroundColor: 'var(--color-bg-muted)', - }, - }, - }; + baseVars['--ai-bg'] = 'transparent'; + baseVars['--ai-hover'] = 'var(--color-bg-hover)'; + baseVars['--ai-color'] = 'var(--color-text)'; } if (props.variant === 'default') { - return { - root: { - backgroundColor: 'var(--color-bg-hover)', - color: 'var(--color-text)', - border: 'none', - '&:hover': { - backgroundColor: 'var(--color-bg-muted)', - }, - '&:active': { - backgroundColor: 'var(--color-bg-muted)', - }, - }, - }; + baseVars['--ai-bg'] = 'var(--color-bg-hover)'; + baseVars['--ai-hover'] = 'var(--color-bg-muted)'; + baseVars['--ai-color'] = 'var(--color-text)'; + baseVars['--ai-bd'] = 'none'; } if (props.variant === 'primary') { - return { - root: { - backgroundColor: 'var(--mantine-color-green-light)', - color: 'var(--mantine-color-green-light-color)', - '&:hover': { - backgroundColor: 'var(--mantine-color-green-light-hover)', - }, - }, - }; + baseVars['--ai-bg'] = 'var(--mantine-color-green-light)'; + baseVars['--ai-hover'] = 'var(--mantine-color-green-light-hover)'; + baseVars['--ai-color'] = 'var(--mantine-color-green-light-color)'; } if (props.variant === 'secondary') { - return { - root: { - backgroundColor: 'var(--color-bg-surface)', - color: 'var(--color-text)', - border: '1px solid var(--color-border)', - '&:hover': { - backgroundColor: 'var(--color-bg-hover)', - }, - }, - }; + baseVars['--ai-bg'] = 'var(--color-bg-surface)'; + baseVars['--ai-hover'] = 'var(--color-bg-hover)'; + baseVars['--ai-color'] = 'var(--color-text)'; + baseVars['--ai-bd'] = '1px solid var(--color-border)'; } if (props.variant === 'danger') { - return { - root: { - backgroundColor: 'var(--mantine-color-red-light)', - color: 'var(--mantine-color-red-light-color)', - '&:hover': { - backgroundColor: 'var(--mantine-color-red-light-hover)', - }, - }, - }; + baseVars['--ai-bg'] = 'var(--mantine-color-red-light)'; + baseVars['--ai-hover'] = 'var(--mantine-color-red-light-hover)'; + baseVars['--ai-color'] = 'var(--mantine-color-red-light-color)'; } - return {}; + return { root: baseVars }; }, }), }, From a566633f49090747cff3d5b848b2b8215133d163 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Tue, 20 Jan 2026 13:43:10 +0000 Subject: [PATCH 14/36] refactor(theme): enhance AppThemeProvider for better hydration handling and developer experience - Improved comments in ThemeProvider to clarify SSR and hydration behavior. - Updated useEffect logic to ensure theme consistency in production while allowing localStorage/URL overrides in development. - Added documentation in _base-tokens.scss to explain theme-specific token imports and their usage. --- packages/app/src/theme/ThemeProvider.tsx | 16 +++++++++++----- packages/app/src/theme/themes/_base-tokens.scss | 6 ++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/app/src/theme/ThemeProvider.tsx b/packages/app/src/theme/ThemeProvider.tsx index f9430e3dc5..75155081d8 100644 --- a/packages/app/src/theme/ThemeProvider.tsx +++ b/packages/app/src/theme/ThemeProvider.tsx @@ -52,14 +52,20 @@ export function AppThemeProvider({ themeName?: ThemeName; children: React.ReactNode; }) { - // SSR/initial render: Always use props or DEFAULT_THEME for hydration consistency - // The server cannot read localStorage/URL, so we must start with a deterministic value + // SSR/initial render: Always use props or DEFAULT_THEME for hydration consistency. + // The server cannot read localStorage/URL, so we must start with a deterministic value. + // + // HYDRATION NOTE: In dev mode, the useEffect below may update the theme after hydration + // if localStorage/URL contains a different theme. This is intentional for dev testing + // and will cause a brief flash. In production (IS_DEV=false), theme is stable and + // matches server render. To avoid any flash in production, pass themeName prop explicitly. const [resolvedThemeName, setResolvedThemeName] = useState( () => propsThemeName ?? DEFAULT_THEME, ); - // After hydration, read from localStorage/URL in dev mode - // Uses consolidated getDevThemeName() from index.ts as single source of truth + // After hydration, read from localStorage/URL in dev mode only. + // Uses consolidated getDevThemeName() from index.ts as single source of truth. + // This effect only changes state in dev mode, so production has no hydration mismatch. useEffect(() => { // If theme is explicitly passed via props, use that (no dev override) if (propsThemeName) { @@ -67,7 +73,7 @@ export function AppThemeProvider({ return; } - // In dev mode, allow URL/localStorage overrides + // In dev mode only, allow URL/localStorage overrides for testing themes if (IS_DEV) { const devTheme = getDevThemeName(); setResolvedThemeName(devTheme); diff --git a/packages/app/src/theme/themes/_base-tokens.scss b/packages/app/src/theme/themes/_base-tokens.scss index 7821a4d469..bd771e741d 100644 --- a/packages/app/src/theme/themes/_base-tokens.scss +++ b/packages/app/src/theme/themes/_base-tokens.scss @@ -2,6 +2,12 @@ /* Uses HyperDX theme as default */ +/* + * Import theme-specific tokens. These files define CSS custom properties + * scoped to their theme class selectors (e.g., .theme-hyperdx, .theme-clickstack). + * The @use imports ensure these scoped styles are included in the compiled CSS. + * Note: SCSS partials are prefixed with underscore in filename but imported without it. + */ @use './hyperdx/tokens' as hyperdx-tokens; @use './clickstack/tokens' as clickstack-tokens; From 40420f2874f48161c2d7426bac7d1465919836a7 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Tue, 20 Jan 2026 13:59:33 +0000 Subject: [PATCH 15/36] refactor(theme): remove mantineTheme.ts and streamline token management - Deleted the mantineTheme.ts file to simplify theme structure. - Updated _base-tokens.scss to utilize HyperDX tokens for dark and light modes, enhancing consistency. - Introduced mixins in hyperdx/_tokens.scss for better organization of design tokens. - Extended Tabs component in hyperdx theme with custom variables for improved styling. --- packages/app/src/theme/mantineTheme.ts | 371 ------------------ .../app/src/theme/themes/_base-tokens.scss | 153 +------- .../app/src/theme/themes/hyperdx/_tokens.scss | 22 +- .../src/theme/themes/hyperdx/mantineTheme.ts | 8 + 4 files changed, 36 insertions(+), 518 deletions(-) delete mode 100644 packages/app/src/theme/mantineTheme.ts diff --git a/packages/app/src/theme/mantineTheme.ts b/packages/app/src/theme/mantineTheme.ts deleted file mode 100644 index 5d59c75f2c..0000000000 --- a/packages/app/src/theme/mantineTheme.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { - ActionIcon, - Button, - MantineTheme, - MantineThemeOverride, - rem, - Select, - Text, - Tooltip, -} from '@mantine/core'; - -export const makeTheme = ({ - fontFamily = '"IBM Plex Sans", monospace', -}: { - fontFamily?: string; -}): MantineThemeOverride => ({ - cursorType: 'pointer', - fontFamily, - primaryColor: 'green', - primaryShade: 8, - autoContrast: true, - white: '#fff', - fontSizes: { - xxs: '11px', - xs: '12px', - sm: '13px', - md: '15px', - lg: '16px', - xl: '18px', - }, - spacing: { - xxxs: 'calc(0.375rem * var(--mantine-scale))', - xxs: 'calc(0.5rem * var(--mantine-scale))', - xs: 'calc(0.625rem * var(--mantine-scale))', - sm: 'calc(0.75rem * var(--mantine-scale))', - md: 'calc(1rem * var(--mantine-scale))', - lg: 'calc(1.25rem * var(--mantine-scale))', - xl: 'calc(2rem * var(--mantine-scale))', - }, - colors: { - green: [ - '#eafff6', - '#cdfee7', - '#a0fad5', - '#63f2bf', - '#25e2a5', - '#00c28a', - '#00a475', - '#008362', - '#00674e', - '#005542', - ], - gray: [ - '#FAFAFA', - '#e6e6ee', - '#D7D8DB', - '#aeaeb7', - '#A1A1AA', - '#868691', - '#7e7e8b', - '#6c6c79', - '#5f5f6e', - '#515264', - ], - dark: [ - '#C1C2C5', - '#A6A7AB', - '#909296', - '#5C5F66', - '#373A40', - '#2C2E33', - '#25262B', - '#1A1B1E', - '#141517', - '#101113', - ], - }, - headings: { - fontFamily, - }, - components: { - Tooltip: Tooltip.extend({ - styles: () => ({ - tooltip: { - fontFamily: 'var(--mantine-font-family)', - }, - }), - }), - Modal: { - styles: { - header: { - fontFamily, - fontWeight: 'bold', - }, - }, - }, - InputWrapper: { - styles: { - label: { - marginBottom: 4, - }, - description: { - marginBottom: 8, - lineHeight: 1.3, - }, - }, - }, - Select: Select.extend({ - styles: { - input: { - border: '1px solid var(--color-border)', - }, - }, - }), - Input: { - styles: { - input: { - backgroundColor: 'var(--color-bg-field)', - border: '1px solid var(--color-border)', - }, - }, - }, - Card: { - styles: (_theme: MantineTheme, props: { variant?: string }) => { - if (props.variant === 'muted') { - return { - root: { - backgroundColor: 'var(--color-bg-muted)', - border: '1px solid var(--color-border)', - }, - }; - } - return { - root: { - backgroundColor: 'var(--color-bg-body)', - }, - }; - }, - }, - Divider: { - styles: { - root: { - borderColor: 'var(--color-border)', - borderTopColor: 'var(--color-border)', - '--divider-color': 'var(--color-border)', - '--item-border-color': 'var(--color-border)', - }, - }, - }, - Accordion: { - styles: (_theme: MantineTheme, props: { variant?: string }) => { - const base = { - control: { - '--item-border-color': 'var(--color-border)', - }, - item: { - borderColor: 'var(--color-border)', - }, - }; - if (props.variant === 'noPadding') { - return { - ...base, - content: { - paddingInline: 0, - }, - control: { - paddingInlineStart: 0, - }, - }; - } - return base; - }, - }, - UnstyledButton: { - styles: { - root: { - '--item-border-color': 'var(--color-border)', - }, - }, - }, - Paper: { - classNames: (_theme: MantineTheme, props: { variant?: string }) => { - if (props.variant === 'muted') { - return { - root: 'paper-muted', - }; - } - return {}; - }, - styles: (_theme: MantineTheme, props: { variant?: string }) => { - if (props.variant === 'muted') { - return { - root: { - backgroundColor: 'var(--color-bg-muted)', - border: '1px solid var(--color-border)', - }, - }; - } - return { - root: { - border: '1px solid var(--color-border)', - }, - }; - }, - }, - Text: Text.extend({ - styles: (theme, props) => { - if (props.variant === 'danger') { - return { - root: { - color: 'var(--color-text-danger)', - }, - }; - } - return {}; - }, - }), - Button: Button.extend({ - vars: (theme, props) => { - if (props.size === 'xxs') { - return { - root: { - '--button-height': rem(22), - '--button-padding-x': rem(4), - '--button-fz': rem(12), - }, - }; - } - - return { root: {} }; - }, - styles: (_theme, props) => { - // Primary variant - light green style - if (props.variant === 'primary') { - return { - root: { - backgroundColor: 'var(--mantine-color-green-light)', - color: 'var(--mantine-color-green-light-color)', - '&:hover': { - backgroundColor: 'var(--mantine-color-green-light-hover)', - }, - }, - }; - } - - // Secondary variant - similar to default - if (props.variant === 'secondary') { - return { - root: { - backgroundColor: 'var(--color-bg-body)', - color: 'var(--color-text)', - border: '1px solid var(--color-border)', - '&:hover': { - backgroundColor: 'var(--color-bg-hover)', - }, - }, - }; - } - - // Danger variant - light red style - if (props.variant === 'danger') { - return { - root: { - backgroundColor: 'var(--mantine-color-red-light)', - color: 'var(--mantine-color-red-light-color)', - '&:hover': { - backgroundColor: 'var(--mantine-color-red-light-hover)', - }, - }, - }; - } - - return {}; - }, - }), - SegmentedControl: { - styles: { - root: { - background: 'var(--color-bg-field)', - }, - indicator: { - background: 'var(--color-bg-field-highlighted)', - }, - }, - }, - ActionIcon: ActionIcon.extend({ - defaultProps: { - variant: 'subtle', - color: 'gray', - }, - styles: (_theme, props) => { - // Subtle variant stays transparent - if (props.variant === 'subtle') { - return { - root: { - backgroundColor: 'transparent', - color: 'var(--color-text)', - '&:hover': { - backgroundColor: 'var(--color-bg-hover)', - }, - '&:active': { - backgroundColor: 'var(--color-bg-muted)', - }, - }, - }; - } - - // Default variant - if (props.variant === 'default') { - return { - root: { - backgroundColor: 'var(--color-bg-hover)', - color: 'var(--color-text)', - border: 'none', - '&:hover': { - backgroundColor: 'var(--color-bg-muted)', - }, - '&:active': { - backgroundColor: 'var(--color-bg-muted)', - }, - }, - }; - } - - // Primary variant - light green style - if (props.variant === 'primary') { - return { - root: { - backgroundColor: 'var(--mantine-color-green-light)', - color: 'var(--mantine-color-green-light-color)', - '&:hover': { - backgroundColor: 'var(--mantine-color-green-light-hover)', - }, - }, - }; - } - - // Secondary variant - similar to default - if (props.variant === 'secondary') { - return { - root: { - backgroundColor: 'var(--color-bg-surface)', - color: 'var(--color-text)', - border: '1px solid var(--color-border)', - '&:hover': { - backgroundColor: 'var(--color-bg-hover)', - }, - }, - }; - } - - // Danger variant - light red style - if (props.variant === 'danger') { - return { - root: { - backgroundColor: 'var(--mantine-color-red-light)', - color: 'var(--mantine-color-red-light-color)', - '&:hover': { - backgroundColor: 'var(--mantine-color-red-light-hover)', - }, - }, - }; - } - - return {}; - }, - }), - }, -}); - -export const theme = makeTheme({}); diff --git a/packages/app/src/theme/themes/_base-tokens.scss b/packages/app/src/theme/themes/_base-tokens.scss index bd771e741d..1a5c4d25d8 100644 --- a/packages/app/src/theme/themes/_base-tokens.scss +++ b/packages/app/src/theme/themes/_base-tokens.scss @@ -1,155 +1,22 @@ /* Base Design Tokens - Fallback values when no theme class is applied */ -/* Uses HyperDX theme as default */ - /* - * Import theme-specific tokens. These files define CSS custom properties - * scoped to their theme class selectors (e.g., .theme-hyperdx, .theme-clickstack). - * The @use imports ensure these scoped styles are included in the compiled CSS. - * Note: SCSS partials are prefixed with underscore in filename but imported without it. + * Uses HyperDX theme as default fallback for SSR and initial render. + * This ensures CSS variables are defined before JavaScript applies the theme class. + * + * The hyperdx/_tokens.scss file defines mixins that are reused here, + * avoiding duplication of token definitions. */ + @use './hyperdx/tokens' as hyperdx-tokens; @use './clickstack/tokens' as clickstack-tokens; -/* Dark Mode (default) */ +/* Dark Mode (default fallback) - uses HyperDX tokens */ [data-mantine-color-scheme='dark'] { - /* Backgrounds */ - --color-bg-body: var(--mantine-color-dark-9); - --color-bg-surface: var(--mantine-color-dark-5); - --color-bg-inverted: var(--mantine-color-dark-1); - --color-bg-muted: var(--mantine-color-dark-7); - --color-bg-highlighted: rgb(55 58 64 / 70%); - --color-bg-sidenav: var(--mantine-color-dark-9); - --color-bg-sidenav-link-active: var(--mantine-color-dark-6); - --color-bg-sidenav-link-active-hover: var(--mantine-color-dark-7); - --color-bg-header: var(--mantine-color-dark-9); - --color-bg-hover: var(--mantine-color-dark-6); - --color-bg-active: var(--mantine-color-dark-5); - --color-bg-field: var(--mantine-color-dark-6); - --color-bg-field-highlighted: var(--mantine-color-dark-3); - --color-bg-neutral: var(--mantine-color-dark-4); - --color-bg-success: var(--mantine-color-green-5); - --color-bg-danger: var(--mantine-color-red-4); - --color-bg-warning: var(--mantine-color-orange-5); - --color-bg-brand: #4ffa7a; - - /* Borders & Dividers */ - --color-border: var(--mantine-color-dark-5); - --color-border-muted: rgb(255 255 255 / 8%); - - /* Text */ - --color-text: var(--mantine-color-dark-0); - --color-text-inverted: var(--mantine-color-dark-9); - --color-text-primary: var(--mantine-color-green-4); - --color-text-primary-hover: var(--mantine-color-green-3); - --color-text-secondary: var(--mantine-color-dark-2); - --color-text-muted: var(--mantine-color-dark-2); - --color-text-muted-hover: var(--mantine-color-dark-1); - --color-text-success: var(--mantine-color-green-4); - --color-text-success-hover: var(--mantine-color-green-3); - --color-text-danger: var(--mantine-color-red-3); - --color-text-sidenav-link: var(--mantine-color-dark-0); - --color-text-sidenav-link-active: var(--color-text-primary); - - /* Icons */ - --color-icon-primary: var(--mantine-color-dark-1); - --color-icon-muted: var(--mantine-color-dark-4); - - /* Overlay & Backdrop */ - --color-overlay: rgb(0 0 0 / 50%); - --color-backdrop: rgb(0 0 0 / 30%); - - /* States */ - --color-state-hover: var(--mantine-color-dark-6); - --color-state-selected: var(--mantine-color-dark-5); - --color-state-focus: var(--mantine-color-dark-4); - - /* Code / Misc UI */ - --color-bg-code: var(--mantine-color-dark-8); - --color-border-code: var(--mantine-color-dark-6); - --color-bg-kbd: var(--mantine-color-dark-9); - - /* JSON Syntax Highlighting */ - --color-json-string: var(--mantine-color-green-3); - --color-json-number: var(--mantine-color-orange-3); - --color-json-boolean: var(--mantine-color-orange-3); - --color-json-key: var(--mantine-color-violet-3); - --color-json-object: var(--mantine-color-green-3); - --color-json-array: var(--mantine-color-green-3); - --color-json-punctuation: var(--mantine-color-dark-4); - - /* Mantine Overrides */ - --mantine-color-body: var(--color-bg-body) !important; + @include hyperdx-tokens.dark-mode-tokens; } -/* Light Mode */ +/* Light Mode (fallback) - uses HyperDX tokens */ [data-mantine-color-scheme='light'] { - /* Mantine Overrides */ - --mantine-color-body: var(--color-bg-body); - - /* Backgrounds - inverted (dark becomes light) */ - --color-bg-body: var(--mantine-color-white); - --color-bg-surface: var(--mantine-color-white); - --color-bg-inverted: var(--mantine-color-dark-3); - --color-bg-muted: #f6f6fa; - --color-bg-highlighted: rgb(230 232 237 / 70%); - --color-bg-sidenav: var(--mantine-color-white); - --color-bg-sidenav-link-active: #f6f6fa; - --color-bg-sidenav-link-active-hover: var(--mantine-color-gray-2); - --color-bg-header: var(--mantine-color-gray-1); - --color-bg-modal: var(--mantine-color-white); - --color-bg-hover: var(--mantine-color-gray-3); - --color-bg-active: var(--mantine-color-gray-4); - --color-bg-field: #ececf5; - --color-bg-field-highlighted: var(--mantine-color-white); - --color-bg-neutral: var(--mantine-color-gray-5); - --color-bg-success: var(--mantine-color-green-8); - --color-bg-danger: var(--mantine-color-red-8); - --color-bg-warning: var(--mantine-color-orange-8); - --color-bg-brand: var(--mantine-color-green-8); - - /* Borders & Dividers - inverted */ - --color-border: var(--mantine-color-gray-1); - --color-border-muted: rgb(0 0 0 / 5%); - - /* Text - inverted */ - --color-text: var(--mantine-color-dark-9); - --color-text-inverted: var(--mantine-color-white); - --color-text-primary: var(--mantine-color-green-8); - --color-text-primary-hover: var(--mantine-color-green-9); - --color-text-secondary: var(--mantine-color-gray-5); - --color-text-muted: var(--mantine-color-dark-6); - --color-text-muted-hover: var(--mantine-color-dark-9); - --color-text-success: var(--mantine-color-green-8); - --color-text-success-hover: var(--mantine-color-green-9); - --color-text-danger: var(--mantine-color-red-8); - --color-text-sidenav-link: var(--mantine-color-dark-6); - --color-text-sidenav-link-active: var(--mantine-color-green-9); - - /* Icons - inverted */ - --color-icon-primary: var(--mantine-color-dark-8); - --color-icon-muted: var(--mantine-color-dark-5); - - /* Overlay & Backdrop */ - --color-overlay: rgb(0 0 0 / 30%); - --color-backdrop: rgb(0 0 0 / 10%); - - /* States - inverted */ - --color-state-hover: var(--mantine-color-dark-3); - --color-state-selected: var(--mantine-color-dark-4); - --color-state-focus: var(--mantine-color-dark-5); - - /* Code / Misc UI - inverted */ - --color-bg-code: var(--mantine-color-dark-1); - --color-border-code: var(--mantine-color-dark-3); - --color-bg-kbd: var(--mantine-color-dark-0); - - /* JSON Syntax Highlighting */ - --color-json-string: var(--mantine-color-green-9); - --color-json-number: var(--mantine-color-orange-7); - --color-json-boolean: var(--mantine-color-orange-7); - --color-json-key: var(--mantine-color-violet-9); - --color-json-object: var(--mantine-color-green-7); - --color-json-array: var(--mantine-color-green-7); - --color-json-punctuation: var(--mantine-color-dark-5); + @include hyperdx-tokens.light-mode-tokens; } diff --git a/packages/app/src/theme/themes/hyperdx/_tokens.scss b/packages/app/src/theme/themes/hyperdx/_tokens.scss index c55825821a..fcae593199 100644 --- a/packages/app/src/theme/themes/hyperdx/_tokens.scss +++ b/packages/app/src/theme/themes/hyperdx/_tokens.scss @@ -2,8 +2,13 @@ /* Default theme with green accent */ -/* Dark Mode */ -.theme-hyperdx[data-mantine-color-scheme='dark'] { +/* + * These mixins define all design tokens for HyperDX theme. + * They are used both here (for .theme-hyperdx scoped selectors) and + * in _base-tokens.scss (for unscoped fallback selectors during SSR). + */ + +@mixin dark-mode-tokens { /* Backgrounds */ --color-bg-body: var(--mantine-color-dark-9); --color-bg-surface: var(--mantine-color-dark-5); @@ -80,8 +85,7 @@ --mantine-color-body: var(--color-bg-body) !important; } -/* Light Mode */ -.theme-hyperdx[data-mantine-color-scheme='light'] { +@mixin light-mode-tokens { /* Mantine Overrides */ --mantine-color-body: var(--color-bg-body); @@ -153,3 +157,13 @@ --color-json-array: var(--mantine-color-green-7); --color-json-punctuation: var(--mantine-color-dark-5); } + +/* Dark Mode - scoped to .theme-hyperdx */ +.theme-hyperdx[data-mantine-color-scheme='dark'] { + @include dark-mode-tokens; +} + +/* Light Mode - scoped to .theme-hyperdx */ +.theme-hyperdx[data-mantine-color-scheme='light'] { + @include light-mode-tokens; +} diff --git a/packages/app/src/theme/themes/hyperdx/mantineTheme.ts b/packages/app/src/theme/themes/hyperdx/mantineTheme.ts index b47dcb276e..71f01aa46f 100644 --- a/packages/app/src/theme/themes/hyperdx/mantineTheme.ts +++ b/packages/app/src/theme/themes/hyperdx/mantineTheme.ts @@ -5,6 +5,7 @@ import { MantineThemeOverride, rem, Select, + Tabs, Text, Tooltip, } from '@mantine/core'; @@ -258,6 +259,13 @@ export const makeTheme = ({ }, }, }, + Tabs: Tabs.extend({ + vars: () => ({ + root: { + '--tabs-color': 'var(--color-text-brand)', + }, + }), + }), ActionIcon: ActionIcon.extend({ defaultProps: { variant: 'subtle', From 67694e8db73acd949031180bd45fb25719a8a382 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Tue, 20 Jan 2026 14:52:58 +0000 Subject: [PATCH 16/36] refactor(theme): enhance user preferences and background handling - Introduced URL validation to prevent XSS attacks in background settings. - Added support for valid CSS mix-blend-mode values to improve background styling. - Refactored theme management functions to safely handle localStorage access, ensuring compatibility with private browsing and SSR. - Updated AppThemeProvider to utilize new safe localStorage functions for theme persistence. --- packages/app/src/theme/ThemeProvider.tsx | 12 ++--- packages/app/src/theme/index.ts | 57 +++++++++++++++++++----- packages/app/src/useUserPreferences.tsx | 49 +++++++++++++++++++- 3 files changed, 101 insertions(+), 17 deletions(-) diff --git a/packages/app/src/theme/ThemeProvider.tsx b/packages/app/src/theme/ThemeProvider.tsx index 75155081d8..ff17c0e7bc 100644 --- a/packages/app/src/theme/ThemeProvider.tsx +++ b/packages/app/src/theme/ThemeProvider.tsx @@ -11,6 +11,8 @@ import { DEFAULT_THEME, getDevThemeName, getTheme, + safeLocalStorageRemove, + safeLocalStorageSet, THEME_STORAGE_KEY, themes, } from './index'; @@ -91,7 +93,7 @@ export function AppThemeProvider({ return; } if (themes[name]) { - localStorage.setItem(THEME_STORAGE_KEY, name); + safeLocalStorageSet(THEME_STORAGE_KEY, name); setResolvedThemeName(name); } }, []); @@ -103,16 +105,14 @@ export function AppThemeProvider({ const currentIndex = themeNames.indexOf(current); const nextIndex = (currentIndex + 1) % themeNames.length; const nextTheme = themeNames[nextIndex]; - localStorage.setItem(THEME_STORAGE_KEY, nextTheme); + safeLocalStorageSet(THEME_STORAGE_KEY, nextTheme); return nextTheme; }); }, []); const clearThemeOverride = useCallback(() => { - if (typeof window !== 'undefined') { - localStorage.removeItem(THEME_STORAGE_KEY); - setResolvedThemeName(propsThemeName ?? DEFAULT_THEME); - } + safeLocalStorageRemove(THEME_STORAGE_KEY); + setResolvedThemeName(propsThemeName ?? DEFAULT_THEME); }, [propsThemeName]); const contextValue = useMemo( diff --git a/packages/app/src/theme/index.ts b/packages/app/src/theme/index.ts index 2cd9a060d0..047ad8f777 100644 --- a/packages/app/src/theme/index.ts +++ b/packages/app/src/theme/index.ts @@ -16,9 +16,45 @@ const IS_DEV = // LocalStorage key for dev theme override (exported for ThemeProvider) export const THEME_STORAGE_KEY = 'hdx-dev-theme'; -// Default theme (can be overridden via environment variable) -export const DEFAULT_THEME: ThemeName = - (process.env.NEXT_PUBLIC_THEME as ThemeName) || 'hyperdx'; +// Validate that a theme name is valid +function isValidThemeName(name: string | null | undefined): name is ThemeName { + return name != null && name in themes; +} + +// Safe localStorage access (handles private browsing, SSR, etc.) +function safeLocalStorageGet(key: string): string | undefined { + try { + if (typeof window === 'undefined') return undefined; + return localStorage.getItem(key) ?? undefined; + } catch { + // localStorage may throw in private browsing or when disabled + return undefined; + } +} + +function safeLocalStorageSet(key: string, value: string): void { + try { + if (typeof window === 'undefined') return; + localStorage.setItem(key, value); + } catch { + // localStorage may throw in private browsing or when disabled + } +} + +function safeLocalStorageRemove(key: string): void { + try { + if (typeof window === 'undefined') return; + localStorage.removeItem(key); + } catch { + // localStorage may throw in private browsing or when disabled + } +} + +// Default theme (validated against registry, falls back to hyperdx) +const envTheme = process.env.NEXT_PUBLIC_THEME; +export const DEFAULT_THEME: ThemeName = isValidThemeName(envTheme) + ? envTheme + : 'hyperdx'; /** * Get the theme name from various sources (dev mode only). @@ -39,18 +75,16 @@ export function getDevThemeName(): ThemeName { // Check URL query param first (highest priority for testing) const urlParams = new URLSearchParams(window.location.search); - const urlTheme = urlParams.get('theme') as ThemeName | null; - if (urlTheme && themes[urlTheme]) { + const urlTheme = urlParams.get('theme'); + if (isValidThemeName(urlTheme)) { // Persist to localStorage when set via URL - localStorage.setItem(THEME_STORAGE_KEY, urlTheme); + safeLocalStorageSet(THEME_STORAGE_KEY, urlTheme); return urlTheme; } // Check localStorage - const storedTheme = localStorage.getItem( - THEME_STORAGE_KEY, - ) as ThemeName | null; - if (storedTheme && themes[storedTheme]) { + const storedTheme = safeLocalStorageGet(THEME_STORAGE_KEY); + if (isValidThemeName(storedTheme)) { return storedTheme; } @@ -76,5 +110,8 @@ export function getCurrentThemeName(): ThemeName { // Re-export types export type { ThemeConfig, ThemeName } from './types'; +// Export safe localStorage helpers for ThemeProvider +export { safeLocalStorageGet, safeLocalStorageRemove, safeLocalStorageSet }; + // Re-export for backwards compatibility export { makeTheme, theme } from './themes/hyperdx/mantineTheme'; diff --git a/packages/app/src/useUserPreferences.tsx b/packages/app/src/useUserPreferences.tsx index da7b9b20c0..86042d9e95 100644 --- a/packages/app/src/useUserPreferences.tsx +++ b/packages/app/src/useUserPreferences.tsx @@ -43,12 +43,59 @@ export const useUserPreferences = () => { return { userPreferences, setUserPreference }; }; +// Validate that a URL is safe to use (prevent XSS via javascript: URLs) +function isSafeBackgroundUrl(url: string): boolean { + try { + const parsed = new URL(url, window.location.origin); + // Only allow http, https, and data URLs (for base64 images) + return ['http:', 'https:', 'data:'].includes(parsed.protocol); + } catch { + // Invalid URL - could be a relative path, which is safe + return !url.toLowerCase().startsWith('javascript:'); + } +} + +// Valid CSS mix-blend-mode values +const VALID_BLEND_MODES = [ + 'normal', + 'multiply', + 'screen', + 'overlay', + 'darken', + 'lighten', + 'color-dodge', + 'color-burn', + 'hard-light', + 'soft-light', + 'difference', + 'exclusion', + 'hue', + 'saturation', + 'color', + 'luminosity', +] as const; + +type BlendMode = (typeof VALID_BLEND_MODES)[number]; + +function isValidBlendMode(mode: string | undefined): mode is BlendMode { + return VALID_BLEND_MODES.includes(mode as BlendMode); +} + export const useBackground = (prefs: UserPreferences) => { if (!prefs.backgroundEnabled || !prefs.backgroundUrl) { return null; } + // Validate URL to prevent XSS attacks (e.g., javascript: URLs) + if (!isSafeBackgroundUrl(prefs.backgroundUrl)) { + console.warn('useBackground: Invalid or unsafe background URL'); + return null; + } + const blurOffset = -1.5 * (prefs.backgroundBlur || 0) + 'px'; + const blendMode: BlendMode = isValidBlendMode(prefs.backgroundBlendMode) + ? prefs.backgroundBlendMode + : 'screen'; return (
{ right: blurOffset, bottom: blurOffset, filter: `blur(${prefs.backgroundBlur}px)`, - mixBlendMode: (prefs.backgroundBlendMode as any) ?? 'screen', + mixBlendMode: blendMode, }} /> ); From 0dbc457aa0e9c3615991973f8c7bf85eec8871f4 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Tue, 20 Jan 2026 19:55:43 +0000 Subject: [PATCH 17/36] style(theme): update active sidenav link color for consistency - Changed the active sidenav link color to use the default text color variable for improved consistency across the Clickstack theme. --- packages/app/src/theme/themes/clickstack/_tokens.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/theme/themes/clickstack/_tokens.scss b/packages/app/src/theme/themes/clickstack/_tokens.scss index 9de1456c0c..0f1dbfed17 100644 --- a/packages/app/src/theme/themes/clickstack/_tokens.scss +++ b/packages/app/src/theme/themes/clickstack/_tokens.scss @@ -131,7 +131,7 @@ --color-text-success-hover: #4ade80; --color-text-danger: var(--click-global-color-text-danger); --color-text-sidenav-link: var(--click-global-color-text-default); - --color-text-sidenav-link-active: var(--color-text-primary); + --color-text-sidenav-link-active: var(--click-global-color-text-default); /* Icons */ --color-icon-primary: #d5d7e0; From 742fe8427e64db17aa14c06fe07a6452b6701fc3 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 22 Jan 2026 15:45:24 +0000 Subject: [PATCH 18/36] fix: update import path for focusClasses in mantineTheme.ts --- packages/app/src/theme/themes/hyperdx/mantineTheme.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/theme/themes/hyperdx/mantineTheme.ts b/packages/app/src/theme/themes/hyperdx/mantineTheme.ts index a4bc9ce0fe..7f3c42fd31 100644 --- a/packages/app/src/theme/themes/hyperdx/mantineTheme.ts +++ b/packages/app/src/theme/themes/hyperdx/mantineTheme.ts @@ -10,7 +10,7 @@ import { Tooltip, } from '@mantine/core'; -import focusClasses from '../../styles/focus.module.scss'; +import focusClasses from '../../../../styles/focus.module.scss'; export const makeTheme = ({ fontFamily = '"IBM Plex Sans", monospace', From 2440ade4f00e0af0e5449dc5dee51559eff6f3e6 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 22 Jan 2026 15:47:39 +0000 Subject: [PATCH 19/36] fix: update success marker color in PlaybarSlider component --- packages/app/src/PlaybarSlider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/PlaybarSlider.tsx b/packages/app/src/PlaybarSlider.tsx index d3762b786a..9fd01f558b 100644 --- a/packages/app/src/PlaybarSlider.tsx +++ b/packages/app/src/PlaybarSlider.tsx @@ -61,7 +61,7 @@ export const PlaybarSlider = ({ className={styles.markerDot} style={{ backgroundColor: mark.isSuccess - ? 'var(--color-text-brand)' + ? 'var(--color-text-success)' : mark.isError ? 'var(--color-text-danger)' : 'var(--color-text-muted)', From 84684722215170754f88c4d076bea365b3b7cf36 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 22 Jan 2026 16:53:37 +0000 Subject: [PATCH 20/36] feat: add hover state for brand color and extend Slider styles in themes - Introduced `--color-bg-brand-hover` token in both Clickstack and HyperDX themes for hover state styling. - Updated Slider component styles to utilize the new brand color for bar and thumb elements. --- packages/app/src/Icon.tsx | 27 --------- packages/app/src/LandingHeader.tsx | 6 +- packages/app/src/Logo.tsx | 55 ------------------- packages/app/src/OnboardingChecklist.tsx | 4 +- packages/app/src/Spotlights.tsx | 8 +-- packages/app/src/components/AppNav/AppNav.tsx | 10 ++-- packages/app/src/nextra.config.tsx | 6 +- packages/app/src/theme/ThemeProvider.tsx | 8 +-- .../clickstack/{Icon.tsx => Logomark.tsx} | 4 +- .../clickstack/{Logo.tsx => Wordmark.tsx} | 2 +- .../src/theme/themes/clickstack/_tokens.scss | 6 +- .../app/src/theme/themes/clickstack/index.ts | 8 +-- .../theme/themes/clickstack/mantineTheme.ts | 11 ++++ .../themes/hyperdx/{Icon.tsx => Logomark.tsx} | 4 +- .../themes/hyperdx/{Logo.tsx => Wordmark.tsx} | 10 ++-- .../app/src/theme/themes/hyperdx/_tokens.scss | 8 +-- .../app/src/theme/themes/hyperdx/index.ts | 8 +-- .../src/theme/themes/hyperdx/mantineTheme.ts | 11 ++++ packages/app/src/theme/types.ts | 4 +- 19 files changed, 71 insertions(+), 129 deletions(-) delete mode 100644 packages/app/src/Icon.tsx delete mode 100644 packages/app/src/Logo.tsx rename packages/app/src/theme/themes/clickstack/{Icon.tsx => Logomark.tsx} (91%) rename packages/app/src/theme/themes/clickstack/{Logo.tsx => Wordmark.tsx} (99%) rename packages/app/src/theme/themes/hyperdx/{Icon.tsx => Logomark.tsx} (90%) rename packages/app/src/theme/themes/hyperdx/{Logo.tsx => Wordmark.tsx} (67%) diff --git a/packages/app/src/Icon.tsx b/packages/app/src/Icon.tsx deleted file mode 100644 index 873602b887..0000000000 --- a/packages/app/src/Icon.tsx +++ /dev/null @@ -1,27 +0,0 @@ -export default function Icon({ size = 16 }: { size?: number }) { - return ( - - - - - - - - - - - - ); -} diff --git a/packages/app/src/LandingHeader.tsx b/packages/app/src/LandingHeader.tsx index 304f895975..e2af10450a 100644 --- a/packages/app/src/LandingHeader.tsx +++ b/packages/app/src/LandingHeader.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { Anchor, Burger, Button, Container, Group } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; -import { useLogo } from './theme/ThemeProvider'; +import { useWordmark } from './theme/ThemeProvider'; import api from './api'; export default function LandingHeader({ @@ -12,7 +12,7 @@ export default function LandingHeader({ activeKey: string; fixed?: boolean; }) { - const Logo = useLogo(); + const Wordmark = useWordmark(); const { data: me } = api.useMe(); const isLoggedIn = Boolean(me); @@ -36,7 +36,7 @@ export default function LandingHeader({ - + -
- -
- - - HyperDX - -
- ); -} diff --git a/packages/app/src/OnboardingChecklist.tsx b/packages/app/src/OnboardingChecklist.tsx index a7953391cb..068480aec1 100644 --- a/packages/app/src/OnboardingChecklist.tsx +++ b/packages/app/src/OnboardingChecklist.tsx @@ -20,7 +20,7 @@ import { } from '@tabler/icons-react'; import { useQueriedChartConfig } from './hooks/useChartConfig'; -import { useIcon } from './theme/ThemeProvider'; +import { useLogomark } from './theme/ThemeProvider'; import api from './api'; import { useConnections } from './connection'; import { useSources } from './source'; @@ -41,7 +41,7 @@ const OnboardingChecklist = ({ }: { onAddDataClick?: () => void; }) => { - const Icon = useIcon(); + const Logomark = useLogomark(); const [isCollapsed, setIsCollapsed] = useLocalStorage( 'onboardingChecklistCollapsed', false, diff --git a/packages/app/src/Spotlights.tsx b/packages/app/src/Spotlights.tsx index 8310f2609f..3ccbb59a16 100644 --- a/packages/app/src/Spotlights.tsx +++ b/packages/app/src/Spotlights.tsx @@ -14,7 +14,7 @@ import { IconSettings, } from '@tabler/icons-react'; -import { useIcon } from './theme/ThemeProvider'; +import { useLogomark } from './theme/ThemeProvider'; import api from './api'; import { useSavedSearches } from './savedSearch'; @@ -22,7 +22,7 @@ import '@mantine/spotlight/styles.css'; export const useSpotlightActions = () => { const router = useRouter(); - const Icon = useIcon(); + const Logomark = useLogomark(); const { data: logViewsData } = useSavedSearches(); const { data: dashboardsData } = api.useDashboards(); @@ -151,7 +151,7 @@ export const useSpotlightActions = () => { { id: 'cloud', group: 'Menu', - leftSection: , + leftSection: , label: 'HyperDX Cloud', description: 'Ready to use HyperDX Cloud? Get started for free.', keywords: ['account', 'profile'], @@ -162,7 +162,7 @@ export const useSpotlightActions = () => { ); return logViewActions; - }, [Icon, logViewsData, dashboardsData, router]); + }, [Logomark, logViewsData, dashboardsData, router]); return { actions }; }; diff --git a/packages/app/src/components/AppNav/AppNav.tsx b/packages/app/src/components/AppNav/AppNav.tsx index 6acd6d49f2..403be41914 100644 --- a/packages/app/src/components/AppNav/AppNav.tsx +++ b/packages/app/src/components/AppNav/AppNav.tsx @@ -50,7 +50,7 @@ import { import InstallInstructionModal from '@/InstallInstructionsModal'; import OnboardingChecklist from '@/OnboardingChecklist'; import { useSavedSearches, useUpdateSavedSearch } from '@/savedSearch'; -import { useIcon, useLogo } from '@/theme/ThemeProvider'; +import { useLogomark, useWordmark } from '@/theme/ThemeProvider'; import type { SavedSearch, ServerDashboard } from '@/types'; import { UserPreferencesModal } from '@/UserPreferencesModal'; import { useUserPreferences } from '@/useUserPreferences'; @@ -391,8 +391,8 @@ function useSearchableList({ } export default function AppNav({ fixed = false }: { fixed?: boolean }) { - const Logo = useLogo(); - const Icon = useIcon(); + const Wordmark = useWordmark(); + const Logomark = useLogomark(); useEffect(() => { let redirectUrl; @@ -668,11 +668,11 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) { {isCollapsed ? (
- +
) : ( - + {isUTC && ( ; + const Wordmark = useWordmark(); + return ; } const theme = { diff --git a/packages/app/src/theme/ThemeProvider.tsx b/packages/app/src/theme/ThemeProvider.tsx index ff17c0e7bc..5fcc82caea 100644 --- a/packages/app/src/theme/ThemeProvider.tsx +++ b/packages/app/src/theme/ThemeProvider.tsx @@ -221,14 +221,14 @@ export function useAppTheme(): ThemeContextValue { } // Convenience hooks -export function useLogo() { +export function useWordmark() { const { theme } = useAppTheme(); - return theme.Logo; + return theme.Wordmark; } -export function useIcon() { +export function useLogomark() { const { theme } = useAppTheme(); - return theme.Icon; + return theme.Logomark; } // Hook to get current theme name (useful for conditional rendering) diff --git a/packages/app/src/theme/themes/clickstack/Icon.tsx b/packages/app/src/theme/themes/clickstack/Logomark.tsx similarity index 91% rename from packages/app/src/theme/themes/clickstack/Icon.tsx rename to packages/app/src/theme/themes/clickstack/Logomark.tsx index b4451df8f0..53678b7c13 100644 --- a/packages/app/src/theme/themes/clickstack/Icon.tsx +++ b/packages/app/src/theme/themes/clickstack/Logomark.tsx @@ -1,8 +1,8 @@ /** - * ClickStack Icon + * ClickStack Logomark * A stylized database/stack icon with the brand yellow color */ -export default function Icon({ size = 16 }: { size?: number }) { +export default function Logomark({ size = 16 }: { size?: number }) { return ( diff --git a/packages/app/src/theme/themes/hyperdx/Logo.tsx b/packages/app/src/theme/themes/hyperdx/Wordmark.tsx similarity index 67% rename from packages/app/src/theme/themes/hyperdx/Logo.tsx rename to packages/app/src/theme/themes/hyperdx/Wordmark.tsx index f964d420ee..51fc4ea0ba 100644 --- a/packages/app/src/theme/themes/hyperdx/Logo.tsx +++ b/packages/app/src/theme/themes/hyperdx/Wordmark.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import Icon from './Icon'; +import Logomark from './Logomark'; -export default function Logo() { +export default function Wordmark() { return (
- +
HyperDX diff --git a/packages/app/src/theme/themes/hyperdx/_tokens.scss b/packages/app/src/theme/themes/hyperdx/_tokens.scss index c5ca10b34b..da2feb57b2 100644 --- a/packages/app/src/theme/themes/hyperdx/_tokens.scss +++ b/packages/app/src/theme/themes/hyperdx/_tokens.scss @@ -24,6 +24,8 @@ --color-bg-field: var(--mantine-color-dark-6); --color-bg-field-highlighted: var(--mantine-color-dark-3); --color-bg-neutral: var(--mantine-color-dark-4); + --color-bg-brand: var(--mantine-color-green-4); + --color-bg-brand-hover: var(--mantine-color-green-3); --color-bg-success: var(--mantine-color-green-5); --color-bg-danger: var(--mantine-color-red-4); --color-bg-warning: var(--mantine-color-orange-5); @@ -49,8 +51,6 @@ --color-text-brand-hover: var(--mantine-color-green-3); --color-text-success: var(--mantine-color-green-4); --color-text-success-hover: var(--mantine-color-green-3); - --color-text-brand: var(--mantine-color-green-4); - --color-text-brand-hover: var(--mantine-color-green-3); --color-text-danger: var(--mantine-color-red-3); --color-text-sidenav-link: var(--mantine-color-dark-0); --color-text-sidenav-link-active: var(--color-text-primary); @@ -130,6 +130,8 @@ --color-bg-field: #ececf5; --color-bg-field-highlighted: var(--mantine-color-white); --color-bg-neutral: var(--mantine-color-gray-5); + --color-bg-brand: var(--mantine-color-green-8); + --color-bg-brand-hover: var(--mantine-color-green-9); --color-bg-success: var(--mantine-color-green-8); --color-bg-danger: var(--mantine-color-red-8); --color-bg-warning: var(--mantine-color-orange-8); @@ -150,8 +152,6 @@ --color-text-brand-hover: var(--mantine-color-green-9); --color-text-success: var(--mantine-color-green-8); --color-text-success-hover: var(--mantine-color-green-9); - --color-text-brand: var(--mantine-color-green-8); - --color-text-brand-hover: var(--mantine-color-green-9); --color-text-danger: var(--mantine-color-red-8); --color-text-sidenav-link: var(--mantine-color-dark-6); --color-text-sidenav-link-active: var(--mantine-color-green-9); diff --git a/packages/app/src/theme/themes/hyperdx/index.ts b/packages/app/src/theme/themes/hyperdx/index.ts index a088c0bc0e..ee6ff55d8c 100644 --- a/packages/app/src/theme/themes/hyperdx/index.ts +++ b/packages/app/src/theme/themes/hyperdx/index.ts @@ -1,14 +1,14 @@ import { ThemeConfig } from '../../types'; -import Icon from './Icon'; -import Logo from './Logo'; +import Logomark from './Logomark'; import { theme } from './mantineTheme'; +import Wordmark from './Wordmark'; export const hyperdxTheme: ThemeConfig = { name: 'hyperdx', displayName: 'HyperDX', mantineTheme: theme, - Logo, - Icon, + Wordmark, + Logomark, cssClass: 'theme-hyperdx', }; diff --git a/packages/app/src/theme/themes/hyperdx/mantineTheme.ts b/packages/app/src/theme/themes/hyperdx/mantineTheme.ts index 7f3c42fd31..43e405f939 100644 --- a/packages/app/src/theme/themes/hyperdx/mantineTheme.ts +++ b/packages/app/src/theme/themes/hyperdx/mantineTheme.ts @@ -5,6 +5,7 @@ import { MantineThemeOverride, rem, Select, + Slider, Tabs, Text, Tooltip, @@ -116,6 +117,16 @@ export const makeTheme = ({ }, }, }), + Slider: Slider.extend({ + styles: { + bar: { + backgroundColor: 'var(--color-bg-brand)', + }, + thumb: { + borderColor: 'var(--color-bg-brand)', + }, + }, + }), Input: { styles: { input: { diff --git a/packages/app/src/theme/types.ts b/packages/app/src/theme/types.ts index 1d78582df5..621417520e 100644 --- a/packages/app/src/theme/types.ts +++ b/packages/app/src/theme/types.ts @@ -6,7 +6,7 @@ export interface ThemeConfig { name: ThemeName; displayName: string; mantineTheme: MantineThemeOverride; - Logo: React.ComponentType<{ size?: 'sm' | 'md' | 'lg' | 'xl' }>; - Icon: React.ComponentType<{ size?: number }>; + Wordmark: React.ComponentType<{ size?: 'sm' | 'md' | 'lg' | 'xl' }>; + Logomark: React.ComponentType<{ size?: number }>; cssClass: string; // Applied to html element for CSS variable scoping } From 076a33b22707d9bdf94a34aa3a092c24bf8c8815 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 22 Jan 2026 18:33:43 +0000 Subject: [PATCH 21/36] feat: implement dynamic favicon support and update theme configurations - Added `DynamicFavicon` component to manage favicons based on the current theme. - Removed old favicon file and added new SVG and PNG favicon files for both Clickstack and HyperDX themes. - Updated theme configurations to include favicon settings for better compatibility and modern best practices. --- packages/app/pages/_app.tsx | 4 +- packages/app/public/Icon32.png | Bin 770 -> 0 bytes .../favicons/clickstack/apple-touch-icon.png | Bin 0 -> 3799 bytes .../favicons/clickstack/favicon-16x16.png | Bin 0 -> 386 bytes .../favicons/clickstack/favicon-32x32.png | Bin 0 -> 587 bytes .../public/favicons/clickstack/favicon.ico | Bin 0 -> 15406 bytes .../public/favicons/clickstack/favicon.svg | 15 +++++++ .../favicons/hyperdx/apple-touch-icon.png | Bin 0 -> 3837 bytes .../public/favicons/hyperdx/favicon-16x16.png | Bin 0 -> 511 bytes .../public/favicons/hyperdx/favicon-32x32.png | Bin 0 -> 1032 bytes .../app/public/favicons/hyperdx/favicon.svg | 6 +++ .../app/src/components/DynamicFavicon.tsx | 41 ++++++++++++++++++ .../app/src/theme/themes/clickstack/index.ts | 7 +++ .../app/src/theme/themes/hyperdx/index.ts | 7 +++ packages/app/src/theme/types.ts | 18 ++++++++ 15 files changed, 96 insertions(+), 2 deletions(-) delete mode 100644 packages/app/public/Icon32.png create mode 100644 packages/app/public/favicons/clickstack/apple-touch-icon.png create mode 100644 packages/app/public/favicons/clickstack/favicon-16x16.png create mode 100644 packages/app/public/favicons/clickstack/favicon-32x32.png create mode 100644 packages/app/public/favicons/clickstack/favicon.ico create mode 100644 packages/app/public/favicons/clickstack/favicon.svg create mode 100644 packages/app/public/favicons/hyperdx/apple-touch-icon.png create mode 100644 packages/app/public/favicons/hyperdx/favicon-16x16.png create mode 100644 packages/app/public/favicons/hyperdx/favicon-32x32.png create mode 100644 packages/app/public/favicons/hyperdx/favicon.svg create mode 100644 packages/app/src/components/DynamicFavicon.tsx diff --git a/packages/app/pages/_app.tsx b/packages/app/pages/_app.tsx index 3625fc7854..912692e375 100644 --- a/packages/app/pages/_app.tsx +++ b/packages/app/pages/_app.tsx @@ -16,6 +16,7 @@ import { } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { DynamicFavicon } from '@/components/DynamicFavicon'; import { IS_LOCAL_MODE } from '@/config'; import { DEFAULT_FONT_VAR, @@ -138,12 +139,10 @@ export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { HyperDX - - + diff --git a/packages/app/public/Icon32.png b/packages/app/public/Icon32.png deleted file mode 100644 index f97e18aafa1d4b66479da8f58edae49d6bc67a89..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 770 zcmV+d1O5DoP)hD5BCfORTT6z#nbY z^}2`Y+sqq}gpZR)Nq}_USos<08yp)36-X1B}nE>ti zG*BLl)GmVzGUve6n5J)w&!O}8U!?ES@-69PRN97g^u~kJ%#Z9O+~8X=3|ZZC6W9zT zql)gi350h;dFh7oA*4hgtUt}14xKy*2;iE7yNJk(Bz1c?g-eTg7u&sgIAIP z@X82EA_re@FT*w`p|ItIC3`{wGTNe z03O%^!@X}|!|rMW8L(flQq3rqCFvAffLPCcF-Z_f$q#S2UAJx>d)kDIkiVRgEPdK6 zCsK_#qMR{|rs(ot<807*qoM6N<$g3Z8H AKmY&$ diff --git a/packages/app/public/favicons/clickstack/apple-touch-icon.png b/packages/app/public/favicons/clickstack/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cb0d934b521e2b47c133bcbff5442c2865b496f1 GIT binary patch literal 3799 zcmZ`+c_5T)+c#sx5W=C7LEemnv2SA;O(W}&WII`kY-48-!pK-cb`d6x4n@}N#xikG zvP@*nSi>kwS(EL1I_G=;eE)d1`?{{*wcO8r-@oh84HG?1c0qO~CMHe;eaubZS^N89 zI|bae`cEk^F>xgsV6NV}4_eI+OR}|q^|UXquZ{-2#6t~2u477gudSOFye5t&#(ixu?aZG!UMuT; zTE}gQc9V8N4SVYYQ%$bxdrQxn6oUuU+h_I%E9@&*?qA(a;tLWEjmMyahH` zy6-$@aWoX?5wWsm5!tVy-0u0ZHMu;bu1MGMj4IBQl>sJM2a)!S1}E2gS7SAfuzr4q z8P;O(&H1C)DkpD#W-F_?4gdHE81pyHDj}*(kmf*g4G7hrJdnHV$WxU&=79=p;J`bo z!~B-mNn{QDyMwNSkCMiB^D=4Eoi?-n{2J|+o!fboGpPC51K89Ams&UTK_0cBx}~{1 z5Hv8M9>y`ruK(ODtmjuAhn&Qr>*~~T(EK^8;O@QQi_Msq3;|f)>C#(hMYd>*>g`&= zE3buc+)T%gM(c7M15#D6+C&{3cvz5YKar%a+rT(ywd)MU}*! zb!yCLGSh<;m^n+`@ucn@B~ZYt^>p=^7<^I%NQw*BsM>rmUgwxL7~d~A)4KCti9j@gZPdz#{YV#(RqRjl!O67R9= z{zh+2jD`dQuq5mrL)31{su3DfZ>rlA+ZM3Osy*$Pe=lRD)bmWOt07^4gkfC~BjsQI z)rj62zR{Vyf&3d)2j!~kHck4S)e?j;O~$904E6s|IA`v5@JD88&JL%jz5AZL-sBw< zH&(!v3i$Brm~E2P5EhBFgctBmA}5=ApOV#l!wi?jy9XKmovyD1=?&~|e@wAqN z;LJX!u$$`K*!TorxgS^qzgscFqDwHDRP~nAW99Z#qez_apqYea6&)z zJ#ap-T;QetIB;>}i}Gbti}-eTz0JX8fTv%$WBY~B{07sr?ybZ1;%K%nB*I291)qc1 zVz=}EKC?~rG@^SOteR2Sh}Q^vLp3W^MYalLsc@Y`i!gHgAW(8v1L{;!XI7gVTQyx( z;~>j1mBVaEtSXP7k&e)#$7_3JyJv*%g}^YPI$(U`y8r3m{J4b~4Nl+lhE47ea6vrR z@Z!79NS5!1J&94W1}kUt2pfeT(!$GsgfQQ=^8B6a9PcYipHwX!6$XWQU(5<25h^L3 zmU=a|&5@G!A{DGW@AyE-)ieDGt8LLI5)Y$Qc-V3XtU`86<=2-K!V>MvyY+|FPGyX% zA+SQbi#e@m@rYhOp@L#R<9VjAz%PwTe&g9rySasY1xR+%N(WouLU%(IqZhT1o+xZe zJC1)Xp!KL-dn@ROITb!ZCZ97!9ieRZJ0C;mmHe9==dR14lCgYJ$y(1Ma8ggCBNY6k zcf)(TeLZun`@9zTe4?7*PCwNm@Lkt6out)Nuw}GgE(mb~ z&wsdm-nQ!aD4hVnn#@ff){`S{>KhNAdBc(me?IGe8(iMqL0Fd<3 z?q}8$i$Ud;X5k1oj!}BUd7(VGrCYe-*<8YvtgE-MtBp%(e^t+Zu3tk71>iR=YxWzw zL?9^!^HtmjmfC`~)o@W2DvV+cg9qMN;nJRm5EEdv>Y2B2?BE3K37m}n^JCGK*zBws4c{Q4{CCPoLnTq4-(+za{h%64YWFhndh9WaL))3>mI zh@#z#5C?2{bDEd)2*iQl@Ju^Hz`eL!ph$*}w}~NRDii%kDVjj^KBe2Ab^#En7Tpl* z7ZoMvT{R2%5)0U1B{TIE+4LDMH}c)MTC*hqI7x374`mRPj_b%pnFBWFQ|zNSApH@d za%3?Ob8#bo*$8A(690Ovc%MUG2Yt4RL29`NrN1AMnVr0Z2PICwH+n)N+0wxk*x$kl zS#?T0FH&PPArl1PgYLz=O9osg2d1XYk2`&M;??N2mS|n)Z(dmMvcoanvLAlG;3!=j zzuOm`peA>t3)avYpBGUN_3%E@E8N}6Q@KkIKY^f5+sIm?U9p-~_eXF^sxv>?vQmKK zM^D+zX{KRDxJnAYo9zaryS)9QtqF)aAOu&h0uSm@-pX1X+ADk4(wN`?}IK(wxYh!=yx zteZ4Yc82P%CL#hd+5#V}`mmoW{`410*8asrLAws>Sus|hN;z@>F$i>we3}5loKUp- zI}W@!91xn zmMcL#Pp*&?Z!Ch#&Cq9Gjq6&M5)tSuj@s)`yXTH6_Be?)U>R7CfGbO6(AgifE@z41 z1Di1AxBMC3@?tML*XOraR`xhvO6ihce5xhz;bQ+%*+H4j@Xbn(b{nnPai!|QcVyf3 z`oN|y{r6R^tqU1bGDTwMtRF}C=fajFdA62C9w`oJikWHbwWv2fcq`9XTQkXGN<5DK zkbJ5~;<56{l={x0ORk*z!yngz-^S<_B|3j=rv0;d+m7weobcv&3+@@FJ?yjwERjnWUNZ?+N4Rjhfpc*4~j52(JgNYfY>X3$+r<$X%c1>7cfnB*6%oIqZxy-E{SiW*Bh8TL{lIPIlJU$ zo~M1sBbxA%e2l0%=;VrBGGS9i^cRJ5${FA_ME&24V9^V%R4Tjbl`Y{R+FMWt6 zeP^jk-0pGyw-ieC7JmwZn{eSFa9Af0E)@RDIlNXxZpe2=I=6!w4&1{^>>vc4b3e;D zgSlVH%{9}MD8v<6@c139m3#DaEjBPd@Y;L8Q&l;l7Us1f@|_w=5eXT#AO9~w3R_mi ze%jGFq59?)u&oI_fG%QpyXm9a^~H9OO=CjxG}d)x-M8@L_l)l9NKy14^X{R%M@#vtSCrVLWfprben+3wJi^@XYg!L1m$}kcPR{XJ4+f z^$$PJ`?k3yyna2b-$$zjR=0uG8T_nEKy2RT45&G52{`(}z-WsrX;}B*pr22`7%3sD zLt=nfTM-<*CPozj_jkFm(EobjB(6|QTV+hqHJarXf`!w<1)obPlV8;(GReLQa!&UC zD+3?Ug6ZbZWnMMEvC>h$BfRb(c2uT3o%B2Gd)EmAT1Nl!-%TPqKorF(zJ2`OyQ8vx zEalII8PaYLz^BSD>GUurU*J=?hPuL8re4zWsoMM&&plB#yV8EtozY{b<^A{700(tL zMqNa@to=f4OOwMmS*r(S(rEcz@T91$4b0^kchmbE{@J8Rm~S-GPxm*>pD=gcK>End zMoWLJ7dqh|4=7I=sdzb()nV;hHSeXwrw;F(?fszDj9{8N+BFE}pdvhgra=MnGqN-1 z+Ij@?pEe@^LGMz?qIEu1V2jR@JMys`Z>o)TYm57~_dO+a)|u~+nfk*jqeZ4EBiwUYyNXZtpjok^fa_yX`ai7;yc%oBm{Su_3-VrzqvuqY z5T|b&`f_N^CwdMU!K&D)8*4uw!e*F-Drf6Yxk(9tUWc^qZmb1GnUod&2Ol$#*+=pY vh)4YHz79}8S9)@y&^YH%w0Jtm5 literal 0 HcmV?d00001 diff --git a/packages/app/public/favicons/clickstack/favicon-16x16.png b/packages/app/public/favicons/clickstack/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..5b8fe239f2f26ef59256a3242a81bd2cdaf5c88b GIT binary patch literal 386 zcmV-|0e$|7P)Y6r5cxY=l4>Eexbtv9$>C0~=dEO0CpFY(+=}|AHWBD;B2HQd8TAdLf0acugP{ z+5`eN7MFYOY?8Z_cUJ^%ZfD<{nSHxjYUw(8NVdkAWPlmTdqq@UGF znO>3-UB2H%z21TdP`mv=r}GEz?Z<(fHTI~DAtf8oKl%0L8yDyA2t&ci@iV^Kzqr5i z9om+&#$If8Of$f-tiSOG_@7<(KEsJqPjGbjgvW>0(BFT+XqSPcp5Xds9b4PQ=%~~I zWHgH_AuRjO|NmRSr;n59@7u7m`MklmuTvJAnclP5?#{8T$zx8C zJ=Wia%Nb%m5#j=6oXyQ6Y-F<7-#f9rLct)jp0!x^@XTVTrx&&+k2%3Z>9JlHE@z1O zh35i(L-*Ma76=@8{0^+H9AIp83;d1ndU~8YFrMBR;i{-*@=&GjgL^k~v?XM9VV`76=@8m=4s|zC<({MkMmw zo?I>eonx`E#lkhuELL6p)YjxNCs-pr*0H)UI77@Y=s@l^X?Wxd7MFgZrS-jSRaGg> z&No=>`gwl+=7p`vV@@J_tY_yMz!_pbk&_n_mGL1tYa6JHn2VG%1)m!$Af=z6XF`%8 zAMU-ksYz|ElrkEB`W~R&V*ohzHSZXiW(|GO=@2O00960L)Dl#00006Nkll%(?dtV>#>$ zD=1)Cds**U#_AYjUaxdtQOMXNw$;?gbRT1Hiy5mx9uz?dsU0iB-#q}|80@c%!hQ@3 zSS&G;(a2hrJHn^!w|jE~u1`OQ(SsnJeCS+Tg}T83UOLy_@iwCAs zU#B3?F(~2pnT+4C0e-`V_?73tvGLH`XLPTdgX&x`;?=dO^Vcva*l46Uh@)ed`k=n3PqK;XQHV{%;diocovzq~^4yv4TZ-ozpX+S1 z9Pd+-%fNUivd<7}SMK!icShs*11>)j-G%ao2Hw^-GtJw2zHIV)ikoyzV@Nc*FK=uB zl1X-4%j*4*o14quQKVydZwK-V3wc{}_z~9|!uJ^QP@!w$YsUCZ_ryywqW|P@Ec`F< z=hXZQt}OGsN{Jg*=A@JOqg+i!n|MV3&f(u}|GSMptC-XBIf*~5UaQxlf9LRT z)#qBi)9!yM|MWxS_F2qsjr`Lv2iA^98n5|!ERFh2 z-+9Vf(7pbpb*P)`Ph}0PSqr8U569NuX8QuCfkyizxJAoCSQ71%APa3+;9d!oAZCL& zX7gBYV>xgO!90wKeCl)jD{3ByYnX4xX=kaXTx65;y;!IZB zk9M2JNIFMqUwjFHxfuw|&%(fuZ|ZZ7#Qs71>sJyD>9;?9P-(-s#}`_EuhQt6+7v%^ zAurd7a-@TFDKwk4X?$;AT!2fJl~CmIz=g}D&=z^2_A|8e2QK=2oJRE8{yvp<_t!Sm zbcLX()WZv1Q=4L2ZGW#SM>+jmj|s+rqq6h{{DvZ#l`AgXH`c~ zr49bvg33UfN~3FPQ*5j1xT(@e2kBC1PS(PHX&+knKe_lQ@FVb(N<0$y5%>}K@$h$j zrt*i-`YKf4>VRs@F>3pIq3h#eUTAkL+?c z1b$8*ezHA&+KusM{BGdi%pSWmVZZ(PYuCqS{KEgj|7Q3|L=*TC_(_BjGkUh;C*}Qf zTtCqo>e&7{t_!K1iUa%SanAsx^NR&MYlz_a#Y~*@3#1|4*7*glX}M06bKv|UtIv<* zGq9xB$Y%2=-89*DI)lvO^HY8Xyp(QVr9nSGzn0G6v-kS}`UWDazu%C*LD75*p`6K^ Yo(-n~5AkA1!tZC4M-a8}fL#y#2c*%qCjbBd literal 0 HcmV?d00001 diff --git a/packages/app/public/favicons/clickstack/favicon.svg b/packages/app/public/favicons/clickstack/favicon.svg new file mode 100644 index 0000000000..23647b5370 --- /dev/null +++ b/packages/app/public/favicons/clickstack/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/app/public/favicons/hyperdx/apple-touch-icon.png b/packages/app/public/favicons/hyperdx/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..80385d60e1fac56f4266e09e5eddc51bdee221df GIT binary patch literal 3837 zcmVAzFwox>0H{37B+IyG?5{{e9-$^KxhA-aF@>`)AHM_gn7oopaAUe}3ot z`@A#1&UZ#&*v|hebej<*7l&;eP+6*sQ|Hm5Ejf}0yf?< zDc*+}*nuIt?|gLv+MM~KIrEDfzMYjTN z6Mf4jx@305OqUecg#xg5sbH~j{YIdR3Epg+K@X+CcnZ8(Fj>1|Ezmab2^%GtO$1$0 zDFD>4S?N?I&_xpdM&$@KN`c)g@J7Mth9{LkTN3_r>q*{XMgEs|n4AJf3Pv{^-vHWD zjTfw!-pw!(bVaSese;uNCs%+jembM~nO7vzO~eYEE0{fSaRq4m>C8(9PgIct2`I46 z>;cflFNNsG*$D(uF0LqWx?p$NcmVW#KL5e8MPH;qVhWVmT>@=yfjne;sQ9fpYttnMPw2&B@s0YfdLjklSHLm6XYb`5wj4x%X%c_T zglIc%pvKeh-;tIerC-nUj*FoFQM}hkRnoqUKXG_}2lohxSprkC#Th(_|XFRRICLRidyCdyjs0_Q{`qZ+7U}dw>2$9k>N{n5I6X1hkJzE&4c+ zfth;MP{0<{Rmya46VTl?JeUDUn?OG3XceH=X>CIEd?5H#PBYA*J9%21@TiH657)sIi(ZYW7f|8_*sLTLKNpoXFUgu7f=W zY@o)M!4}D6VgX$fNZ|LZG>wd&~ zFQ7}20boD|no@I=ReTiKo1qcVrilX?n5kP+G8bU;ijs`O13Nl-pn(`m4dgNxK&{hy zUy;cI&8Y?iDTBNp!h2r@_H6%?lkQ29e+_^+kqLgTyJc_ek#E{u_gJ^D$(+y*WCBgS zPLTHPagJb*fIT&+Cjm4;oeIe7-(EmXYJ~>t&vLxa8fbz@FbQgo!E3W8G(Zb9X)`Xh zzF7jz%Pl7|K};zBFh)zAc}v0f*RWU{P)b|3>Y^++^sY^@HdnS|M+)avj*J+J|UkEnewT)-ySv5gUjx z>O%x}>^Vs_#$K~-J)lNgz^>~Pb9+)iBQj#=0y1C#b5*LrGxkfl3j^#F(~u;924Fx& zozKAJ>boxSrQsE0*X|vxA1%;8 zjO7S2>XT|vrer`J`)M5=(3}E@mMh2r49sS5i7LcBYQJrHv)fmUusGjhE(P?6(IM3sak)t@C7_26doe#SZ_AsDuCti)XXj(A zrGOqGGCAeVBgoZh2?5 z)D`BAE@*g30RdeSBc$3jZ+2@ce|AcAD+P21{YW)B3||?l{CP!=;2QxQC>=2Om1+p) zz#vo%6VMf+Y-{TC{btq@(5TRl3FrVFIh_NB)eceqTq{lM2LigKWWdY`-EvrCcb(9U z^0ay=pj*fV%zfp}alXi>Acbq(G97FVBQRffZo((*#gX*(94?$zwp`F5xTW=mjrZ02!J{ERHMS} zbenqgTtKgh$*CMW%t|%Zf>yc}&?PabaynDSXv&+nM@2xFGy-N$=u%TuRprgw!y%wc z8pWP!lw4J{Mgaj`BTP)qdcWB}8Unhc6*+3@Hiqg9pY%V0{-F@iC9MF5)3DUE^Q!XZ zb|DbZCB58f$iI`=)`s{-7-G)ScC)Ms=t#o~%)DYm>Rz;N3Fw|e)92fL?vWeFCk#IXo!o0v*2R-beBSnZ70^)#z)kDb6FHBZ@jOD{t-=5CPpyzvbvY(hcR! zODQ{l5zw6_oGm|rwCgm=o4YEwKVCrZ>k$04kJFh{BiI2ATv0$kugKBHUy1-1T!3piV zmiqZAAfWwps_Ye-KLfK)=SoRCHwx$u5{8`6oXUc^gXmj^3g|5YH9Zaix?h-^yqo4T z0Ua3k5SU3dIH3cV)G!YP1hj`pRbATV&%mtHxk|}?+XQr=o^4KOPG!LyD0ItV0=lJO zu6rAx1+!Ca_)Tw4s~w)`2Gb3L5_n3xco7-CW7L$pBSECM24Y*6D{m2oQP z2s3d2WQ=;%-gnrxvUJoyJ5Pa{m$(?f#uw+;dYL24Xr2PdE*41u?VJUsU4q?oI+1E{ zLOWkmFWDey(Fqc85UfyF$^<;r|A_Fxqjbnga73dsbMmtXAs{J{gQz?@N+6g@d*qp|0JxWrI z^)}k0u`P}OFd(DdmafS>&IV|wIKkn>Ay}@huaeUkh?$R)d+Mnf+5j>}JvINE{LKz% zHVf1Q3$VHG(}0*y_owW0-ZAC^UUb2zkFn<1Yh($u6A`e-%CFs;v6g&u=M1vbJ!_!d zCZ`NGCov$Sfzu=BauPth01{MADtDiA=|Bc%>N)SVythdK&BlTH_Tw+kat0fSu_=K} zY_Z7!&8D#&!RDKk|7VyY$ix(zEYNHo18hz+VBkJQ17P;RUQ+bGPoF0bG-3h-Fr)@G zegiT_oxM!m1ayf2pynkz1+ei^AeVwyR0VV)M7f9=Y))dn>F}}av+DZ_Yy!RCuCJy5 z&~Oq0k=^d}!Us-dY^RPyzPNzi5+I-kBEa6#b&$tpkf{%{NJf(iXwP5*HZS#Kg3Vfd zGYMqRAXzs7Z6dU}#sC|D0hzVh_@=(InSc(Y0N6M|1BX`*b07mV^~z3aY*au%H_Fre z5UBe~Lx30?6XfRIrTJ7q_Yk)(U~|gzO(Bqb2!u@`piP9nt~Ou;Fefr&`dVM}D2E<| z0U+h%t(x3{49wK4`lu~j0iDp{+BJ>@YEp?Z*wh7bqimfY3h2%fHc#Nd25#!jz4f%K z0s#GC*GcjluN@_-z=r_+L88vfZ7Hy>zz2Ja#p1u~t-6;2DJlTVoY3#4=)8QE0u>6p z3()UWJW!JqNKt`z0Q&6|J(tf?phAJS_vZ8YpDG^IHtB&Bh*bet-n(|~+W#z>{$s38 z$}cJ4qW}!UGAYN*$o`$rAoY;~u`1vgUIKmS(4k+&>ZtsZ0zL{jhL=EJy?XVQ4LWb8 zOA4f@!1;pVdw;QFImpja+=+Z1Oo3!3F0;Elq4E6s_3MAL@`9NzDUgx^7YcTlJ|6(h z-QBx)U#65vfm9S&XLbeX8#iwJ%QkY>OqUc$L4mUcvj@Je08OjCm-mX57tM4@ftVGz zSg^X{;|9vbYLscjJAm`TN3_dtLxnl6G7Lg3S5Le81z}y3uq=H3AahVZgss+Z6fFz zMS-(02YbG&c?0bxv;|(VpU!;V?yG)GZq2%+KxYLmz! zdb4LIpxtzqZ5eqzpi&DtI?C!aEV+3dGw|FE|A%=8IOCc;E7V*eg2n85^WJY`xu z05h-yLw4W!`WOHJ|NoD3K#~9e00v1!K~w_(va7$=*x8zB00000NkvXXu0mjfkZ~L0 literal 0 HcmV?d00001 diff --git a/packages/app/public/favicons/hyperdx/favicon-16x16.png b/packages/app/public/favicons/hyperdx/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..fd6e3b78e9ce53f59f2704107aa72942b50089de GIT binary patch literal 511 zcmVZ@R^48hnMOZoB;NL{7U<0MoelM*lV@g8{g zra(Jel0Ez`0@6?e&;B&@v2KuJPZCdGih!{igf`jh+yLgJd;>wsRkio%@FThT3z+XO)SUiN-SXMO?<`-Z#_~kuF zbs6~cJ7}N@AO!hNegTa_UmgT0FM-zE2HMvSx1$Pt$1{D=Pe6RC&-6n%-!Uas1gyCb z*lrkzS>R7^&=&{rw^)I(P#D&1$X$Sc#gN!OL40HrJQ4+ItQE@x?C>q(!y8EKoPcLf zDWv2Cws;lekylvr`>-Z=U`+^LH~@c)Dm_kOQ$omvljJ=1)dTG7ha5Z&NG{bhEhvI> z0p?61FgLR$M{<7w00960jpMs000006NklqYr5eNw>cvi9 zY_TQQTT2NdLC~IM#y_XMCwIU1+@zhHnfd46zVFUIXU^qlXe=);Z&q$@?!BCxoI2{V ziF%#FP*?=Zs6!E8q}(TjD3?+WCuNX&Q$0b2p|BJtfiO;NW|DFREt>UFmEQWD^Ox*)J; z5%g_EU|rfKP9-Jm9p@k(hdev$+KysoBd}`$c(2Z$K9P`=fHrLej6=m>1w9hQc{=Cp z9XPk1gAdH}bYX!F=>_9(F|?^8!m7AADFNQSBcLTUyNT4c+htYm*0VlcU0vLuYv4U7 zSDc|i!5gM5#^3*7ZK(h(8=T$-7+Mxd;C>7I2R7DS*8Xr~`JnFZoXwEc4mg!};oN-Y z;mPm6AkJ4qT)Gbk>FF~Od=ro_KEf`&5|!JzPz`J6MK~3=0ot7?W159O0(BOwO{d+u z$L3`%1JF0kM7o0;8|ROJ&Y{mKgt2xKwDCjWH3sNwCW56n?VfLpz+={_m+F<8I23%! zaIiM5JYQ{00rL6a#!mayes(YgELxyCM z5)h%UfGrZH5l=tRL3E&(OaSZIDWQ+2bOglPFWxd%NJ_w|yaQ{=e#i%JVq+j5zk@b9 zAAEf3y36~oVJ+MT=iI%x9AQOL0?I+Wtc6)}1op;qgl|2i=fiqKUpE=aKDYAI57_HZ z!d!h6;`L{wYAFf0fpoDr*Pg(fyBp4-tH3`KOkKV7-n;h5fzF0=unOip^1VSfCB;UX z6Upfbgrn*$*vGEHEZPZC^$1Z{E?vLk@&lN2cEdh#9U$LuSh_iL0*#sEx1X@KR=`?O z=Kc$kPikN-FN3wc63W)-mhq$r)MxA+;V7QIhe^O@@j(dncPzr@L#*eFv4=haqFrh0 zqe>BNO}kRqioo8qo=xOH1orY!D0GH|15JdHG_Ar^*y;?(nwpxGobw`5R#QY)XM*Aq zJ~7IAg`uz%CfVeF00030{{=rtp#T5?21!IgR09Ca{8LsXxk@qs0000 + + + \ No newline at end of file diff --git a/packages/app/src/components/DynamicFavicon.tsx b/packages/app/src/components/DynamicFavicon.tsx new file mode 100644 index 0000000000..df8a341f1c --- /dev/null +++ b/packages/app/src/components/DynamicFavicon.tsx @@ -0,0 +1,41 @@ +import Head from 'next/head'; + +import { useAppTheme } from '@/theme/ThemeProvider'; + +/** + * Dynamic favicon component that updates based on the current theme. + * + * Favicon best practices (2024+): + * - SVG favicon: Modern browsers, scalable, supports dark mode via CSS + * - PNG 32x32: Standard fallback for older browsers + * - PNG 16x16: Small contexts (bookmarks, tabs in some browsers) + * - Apple Touch Icon: iOS home screen (180x180) + * - theme-color: Browser UI color (address bar on mobile) + * + * This component must be rendered inside AppThemeProvider to access theme context. + */ +export function DynamicFavicon() { + const { theme } = useAppTheme(); + const { favicon } = theme; + + return ( + + {/* SVG favicon - modern browsers, scalable */} + + + {/* PNG fallbacks for older browsers */} + + + + {/* Apple Touch Icon for iOS */} + + + {/* Theme color for browser UI */} + + + ); +} diff --git a/packages/app/src/theme/themes/clickstack/index.ts b/packages/app/src/theme/themes/clickstack/index.ts index 49d5b1ef9d..bef491140d 100644 --- a/packages/app/src/theme/themes/clickstack/index.ts +++ b/packages/app/src/theme/themes/clickstack/index.ts @@ -11,4 +11,11 @@ export const clickstackTheme: ThemeConfig = { Wordmark, Logomark, cssClass: 'theme-clickstack', + favicon: { + svg: '/favicons/clickstack/favicon.svg', + png32: '/favicons/clickstack/favicon-32x32.png', + png16: '/favicons/clickstack/favicon-16x16.png', + appleTouchIcon: '/favicons/clickstack/apple-touch-icon.png', + themeColor: '#1a1a1a', // Dark background for ClickStack + }, }; diff --git a/packages/app/src/theme/themes/hyperdx/index.ts b/packages/app/src/theme/themes/hyperdx/index.ts index ee6ff55d8c..81b16aa7d3 100644 --- a/packages/app/src/theme/themes/hyperdx/index.ts +++ b/packages/app/src/theme/themes/hyperdx/index.ts @@ -11,4 +11,11 @@ export const hyperdxTheme: ThemeConfig = { Wordmark, Logomark, cssClass: 'theme-hyperdx', + favicon: { + svg: '/favicons/hyperdx/favicon.svg', + png32: '/favicons/hyperdx/favicon-32x32.png', + png16: '/favicons/hyperdx/favicon-16x16.png', + appleTouchIcon: '/favicons/hyperdx/apple-touch-icon.png', + themeColor: '#25292e', // Dark background + }, }; diff --git a/packages/app/src/theme/types.ts b/packages/app/src/theme/types.ts index 621417520e..9cbc91d054 100644 --- a/packages/app/src/theme/types.ts +++ b/packages/app/src/theme/types.ts @@ -2,6 +2,23 @@ import { MantineThemeOverride } from '@mantine/core'; export type ThemeName = 'hyperdx' | 'clickstack'; +/** + * Favicon configuration for a theme. + * Modern best practice includes multiple formats for broad compatibility. + */ +export interface FaviconConfig { + /** SVG favicon - best for modern browsers, scalable */ + svg: string; + /** PNG 32x32 - standard fallback */ + png32: string; + /** PNG 16x16 - for small contexts */ + png16: string; + /** Apple Touch Icon 180x180 - for iOS home screen */ + appleTouchIcon: string; + /** Theme color for browser UI (address bar, etc.) */ + themeColor: string; +} + export interface ThemeConfig { name: ThemeName; displayName: string; @@ -9,4 +26,5 @@ export interface ThemeConfig { Wordmark: React.ComponentType<{ size?: 'sm' | 'md' | 'lg' | 'xl' }>; Logomark: React.ComponentType<{ size?: number }>; cssClass: string; // Applied to html element for CSS variable scoping + favicon: FaviconConfig; } From 6157ee4de7a8005da1556843950fe676ee723c85 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 22 Jan 2026 18:45:14 +0000 Subject: [PATCH 22/36] refactor: remove background-related preferences and components - Eliminated background-related settings from user preferences, including background URL, opacity, blur, and blend mode. - Removed the `useBackground` hook and associated background image styling from the application. - Updated `UserPreferencesModal` to reflect the removal of background settings, streamlining the user interface. --- packages/app/pages/_app.tsx | 4 +- packages/app/src/UserPreferencesModal.tsx | 128 +--------------------- packages/app/src/useUserPreferences.tsx | 76 ------------- packages/app/styles/app.scss | 14 --- 4 files changed, 2 insertions(+), 220 deletions(-) diff --git a/packages/app/pages/_app.tsx b/packages/app/pages/_app.tsx index 912692e375..0d604caade 100644 --- a/packages/app/pages/_app.tsx +++ b/packages/app/pages/_app.tsx @@ -28,7 +28,7 @@ import { AppThemeProvider } from '@/theme/ThemeProvider'; import { ThemeWrapper } from '@/ThemeWrapper'; import { useConfirmModal } from '@/useConfirm'; import { QueryParamProvider as HDXQueryParamProvider } from '@/useQueryParam'; -import { useBackground, useUserPreferences } from '@/useUserPreferences'; +import { useUserPreferences } from '@/useUserPreferences'; import '@mantine/core/styles.css'; import '@mantine/notifications/styles.css'; @@ -67,7 +67,6 @@ type AppPropsWithLayout = AppProps & { export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { const { userPreferences } = useUserPreferences(); const confirmModal = useConfirmModal(); - const background = useBackground(userPreferences); // Only override font if user has explicitly set a preference // Otherwise, let the theme use its default font @@ -164,7 +163,6 @@ export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { {confirmModal} - {background} diff --git a/packages/app/src/UserPreferencesModal.tsx b/packages/app/src/UserPreferencesModal.tsx index 8da9911859..c00ffdcc89 100644 --- a/packages/app/src/UserPreferencesModal.tsx +++ b/packages/app/src/UserPreferencesModal.tsx @@ -2,19 +2,16 @@ import * as React from 'react'; import { Autocomplete, Badge, - Button, Divider, Group, - Input, Modal, Select, - Slider, Stack, Switch, Text, Tooltip, } from '@mantine/core'; -import { IconFlask, IconWorld } from '@tabler/icons-react'; +import { IconFlask } from '@tabler/icons-react'; import { OPTIONS_FONTS } from './config/fonts'; import { useAppTheme } from './theme/ThemeProvider'; @@ -33,27 +30,6 @@ const OPTIONS_BRAND_THEMES = Object.values(themes).map(t => ({ value: t.name, })); -const OPTIONS_MIX_BLEND_MODE = [ - 'normal', - 'multiply', - 'screen', - 'overlay', - 'darken', - 'lighten', - 'color-dodge', - 'color-burn', - 'hard-light', - 'soft-light', - 'difference', - 'exclusion', - 'hue', - 'saturation', - 'color', - 'luminosity', - 'plus-darker', - 'plus-lighter', -]; - const SettingContainer = ({ label, description, @@ -209,108 +185,6 @@ export const UserPreferencesModal = ({ data={OPTIONS_FONTS} /> - - - - setUserPreference({ - backgroundEnabled: !userPreferences.backgroundEnabled, - }) - } - checked={userPreferences.backgroundEnabled} - /> - - - {userPreferences.backgroundEnabled && ( - <> - Background} labelPosition="left" /> - - - - - } - > - } - onChange={(e: React.ChangeEvent) => - setUserPreference({ - backgroundUrl: e.currentTarget.value, - }) - } - /> - - - - setUserPreference({ - backgroundOpacity: value, - }) - } - /> - - - - setUserPreference({ - backgroundBlur: value, - }) - } - /> - - - value && setUserPreference({ - theme: value as UserPreferences['theme'], + colorMode: value as UserPreferences['colorMode'], }) } data={OPTIONS_COLOR_MODE} @@ -137,6 +137,15 @@ export const UserPreferencesModal = ({ /> + {/* + Brand Theme Selector - DEV MODE ONLY + + This is intentionally NOT available in production. Brand theme (HyperDX vs ClickStack) + is deployment-configured via NEXT_PUBLIC_THEME environment variable. + Each deployment is branded for a specific product; users don't choose this. + + This dev-only UI exists for testing theme implementations during development. + */} {isDev && ( - {/* Theme color for browser UI */} - + {/* Theme color for browser UI - validated to prevent XSS */} + ); } diff --git a/packages/app/src/components/__tests__/DynamicFavicon.test.tsx b/packages/app/src/components/__tests__/DynamicFavicon.test.tsx new file mode 100644 index 0000000000..930563cbc0 --- /dev/null +++ b/packages/app/src/components/__tests__/DynamicFavicon.test.tsx @@ -0,0 +1,122 @@ +/** + * Unit tests for DynamicFavicon component + * + * Tests cover: + * - sanitizeThemeColor XSS prevention + * - Hydration safety (uses default theme favicon initially) + * - Correct favicon paths based on theme + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { + DEFAULT_THEME_COLOR, + HEX_COLOR_PATTERN, + sanitizeThemeColor, +} from '../DynamicFavicon'; + +// Note: Testing the full DynamicFavicon component requires mocking +// the Next.js Head component and ThemeProvider, which is complex. +// These tests focus on the sanitization logic which is the security-critical part. + +describe('DynamicFavicon', () => { + describe('HEX_COLOR_PATTERN', () => { + it('should match valid 6-character hex colors', () => { + expect(HEX_COLOR_PATTERN.test('#000000')).toBe(true); + expect(HEX_COLOR_PATTERN.test('#FFFFFF')).toBe(true); + expect(HEX_COLOR_PATTERN.test('#25292e')).toBe(true); + expect(HEX_COLOR_PATTERN.test('#1a1a1a')).toBe(true); + expect(HEX_COLOR_PATTERN.test('#AbCdEf')).toBe(true); + }); + + it('should not match invalid hex colors', () => { + // Too short + expect(HEX_COLOR_PATTERN.test('#000')).toBe(false); + expect(HEX_COLOR_PATTERN.test('#FFF')).toBe(false); + + // Too long + expect(HEX_COLOR_PATTERN.test('#0000000')).toBe(false); + expect(HEX_COLOR_PATTERN.test('#00000000')).toBe(false); + + // Missing hash + expect(HEX_COLOR_PATTERN.test('000000')).toBe(false); + + // Invalid characters + expect(HEX_COLOR_PATTERN.test('#GGGGGG')).toBe(false); + expect(HEX_COLOR_PATTERN.test('#00000G')).toBe(false); + + // XSS attempts + expect(HEX_COLOR_PATTERN.test('#000000">')).toBe( + DEFAULT_THEME_COLOR, + ); + + // Event handler injection + expect(sanitizeThemeColor('#000000" onload="alert(1)"')).toBe( + DEFAULT_THEME_COLOR, + ); + + // JavaScript protocol + expect(sanitizeThemeColor("javascript:alert('xss')")).toBe( + DEFAULT_THEME_COLOR, + ); + + // Data URI + expect( + sanitizeThemeColor('data:text/html,'), + ).toBe(DEFAULT_THEME_COLOR); + + // CSS injection + expect(sanitizeThemeColor('#000000; background: url(evil.com)')).toBe( + DEFAULT_THEME_COLOR, + ); + }); + + it('should handle edge cases', () => { + // @ts-expect-error Testing runtime behavior with null + expect(sanitizeThemeColor(null)).toBe(DEFAULT_THEME_COLOR); + + // @ts-expect-error Testing runtime behavior with undefined + expect(sanitizeThemeColor(undefined)).toBe(DEFAULT_THEME_COLOR); + + // @ts-expect-error Testing runtime behavior with number + expect(sanitizeThemeColor(123)).toBe(DEFAULT_THEME_COLOR); + + // @ts-expect-error Testing runtime behavior with object + expect(sanitizeThemeColor({})).toBe(DEFAULT_THEME_COLOR); + }); + }); + + describe('DEFAULT_THEME_COLOR', () => { + it('should be a valid hex color', () => { + expect(HEX_COLOR_PATTERN.test(DEFAULT_THEME_COLOR)).toBe(true); + }); + + it('should be the expected value', () => { + expect(DEFAULT_THEME_COLOR).toBe('#25292e'); + }); + }); +}); diff --git a/packages/app/src/theme/ThemeProvider.tsx b/packages/app/src/theme/ThemeProvider.tsx index 5fcc82caea..05c465f553 100644 --- a/packages/app/src/theme/ThemeProvider.tsx +++ b/packages/app/src/theme/ThemeProvider.tsx @@ -11,6 +11,7 @@ import { DEFAULT_THEME, getDevThemeName, getTheme, + IS_DEV, safeLocalStorageRemove, safeLocalStorageSet, THEME_STORAGE_KEY, @@ -18,10 +19,6 @@ import { } from './index'; import { ThemeConfig, ThemeName } from './types'; -const IS_DEV = - process.env.NODE_ENV === 'development' || - process.env.NEXT_PUBLIC_IS_LOCAL_MODE === 'true'; - // Type declaration for window namespace (avoids conflicts) declare global { interface Window { @@ -86,10 +83,15 @@ export function AppThemeProvider({ return getTheme(resolvedThemeName); }, [resolvedThemeName]); - // Theme control functions - update state without page reload + // Theme control functions - DEV MODE ONLY + // Brand theme is deployment-configured in production (via NEXT_PUBLIC_THEME). + // These functions are intentionally disabled in production - users should not + // be able to switch brand themes; each deployment is branded for one product. const setTheme = useCallback((name: ThemeName) => { if (!IS_DEV) { - console.warn('setTheme only works in development mode'); + console.warn( + 'setTheme only works in development mode. Brand theme is deployment-configured in production.', + ); return; } if (themes[name]) { diff --git a/packages/app/src/theme/__tests__/ThemeProvider.test.tsx b/packages/app/src/theme/__tests__/ThemeProvider.test.tsx new file mode 100644 index 0000000000..c5a25e3634 --- /dev/null +++ b/packages/app/src/theme/__tests__/ThemeProvider.test.tsx @@ -0,0 +1,208 @@ +/** + * Unit tests for ThemeProvider + * + * Tests cover: + * - Context provides correct theme data + * - useAppTheme hook functionality + * - useWordmark and useLogomark hooks + * - Fallback behavior outside provider + * - Theme CSS class application + */ + +import React from 'react'; +import { act, renderHook } from '@testing-library/react'; + +import { DEFAULT_THEME, themes } from '../index'; +import { + AppThemeProvider, + useAppTheme, + useLogomark, + useThemeName, + useWordmark, +} from '../ThemeProvider'; + +// Mock localStorage +let localStorageMock: jest.Mocked; + +beforeEach(() => { + localStorageMock = { + getItem: jest.fn().mockReturnValue(null), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), + key: jest.fn(), + length: 0, + }; + + Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + writable: true, + }); + + // Reset document classList + document.documentElement.className = ''; +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('ThemeProvider', () => { + describe('AppThemeProvider', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + it('should provide default theme when no props passed', () => { + const { result } = renderHook(() => useAppTheme(), { wrapper }); + + expect(result.current.theme).toBeDefined(); + expect(result.current.themeName).toBe(DEFAULT_THEME); + }); + + it('should use themeName prop when provided', () => { + const customWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useAppTheme(), { + wrapper: customWrapper, + }); + + expect(result.current.themeName).toBe('clickstack'); + expect(result.current.theme.name).toBe('clickstack'); + }); + + it('should provide list of available themes', () => { + const { result } = renderHook(() => useAppTheme(), { wrapper }); + + expect(result.current.availableThemes).toEqual( + expect.arrayContaining(['hyperdx', 'clickstack']), + ); + }); + + it('should provide isDev flag', () => { + const { result } = renderHook(() => useAppTheme(), { wrapper }); + + expect(typeof result.current.isDev).toBe('boolean'); + }); + + it('should apply theme CSS class to document', () => { + renderHook(() => useAppTheme(), { wrapper }); + + // Check that exactly one theme class is applied + const appliedThemeClasses = Object.values(themes) + .map(t => t.cssClass) + .filter(cls => document.documentElement.classList.contains(cls)); + + expect(appliedThemeClasses.length).toBe(1); + }); + }); + + describe('useAppTheme outside provider', () => { + it('should return fallback values without crashing', () => { + // Mock console.warn to avoid noise + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const { result } = renderHook(() => useAppTheme()); + + expect(result.current.theme).toBeDefined(); + expect(result.current.themeName).toBe(DEFAULT_THEME); + expect(result.current.availableThemes).toBeDefined(); + + // Calling setTheme outside provider should warn + result.current.setTheme('clickstack'); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + }); + + describe('useWordmark', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + it('should return a component', () => { + const { result } = renderHook(() => useWordmark(), { wrapper }); + + expect(result.current).toBeDefined(); + expect(typeof result.current).toBe('function'); + }); + }); + + describe('useLogomark', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + it('should return a component', () => { + const { result } = renderHook(() => useLogomark(), { wrapper }); + + expect(result.current).toBeDefined(); + expect(typeof result.current).toBe('function'); + }); + }); + + describe('useThemeName', () => { + it('should return current theme name', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useThemeName(), { wrapper }); + + expect(result.current).toBe('clickstack'); + }); + }); + + describe('theme switching (dev mode)', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + it('should have setTheme function', () => { + const { result } = renderHook(() => useAppTheme(), { wrapper }); + + expect(typeof result.current.setTheme).toBe('function'); + }); + + it('should have toggleTheme function', () => { + const { result } = renderHook(() => useAppTheme(), { wrapper }); + + expect(typeof result.current.toggleTheme).toBe('function'); + }); + + it('should have clearThemeOverride function', () => { + const { result } = renderHook(() => useAppTheme(), { wrapper }); + + expect(typeof result.current.clearThemeOverride).toBe('function'); + }); + }); + + describe('hydration safety', () => { + it('should start with deterministic theme for SSR consistency', () => { + // Initial render should use DEFAULT_THEME or props, not localStorage + // This ensures server and client render the same initially + const { result } = renderHook(() => useAppTheme(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + // Should be deterministic (props or DEFAULT_THEME) + expect(result.current.themeName).toBe(DEFAULT_THEME); + }); + + it('should use props theme for consistent hydration', () => { + const { result } = renderHook(() => useAppTheme(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + // Props should take precedence for hydration consistency + expect(result.current.themeName).toBe('clickstack'); + }); + }); +}); diff --git a/packages/app/src/theme/__tests__/index.test.ts b/packages/app/src/theme/__tests__/index.test.ts new file mode 100644 index 0000000000..2589ab2a96 --- /dev/null +++ b/packages/app/src/theme/__tests__/index.test.ts @@ -0,0 +1,215 @@ +/** + * Unit tests for theme utilities + * + * Tests cover: + * - Theme registry and validation + * - getTheme() function + * - Safe localStorage helpers + * - THEME_STORAGE_KEY constant + * + * Note: getDevThemeName() tests that require URL manipulation are skipped + * in JSDOM as window.location cannot be easily mocked. + */ + +import { + DEFAULT_THEME, + getDevThemeName, + getTheme, + safeLocalStorageGet, + safeLocalStorageRemove, + safeLocalStorageSet, + THEME_STORAGE_KEY, + themes, +} from '../index'; + +describe('theme/index', () => { + beforeEach(() => { + // Clear localStorage before each test + window.localStorage.clear(); + }); + + afterEach(() => { + jest.clearAllMocks(); + window.localStorage.clear(); + }); + + describe('themes registry', () => { + it('should contain hyperdx theme', () => { + expect(themes.hyperdx).toBeDefined(); + expect(themes.hyperdx.name).toBe('hyperdx'); + expect(themes.hyperdx.displayName).toBe('HyperDX'); + }); + + it('should contain clickstack theme', () => { + expect(themes.clickstack).toBeDefined(); + expect(themes.clickstack.name).toBe('clickstack'); + expect(themes.clickstack.displayName).toBe('ClickStack'); + }); + + it('should have required properties for each theme', () => { + Object.values(themes).forEach(theme => { + expect(theme.name).toBeDefined(); + expect(theme.displayName).toBeDefined(); + expect(theme.mantineTheme).toBeDefined(); + expect(theme.Wordmark).toBeDefined(); + expect(theme.Logomark).toBeDefined(); + expect(theme.cssClass).toBeDefined(); + expect(theme.favicon).toBeDefined(); + }); + }); + + it('should have valid favicon config for each theme', () => { + Object.values(themes).forEach(theme => { + expect(theme.favicon.svg).toBeDefined(); + expect(theme.favicon.png32).toBeDefined(); + expect(theme.favicon.png16).toBeDefined(); + expect(theme.favicon.appleTouchIcon).toBeDefined(); + expect(theme.favicon.themeColor).toMatch(/^#[0-9A-Fa-f]{6}$/); + }); + }); + + it('should have unique cssClass for each theme', () => { + const cssClasses = Object.values(themes).map(t => t.cssClass); + const uniqueClasses = new Set(cssClasses); + expect(uniqueClasses.size).toBe(cssClasses.length); + }); + }); + + describe('DEFAULT_THEME', () => { + it('should be a valid theme name', () => { + expect(['hyperdx', 'clickstack']).toContain(DEFAULT_THEME); + }); + + it('should exist in themes registry', () => { + expect(themes[DEFAULT_THEME]).toBeDefined(); + }); + }); + + describe('getTheme', () => { + it('should return hyperdx theme for "hyperdx"', () => { + const theme = getTheme('hyperdx'); + expect(theme.name).toBe('hyperdx'); + }); + + it('should return clickstack theme for "clickstack"', () => { + const theme = getTheme('clickstack'); + expect(theme.name).toBe('clickstack'); + }); + + it('should return default theme when called without arguments', () => { + const theme = getTheme(); + expect(theme).toBeDefined(); + expect(theme.name).toBe(DEFAULT_THEME); + }); + + it('should fallback to hyperdx for invalid theme name', () => { + // @ts-expect-error Testing invalid input + const theme = getTheme('invalid-theme'); + expect(theme.name).toBe('hyperdx'); + }); + + it('should return theme with all required properties', () => { + const theme = getTheme('hyperdx'); + expect(theme.name).toBeDefined(); + expect(theme.displayName).toBeDefined(); + expect(theme.mantineTheme).toBeDefined(); + expect(theme.Wordmark).toBeDefined(); + expect(theme.Logomark).toBeDefined(); + expect(theme.cssClass).toBeDefined(); + expect(theme.favicon).toBeDefined(); + }); + }); + + describe('safeLocalStorageGet', () => { + it('should return value from localStorage', () => { + window.localStorage.setItem(THEME_STORAGE_KEY, 'clickstack'); + const result = safeLocalStorageGet(THEME_STORAGE_KEY); + expect(result).toBe('clickstack'); + }); + + it('should return undefined when localStorage item does not exist', () => { + const result = safeLocalStorageGet('non-existent-key'); + expect(result).toBeUndefined(); + }); + + it('should handle different value types stored as strings', () => { + window.localStorage.setItem('test-key', 'some-value'); + const result = safeLocalStorageGet('test-key'); + expect(result).toBe('some-value'); + }); + }); + + describe('safeLocalStorageSet', () => { + it('should set value in localStorage', () => { + safeLocalStorageSet(THEME_STORAGE_KEY, 'clickstack'); + expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('clickstack'); + }); + + it('should overwrite existing value', () => { + window.localStorage.setItem(THEME_STORAGE_KEY, 'hyperdx'); + safeLocalStorageSet(THEME_STORAGE_KEY, 'clickstack'); + expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('clickstack'); + }); + }); + + describe('safeLocalStorageRemove', () => { + it('should remove item from localStorage', () => { + window.localStorage.setItem(THEME_STORAGE_KEY, 'clickstack'); + safeLocalStorageRemove(THEME_STORAGE_KEY); + expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBeNull(); + }); + + it('should not throw when removing non-existent key', () => { + expect(() => safeLocalStorageRemove('non-existent-key')).not.toThrow(); + }); + }); + + describe('getDevThemeName', () => { + it('should return DEFAULT_THEME when no overrides exist', () => { + // No URL params, no localStorage + const result = getDevThemeName(); + expect(result).toBe(DEFAULT_THEME); + }); + + it('should use localStorage when set', () => { + window.localStorage.setItem(THEME_STORAGE_KEY, 'clickstack'); + const result = getDevThemeName(); + expect(result).toBe('clickstack'); + }); + + it('should ignore invalid localStorage value', () => { + window.localStorage.setItem(THEME_STORAGE_KEY, 'invalid-theme'); + const result = getDevThemeName(); + expect(result).toBe(DEFAULT_THEME); + }); + }); + + describe('THEME_STORAGE_KEY', () => { + it('should be the expected key', () => { + expect(THEME_STORAGE_KEY).toBe('hdx-dev-theme'); + }); + + it('should be a non-empty string', () => { + expect(typeof THEME_STORAGE_KEY).toBe('string'); + expect(THEME_STORAGE_KEY.length).toBeGreaterThan(0); + }); + }); + + describe('theme favicon paths', () => { + it('should have consistent path structure for hyperdx', () => { + const favicon = themes.hyperdx.favicon; + expect(favicon.svg).toContain('/favicons/hyperdx/'); + expect(favicon.png32).toContain('/favicons/hyperdx/'); + expect(favicon.png16).toContain('/favicons/hyperdx/'); + expect(favicon.appleTouchIcon).toContain('/favicons/hyperdx/'); + }); + + it('should have consistent path structure for clickstack', () => { + const favicon = themes.clickstack.favicon; + expect(favicon.svg).toContain('/favicons/clickstack/'); + expect(favicon.png32).toContain('/favicons/clickstack/'); + expect(favicon.png16).toContain('/favicons/clickstack/'); + expect(favicon.appleTouchIcon).toContain('/favicons/clickstack/'); + }); + }); +}); diff --git a/packages/app/src/theme/index.ts b/packages/app/src/theme/index.ts index 197adb9df1..ba95a2390f 100644 --- a/packages/app/src/theme/index.ts +++ b/packages/app/src/theme/index.ts @@ -2,6 +2,23 @@ import { clickstackTheme } from './themes/clickstack'; import { hyperdxTheme } from './themes/hyperdx'; import { ThemeConfig, ThemeName } from './types'; +/** + * Brand Theme System + * + * DESIGN DECISION: Brand theme (hyperdx/clickstack) is DEPLOYMENT-CONFIGURED, not user-selectable. + * + * - Production: Theme is set via NEXT_PUBLIC_THEME environment variable at build/deploy time. + * Each deployment is branded for a specific product (HyperDX or ClickStack). + * Users cannot and should not change the brand theme. + * + * - Development: Theme switching is enabled for testing via: + * - URL param: ?theme=clickstack (temporary, for testing) + * - localStorage: hdx-dev-theme (persisted via dev UI) + * - Keyboard shortcut: Ctrl+Shift+T + * + * This is intentionally different from colorMode (light/dark), which IS user-selectable. + */ + // Theme registry export const themes: Record = { hyperdx: hyperdxTheme, @@ -9,7 +26,7 @@ export const themes: Record = { }; // Check if we're in development/local mode -const IS_DEV = +export const IS_DEV = process.env.NODE_ENV === 'development' || process.env.NEXT_PUBLIC_IS_LOCAL_MODE === 'true'; diff --git a/packages/app/src/theme/types.ts b/packages/app/src/theme/types.ts index 04e106a71a..7cca30a27f 100644 --- a/packages/app/src/theme/types.ts +++ b/packages/app/src/theme/types.ts @@ -1,6 +1,39 @@ import React from 'react'; import { MantineThemeOverride } from '@mantine/core'; +/** + * ============================================================================ + * THEMING CONCEPTS: Color Mode vs Brand Theme + * ============================================================================ + * + * This codebase has TWO separate theming concepts: + * + * 1. COLOR MODE (light/dark) + * - User-selectable preference stored in `useUserPreferences().colorMode` + * - Affects visual appearance: backgrounds, text colors, etc. + * - Managed by Mantine's color scheme system + * - Persisted to localStorage via `hdx-user-preferences` + * + * 2. BRAND THEME (hyperdx/clickstack) + * - Deployment-configured, NOT user-selectable in production + * - Set via `NEXT_PUBLIC_THEME` environment variable + * - Affects branding: logos, accent colors, favicons + * - Each deployment is branded for one specific product + * - Dev mode allows switching via URL (?theme=), localStorage, or keyboard (Ctrl+Shift+T) + * + * WHY SEPARATE? + * - Color mode is personal preference (accessibility, comfort) + * - Brand theme is business identity (product differentiation) + * - A ClickStack deployment should never show HyperDX branding, regardless of color mode + * + * @see useUserPreferences - manages colorMode (user preference) + * @see AppThemeProvider - manages brand theme (deployment config) + */ + +/** + * Brand theme identifier. + * This is DEPLOYMENT-CONFIGURED, not user-selectable in production. + */ export type ThemeName = 'hyperdx' | 'clickstack'; /** diff --git a/packages/app/src/useUserPreferences.tsx b/packages/app/src/useUserPreferences.tsx index 3e75eea22b..ba4597c10a 100644 --- a/packages/app/src/useUserPreferences.tsx +++ b/packages/app/src/useUserPreferences.tsx @@ -6,7 +6,8 @@ import { atomWithStorage } from 'jotai/utils'; export type UserPreferences = { isUTC: boolean; timeFormat: '12h' | '24h'; - theme: 'light' | 'dark'; + /** Color mode preference (light/dark). Separate from brand theme (hyperdx/clickstack). */ + colorMode: 'light' | 'dark'; font: 'IBM Plex Mono' | 'Roboto Mono' | 'Inter' | 'Roboto'; expandSidebarHeader?: boolean; }; @@ -16,7 +17,7 @@ export const userPreferencesAtom = atomWithStorage( { isUTC: false, timeFormat: '12h', - theme: 'dark', + colorMode: 'dark', font: 'IBM Plex Mono', }, ); From d044e02e976c83629233e2095312c0d7d4796a92 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 22 Jan 2026 19:30:34 +0000 Subject: [PATCH 25/36] refactor: export safe localStorage helpers and optimize theme class management - Changed localStorage helper functions to be exported for broader usage. - Improved theme class application logic in `AppThemeProvider` to efficiently manage class swaps, reducing unnecessary DOM manipulations. - Introduced a reference to track the previous theme class for better performance during theme changes. --- packages/app/src/theme/ThemeProvider.tsx | 26 +++++++++++++++++------- packages/app/src/theme/index.ts | 9 +++----- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/app/src/theme/ThemeProvider.tsx b/packages/app/src/theme/ThemeProvider.tsx index 05c465f553..3b7e702db4 100644 --- a/packages/app/src/theme/ThemeProvider.tsx +++ b/packages/app/src/theme/ThemeProvider.tsx @@ -4,6 +4,7 @@ import React, { useContext, useEffect, useMemo, + useRef, useState, } from 'react'; @@ -130,15 +131,26 @@ export function AppThemeProvider({ [theme, setTheme, toggleTheme, clearThemeOverride], ); - // Apply theme CSS class to document + // Track previous theme class for efficient swap + const prevThemeClassRef = useRef(null); + + // Apply theme CSS class to document (single class swap for performance) useEffect(() => { if (typeof document !== 'undefined') { - // Remove all theme classes - Object.values(themes).forEach(t => { - document.documentElement.classList.remove(t.cssClass); - }); - // Add current theme class - document.documentElement.classList.add(theme.cssClass); + const html = document.documentElement; + const newClass = theme.cssClass; + + // Remove only the previous theme class (not all themes) + if (prevThemeClassRef.current && prevThemeClassRef.current !== newClass) { + html.classList.remove(prevThemeClassRef.current); + } + + // Add new theme class if not already present + if (!html.classList.contains(newClass)) { + html.classList.add(newClass); + } + + prevThemeClassRef.current = newClass; } }, [theme]); diff --git a/packages/app/src/theme/index.ts b/packages/app/src/theme/index.ts index ba95a2390f..6aefb7c0be 100644 --- a/packages/app/src/theme/index.ts +++ b/packages/app/src/theme/index.ts @@ -39,7 +39,7 @@ function isValidThemeName(name: string | null | undefined): name is ThemeName { } // Safe localStorage access (handles private browsing, SSR, etc.) -function safeLocalStorageGet(key: string): string | undefined { +export function safeLocalStorageGet(key: string): string | undefined { try { if (typeof window === 'undefined') return undefined; return localStorage.getItem(key) ?? undefined; @@ -49,7 +49,7 @@ function safeLocalStorageGet(key: string): string | undefined { } } -function safeLocalStorageSet(key: string, value: string): void { +export function safeLocalStorageSet(key: string, value: string): void { try { if (typeof window === 'undefined') return; localStorage.setItem(key, value); @@ -58,7 +58,7 @@ function safeLocalStorageSet(key: string, value: string): void { } } -function safeLocalStorageRemove(key: string): void { +export function safeLocalStorageRemove(key: string): void { try { if (typeof window === 'undefined') return; localStorage.removeItem(key); @@ -123,8 +123,5 @@ export function getCurrentThemeName(): ThemeName { // Re-export types export type { ThemeConfig, ThemeName } from './types'; -// Export safe localStorage helpers for ThemeProvider -export { safeLocalStorageGet, safeLocalStorageRemove, safeLocalStorageSet }; - // Re-export for backwards compatibility export { makeTheme, theme } from './themes/hyperdx/mantineTheme'; From a051bae7d0ad0ffffb78c4dec1aeb4d0607cf314 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Fri, 23 Jan 2026 14:56:00 +0000 Subject: [PATCH 26/36] fix: update Wordmark font weight for improved styling - Changed font weight from "fw-500" to "fw-bold" in the Wordmark component to enhance visual emphasis. --- packages/app/src/theme/themes/hyperdx/Wordmark.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/theme/themes/hyperdx/Wordmark.tsx b/packages/app/src/theme/themes/hyperdx/Wordmark.tsx index 51fc4ea0ba..9a4e0e4c57 100644 --- a/packages/app/src/theme/themes/hyperdx/Wordmark.tsx +++ b/packages/app/src/theme/themes/hyperdx/Wordmark.tsx @@ -16,7 +16,7 @@ export default function Wordmark() {
Date: Fri, 23 Jan 2026 19:06:24 +0000 Subject: [PATCH 27/36] feat: Implement user preferences migration from legacy theme to colorMode - Added a migration function to transition user preferences from the old `theme` property to the new `colorMode` structure, ensuring existing users retain their settings. - Introduced a custom storage implementation that handles migration and localStorage interactions. - Updated the user preferences atom to utilize the new migration logic while maintaining default preferences. --- packages/app/src/useUserPreferences.tsx | 102 ++++++++++++++++++++++-- 1 file changed, 95 insertions(+), 7 deletions(-) diff --git a/packages/app/src/useUserPreferences.tsx b/packages/app/src/useUserPreferences.tsx index ba4597c10a..c31c79906e 100644 --- a/packages/app/src/useUserPreferences.tsx +++ b/packages/app/src/useUserPreferences.tsx @@ -12,14 +12,102 @@ export type UserPreferences = { expandSidebarHeader?: boolean; }; -export const userPreferencesAtom = atomWithStorage( - 'hdx-user-preferences', - { - isUTC: false, - timeFormat: '12h', - colorMode: 'dark', - font: 'IBM Plex Mono', +// Legacy type for migration +type LegacyUserPreferences = Omit & { + theme?: 'light' | 'dark'; +}; + +const STORAGE_KEY = 'hdx-user-preferences'; +const DEFAULT_PREFERENCES: UserPreferences = { + isUTC: false, + timeFormat: '12h', + colorMode: 'dark', + font: 'IBM Plex Mono', +}; + +/** + * Migrates old localStorage data from `theme` to `colorMode`. + * This ensures existing users don't lose their light/dark mode preference. + */ +function migrateUserPreferences(stored: string | null): UserPreferences | null { + if (!stored) { + return null; + } + + try { + const parsed = JSON.parse(stored) as + | LegacyUserPreferences + | UserPreferences; + + // Check if migration is needed (old format has `theme` instead of `colorMode`) + if ('theme' in parsed && !('colorMode' in parsed)) { + const migrated: UserPreferences = { + ...DEFAULT_PREFERENCES, + ...parsed, + colorMode: parsed.theme ?? DEFAULT_PREFERENCES.colorMode, + }; + // Remove old `theme` property + delete (migrated as any).theme; + + // Save migrated data back to localStorage + try { + if (typeof window !== 'undefined') { + localStorage.setItem(STORAGE_KEY, JSON.stringify(migrated)); + } + } catch { + // Ignore localStorage errors (private browsing, etc.) + } + + return migrated; + } + + // Already migrated or new format + return parsed as UserPreferences; + } catch { + // Invalid JSON, return null to use defaults + return null; + } +} + +// Custom storage implementation with migration support +const storageWithMigration = { + getItem: (key: string, initialValue: UserPreferences): UserPreferences => { + if (typeof window === 'undefined') { + return initialValue; + } + + try { + const stored = localStorage.getItem(key); + const migrated = migrateUserPreferences(stored); + return migrated ?? initialValue; + } catch { + return initialValue; + } + }, + setItem: (key: string, value: UserPreferences): void => { + try { + if (typeof window !== 'undefined') { + localStorage.setItem(key, JSON.stringify(value)); + } + } catch { + // Ignore localStorage errors (private browsing, etc.) + } }, + removeItem: (key: string): void => { + try { + if (typeof window !== 'undefined') { + localStorage.removeItem(key); + } + } catch { + // Ignore localStorage errors (private browsing, etc.) + } + }, +}; + +export const userPreferencesAtom = atomWithStorage( + STORAGE_KEY, + DEFAULT_PREFERENCES, + storageWithMigration, ); export const useUserPreferences = () => { From 07c767da602f0ab7edc76bfb35107914c2fd8c6e Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Fri, 23 Jan 2026 19:16:14 +0000 Subject: [PATCH 28/36] refactor: enhance user preferences handling with type guards and comments - Added type guards to validate user preferences and legacy preferences, ensuring proper migration and type safety. - Updated comments in the `_app.tsx` file to clarify font overriding behavior based on user preferences. --- packages/app/pages/_app.tsx | 6 ++- packages/app/src/useUserPreferences.tsx | 55 ++++++++++++++++++++----- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/packages/app/pages/_app.tsx b/packages/app/pages/_app.tsx index 586959394b..7e324751e0 100644 --- a/packages/app/pages/_app.tsx +++ b/packages/app/pages/_app.tsx @@ -68,8 +68,10 @@ export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { const { userPreferences } = useUserPreferences(); const confirmModal = useConfirmModal(); - // Only override font if user has explicitly set a preference - // Otherwise, let the theme use its default font + // Only override font if user has explicitly set a preference. + // Otherwise, return undefined to let the theme use its default font: + // - HyperDX theme: "IBM Plex Sans", monospace + // - ClickStack theme: "Inter", sans-serif const selectedMantineFont = userPreferences.font ? MANTINE_FONT_MAP[userPreferences.font] : undefined; diff --git a/packages/app/src/useUserPreferences.tsx b/packages/app/src/useUserPreferences.tsx index c31c79906e..37a9f4a9cd 100644 --- a/packages/app/src/useUserPreferences.tsx +++ b/packages/app/src/useUserPreferences.tsx @@ -25,6 +25,38 @@ const DEFAULT_PREFERENCES: UserPreferences = { font: 'IBM Plex Mono', }; +/** + * Type guard to check if an object is a valid UserPreferences (already migrated). + */ +function isUserPreferences(obj: unknown): obj is UserPreferences { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + return ( + 'colorMode' in obj && + typeof (obj as { colorMode: unknown }).colorMode === 'string' && + ((obj as { colorMode: string }).colorMode === 'light' || + (obj as { colorMode: string }).colorMode === 'dark') + ); +} + +/** + * Type guard to check if an object is a LegacyUserPreferences (needs migration). + */ +function isLegacyUserPreferences(obj: unknown): obj is LegacyUserPreferences { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + return ( + 'theme' in obj && + !('colorMode' in obj) && + (typeof (obj as { theme?: unknown }).theme === 'string' || + (obj as { theme?: unknown }).theme === undefined) + ); +} + /** * Migrates old localStorage data from `theme` to `colorMode`. * This ensures existing users don't lose their light/dark mode preference. @@ -35,19 +67,17 @@ function migrateUserPreferences(stored: string | null): UserPreferences | null { } try { - const parsed = JSON.parse(stored) as - | LegacyUserPreferences - | UserPreferences; + const parsed: unknown = JSON.parse(stored); // Check if migration is needed (old format has `theme` instead of `colorMode`) - if ('theme' in parsed && !('colorMode' in parsed)) { + if (isLegacyUserPreferences(parsed)) { + // Use destructuring to exclude `theme` property for better type safety + const { theme, ...rest } = parsed; const migrated: UserPreferences = { ...DEFAULT_PREFERENCES, - ...parsed, - colorMode: parsed.theme ?? DEFAULT_PREFERENCES.colorMode, + ...rest, + colorMode: theme ?? DEFAULT_PREFERENCES.colorMode, }; - // Remove old `theme` property - delete (migrated as any).theme; // Save migrated data back to localStorage try { @@ -61,8 +91,13 @@ function migrateUserPreferences(stored: string | null): UserPreferences | null { return migrated; } - // Already migrated or new format - return parsed as UserPreferences; + // Already migrated or new format - validate it's a proper UserPreferences + if (isUserPreferences(parsed)) { + return parsed; + } + + // Invalid format, return null to use defaults + return null; } catch { // Invalid JSON, return null to use defaults return null; From 9a972707bbc47c4e5db2cd3e0090851a45def992 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Mon, 26 Jan 2026 11:06:03 +0000 Subject: [PATCH 29/36] Add changeset --- .changeset/light-penguins-do.md | 36 +++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .changeset/light-penguins-do.md diff --git a/.changeset/light-penguins-do.md b/.changeset/light-penguins-do.md new file mode 100644 index 0000000000..2b31e26307 --- /dev/null +++ b/.changeset/light-penguins-do.md @@ -0,0 +1,36 @@ +--- +"@hyperdx/app": minor +--- + +feat: Multi-theme system with HyperDX and ClickStack branding support + +## Major Features + +### Multi-Theme System +- Add infrastructure for supporting multiple brand themes (HyperDX & ClickStack) +- Theme switching available in dev/local mode via URL param, localStorage, or keyboard shortcut (Ctrl+Shift+T) +- Production deployments use `NEXT_PUBLIC_THEME` environment variable (deployment-configured) +- Each theme provides its own logos, colors, favicons, and default fonts + +### Dynamic Favicons +- Implement theme-aware favicon system with SVG, PNG fallbacks, and Apple Touch Icon +- Add hydration-safe `DynamicFavicon` component +- Include XSS protection for theme-color meta tag validation + +### Component Refactoring +- Rename `Icon` → `Logomark` (icon/symbol only) +- Rename `Logo` → `Wordmark` (icon + text branding) +- Each theme provides its own `Logomark` and `Wordmark` components +- Update all component imports across the codebase + +### User Preferences Updates +- Rename `theme` property to `colorMode` to clarify light/dark mode vs brand theme +- Remove background overlay feature (backgroundEnabled, backgroundUrl, etc.) +- Add automatic data migration from legacy `theme` → `colorMode` in localStorage +- Ensure existing users don't lose their preferences during migration + + +### Performance & Type Safety +- Optimize theme CSS class management (single class swap instead of iterating all themes) +- Improve type safety in migration function using destructuring +- Add type guards for runtime validation of localStorage data From ea766eb0325b606309be4aac46663028498c57c9 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Mon, 26 Jan 2026 13:09:29 +0000 Subject: [PATCH 30/36] feat: Enhance multi-theme support and user preferences migration - Updated theme switching functionality to only utilize localStorage in development mode, removing URL parameter and keyboard shortcut options. - Added runtime validation for theme names to ensure type safety in user preferences. - Refactored user preferences migration function to be exported for testing, ensuring legacy theme data is correctly transitioned to the new colorMode structure. - Introduced comprehensive unit tests for the migration function, covering various scenarios including legacy data handling and error cases. --- .changeset/light-penguins-do.md | 2 +- packages/app/src/UserPreferencesModal.tsx | 14 +- .../src/__tests__/useUserPreferences.test.tsx | 358 ++++++++++++++++++ packages/app/src/theme/ThemeProvider.tsx | 26 +- .../app/src/theme/__tests__/index.test.ts | 8 +- packages/app/src/theme/index.ts | 16 +- packages/app/src/theme/types.ts | 2 +- packages/app/src/useUserPreferences.tsx | 6 +- 8 files changed, 388 insertions(+), 44 deletions(-) create mode 100644 packages/app/src/__tests__/useUserPreferences.test.tsx diff --git a/.changeset/light-penguins-do.md b/.changeset/light-penguins-do.md index 2b31e26307..dc301095ce 100644 --- a/.changeset/light-penguins-do.md +++ b/.changeset/light-penguins-do.md @@ -8,7 +8,7 @@ feat: Multi-theme system with HyperDX and ClickStack branding support ### Multi-Theme System - Add infrastructure for supporting multiple brand themes (HyperDX & ClickStack) -- Theme switching available in dev/local mode via URL param, localStorage, or keyboard shortcut (Ctrl+Shift+T) +- Theme switching available in dev/local mode via localStorage - Production deployments use `NEXT_PUBLIC_THEME` environment variable (deployment-configured) - Each theme provides its own logos, colors, favicons, and default fonts diff --git a/packages/app/src/UserPreferencesModal.tsx b/packages/app/src/UserPreferencesModal.tsx index 9da8c9e9bc..706572214b 100644 --- a/packages/app/src/UserPreferencesModal.tsx +++ b/packages/app/src/UserPreferencesModal.tsx @@ -30,6 +30,14 @@ const OPTIONS_BRAND_THEMES = Object.values(themes).map(t => ({ value: t.name, })); +/** + * Runtime validation for ThemeName to ensure type safety. + * Validates that a value is a valid theme name before type assertion. + */ +function isValidThemeName(value: string | null): value is ThemeName { + return value === 'hyperdx' || value === 'clickstack'; +} + const SettingContainer = ({ label, description, @@ -172,7 +180,11 @@ export const UserPreferencesModal = ({ >