feat: Add multi-theme system (#1618)

## Multi-Theme System

Adds infrastructure for supporting multiple brand themes (HyperDX & ClickStack) with the ability to switch between them in development mode.

### Preview

https://hyperdx-v2-oss-app-git-add-themes-hyperdx.vercel.app/

### How theme resolution works

Theme switching is **only available in dev/local mode** (`NODE_ENV=development` or `NEXT_PUBLIC_IS_LOCAL_MODE=true`).

**Resolution priority (highest to lowest):**

1. **localStorage** → `hdx-dev-theme` key _(set via explicit UI action)_
2. **Environment variable** → `NEXT_PUBLIC_THEME`
3. **Default** → `hyperdx`

**In production**, the theme is determined solely by `NEXT_PUBLIC_THEME` (defaults to `hyperdx`).

### Changes

#### Theme System
- Added `ThemeProvider` with context for theme-aware components
- Added theme configurations for HyperDX and ClickStack (logos, colors, favicons)
- Dynamic favicon switching based on active theme

#### Component Renaming
- `Icon` → `Logomark` (the icon/symbol only)
- `Logo` → `Wordmark` (icon + text branding)
- Each theme provides its own `Logomark` and `Wordmark` components

#### User Preferences (`useUserPreferences`)
> **Note:** The hook was **modified**, not deleted. It's still actively used across the codebase.

- Renamed `theme` property to `colorMode` to clarify it controls light/dark mode (not brand theme)
- Removed background overlay feature:
  - Deleted `backgroundEnabled`, `backgroundUrl`, `backgroundBlur`, `backgroundOpacity`, `backgroundBlendMode` properties
  - Deleted `useBackground` hook
  - Removed background overlay UI from `UserPreferencesModal`
  - Removed `.hdx-background-image` CSS rules

#### Security
- Added hex color validation for `theme-color` meta tag to prevent XSS
- Hydration-safe `DynamicFavicon` component

#### Type Safety & Documentation
- Added comprehensive documentation in `types.ts` explaining the distinction between:
  - **Color Mode** (light/dark) — user-selectable, stored in `useUserPreferences`
  - **Brand Theme** (hyperdx/clickstack) — deployment-configured, NOT user-selectable in production
- Clear type definitions for `ThemeName`, `ThemeConfig`, and `FaviconConfig`

#### Tests Added
- `theme/__tests__/index.test.ts` — Theme registry, `getTheme()`, localStorage helpers
- `theme/__tests__/ThemeProvider.test.tsx` — Context, hooks, hydration safety
- `components/__tests__/DynamicFavicon.test.tsx` — XSS sanitization, hex color validation

---

*This PR is a work in progress. Feedback welcome!*
This commit is contained in:
Elizabet Oliveira 2026-01-30 11:27:55 +00:00 committed by GitHub
parent 0d321ea15f
commit 2f1a13cc81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 2898 additions and 327 deletions

View file

@ -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

View file

@ -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 confirmModal = useConfirmModal();
const background = useBackground(userPreferences);
const { theme } = useAppTheme();
const selectedMantineFont =
MANTINE_FONT_MAP[userPreferences.font] || DEFAULT_MANTINE_FONT;
return (
<Head>
<title>{theme.displayName}</title>
<meta name="viewport" content="width=device-width, initial-scale=0.75" />
<meta name="google" content="notranslate" />
<ColorSchemeScript
forceColorScheme={
userPreferences.colorMode === 'dark' ? 'dark' : 'light'
}
/>
</Head>
);
}
// 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();
// 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 (
<ThemeWrapper
fontFamily={selectedMantineFont}
colorScheme={userPreferences.colorMode === 'dark' ? 'dark' : 'light'}
>
{getLayout(<Component {...pageProps} />)}
{confirmModal}
</ThemeWrapper>
);
}
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 (
<React.Fragment>
<Head>
<title>HyperDX</title>
<link rel="icon" type="image/png" sizes="32x32" href="/Icon32.png" />
<meta
name="viewport"
content="width=device-width, initial-scale=0.75"
/>
<meta name="theme-color" content="#25292e"></meta>
<meta name="google" content="notranslate" />
<ColorSchemeScript
forceColorScheme={userPreferences.theme === 'dark' ? 'dark' : 'light'}
/>
</Head>
<HDXQueryParamProvider>
<QueryParamProvider adapter={NextAdapter}>
<QueryClientProvider client={queryClient}>
<ThemeWrapper
fontFamily={selectedMantineFont}
colorScheme={userPreferences.theme === 'dark' ? 'dark' : 'light'}
>
{getLayout(<Component {...pageProps} />)}
{confirmModal}
</ThemeWrapper>
<ReactQueryDevtools initialIsOpen={true} />
{background}
</QueryClientProvider>
</QueryParamProvider>
</HDXQueryParamProvider>
<AppThemeProvider>
<AppHeadContent />
<DynamicFavicon />
<HDXQueryParamProvider>
<QueryParamProvider adapter={NextAdapter}>
<QueryClientProvider client={queryClient}>
<AppContent
Component={Component}
pageProps={pageProps}
confirmModal={confirmModal}
/>
<ReactQueryDevtools initialIsOpen={true} />
</QueryClientProvider>
</QueryParamProvider>
</HDXQueryParamProvider>
</AppThemeProvider>
</React.Fragment>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 770 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,15 @@
<svg width="260" height="260" viewBox="0 0 260 260" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="260" height="260" rx="40" fill="#FCFF74"/>
<g clip-path="url(#clip0_1_532)">
<rect x="42.25" y="42.25" width="19.4993" height="175.494" rx="2.05109" fill="#0B0B0B"/>
<rect x="81.2509" y="42.25" width="19.4993" height="175.494" rx="2.05109" fill="#0B0B0B"/>
<rect x="120.252" y="42.25" width="19.4993" height="175.494" rx="2.05109" fill="#0B0B0B"/>
<rect x="159.243" y="42.25" width="19.4993" height="175.494" rx="2.05109" fill="#0B0B0B"/>
<rect x="198.254" y="110.501" width="19.4993" height="38.9987" rx="2.05109" fill="#0B0B0B"/>
</g>
<defs>
<clipPath id="clip0_1_532">
<rect width="208" height="208" fill="white" transform="translate(26 26)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1,6 @@
<svg width="260" height="260" viewBox="0 0 260 260" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="260" height="260" rx="40" fill="#1E1E1E" />
<path
d="M207.885 85V175L129.942 220L52 175V85L129.942 40L207.885 85ZM149.406 69.6953C148.192 68.8065 146.651 69.0886 145.688 70.376L97.3643 134.997C96.5385 136.102 96.3139 137.718 96.792 139.11C97.2698 140.502 98.3597 141.404 99.5645 141.404H119.637L108.759 185.901C108.346 187.591 108.912 189.417 110.126 190.306C111.34 191.194 112.882 190.912 113.845 189.625L162.168 125.003C162.994 123.898 163.219 122.282 162.741 120.89C162.263 119.498 161.173 118.597 159.969 118.597H139.896L150.774 74.0986C151.187 72.409 150.62 70.5841 149.406 69.6953Z"
fill="#25E2A5" />
</svg>

After

Width:  |  Height:  |  Size: 738 B

View file

@ -115,7 +115,6 @@ function DBServiceMapPage() {
<div style={{ minWidth: '200px' }}>
<Slider
label={null}
color="green"
min={0}
max={SAMPLING_FACTORS.length - 1}
value={SAMPLING_FACTORS.findIndex(

View file

@ -2,8 +2,8 @@ import Link from 'next/link';
import { Anchor, Burger, Button, Container, Group } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useWordmark } from './theme/ThemeProvider';
import api from './api';
import Logo from './Logo';
export default function LandingHeader({
activeKey,
@ -12,6 +12,7 @@ export default function LandingHeader({
activeKey: string;
fixed?: boolean;
}) {
const Wordmark = useWordmark();
const { data: me } = api.useMe();
const isLoggedIn = Boolean(me);
@ -35,7 +36,7 @@ export default function LandingHeader({
<Container fluid px="xl" py="md">
<Group justify="space-between" align="center">
<Link href="/" style={{ textDecoration: 'none' }}>
<Logo />
<Wordmark />
</Link>
<Burger

View file

@ -1,55 +0,0 @@
import React from 'react';
import Icon from './Icon';
export default function Logo({
size = 'sm',
}: {
size?: 'sm' | 'md' | 'lg' | 'xl';
}) {
const configs = {
sm: {
fontSize: 14,
iconSize: 14,
iconMarginBottom: 3,
},
md: {
fontSize: 16,
iconSize: 16,
iconMarginBottom: 3,
},
lg: {
fontSize: 18,
iconSize: 18,
iconMarginBottom: 3,
},
xl: {
fontSize: 22,
iconSize: 22,
iconMarginBottom: 3,
},
};
return (
<div className="align-items-center d-flex">
<div
className="me-2"
style={{
display: 'inline-flex',
alignItems: 'center',
}}
>
<Icon size={configs[size].iconSize} />
</div>
<span
className="fw-bold mono"
style={{
fontSize: configs[size].fontSize,
}}
>
HyperDX
</span>
</div>
);
}

View file

@ -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,

View file

@ -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: <Logo />,
leftSection: <Logomark size={16} />,
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 };
};

View file

@ -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<MantineThemeOverride>(() => {
// 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 (
<MantineProvider forceColorScheme={colorScheme} theme={theme}>
<MantineProvider forceColorScheme={colorScheme} theme={mantineTheme}>
<Notifications zIndex={999999} />
{children}
</MantineProvider>

View file

@ -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 (
<Modal
@ -134,22 +121,68 @@ export const UserPreferencesModal = ({
mt="sm"
/>
<SettingContainer
label="Theme"
label="Color Mode"
description="Switch between light and dark mode"
>
<Select
value={userPreferences.theme}
value={userPreferences.colorMode}
onChange={value =>
value &&
setUserPreference({
theme: value as UserPreferences['theme'],
colorMode: value as UserPreferences['colorMode'],
})
}
data={OPTIONS_THEMES}
data={OPTIONS_COLOR_MODE}
allowDeselect={false}
/>
</SettingContainer>
{/*
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 && (
<SettingContainer
label={
<Group gap="xs">
Brand Theme
<Tooltip
label="Only available in local/dev mode. Changes logo, colors, and branding."
multiline
w={220}
>
<Badge
variant="light"
color="violet"
fw="normal"
size="xs"
leftSection={<IconFlask size={10} />}
>
Dev Only
</Badge>
</Tooltip>
</Group>
}
description="Switch between HyperDX and ClickStack branding"
>
<Select
value={themeName}
onChange={value => {
if (value && isValidThemeName(value)) {
setTheme(value);
}
}}
data={OPTIONS_BRAND_THEMES}
allowDeselect={false}
/>
</SettingContainer>
)}
<SettingContainer
label="Font"
description="If using custom font, make sure it's installed on your system"
@ -165,108 +198,6 @@ export const UserPreferencesModal = ({
data={OPTIONS_FONTS}
/>
</SettingContainer>
<SettingContainer label="Background overlay">
<Switch
size="md"
variant="default"
onClick={() =>
setUserPreference({
backgroundEnabled: !userPreferences.backgroundEnabled,
})
}
checked={userPreferences.backgroundEnabled}
/>
</SettingContainer>
{userPreferences.backgroundEnabled && (
<>
<Divider label={<>Background</>} labelPosition="left" />
<SettingContainer
label="Background URL"
description={
<Group gap={4}>
<Button
variant="secondary"
size="compact-xs"
onClick={() =>
setUserPreference({
backgroundUrl: 'https://i.imgur.com/CrHYfTG.jpeg',
})
}
>
Try this
</Button>
<Button
variant="secondary"
size="compact-xs"
onClick={() =>
setUserPreference({
backgroundUrl: 'https://i.imgur.com/hnkdzAX.jpeg',
})
}
>
or this
</Button>
</Group>
}
>
<Input
placeholder="https:// or data:"
value={userPreferences.backgroundUrl}
leftSection={<IconWorld size={16} />}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setUserPreference({
backgroundUrl: e.currentTarget.value,
})
}
/>
</SettingContainer>
<SettingContainer label="Opacity">
<Slider
defaultValue={0.1}
step={0.01}
max={1}
min={0}
value={userPreferences.backgroundOpacity}
onChange={value =>
setUserPreference({
backgroundOpacity: value,
})
}
/>
</SettingContainer>
<SettingContainer label="Blur">
<Slider
defaultValue={0}
step={0.01}
max={90}
min={0}
value={userPreferences.backgroundBlur}
onChange={value =>
setUserPreference({
backgroundBlur: value,
})
}
/>
</SettingContainer>
<SettingContainer label="Blend mode">
<Select
value={userPreferences.backgroundBlendMode}
defaultValue="plus-lighter"
onChange={value =>
value &&
setUserPreference({
backgroundBlendMode:
value as UserPreferences['backgroundBlendMode'],
})
}
data={OPTIONS_MIX_BLEND_MODE}
allowDeselect={false}
/>
</SettingContainer>
</>
)}
</Stack>
</Modal>
);

View file

@ -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<Storage>;
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',
});
});
});
});

View file

@ -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<T extends AppNavLinkItem>({
}
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 }) {
<Link href="/search" className={styles.logoLink}>
{isCollapsed ? (
<div className={styles.logoIconWrapper}>
<Icon size={22} />
<Logomark size={22} />
</div>
) : (
<Group gap="xs" align="center">
<Group gap="xs" align="center">
<Icon size={22} />
<span
className="fw-bold mono"
style={{
fontSize: '14px',
}}
>
HyperDX
</span>
</Group>
<Wordmark />
{isUTC && (
<Badge
size="xs"

View file

@ -0,0 +1,89 @@
import { useEffect, useState } from 'react';
import Head from 'next/head';
import { DEFAULT_THEME, getTheme } from '@/theme';
import { useAppTheme } from '@/theme/ThemeProvider';
// Validate hex color to prevent XSS injection
// Exported for testing
export const HEX_COLOR_PATTERN = /^#[0-9A-Fa-f]{6}$/;
export const DEFAULT_THEME_COLOR = '#25292e';
export function sanitizeThemeColor(color: string): string {
// Explicit type guard to prevent type coercion issues
if (typeof color !== 'string') {
return DEFAULT_THEME_COLOR;
}
return HEX_COLOR_PATTERN.test(color) ? color : DEFAULT_THEME_COLOR;
}
/**
* Dynamic favicon component that updates based on the current theme.
*
* Favicon best practices (2024+):
* - SVG favicon: Modern browsers, scalable, supports dark mode via CSS
* - PNG 32x32: Standard fallback for older browsers
* - PNG 16x16: Small contexts (bookmarks, tabs in some browsers)
* - Apple Touch Icon: iOS home screen (180x180)
* - theme-color: Browser UI color (address bar on mobile)
*
* HYDRATION NOTE: To avoid SSR/client mismatch, we render the default theme's
* favicon during SSR and initial hydration, then update to the actual theme
* after mount. This ensures consistent server/client rendering.
*/
export function DynamicFavicon() {
const { theme } = useAppTheme();
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
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 (
<Head>
{/* SVG favicon - modern browsers, scalable */}
<link
key="favicon-svg"
rel="icon"
type="image/svg+xml"
href={favicon.svg}
/>
{/* PNG fallbacks for older browsers */}
<link
key="favicon-32"
rel="icon"
type="image/png"
sizes="32x32"
href={favicon.png32}
/>
<link
key="favicon-16"
rel="icon"
type="image/png"
sizes="16x16"
href={favicon.png16}
/>
{/* Apple Touch Icon for iOS */}
<link
key="apple-touch-icon"
rel="apple-touch-icon"
sizes="180x180"
href={favicon.appleTouchIcon}
/>
{/* Theme color for browser UI - validated to prevent XSS */}
<meta
key="theme-color"
name="theme-color"
content={sanitizeThemeColor(favicon.themeColor)}
/>
</Head>
);
}

View file

@ -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"><script>')).toBe(false);
expect(HEX_COLOR_PATTERN.test("javascript:alert('xss')")).toBe(false);
expect(HEX_COLOR_PATTERN.test('')).toBe(false);
});
});
describe('sanitizeThemeColor', () => {
it('should return valid hex colors unchanged', () => {
expect(sanitizeThemeColor('#000000')).toBe('#000000');
expect(sanitizeThemeColor('#FFFFFF')).toBe('#FFFFFF');
expect(sanitizeThemeColor('#25292e')).toBe('#25292e');
expect(sanitizeThemeColor('#1a1a1a')).toBe('#1a1a1a');
});
it('should return default color for invalid inputs', () => {
expect(sanitizeThemeColor('')).toBe(DEFAULT_THEME_COLOR);
expect(sanitizeThemeColor('#000')).toBe(DEFAULT_THEME_COLOR);
expect(sanitizeThemeColor('000000')).toBe(DEFAULT_THEME_COLOR);
expect(sanitizeThemeColor('#GGGGGG')).toBe(DEFAULT_THEME_COLOR);
});
it('should sanitize XSS injection attempts', () => {
// Script injection
expect(sanitizeThemeColor('#000000"><script>alert(1)</script>')).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,<script>alert(1)</script>'),
).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');
});
});
});

View file

@ -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 <Wordmark />;
}
const theme = {
useNextSeoProps: useNextraSeoProps,
logo: <Logo />,
logo: <ThemedLogo />,
footer: {
text: 'Made with ♥ in San Francisco, © 2024 DeploySentinel, Inc.',
},

View file

@ -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<ThemeContextValue | null>(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<ThemeName>(
() => 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<string | null>(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 (
<ThemeContext.Provider value={contextValue}>
{children}
</ThemeContext.Provider>
);
}
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;
}

View file

@ -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<Storage>;
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 }) => (
<AppThemeProvider>{children}</AppThemeProvider>
);
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 }) => (
<AppThemeProvider themeName="clickstack">{children}</AppThemeProvider>
);
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 }) => (
<AppThemeProvider>{children}</AppThemeProvider>
);
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 }) => (
<AppThemeProvider>{children}</AppThemeProvider>
);
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 }) => (
<AppThemeProvider themeName="clickstack">{children}</AppThemeProvider>
);
const { result } = renderHook(() => useThemeName(), { wrapper });
expect(result.current).toBe('clickstack');
});
});
describe('theme switching (dev mode)', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AppThemeProvider>{children}</AppThemeProvider>
);
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 }) => (
<AppThemeProvider>{children}</AppThemeProvider>
),
});
// 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 }) => (
<AppThemeProvider themeName="clickstack">{children}</AppThemeProvider>
),
});
// Props should take precedence for hydration consistency
expect(result.current.themeName).toBe('clickstack');
});
});
});

