diff --git a/.changeset/light-penguins-do.md b/.changeset/light-penguins-do.md new file mode 100644 index 0000000000..dc301095ce --- /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 localStorage +- 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 diff --git a/packages/app/pages/_app.tsx b/packages/app/pages/_app.tsx index 1a1c162e10..b702272a0a 100644 --- a/packages/app/pages/_app.tsx +++ b/packages/app/pages/_app.tsx @@ -16,18 +16,19 @@ 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, - DEFAULT_MANTINE_FONT, FONT_VAR_MAP, MANTINE_FONT_MAP, } from '@/config/fonts'; import { ibmPlexMono, inter, roboto, robotoMono } from '@/fonts'; +import { AppThemeProvider, useAppTheme } 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'; @@ -63,13 +64,70 @@ type AppPropsWithLayout = AppProps & { Component: NextPageWithLayout; }; -export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { +// Component that renders Head content requiring user preferences +// Must be rendered inside AppThemeProvider to avoid hydration mismatch +function AppHeadContent() { + const { userPreferences } = useUserPreferences(); + const { theme } = useAppTheme(); + + return ( + + {theme.displayName} + + + + + ); +} + +// Component that uses user preferences for theme wrapper +// Must be rendered inside AppThemeProvider to avoid hydration mismatch +function AppContent({ + Component, + pageProps, + confirmModal, +}: { + Component: NextPageWithLayout; + pageProps: AppProps['pageProps']; + confirmModal: React.ReactNode; +}) { const { userPreferences } = useUserPreferences(); - 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, 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 + : undefined; + + useEffect(() => { + // Update CSS variable for global font cascading + if (typeof document !== 'undefined') { + const fontVar = FONT_VAR_MAP[userPreferences.font] || DEFAULT_FONT_VAR; + document.documentElement.style.setProperty('--app-font-family', fontVar); + } + }, [userPreferences.font]); + + const getLayout = Component.getLayout ?? (page => page); + + return ( + + {getLayout()} + {confirmModal} + + ); +} + +export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { + const confirmModal = useConfirmModal(); // port to react query ? (needs to wrap with QueryClientProvider) useEffect(() => { @@ -121,47 +179,24 @@ export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { } }, []); - useEffect(() => { - // Update CSS variable for global font cascading - if (typeof document !== 'undefined') { - const fontVar = FONT_VAR_MAP[userPreferences.font] || DEFAULT_FONT_VAR; - document.documentElement.style.setProperty('--app-font-family', fontVar); - } - }, [userPreferences.font]); - - const getLayout = Component.getLayout ?? (page => page); - return ( - - HyperDX - - - - - - - - - - - - {getLayout()} - {confirmModal} - - - {background} - - - + + + + + + + + + + + + ); } diff --git a/packages/app/public/Icon32.png b/packages/app/public/Icon32.png deleted file mode 100644 index f97e18aafa..0000000000 Binary files a/packages/app/public/Icon32.png and /dev/null differ 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 0000000000..cb0d934b52 Binary files /dev/null and b/packages/app/public/favicons/clickstack/apple-touch-icon.png differ 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 0000000000..5b8fe239f2 Binary files /dev/null and b/packages/app/public/favicons/clickstack/favicon-16x16.png differ diff --git a/packages/app/public/favicons/clickstack/favicon-32x32.png b/packages/app/public/favicons/clickstack/favicon-32x32.png new file mode 100644 index 0000000000..3ab6e7336c Binary files /dev/null and b/packages/app/public/favicons/clickstack/favicon-32x32.png differ diff --git a/packages/app/public/favicons/clickstack/favicon.ico b/packages/app/public/favicons/clickstack/favicon.ico new file mode 100644 index 0000000000..6215c19634 Binary files /dev/null and b/packages/app/public/favicons/clickstack/favicon.ico differ 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 0000000000..80385d60e1 Binary files /dev/null and b/packages/app/public/favicons/hyperdx/apple-touch-icon.png differ 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 0000000000..fd6e3b78e9 Binary files /dev/null and b/packages/app/public/favicons/hyperdx/favicon-16x16.png differ diff --git a/packages/app/public/favicons/hyperdx/favicon-32x32.png b/packages/app/public/favicons/hyperdx/favicon-32x32.png new file mode 100644 index 0000000000..e392e035e2 Binary files /dev/null and b/packages/app/public/favicons/hyperdx/favicon-32x32.png differ diff --git a/packages/app/public/favicons/hyperdx/favicon.svg b/packages/app/public/favicons/hyperdx/favicon.svg new file mode 100644 index 0000000000..595b064e9b --- /dev/null +++ b/packages/app/public/favicons/hyperdx/favicon.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file 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() {
- + -
- -
- - - HyperDX - -
- ); -} diff --git a/packages/app/src/OnboardingChecklist.tsx b/packages/app/src/OnboardingChecklist.tsx index 331d1c3559..068480aec1 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 { useLogomark } 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 Logomark = useLogomark(); const [isCollapsed, setIsCollapsed] = useLocalStorage( 'onboardingChecklistCollapsed', false, diff --git a/packages/app/src/Spotlights.tsx b/packages/app/src/Spotlights.tsx index cef8392ce5..3ccbb59a16 100644 --- a/packages/app/src/Spotlights.tsx +++ b/packages/app/src/Spotlights.tsx @@ -14,14 +14,15 @@ import { IconSettings, } from '@tabler/icons-react'; +import { useLogomark } 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 Logomark = useLogomark(); 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]); + }, [Logomark, logViewsData, dashboardsData, router]); return { actions }; }; diff --git a/packages/app/src/ThemeWrapper.tsx b/packages/app/src/ThemeWrapper.tsx index e1ed983a58..fc47372faf 100644 --- a/packages/app/src/ThemeWrapper.tsx +++ b/packages/app/src/ThemeWrapper.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { MantineProvider } from '@mantine/core'; +import { MantineProvider, MantineThemeOverride } from '@mantine/core'; import { Notifications } from '@mantine/notifications'; -import { makeTheme, theme as defaultTheme } from './theme/mantineTheme'; +import { useAppTheme } from './theme/ThemeProvider'; export const ThemeWrapper = ({ fontFamily, @@ -13,12 +13,29 @@ export const ThemeWrapper = ({ colorScheme?: 'dark' | 'light'; children: React.ReactNode; }) => { - 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/UserPreferencesModal.tsx b/packages/app/src/UserPreferencesModal.tsx index 9360c0063e..6484ec02a7 100644 --- a/packages/app/src/UserPreferencesModal.tsx +++ b/packages/app/src/UserPreferencesModal.tsx @@ -2,47 +2,33 @@ import * as React from 'react'; import { Autocomplete, Badge, - Button, Divider, Group, - Input, Modal, Select, - Slider, Stack, Switch, Text, + Tooltip, } from '@mantine/core'; -import { IconWorld } from '@tabler/icons-react'; +import { IconFlask } from '@tabler/icons-react'; import { OPTIONS_FONTS } from './config/fonts'; +import { useAppTheme } from './theme/ThemeProvider'; +import { ThemeName } from './theme/types'; +import { isValidThemeName, themes } from './theme'; import { UserPreferences, useUserPreferences } from './useUserPreferences'; -const OPTIONS_THEMES = [ +const OPTIONS_COLOR_MODE = [ { label: 'Dark', value: 'dark' }, { label: 'Light', value: 'light' }, ]; -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', -]; +// Brand theme options (generated from theme registry) +const OPTIONS_BRAND_THEMES = Object.values(themes).map(t => ({ + label: t.displayName, + value: t.name, +})); const SettingContainer = ({ label, @@ -76,6 +62,7 @@ export const UserPreferencesModal = ({ onClose: () => void; }) => { const { userPreferences, setUserPreference } = useUserPreferences(); + const { themeName, setTheme, isDev } = useAppTheme(); return ( { + if (value && isValidThemeName(value)) { + setTheme(value); + } + }} + data={OPTIONS_BRAND_THEMES} + allowDeselect={false} + /> + + )} + - - - - 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, - }) - } - /> - - -