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 (
+ {/*
+ 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 && (
+
+ Brand Theme
+
+ }
+ >
+ Dev Only
+
+
+
+ }
+ description="Switch between HyperDX and ClickStack branding"
+ >
+
+ )}
+
-
-
-
- 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,
- })
- }
- />
-
-
-
- >
- )}
);
diff --git a/packages/app/src/__tests__/useUserPreferences.test.tsx b/packages/app/src/__tests__/useUserPreferences.test.tsx
new file mode 100644
index 0000000000..c7d62fe09f
--- /dev/null
+++ b/packages/app/src/__tests__/useUserPreferences.test.tsx
@@ -0,0 +1,361 @@
+/**
+ * Unit tests for useUserPreferences migration function
+ *
+ * Tests cover:
+ * - Legacy format migration (theme → colorMode)
+ * - Partial/corrupted localStorage data handling
+ * - Migration idempotency (safe to run multiple times)
+ */
+
+import type { UserPreferences } from '../useUserPreferences';
+import { migrateUserPreferences } from '../useUserPreferences';
+
+const STORAGE_KEY = 'hdx-user-preferences';
+
+describe('migrateUserPreferences', () => {
+ // Store original localStorage
+ const originalLocalStorage = window.localStorage;
+ let localStorageMock: jest.Mocked;
+
+ beforeEach(() => {
+ // Create localStorage mock
+ 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,
+ configurable: true,
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterAll(() => {
+ // Restore original localStorage
+ Object.defineProperty(window, 'localStorage', {
+ value: originalLocalStorage,
+ writable: true,
+ configurable: true,
+ });
+ });
+
+ describe('Legacy format migration (theme → colorMode)', () => {
+ it('should migrate theme="dark" to colorMode="dark"', () => {
+ const legacyData = JSON.stringify({
+ isUTC: false,
+ timeFormat: '12h',
+ theme: 'dark',
+ font: 'IBM Plex Mono',
+ });
+
+ const result = migrateUserPreferences(legacyData);
+
+ expect(result).toEqual({
+ isUTC: false,
+ timeFormat: '12h',
+ colorMode: 'dark',
+ font: 'IBM Plex Mono',
+ });
+ // Verify theme property was removed during migration
+ expect('theme' in (result || {})).toBe(false);
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ STORAGE_KEY,
+ JSON.stringify(result),
+ );
+ });
+
+ it('should migrate theme="light" to colorMode="light"', () => {
+ const legacyData = JSON.stringify({
+ isUTC: true,
+ timeFormat: '24h',
+ theme: 'light',
+ font: 'Inter',
+ });
+
+ const result = migrateUserPreferences(legacyData);
+
+ expect(result).toEqual({
+ isUTC: true,
+ timeFormat: '24h',
+ colorMode: 'light',
+ font: 'Inter',
+ });
+ // Verify theme property was removed during migration
+ expect('theme' in (result || {})).toBe(false);
+ });
+
+ it('should use default colorMode when theme property is missing', () => {
+ // JSON.stringify omits undefined properties, so we simulate legacy data
+ // without theme property - this should not match legacy format
+ const legacyDataWithoutTheme = JSON.stringify({
+ isUTC: false,
+ timeFormat: '12h',
+ font: 'IBM Plex Mono',
+ // theme property missing
+ });
+
+ // Should return null since it doesn't match legacy format
+ const result = migrateUserPreferences(legacyDataWithoutTheme);
+ expect(result).toBeNull();
+ });
+
+ it('should return null when theme is explicitly null (invalid value)', () => {
+ // Simulate legacy data where theme was explicitly set to null
+ // null is not a valid theme value, so it won't match legacy format
+ const legacyData = JSON.stringify({
+ isUTC: false,
+ timeFormat: '12h',
+ theme: null,
+ font: 'IBM Plex Mono',
+ });
+
+ // null theme doesn't match legacy format (type guard requires string or undefined)
+ // and it's not a valid UserPreferences (no colorMode)
+ // So it should return null to use defaults
+ const result = migrateUserPreferences(legacyData);
+ expect(result).toBeNull();
+ });
+
+ it('should preserve all other user preferences during migration', () => {
+ const legacyData = JSON.stringify({
+ isUTC: true,
+ timeFormat: '24h',
+ theme: 'light',
+ font: 'Roboto Mono',
+ expandSidebarHeader: true,
+ });
+
+ const result = migrateUserPreferences(legacyData);
+
+ expect(result).toEqual({
+ isUTC: true,
+ timeFormat: '24h',
+ colorMode: 'light',
+ font: 'Roboto Mono',
+ expandSidebarHeader: true,
+ });
+ });
+
+ it('should merge with default preferences for missing fields', () => {
+ const legacyData = JSON.stringify({
+ theme: 'dark',
+ // Missing other fields
+ });
+
+ const result = migrateUserPreferences(legacyData);
+
+ expect(result).toEqual({
+ isUTC: false, // From DEFAULT_PREFERENCES
+ timeFormat: '12h', // From DEFAULT_PREFERENCES
+ colorMode: 'dark', // Migrated from theme
+ font: 'IBM Plex Mono', // From DEFAULT_PREFERENCES
+ });
+ });
+ });
+
+ describe('Partial/corrupted localStorage data', () => {
+ it('should return null for null input', () => {
+ const result = migrateUserPreferences(null);
+ expect(result).toBeNull();
+ expect(localStorageMock.setItem).not.toHaveBeenCalled();
+ });
+
+ it('should return null for invalid JSON', () => {
+ const invalidJson = '{ invalid json }';
+ const result = migrateUserPreferences(invalidJson);
+ expect(result).toBeNull();
+ expect(localStorageMock.setItem).not.toHaveBeenCalled();
+ });
+
+ it('should return null for empty string', () => {
+ const result = migrateUserPreferences('');
+ expect(result).toBeNull();
+ });
+
+ it('should return null for non-object JSON', () => {
+ const result = migrateUserPreferences('"string"');
+ expect(result).toBeNull();
+ });
+
+ it('should return null for array JSON', () => {
+ const result = migrateUserPreferences('[]');
+ expect(result).toBeNull();
+ });
+
+ it('should return null for object without theme or colorMode', () => {
+ const invalidData = JSON.stringify({
+ isUTC: false,
+ timeFormat: '12h',
+ // No theme or colorMode
+ });
+
+ const result = migrateUserPreferences(invalidData);
+ expect(result).toBeNull();
+ });
+
+ it('should return null for object with invalid colorMode value', () => {
+ const invalidData = JSON.stringify({
+ colorMode: 'invalid-value',
+ isUTC: false,
+ });
+
+ const result = migrateUserPreferences(invalidData);
+ expect(result).toBeNull();
+ });
+
+ it('should handle partial legacy data gracefully', () => {
+ const partialData = JSON.stringify({
+ theme: 'light',
+ // Missing other required fields
+ });
+
+ const result = migrateUserPreferences(partialData);
+
+ expect(result).toEqual({
+ isUTC: false, // From DEFAULT_PREFERENCES
+ timeFormat: '12h', // From DEFAULT_PREFERENCES
+ colorMode: 'light', // Migrated from theme
+ font: 'IBM Plex Mono', // From DEFAULT_PREFERENCES
+ });
+ });
+
+ it('should handle extra unknown properties in legacy data', () => {
+ const legacyData = JSON.stringify({
+ theme: 'dark',
+ isUTC: false,
+ timeFormat: '12h',
+ font: 'IBM Plex Mono',
+ unknownProperty: 'should be preserved',
+ anotherUnknown: 123,
+ });
+
+ const result = migrateUserPreferences(legacyData);
+
+ expect(result).toEqual({
+ isUTC: false,
+ timeFormat: '12h',
+ colorMode: 'dark',
+ font: 'IBM Plex Mono',
+ unknownProperty: 'should be preserved',
+ anotherUnknown: 123,
+ });
+ });
+ });
+
+ describe('Migration idempotency', () => {
+ it('should return already migrated data unchanged', () => {
+ const migratedData: UserPreferences = {
+ isUTC: false,
+ timeFormat: '12h',
+ colorMode: 'dark',
+ font: 'IBM Plex Mono',
+ };
+
+ const result = migrateUserPreferences(JSON.stringify(migratedData));
+
+ expect(result).toEqual(migratedData);
+ // Should not call setItem since data is already migrated
+ expect(localStorageMock.setItem).not.toHaveBeenCalled();
+ });
+
+ it('should handle data with both theme and colorMode (edge case)', () => {
+ // If somehow both exist, prefer colorMode (already migrated)
+ const mixedData = JSON.stringify({
+ theme: 'light',
+ colorMode: 'dark',
+ isUTC: false,
+ timeFormat: '12h',
+ font: 'IBM Plex Mono',
+ });
+
+ const result = migrateUserPreferences(mixedData);
+
+ // Should use colorMode (already migrated, ignore theme)
+ expect(result?.colorMode).toBe('dark');
+ expect(localStorageMock.setItem).not.toHaveBeenCalled();
+ });
+
+ it('should be safe to call multiple times on same legacy data', () => {
+ const legacyData = JSON.stringify({
+ theme: 'light',
+ isUTC: true,
+ timeFormat: '24h',
+ font: 'Inter',
+ });
+
+ // First migration
+ const firstResult = migrateUserPreferences(legacyData);
+ expect(firstResult?.colorMode).toBe('light');
+ // Verify theme property was removed during migration
+ expect('theme' in (firstResult || {})).toBe(false);
+
+ // Simulate that localStorage now has migrated data
+ localStorageMock.getItem.mockReturnValue(JSON.stringify(firstResult));
+
+ // Second migration (should be idempotent)
+ const secondResult = migrateUserPreferences(JSON.stringify(firstResult));
+ expect(secondResult).toEqual(firstResult);
+ // Should not call setItem again since already migrated
+ expect(localStorageMock.setItem).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('localStorage error handling', () => {
+ it('should handle localStorage.setItem errors gracefully', () => {
+ localStorageMock.setItem.mockImplementation(() => {
+ throw new Error('localStorage quota exceeded');
+ });
+
+ const legacyData = JSON.stringify({
+ theme: 'dark',
+ isUTC: false,
+ timeFormat: '12h',
+ font: 'IBM Plex Mono',
+ });
+
+ // Should not throw, should return migrated data even if save fails
+ const result = migrateUserPreferences(legacyData);
+ expect(result).toEqual({
+ isUTC: false,
+ timeFormat: '12h',
+ colorMode: 'dark',
+ font: 'IBM Plex Mono',
+ });
+ });
+ });
+
+ describe('SSR safety', () => {
+ it('should return migrated data even if localStorage is unavailable', () => {
+ // Simulate localStorage being unavailable (private browsing, etc.)
+ // The function should still return migrated data even if it can't save
+ localStorageMock.setItem.mockImplementation(() => {
+ throw new Error('localStorage unavailable');
+ });
+
+ const legacyData = JSON.stringify({
+ theme: 'dark',
+ isUTC: false,
+ timeFormat: '12h',
+ font: 'IBM Plex Mono',
+ });
+
+ // Should not throw - should return migrated data
+ const result = migrateUserPreferences(legacyData);
+ expect(result).toEqual({
+ isUTC: false,
+ timeFormat: '12h',
+ colorMode: 'dark',
+ font: 'IBM Plex Mono',
+ });
+ });
+ });
+});
diff --git a/packages/app/src/components/AppNav/AppNav.tsx b/packages/app/src/components/AppNav/AppNav.tsx
index 607d7ed5a7..92127fdb6c 100644
--- a/packages/app/src/components/AppNav/AppNav.tsx
+++ b/packages/app/src/components/AppNav/AppNav.tsx
@@ -47,11 +47,10 @@ import {
useDashboards,
useUpdateDashboard,
} from '@/dashboard';
-import Icon from '@/Icon';
import InstallInstructionModal from '@/InstallInstructionsModal';
-import Logo from '@/Logo';
import OnboardingChecklist from '@/OnboardingChecklist';
import { useSavedSearches, useUpdateSavedSearch } from '@/savedSearch';
+import { useLogomark, useWordmark } from '@/theme/ThemeProvider';
import type { SavedSearch, ServerDashboard } from '@/types';
import { UserPreferencesModal } from '@/UserPreferencesModal';
import { useUserPreferences } from '@/useUserPreferences';
@@ -392,6 +391,9 @@ function useSearchableList({
}
export default function AppNav({ fixed = false }: { fixed?: boolean }) {
+ const Wordmark = useWordmark();
+ const Logomark = useLogomark();
+
useEffect(() => {
let redirectUrl;
try {
@@ -673,22 +675,11 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
{isCollapsed ? (
-
+
) : (
-
-
-
-
- HyperDX
-
-
+
{isUTC && (
{
+ setIsMounted(true);
+ }, []);
+
+ // Use default theme favicon for SSR/initial render to match server
+ // After mount, use the actual theme's favicon
+ const defaultFavicon = getTheme(DEFAULT_THEME).favicon;
+ const favicon = isMounted ? theme.favicon : defaultFavicon;
+
+ return (
+
+ {/* SVG favicon - modern browsers, scalable */}
+
+
+ {/* PNG fallbacks for older browsers */}
+
+
+
+ {/* Apple Touch Icon for iOS */}
+
+
+ {/* 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/nextra.config.tsx b/packages/app/src/nextra.config.tsx
index 103c171fe8..646594639e 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 { useWordmark } from './theme/ThemeProvider';
// import NextraMain from './NextraMain';
import useNextraSeoProps from './useNextraSeoProps';
+function ThemedLogo() {
+ const Wordmark = useWordmark();
+ return ;
+}
+
const theme = {
useNextSeoProps: useNextraSeoProps,
- logo: ,
+ logo: ,
footer: {
text: 'Made with ♥ in San Francisco, © 2024 DeploySentinel, Inc.',
},
diff --git a/packages/app/src/theme/ThemeProvider.tsx b/packages/app/src/theme/ThemeProvider.tsx
new file mode 100644
index 0000000000..c70e261170
--- /dev/null
+++ b/packages/app/src/theme/ThemeProvider.tsx
@@ -0,0 +1,234 @@
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+
+import {
+ DEFAULT_THEME,
+ getDevThemeName,
+ getTheme,
+ IS_DEV,
+ safeLocalStorageRemove,
+ safeLocalStorageSet,
+ THEME_STORAGE_KEY,
+ themes,
+} from './index';
+import { ThemeConfig, ThemeName } from './types';
+
+// 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;
+ 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: propsThemeName,
+ children,
+}: {
+ themeName?: ThemeName;
+ children: React.ReactNode;
+}) {
+ // SSR/initial render: Always use props or DEFAULT_THEME for hydration consistency.
+ // The server cannot read localStorage, so we must start with a deterministic value.
+ //
+ // HYDRATION NOTE: In dev mode, the useEffect below may update the theme after hydration
+ // if localStorage 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 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) {
+ setResolvedThemeName(propsThemeName);
+ return;
+ }
+
+ // In dev mode only, allow localStorage override for testing themes
+ if (IS_DEV) {
+ const devTheme = getDevThemeName();
+ setResolvedThemeName(devTheme);
+ }
+ }, [propsThemeName]);
+
+ const theme = useMemo(() => {
+ return getTheme(resolvedThemeName);
+ }, [resolvedThemeName]);
+
+ // 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. Brand theme is deployment-configured in production.',
+ );
+ return;
+ }
+ if (themes[name]) {
+ safeLocalStorageSet(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];
+ safeLocalStorageSet(THEME_STORAGE_KEY, nextTheme);
+ return nextTheme;
+ });
+ }, []);
+
+ const clearThemeOverride = useCallback(() => {
+ safeLocalStorageRemove(THEME_STORAGE_KEY);
+ setResolvedThemeName(propsThemeName ?? DEFAULT_THEME);
+ }, [propsThemeName]);
+
+ const contextValue = useMemo(
+ () => ({
+ theme,
+ themeName: theme.name,
+ availableThemes: Object.keys(themes) as ThemeName[],
+ setTheme,
+ toggleTheme,
+ clearThemeOverride,
+ isDev: IS_DEV,
+ }),
+ [theme, setTheme, toggleTheme, clearThemeOverride],
+ );
+
+ // 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') {
+ 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]);
+
+ // Dev mode: expose theme API to window (namespaced to avoid global pollution)
+ useEffect(() => {
+ if (!IS_DEV || typeof window === 'undefined') return;
+
+ // eslint-disable-next-line no-console
+ console.info(
+ `🎨 Theme: ${theme.displayName} (${theme.name})`,
+ '\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 (
+
+ {children}
+
+ );
+}
+
+export function useAppTheme(): ThemeContextValue {
+ const context = useContext(ThemeContext);
+ if (!context) {
+ // Fallback for when used outside provider - always use default to avoid hydration issues
+ const theme = getTheme(DEFAULT_THEME);
+ return {
+ theme,
+ themeName: theme.name,
+ availableThemes: Object.keys(themes) as ThemeName[],
+ // 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,
+ };
+ }
+ return context;
+}
+
+// Convenience hooks
+export function useWordmark() {
+ const { theme } = useAppTheme();
+ return theme.Wordmark;
+}
+
+export function useLogomark() {
+ const { theme } = useAppTheme();
+ return theme.Logomark;
+}
+
+// Hook to get current theme name (useful for conditional rendering)
+export function useThemeName(): ThemeName {
+ const { themeName } = useAppTheme();
+ return themeName;
+}
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..d9ea12ac54
--- /dev/null
+++ b/packages/app/src/theme/__tests__/index.test.ts
@@ -0,0 +1,213 @@
+/**
+ * Unit tests for theme utilities
+ *
+ * Tests cover:
+ * - Theme registry and validation
+ * - getTheme() function
+ * - getDevThemeName() function (localStorage-based)
+ * - Safe localStorage helpers
+ * - THEME_STORAGE_KEY constant
+ */
+
+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 localStorage override exists', () => {
+ // No localStorage override, should use default
+ 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
new file mode 100644
index 0000000000..17e5db68d8
--- /dev/null
+++ b/packages/app/src/theme/index.ts
@@ -0,0 +1,275 @@
+import { z } from 'zod';
+
+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:
+ * - localStorage: hdx-dev-theme (persisted via dev UI)
+ *
+ * This is intentionally different from colorMode (light/dark), which IS user-selectable.
+ */
+
+// Zod schema for validating ThemeConfig structure
+// Note: React components and MantineThemeOverride are validated at runtime
+// but cannot be fully validated with Zod schemas
+const faviconConfigSchema = z.object({
+ svg: z
+ .string()
+ .regex(
+ /^\/favicons\/[a-z]+\/[a-z0-9-]+\.svg$/,
+ 'SVG favicon path must match /favicons/{theme}/{name}.svg',
+ ),
+ png32: z
+ .string()
+ .regex(
+ /^\/favicons\/[a-z]+\/[a-z0-9-]+\.png$/,
+ 'PNG32 favicon path must match /favicons/{theme}/{name}.png',
+ ),
+ png16: z
+ .string()
+ .regex(
+ /^\/favicons\/[a-z]+\/[a-z0-9-]+\.png$/,
+ 'PNG16 favicon path must match /favicons/{theme}/{name}.png',
+ ),
+ appleTouchIcon: z
+ .string()
+ .regex(
+ /^\/favicons\/[a-z]+\/[a-z0-9-]+\.png$/,
+ 'Apple Touch Icon path must match /favicons/{theme}/{name}.png',
+ ),
+ themeColor: z.string().regex(/^#[0-9A-F]{6}$/i, 'Must be a valid hex color'),
+});
+
+const themeConfigSchema = z.object({
+ name: z.enum(['hyperdx', 'clickstack']),
+ displayName: z.string().min(1),
+ cssClass: z.string().min(1),
+ favicon: faviconConfigSchema,
+ // Wordmark and Logomark are React components - validate they exist and are callable
+ Wordmark: z
+ .any()
+ .refine(
+ val => typeof val === 'function' || (val && typeof val === 'object'),
+ 'Wordmark must be a React component',
+ ),
+ Logomark: z
+ .any()
+ .refine(
+ val => typeof val === 'function' || (val && typeof val === 'object'),
+ 'Logomark must be a React component',
+ ),
+ // mantineTheme is complex - just check it exists
+ mantineTheme: z
+ .any()
+ .refine(
+ val => val !== null && val !== undefined,
+ 'mantineTheme must be defined',
+ ),
+});
+
+/**
+ * Validates a theme configuration at runtime.
+ * Throws an error with details if validation fails.
+ *
+ * @param theme - Theme configuration to validate
+ * @param themeName - Name of the theme (for error messages)
+ * @throws Error if theme is invalid
+ */
+function validateThemeConfig(
+ theme: unknown,
+ themeName: string,
+): asserts theme is ThemeConfig {
+ try {
+ themeConfigSchema.parse(theme);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ const details = error.errors
+ .map(e => `${e.path.join('.')}: ${e.message}`)
+ .join('; ');
+ throw new Error(
+ `Invalid theme configuration for "${themeName}": ${details}`,
+ );
+ }
+ throw error;
+ }
+}
+
+// Validate themes at module load time
+try {
+ validateThemeConfig(hyperdxTheme, 'hyperdx');
+ validateThemeConfig(clickstackTheme, 'clickstack');
+} catch (error) {
+ // Log error but don't crash - fallback to default theme
+ console.error(
+ '[Theme Validation] Failed to validate theme configurations:',
+ error,
+ );
+ // In production, we might want to throw to prevent deployment with invalid configs
+ if (process.env.NODE_ENV === 'production') {
+ throw error;
+ }
+}
+
+// Theme registry
+export const themes: Record = {
+ hyperdx: hyperdxTheme,
+ clickstack: clickstackTheme,
+};
+
+// Check if we're in development/local mode
+export const IS_DEV =
+ process.env.NODE_ENV === 'development' ||
+ process.env.NEXT_PUBLIC_IS_LOCAL_MODE === 'true';
+
+// LocalStorage key for dev theme override (exported for ThemeProvider)
+export const THEME_STORAGE_KEY = 'hdx-dev-theme';
+
+// Validate that a theme name is valid
+export function isValidThemeName(
+ name: string | null | undefined,
+): name is ThemeName {
+ return name != null && name in themes;
+}
+
+// Safe localStorage access (handles private browsing, SSR, etc.)
+export 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;
+ }
+}
+
+export 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
+ }
+}
+
+export 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;
+let resolvedDefaultTheme: ThemeName = isValidThemeName(envTheme)
+ ? envTheme
+ : 'hyperdx';
+
+// Validate that the resolved default theme exists and is valid
+if (!themes[resolvedDefaultTheme]) {
+ console.warn(
+ `[Theme Validation] Theme "${resolvedDefaultTheme}" from NEXT_PUBLIC_THEME not found in registry. Falling back to "hyperdx".`,
+ );
+ resolvedDefaultTheme = 'hyperdx';
+} else {
+ // Validate the theme config structure
+ try {
+ validateThemeConfig(themes[resolvedDefaultTheme], resolvedDefaultTheme);
+ } catch (error) {
+ console.error(
+ `[Theme Validation] Theme "${resolvedDefaultTheme}" failed validation. Falling back to "hyperdx".`,
+ error,
+ );
+ resolvedDefaultTheme = 'hyperdx';
+ // In production, throw to prevent deployment with invalid configs
+ if (process.env.NODE_ENV === 'production') {
+ throw error;
+ }
+ }
+}
+
+export const DEFAULT_THEME: ThemeName = resolvedDefaultTheme;
+
+/**
+ * Get the theme name from various sources (dev mode only).
+ * This is the single source of truth for resolving dev theme names.
+ *
+ * Priority:
+ * 1. localStorage: hdx-dev-theme (persisted via explicit UI action)
+ * 2. Environment variable: NEXT_PUBLIC_THEME
+ * 3. Default: hyperdx
+ */
+export function getDevThemeName(): ThemeName {
+ if (typeof window === 'undefined') {
+ return DEFAULT_THEME;
+ }
+
+ // Check localStorage (set via explicit user action in ThemeProvider)
+ const storedTheme = safeLocalStorageGet(THEME_STORAGE_KEY);
+ if (isValidThemeName(storedTheme)) {
+ return storedTheme;
+ }
+
+ return DEFAULT_THEME;
+}
+
+// Get theme configuration by name
+export function getTheme(name: ThemeName = DEFAULT_THEME): ThemeConfig {
+ const theme = themes[name] || themes.hyperdx;
+
+ // Runtime validation - ensure theme is valid before returning
+ // This catches cases where theme config was corrupted after module load
+ try {
+ validateThemeConfig(theme, name);
+ } catch (error) {
+ console.error(
+ `[Theme Validation] Theme "${name}" failed runtime validation. Falling back to "hyperdx".`,
+ error,
+ );
+ // Return hyperdx theme as safe fallback
+ return themes.hyperdx;
+ }
+
+ return theme;
+}
+
+/**
+ * IMPORTANT: To get the current theme in React components, use `useAppTheme()` hook
+ * from `./ThemeProvider` instead of calling these functions directly.
+ *
+ * Why?
+ * - `useAppTheme()` ensures consistency with ThemeProvider context
+ * - Prevents hydration mismatches between SSR and client-side rendering
+ * - Properly handles theme switching in dev mode
+ * - Matches the theme resolution used throughout the app
+ *
+ * Example:
+ * ```tsx
+ * import { useAppTheme } from '@/theme/ThemeProvider';
+ *
+ * function MyComponent() {
+ * const { theme, themeName } = useAppTheme();
+ * return {theme.displayName}
;
+ * }
+ * ```
+ *
+ * These utility functions (`getTheme`, `getDevThemeName`) are for internal use
+ * by ThemeProvider and should not be used directly in components.
+ */
+
+// 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/_base-tokens.scss b/packages/app/src/theme/themes/_base-tokens.scss
new file mode 100644
index 0000000000..1a5c4d25d8
--- /dev/null
+++ b/packages/app/src/theme/themes/_base-tokens.scss
@@ -0,0 +1,22 @@
+/* Base Design Tokens - Fallback values when no theme class is applied */
+
+/*
+ * 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 fallback) - uses HyperDX tokens */
+[data-mantine-color-scheme='dark'] {
+ @include hyperdx-tokens.dark-mode-tokens;
+}
+
+/* Light Mode (fallback) - uses HyperDX tokens */
+[data-mantine-color-scheme='light'] {
+ @include hyperdx-tokens.light-mode-tokens;
+}
diff --git a/packages/app/src/theme/themes/clickstack/Logomark.tsx b/packages/app/src/theme/themes/clickstack/Logomark.tsx
new file mode 100644
index 0000000000..53678b7c13
--- /dev/null
+++ b/packages/app/src/theme/themes/clickstack/Logomark.tsx
@@ -0,0 +1,50 @@
+/**
+ * ClickStack Logomark
+ * A stylized database/stack icon with the brand yellow color
+ */
+export default function Logomark({ size = 16 }: { size?: number }) {
+ return (
+
+ );
+}
diff --git a/packages/app/src/theme/themes/clickstack/Wordmark.tsx b/packages/app/src/theme/themes/clickstack/Wordmark.tsx
new file mode 100644
index 0000000000..842f7f64b0
--- /dev/null
+++ b/packages/app/src/theme/themes/clickstack/Wordmark.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+
+export default function Wordmark() {
+ return (
+
+ );
+}
diff --git a/packages/app/src/theme/themes/clickstack/_tokens.scss b/packages/app/src/theme/themes/clickstack/_tokens.scss
new file mode 100644
index 0000000000..22e35078da
--- /dev/null
+++ b/packages/app/src/theme/themes/clickstack/_tokens.scss
@@ -0,0 +1,326 @@
+/* ClickStack Theme Design Tokens */
+
+/* Yellow/Gold accent theme for ClickStack branding */
+
+/* Uses Click UI design tokens */
+
+/* Dark Mode */
+.theme-clickstack[data-mantine-color-scheme='dark'] {
+ /* Brand Palette - Yellow/Gold */
+ --palette-brand-50: #ffffe8;
+ --palette-brand-100: #feffba;
+ --palette-brand-200: #fdffa3;
+ --palette-brand-300: #faff69;
+ --palette-brand-400: #eef400;
+ --palette-brand-500: #c7cc00;
+ --palette-brand-600: #959900;
+ --palette-brand-700: #686b00;
+ --palette-brand-800: #3c4601;
+ --palette-brand-900: #330;
+ --palette-brand-base: #fbff46;
+
+ /* Neutral Palette - Dark Mode */
+ --palette-neutral-0: #fff;
+ --palette-neutral-100: #f9f9f9;
+ --palette-neutral-200: #dfdfdf;
+ --palette-neutral-300: #c0c0c0;
+ --palette-neutral-400: #a0a0a0;
+ --palette-neutral-500: #808080;
+ --palette-neutral-600: #606060;
+ --palette-neutral-650: #505050;
+ --palette-neutral-700: #414141;
+ --palette-neutral-712: #323232;
+ --palette-neutral-725: #282828;
+ --palette-neutral-750: #1f1f1c;
+ --palette-neutral-800: #1d1d1d;
+ --palette-neutral-900: #151515;
+ --palette-neutral-base: #212121;
+
+ /* Mantine Yellow/Primary Override - Uses Brand Palette */
+ --mantine-color-yellow-0: var(--palette-brand-50);
+ --mantine-color-yellow-1: var(--palette-brand-100);
+ --mantine-color-yellow-2: var(--palette-brand-200);
+ --mantine-color-yellow-3: var(--palette-brand-300);
+ --mantine-color-yellow-4: var(--palette-brand-400);
+ --mantine-color-yellow-5: var(--palette-brand-500);
+ --mantine-color-yellow-6: var(--palette-brand-600);
+ --mantine-color-yellow-7: var(--palette-brand-700);
+ --mantine-color-yellow-8: var(--palette-brand-800);
+ --mantine-color-yellow-9: var(--palette-brand-900);
+
+ /* Mantine Gray Override - Uses Neutral Palette */
+ --mantine-color-gray-0: var(--palette-neutral-0);
+ --mantine-color-gray-1: var(--palette-neutral-100);
+ --mantine-color-gray-2: var(--palette-neutral-200);
+ --mantine-color-gray-3: var(--palette-neutral-300);
+ --mantine-color-gray-4: var(--palette-neutral-400);
+ --mantine-color-gray-5: var(--palette-neutral-500);
+ --mantine-color-gray-6: var(--palette-neutral-600);
+ --mantine-color-gray-7: var(--palette-neutral-700);
+ --mantine-color-gray-8: var(--palette-neutral-800);
+ --mantine-color-gray-9: var(--palette-neutral-900);
+
+ /* Mantine Dark Override - Uses Neutral Palette (inverted for dark backgrounds) */
+ --mantine-color-dark-0: var(--palette-neutral-200);
+ --mantine-color-dark-1: var(--palette-neutral-300);
+ --mantine-color-dark-2: var(--palette-neutral-400);
+ --mantine-color-dark-3: var(--palette-neutral-500);
+ --mantine-color-dark-4: var(--palette-neutral-700);
+ --mantine-color-dark-5: var(--palette-neutral-650);
+ --mantine-color-dark-6: var(--palette-neutral-725);
+ --mantine-color-dark-7: var(--palette-neutral-750);
+ --mantine-color-dark-8: var(--palette-neutral-800);
+ --mantine-color-dark-9: var(--palette-neutral-900);
+
+ /* 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: var(--click-global-color-background-default);
+ --color-bg-inverted: var(--mantine-color-dark-1);
+ --color-bg-muted: var(--click-global-color-background-muted);
+ --color-bg-highlighted: rgb(75 78 102 / 70%);
+ --color-bg-sidenav: var(--click-global-color-background-default);
+ --color-bg-sidenav-link-active: var(--palette-neutral-712);
+ --color-bg-sidenav-link-active-hover: var(--mantine-color-gray-7);
+ --color-bg-header: var(--click-global-color-background-default);
+ --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;
+ --color-bg-success: #22c55e;
+ --color-bg-danger: #ef4444;
+ --color-bg-warning: #f59e0b;
+ --color-bg-brand: var(--click-global-color-accent-default);
+ --color-bg-brand-hover: var(--click-global-color-accent-hover);
+
+ /* Primary Button */
+ --color-primary-button-bg: var(--palette-brand-300);
+ --color-primary-button-bg-hover: var(--palette-brand-200);
+ --color-primary-button-text: var(--color-text-inverted);
+
+ /* 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);
+ --color-text-sidenav-link: var(--click-global-color-text-default);
+ --color-text-sidenav-link-active: var(--click-global-color-text-default);
+
+ /* 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'] {
+ /* Brand Palette - Yellow/Gold */
+ --palette-brand-50: #ffffe8;
+ --palette-brand-100: #feffc2;
+ --palette-brand-200: #fdffa3;
+ --palette-brand-300: #faff69;
+ --palette-brand-400: #eef400;
+ --palette-brand-500: #c7cc00;
+ --palette-brand-600: #959900;
+ --palette-brand-700: #686b00;
+ --palette-brand-800: #3c4601;
+ --palette-brand-900: #330;
+ --palette-brand-base: #fbff46;
+
+ /* Slate Palette - Light Mode */
+ --palette-slate-25: #fbfcff;
+ --palette-slate-50: #f6f7fa;
+ --palette-slate-100: #e6e7e9;
+ --palette-slate-200: #cccfd3;
+ --palette-slate-300: #b3b6bd;
+ --palette-slate-400: #9a9ea7;
+ --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;
+
+ /* Mantine Yellow/Primary Override - Uses Brand Palette */
+ --mantine-color-yellow-0: var(--palette-brand-50);
+ --mantine-color-yellow-1: var(--palette-brand-100);
+ --mantine-color-yellow-2: var(--palette-brand-200);
+ --mantine-color-yellow-3: var(--palette-brand-300);
+ --mantine-color-yellow-4: var(--palette-brand-400);
+ --mantine-color-yellow-5: var(--palette-brand-500);
+ --mantine-color-yellow-6: var(--palette-brand-600);
+ --mantine-color-yellow-7: var(--palette-brand-700);
+ --mantine-color-yellow-8: var(--palette-brand-800);
+ --mantine-color-yellow-9: var(--palette-brand-900);
+
+ /* Mantine Gray Override - Uses Slate Palette */
+ --mantine-color-gray-0: var(--palette-slate-25);
+ --mantine-color-gray-1: var(--palette-slate-50);
+ --mantine-color-gray-2: var(--palette-slate-100);
+ --mantine-color-gray-3: var(--palette-slate-200);
+ --mantine-color-gray-4: var(--palette-slate-300);
+ --mantine-color-gray-5: var(--palette-slate-400);
+ --mantine-color-gray-6: var(--palette-slate-500);
+ --mantine-color-gray-7: var(--palette-slate-600);
+ --mantine-color-gray-8: var(--palette-slate-700);
+ --mantine-color-gray-9: var(--palette-slate-800);
+
+ /* Mantine Dark Override - Uses Slate Palette */
+ --mantine-color-dark-0: var(--palette-slate-100);
+ --mantine-color-dark-1: var(--palette-slate-200);
+ --mantine-color-dark-2: var(--palette-slate-300);
+ --mantine-color-dark-3: var(--palette-slate-400);
+ --mantine-color-dark-4: var(--palette-slate-500);
+ --mantine-color-dark-5: var(--palette-slate-600);
+ --mantine-color-dark-6: var(--palette-slate-700);
+ --mantine-color-dark-7: var(--palette-slate-800);
+ --mantine-color-dark-8: var(--palette-slate-900);
+ --mantine-color-dark-9: var(--palette-slate-900);
+
+ /* 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: var(--mantine-color-dark-3);
+ --color-bg-muted: var(--click-global-color-background-muted);
+ --color-bg-highlighted: rgb(241 243 245 / 80%);
+ --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;
+ --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);
+ --color-bg-brand-hover: var(--click-global-color-accent-hover);
+
+ /* Primary Button */
+ --color-primary-button-bg: var(--palette-slate-800);
+ --color-primary-button-bg-hover: var(--palette-slate-725);
+ --color-primary-button-text: var(--color-text-inverted);
+
+ /* 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);
+ --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;
+ --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/src/theme/themes/clickstack/index.ts b/packages/app/src/theme/themes/clickstack/index.ts
new file mode 100644
index 0000000000..bef491140d
--- /dev/null
+++ b/packages/app/src/theme/themes/clickstack/index.ts
@@ -0,0 +1,21 @@
+import { ThemeConfig } from '../../types';
+
+import Logomark from './Logomark';
+import { theme } from './mantineTheme';
+import Wordmark from './Wordmark';
+
+export const clickstackTheme: ThemeConfig = {
+ name: 'clickstack',
+ displayName: 'ClickStack',
+ mantineTheme: theme,
+ 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/clickstack/mantineTheme.ts b/packages/app/src/theme/themes/clickstack/mantineTheme.ts
new file mode 100644
index 0000000000..cfcaadff53
--- /dev/null
+++ b/packages/app/src/theme/themes/clickstack/mantineTheme.ts
@@ -0,0 +1,312 @@
+import {
+ ActionIcon,
+ Button,
+ MantineTheme,
+ MantineThemeOverride,
+ rem,
+ Select,
+ Slider,
+ Tabs,
+ 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: {
+ // Note: Actual color values are overridden via CSS variables in _tokens.scss
+ // These arrays are required for Mantine to recognize the colors
+ // The CSS variables allow different palettes for light/dark modes
+ yellow: [
+ '#ffffe8', // Overridden by --mantine-color-yellow-0
+ '#feffc2', // Overridden by --mantine-color-yellow-1
+ '#fdffa3', // Overridden by --mantine-color-yellow-2
+ '#faff69', // Overridden by --mantine-color-yellow-3
+ '#eef400', // Overridden by --mantine-color-yellow-4
+ '#c7cc00', // Overridden by --mantine-color-yellow-5
+ '#959900', // Overridden by --mantine-color-yellow-6
+ '#686b00', // Overridden by --mantine-color-yellow-7
+ '#3c4601', // Overridden by --mantine-color-yellow-8
+ '#333300', // Overridden by --mantine-color-yellow-9
+ ],
+ },
+ 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)',
+ },
+ },
+ }),
+ Slider: Slider.extend({
+ styles: {
+ bar: {
+ backgroundColor: 'var(--color-bg-brand)',
+ },
+ thumb: {
+ borderColor: 'var(--color-bg-brand)',
+ },
+ },
+ }),
+ 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) => {
+ const baseVars: Record = {};
+
+ if (props.size === 'xxs') {
+ baseVars['--button-height'] = rem(22);
+ baseVars['--button-padding-x'] = rem(4);
+ baseVars['--button-fz'] = rem(12);
+ }
+
+ // Use Mantine's built-in CSS vars for hover support
+ if (props.variant === 'primary') {
+ 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') {
+ 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') {
+ 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 { root: baseVars };
+ },
+ }),
+ SegmentedControl: {
+ styles: {
+ root: {
+ background: 'var(--color-bg-field)',
+ },
+ indicator: {
+ background: 'var(--color-bg-field-highlighted)',
+ },
+ },
+ },
+ Tabs: Tabs.extend({
+ vars: () => ({
+ root: {
+ '--tabs-color': 'var(--color-text-brand)',
+ },
+ }),
+ }),
+ ActionIcon: ActionIcon.extend({
+ defaultProps: {
+ variant: 'subtle',
+ color: 'gray',
+ },
+ vars: (_theme, props) => {
+ const baseVars: Record = {};
+
+ if (props.variant === 'subtle') {
+ baseVars['--ai-bg'] = 'transparent';
+ baseVars['--ai-hover'] = 'var(--color-bg-hover)';
+ baseVars['--ai-color'] = 'var(--color-text)';
+ }
+
+ if (props.variant === 'default') {
+ 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') {
+ 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') {
+ 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') {
+ 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 { root: baseVars };
+ },
+ }),
+ },
+});
+
+export const theme = makeTheme({});
diff --git a/packages/app/src/Icon.tsx b/packages/app/src/theme/themes/hyperdx/Logomark.tsx
similarity index 86%
rename from packages/app/src/Icon.tsx
rename to packages/app/src/theme/themes/hyperdx/Logomark.tsx
index 873602b887..a6fd988da0 100644
--- a/packages/app/src/Icon.tsx
+++ b/packages/app/src/theme/themes/hyperdx/Logomark.tsx
@@ -1,4 +1,4 @@
-export default function Icon({ size = 16 }: { size?: number }) {
+export default function Logomark({ size = 16 }: { size?: number }) {
return (