View file

@ -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/');
});
});
});

View file

@ -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<ThemeName, ThemeConfig> = {
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 <div>{theme.displayName}</div>;
* }
* ```
*
* 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';

View file

@ -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;
}

View file

@ -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 (
<svg width={size} height={size} fill="none" viewBox="0 0 24 24">
<rect
width="2.25"
height="20.249"
x="1.875"
y="1.875"
fill="currentColor"
rx="0.237"
></rect>
<rect
width="2.25"
height="20.249"
x="6.375"
y="1.875"
fill="currentColor"
rx="0.237"
></rect>
<rect
width="2.25"
height="20.249"
x="10.875"
y="1.875"
fill="currentColor"
rx="0.237"
></rect>
<rect
width="2.25"
height="20.249"
x="15.374"
y="1.875"
fill="currentColor"
rx="0.237"
></rect>
<rect
width="2.25"
height="4.5"
x="19.875"
y="9.75"
fill="currentColor"
rx="0.237"
></rect>
</svg>
);
}

View file

@ -0,0 +1,60 @@
import React from 'react';
export default function Wordmark() {
return (
<div className="align-items-center d-flex">
<svg
xmlns="http://www.w3.org/2000/svg"
width="121"
height="25"
fill="none"
viewBox="0 0 121 25"
>
<rect
width="2.25"
height="20.249"
x="1.875"
y="1.875"
fill="currentColor"
rx="0.237"
></rect>
<rect
width="2.25"
height="20.249"
x="6.375"
y="1.875"
fill="currentColor"
rx="0.237"
></rect>
<rect
width="2.25"
height="20.249"
x="10.875"
y="1.875"
fill="currentColor"
rx="0.237"
></rect>
<rect
width="2.25"
height="20.249"
x="15.374"
y="1.875"
fill="currentColor"
rx="0.237"
></rect>
<rect
width="2.25"
height="4.5"
x="19.875"
y="9.75"
fill="currentColor"
rx="0.237"
></rect>
<path
fill="currentColor"
d="M41.2 7.642q-.954 0-1.71.342-.756.324-1.296.972a4.7 4.7 0 0 0-.81 1.566q-.27.918-.27 2.07 0 1.512.45 2.628.45 1.098 1.35 1.692t2.268.594q.828 0 1.584-.144a16 16 0 0 0 1.566-.414v1.674q-.756.288-1.548.414-.792.144-1.836.144-1.962 0-3.276-.81-1.296-.81-1.944-2.304t-.648-3.492q0-1.458.396-2.664a6.1 6.1 0 0 1 1.188-2.106 5.1 5.1 0 0 1 1.908-1.35q1.152-.486 2.646-.486.972 0 1.908.216a7 7 0 0 1 1.692.576l-.72 1.62q-.63-.288-1.368-.504a5 5 0 0 0-1.53-.234M48.793 19h-1.908V5.32h1.908zm4.904-9.702V19h-1.908V9.298zm-.936-3.69q.432 0 .756.252t.324.846q0 .576-.324.846a1.2 1.2 0 0 1-.756.252 1.25 1.25 0 0 1-.792-.252q-.306-.27-.306-.846 0-.594.306-.846a1.25 1.25 0 0 1 .792-.252m7.838 13.572q-1.332 0-2.34-.522t-1.566-1.62q-.558-1.116-.558-2.826 0-1.8.594-2.916.612-1.116 1.638-1.638 1.044-.54 2.376-.54.81 0 1.512.18.72.162 1.188.378l-.576 1.548a9 9 0 0 0-1.08-.342 4.3 4.3 0 0 0-1.062-.144q-.9 0-1.494.396-.576.378-.864 1.152-.27.756-.27 1.908 0 1.098.288 1.854t.846 1.152q.576.378 1.422.378.81 0 1.422-.18t1.152-.468v1.656a4 4 0 0 1-1.134.45q-.612.144-1.494.144m6.808-7.02q0 .378-.036.864-.018.486-.054.9h.054l.342-.432.432-.54a10 10 0 0 1 .396-.468l2.97-3.186h2.214l-3.906 4.158L73.977 19h-2.25l-3.204-4.338-1.116.936V19h-1.89V5.32h1.89zm15.761 3.366q0 1.152-.558 1.962t-1.602 1.26q-1.044.432-2.484.432a10 10 0 0 1-1.35-.09 9 9 0 0 1-1.206-.216 5 5 0 0 1-.99-.36v-1.836q.72.324 1.692.594a7.9 7.9 0 0 0 1.98.252q.864 0 1.44-.234t.864-.648.288-.972q0-.594-.306-.99-.288-.414-.918-.756-.612-.36-1.656-.756-.72-.27-1.314-.594a5.2 5.2 0 0 1-1.026-.792q-.432-.45-.666-1.044t-.234-1.386q0-1.062.522-1.818.54-.756 1.476-1.152.936-.414 2.178-.414a8 8 0 0 1 1.926.216q.9.198 1.71.558l-.612 1.602q-.738-.306-1.494-.504a6.2 6.2 0 0 0-1.584-.198q-.72 0-1.206.216t-.738.594a1.64 1.64 0 0 0-.234.882q0 .594.27.99t.864.738q.594.324 1.566.72 1.08.414 1.836.9.774.486 1.17 1.17.396.666.396 1.674m5.793 2.106q.378 0 .774-.072t.684-.162v1.44q-.306.144-.828.234a5 5 0 0 1-1.044.108q-.792 0-1.458-.27-.648-.27-1.044-.936t-.396-1.854v-5.364h-1.35v-.864l1.422-.72.666-2.052h1.17v2.178h2.772v1.458h-2.772v5.328q0 .792.378 1.17.396.378 1.026.378m7.16-8.514q1.836 0 2.736.81.9.792.9 2.502V19h-1.35l-.378-1.35h-.072a5 5 0 0 1-.864.864 2.8 2.8 0 0 1-1.008.504q-.558.162-1.368.162a3.9 3.9 0 0 1-1.566-.306 2.5 2.5 0 0 1-1.08-.972q-.396-.666-.396-1.656 0-1.476 1.116-2.25 1.134-.774 3.438-.846l1.656-.054v-.54q0-1.08-.486-1.512t-1.368-.432q-.756 0-1.44.216a9 9 0 0 0-1.296.522l-.612-1.386a7.5 7.5 0 0 1 1.566-.594 7 7 0 0 1 1.872-.252m.45 5.256q-1.656.072-2.304.558-.63.468-.63 1.332 0 .756.45 1.098.468.342 1.17.342 1.134 0 1.872-.63t.738-1.89v-.846zm10.03 4.806q-1.332 0-2.34-.522t-1.566-1.62q-.558-1.116-.558-2.826 0-1.8.594-2.916.612-1.116 1.638-1.638 1.044-.54 2.376-.54.81 0 1.512.18.72.162 1.188.378l-.576 1.548a9 9 0 0 0-1.08-.342 4.3 4.3 0 0 0-1.062-.144q-.9 0-1.494.396-.576.378-.864 1.152-.27.756-.27 1.908 0 1.098.288 1.854t.846 1.152q.576.378 1.422.378.81 0 1.422-.18t1.152-.468v1.656a4 4 0 0 1-1.134.45q-.612.144-1.494.144m6.808-7.02q0 .378-.036.864-.018.486-.054.9h.054l.342-.432.432-.54a10 10 0 0 1 .396-.468l2.97-3.186h2.214l-3.906 4.158L119.979 19h-2.25l-3.204-4.338-1.116.936V19h-1.89V5.32h1.89z"
></path>
</svg>
</div>
);
}

View file

@ -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);
}

View file

@ -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
},
};

View file

@ -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<string, string> = {};
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<string, string> = {};
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({});

View file

@ -1,4 +1,4 @@
export default function Icon({ size = 16 }: { size?: number }) {
export default function Logomark({ size = 16 }: { size?: number }) {
return (
<svg
width={size}
@ -10,11 +10,11 @@ export default function Icon({ size = 16 }: { size?: number }) {
<g clipPath="url(#clip0_614_1164)">
<path
d="M256 0L477.703 128V384L256 512L34.2975 384V128L256 0Z"
fill="var(--color-text-brand)"
fill="var(--color-bg-brand)"
/>
<path
d="M311.365 84.4663C314.818 86.9946 316.431 92.1862 315.256 96.9926L284.313 223.563H341.409C344.836 223.563 347.936 226.127 349.295 230.086C350.655 234.046 350.014 238.644 347.665 241.786L210.211 425.598C207.472 429.26 203.089 430.062 199.635 427.534C196.182 425.005 194.569 419.814 195.744 415.007L226.686 288.437H169.591C166.164 288.437 163.064 285.873 161.705 281.914C160.345 277.954 160.986 273.356 163.335 270.214L300.789 86.4023C303.528 82.7403 307.911 81.938 311.365 84.4663Z"
fill="var(--color-text-inverted)"
fill="var(--color-bg-body)"
/>
</g>
<defs>

View file

@ -0,0 +1,28 @@
import React from 'react';
import Logomark from './Logomark';
export default function Wordmark() {
return (
<div className="align-items-center d-flex">
<div
className="me-2"
style={{
display: 'inline-flex',
alignItems: 'center',
}}
>
<Logomark size={20} />
</div>
<span
className="fw-bold mono"
style={{
fontSize: 15,
}}
>
HyperDX
</span>
</div>
);
}

View file

@ -1,5 +1,14 @@
/* Dark Mode (default) */
[data-mantine-color-scheme='dark'] {
/* HyperDX Theme Design Tokens */
/* Default theme with green accent */
/*
* These mixins define all design tokens for HyperDX theme.
* They are used both here (for .theme-hyperdx scoped selectors) and
* in _base-tokens.scss (for unscoped fallback selectors during SSR).
*/
@mixin dark-mode-tokens {
/* Backgrounds */
--color-bg-body: var(--mantine-color-dark-9);
--color-bg-surface: var(--mantine-color-dark-5);
@ -15,10 +24,17 @@
--color-bg-field: var(--mantine-color-dark-6);
--color-bg-field-highlighted: var(--mantine-color-dark-3);
--color-bg-neutral: var(--mantine-color-dark-4);
--color-bg-brand: var(--mantine-color-green-4);
--color-bg-brand-hover: var(--mantine-color-green-3);
--color-bg-success: var(--mantine-color-green-5);
--color-bg-danger: var(--mantine-color-red-4);
--color-bg-warning: var(--mantine-color-orange-5);
/* Primary Button */
--color-primary-button-bg: var(--palette-brand-300);
--color-primary-button-bg-hover: var(--color-brand-200);
--color-primary-button-text: var(--color-text-inverted);
/* Borders & Dividers */
--color-border: var(--mantine-color-dark-5);
--color-border-muted: rgb(255 255 255 / 8%);
@ -31,10 +47,10 @@
--color-text-secondary: var(--mantine-color-dark-2);
--color-text-muted: var(--mantine-color-dark-2);
--color-text-muted-hover: var(--mantine-color-dark-1);
--color-text-success: var(--mantine-color-green-4);
--color-text-success-hover: var(--mantine-color-green-3);
--color-text-brand: var(--mantine-color-green-4);
--color-text-brand-hover: var(--mantine-color-green-3);
--color-text-success: var(--mantine-color-green-4);
--color-text-success-hover: var(--mantine-color-green-3);
--color-text-danger: var(--mantine-color-red-3);
--color-text-sidenav-link: var(--mantine-color-dark-0);
--color-text-sidenav-link-active: var(--color-text-primary);
@ -94,8 +110,7 @@
--mantine-color-body: var(--color-bg-body) !important;
}
/* Light Mode */
[data-mantine-color-scheme='light'] {
@mixin light-mode-tokens {
/* Mantine Overrides */
--mantine-color-body: var(--color-bg-body);
@ -115,6 +130,8 @@
--color-bg-field: #ececf5;
--color-bg-field-highlighted: var(--mantine-color-white);
--color-bg-neutral: var(--mantine-color-gray-5);
--color-bg-brand: var(--mantine-color-green-8);
--color-bg-brand-hover: var(--mantine-color-green-9);
--color-bg-success: var(--mantine-color-green-8);
--color-bg-danger: var(--mantine-color-red-8);
--color-bg-warning: var(--mantine-color-orange-8);
@ -131,10 +148,10 @@
--color-text-secondary: var(--mantine-color-gray-5);
--color-text-muted: var(--mantine-color-dark-6);
--color-text-muted-hover: var(--mantine-color-dark-9);
--color-text-success: var(--mantine-color-green-8);
--color-text-success-hover: var(--mantine-color-green-9);
--color-text-brand: var(--mantine-color-green-8);
--color-text-brand-hover: var(--mantine-color-green-9);
--color-text-success: var(--mantine-color-green-8);
--color-text-success-hover: var(--mantine-color-green-9);
--color-text-danger: var(--mantine-color-red-8);
--color-text-sidenav-link: var(--mantine-color-dark-6);
--color-text-sidenav-link-active: var(--mantine-color-green-9);
@ -188,3 +205,13 @@
--color-chart-error-highlight: #ffa090;
--color-chart-warning-highlight: #f5c94d;
}
/* Dark Mode - scoped to .theme-hyperdx */
.theme-hyperdx[data-mantine-color-scheme='dark'] {
@include dark-mode-tokens;
}
/* Light Mode - scoped to .theme-hyperdx */
.theme-hyperdx[data-mantine-color-scheme='light'] {
@include light-mode-tokens;
}

View file

@ -0,0 +1,21 @@
import { ThemeConfig } from '../../types';
import Logomark from './Logomark';
import { theme } from './mantineTheme';
import Wordmark from './Wordmark';
export const hyperdxTheme: ThemeConfig = {
name: 'hyperdx',
displayName: 'HyperDX',
mantineTheme: theme,
Wordmark,
Logomark,
cssClass: 'theme-hyperdx',
favicon: {
svg: '/favicons/hyperdx/favicon.svg',
png32: '/favicons/hyperdx/favicon-32x32.png',
png16: '/favicons/hyperdx/favicon-16x16.png',
appleTouchIcon: '/favicons/hyperdx/apple-touch-icon.png',
themeColor: '#25292e', // Dark background
},
};

View file

@ -5,11 +5,13 @@ import {
MantineThemeOverride,
rem,
Select,
Slider,
Tabs,
Text,
Tooltip,
} from '@mantine/core';
import focusClasses from '../../styles/focus.module.scss';
import focusClasses from '../../../../styles/focus.module.scss';
export const makeTheme = ({
fontFamily = '"IBM Plex Sans", monospace',
@ -115,6 +117,16 @@ export const makeTheme = ({
},
},
}),
Slider: Slider.extend({
styles: {
bar: {
backgroundColor: 'var(--color-bg-brand)',
},
thumb: {
borderColor: 'var(--color-bg-brand)',
},
},
}),
Input: {
styles: {
input: {
@ -261,6 +273,13 @@ export const makeTheme = ({
},
},
},
Tabs: Tabs.extend({
vars: () => ({
root: {
'--tabs-color': 'var(--color-text-brand)',
},
}),
}),
ActionIcon: ActionIcon.extend({
defaultProps: {
variant: 'subtle',

View file

@ -0,0 +1,64 @@
import React from 'react';
import { MantineThemeOverride } from '@mantine/core';
/**
* ============================================================================
* THEMING CONCEPTS: Color Mode vs Brand Theme
* ============================================================================
*
* This codebase has TWO separate theming concepts:
*
* 1. COLOR MODE (light/dark)
* - User-selectable preference stored in `useUserPreferences().colorMode`
* - Affects visual appearance: backgrounds, text colors, etc.
* - Managed by Mantine's color scheme system
* - Persisted to localStorage via `hdx-user-preferences`
*
* 2. BRAND THEME (hyperdx/clickstack)
* - Deployment-configured, NOT user-selectable in production
* - Set via `NEXT_PUBLIC_THEME` environment variable
* - Affects branding: logos, accent colors, favicons
* - Each deployment is branded for one specific product
* - Dev mode allows switching via localStorage (set via dev UI)
*
* WHY SEPARATE?
* - Color mode is personal preference (accessibility, comfort)
* - Brand theme is business identity (product differentiation)
* - A ClickStack deployment should never show HyperDX branding, regardless of color mode
*
* @see useUserPreferences - manages colorMode (user preference)
* @see AppThemeProvider - manages brand theme (deployment config)
*/
/**
* Brand theme identifier.
* This is DEPLOYMENT-CONFIGURED, not user-selectable in production.
*/
export type ThemeName = 'hyperdx' | 'clickstack';
/**
* Favicon configuration for a theme.
* Modern best practice includes multiple formats for broad compatibility.
*/
export interface FaviconConfig {
/** SVG favicon - best for modern browsers, scalable */
svg: string;
/** PNG 32x32 - standard fallback */
png32: string;
/** PNG 16x16 - for small contexts */
png16: string;
/** Apple Touch Icon 180x180 - for iOS home screen */
appleTouchIcon: string;
/** Theme color for browser UI (address bar, etc.) */
themeColor: string;
}
export interface ThemeConfig {
name: ThemeName;
displayName: string;
mantineTheme: MantineThemeOverride;
Wordmark: React.ComponentType;
Logomark: React.ComponentType<{ size?: number }>;
cssClass: string; // Applied to html element for CSS variable scoping
favicon: FaviconConfig;
}

View file

@ -6,24 +6,198 @@ import { atomWithStorage } from 'jotai/utils';
export type UserPreferences = {
isUTC: boolean;
timeFormat: '12h' | '24h';
theme: 'light' | 'dark';
/** Color mode preference (light/dark). Separate from brand theme (hyperdx/clickstack). */
colorMode: 'light' | 'dark';
font: 'IBM Plex Mono' | 'Roboto Mono' | 'Inter' | 'Roboto';
backgroundEnabled?: boolean;
backgroundUrl?: string;
backgroundBlur?: number;
backgroundOpacity?: number;
backgroundBlendMode?: string;
expandSidebarHeader?: boolean;
};
export const userPreferencesAtom = atomWithStorage<UserPreferences>(
'hdx-user-preferences',
{
isUTC: false,
timeFormat: '12h',
theme: 'dark',
font: 'IBM Plex Mono',
// Legacy type for migration
type LegacyUserPreferences = Omit<UserPreferences, 'colorMode'> & {
theme?: 'light' | 'dark';
};
const STORAGE_KEY = 'hdx-user-preferences';
const DEFAULT_PREFERENCES: UserPreferences = {
isUTC: false,
timeFormat: '12h',
colorMode: 'dark',
font: 'IBM Plex Mono',
};
// Cache migration result in memory to avoid repeated localStorage writes
// This cache stores the migrated result for a given stored value
let migrationCache: {
storedValue: string;
result: UserPreferences | null;
} | null = null;
/**
* Type guard to check if an object is a valid UserPreferences (already migrated).
*/
function isUserPreferences(obj: unknown): obj is UserPreferences {
if (typeof obj !== 'object' || obj === null) {
return false;
}
return (
'colorMode' in obj &&
typeof (obj as { colorMode: unknown }).colorMode === 'string' &&
((obj as { colorMode: string }).colorMode === 'light' ||
(obj as { colorMode: string }).colorMode === 'dark')
);
}
/**
* Type guard to check if an object is a LegacyUserPreferences (needs migration).
*/
function isLegacyUserPreferences(obj: unknown): obj is LegacyUserPreferences {
if (typeof obj !== 'object' || obj === null) {
return false;
}
const hasTheme = 'theme' in obj;
const hasColorMode = 'colorMode' in obj;
if (!hasTheme || hasColorMode) {
return false;
}
const theme = (obj as { theme?: unknown }).theme;
// Validate theme is either undefined or a valid color mode value
return (
theme === undefined ||
(typeof theme === 'string' && (theme === 'light' || theme === 'dark'))
);
}
/**
* Migrates old localStorage data from `theme` to `colorMode`.
* This ensures existing users don't lose their light/dark mode preference.
*
* Uses an in-memory cache to avoid repeated localStorage writes on every read.
*
* @internal Exported for testing only
*/
export function migrateUserPreferences(
stored: string | null,
): UserPreferences | null {
if (!stored) {
// Clear cache if storage is empty
migrationCache = null;
return null;
}
// Check cache first - if we've already processed this exact value, return cached result
if (migrationCache && migrationCache.storedValue === stored) {
return migrationCache.result;
}
try {
const parsed: unknown = JSON.parse(stored);
// Check if migration is needed (old format has `theme` instead of `colorMode`)
if (isLegacyUserPreferences(parsed)) {
// Use destructuring to exclude `theme` property for better type safety
const { theme, ...rest } = parsed;
// Ensure theme is valid before using it
const validTheme: 'light' | 'dark' =
theme === 'light' || theme === 'dark'
? theme
: DEFAULT_PREFERENCES.colorMode;
const migrated: UserPreferences = {
...DEFAULT_PREFERENCES,
...rest,
colorMode: validTheme,
};
// Only write to localStorage if the migrated data differs from what's stored
// This prevents unnecessary writes on every render and avoids race conditions
try {
if (typeof window !== 'undefined') {
const migratedJson = JSON.stringify(migrated);
// Compare with current stored value to avoid unnecessary write
const currentStored = localStorage.getItem(STORAGE_KEY);
if (currentStored !== migratedJson) {
localStorage.setItem(STORAGE_KEY, migratedJson);
}
}
} catch {
// Ignore localStorage errors (private browsing, etc.)
}
// Cache the result to avoid re-processing on subsequent calls
migrationCache = {
storedValue: stored,
result: migrated,
};
return migrated;
}
// Already migrated or new format - validate it's a proper UserPreferences
if (isUserPreferences(parsed)) {
// Cache the result to avoid re-processing on subsequent calls
migrationCache = {
storedValue: stored,
result: parsed,
};
return parsed;
}
// Invalid format, return null to use defaults
migrationCache = {
storedValue: stored,
result: null,
};
return null;
} catch {
// Invalid JSON, return null to use defaults
migrationCache = null;
return null;
}
}
// Custom storage implementation with migration support
const storageWithMigration = {
getItem: (key: string, initialValue: UserPreferences): UserPreferences => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const stored = localStorage.getItem(key);
const migrated = migrateUserPreferences(stored);
return migrated ?? initialValue;
} catch {
return initialValue;
}
},
setItem: (key: string, value: UserPreferences): void => {
try {
if (typeof window !== 'undefined') {
localStorage.setItem(key, JSON.stringify(value));
}
} catch {
// Ignore localStorage errors (private browsing, etc.)
}
},
removeItem: (key: string): void => {
try {
if (typeof window !== 'undefined') {
localStorage.removeItem(key);
}
} catch {
// Ignore localStorage errors (private browsing, etc.)
}
},
};
export const userPreferencesAtom = atomWithStorage<UserPreferences>(
STORAGE_KEY,
DEFAULT_PREFERENCES,
storageWithMigration,
);
export const useUserPreferences = () => {
@ -42,27 +216,3 @@ export const useUserPreferences = () => {
return { userPreferences, setUserPreference };
};
export const useBackground = (prefs: UserPreferences) => {
if (!prefs.backgroundEnabled || !prefs.backgroundUrl) {
return null;
}
const blurOffset = -1.5 * (prefs.backgroundBlur || 0) + 'px';
return (
<div
className="hdx-background-image"
style={{
backgroundImage: `url(${prefs.backgroundUrl})`,
opacity: prefs.backgroundOpacity ?? 0.1,
top: blurOffset,
left: blurOffset,
right: blurOffset,
bottom: blurOffset,
filter: `blur(${prefs.backgroundBlur}px)`,
mixBlendMode: (prefs.backgroundBlendMode as any) ?? 'screen',
}}
/>
);
};

View file

@ -1,7 +1,7 @@
/* stylelint-disable */
// stylelint adds '}}' to end of file.. not sure why. Disabled for now.
@use './_semantic-colors';
@use '../src/theme/themes/base-tokens';
// Bootstrap utilities only (d-flex, flex-column, m-*, p-*, etc.)
@use './_bootstrap-utilities';
@ -285,20 +285,6 @@ button:focus-visible {
}
}
.hdx-background-image {
position: fixed;
inset: 0;
background-size: cover;
background-position: center center;
background-repeat: no-repeat;
mix-blend-mode: screen;
z-index: 9999999;
pointer-events: none;
}
[data-mantine-color-scheme='light'] .hdx-background-image {
display: none;
}
a.mantine-focus-auto:hover {
color: inherit;