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!*
36
.changeset/light-penguins-do.md
Normal 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
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 770 B |
BIN
packages/app/public/favicons/clickstack/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
packages/app/public/favicons/clickstack/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 386 B |
BIN
packages/app/public/favicons/clickstack/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 587 B |
BIN
packages/app/public/favicons/clickstack/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
15
packages/app/public/favicons/clickstack/favicon.svg
Normal 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 |
BIN
packages/app/public/favicons/hyperdx/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
packages/app/public/favicons/hyperdx/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 511 B |
BIN
packages/app/public/favicons/hyperdx/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
6
packages/app/public/favicons/hyperdx/favicon.svg
Normal 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 |
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
361
packages/app/src/__tests__/useUserPreferences.test.tsx
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
89
packages/app/src/components/DynamicFavicon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
packages/app/src/components/__tests__/DynamicFavicon.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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.',
|
||||
},
|
||||
|
|
|
|||
234
packages/app/src/theme/ThemeProvider.tsx
Normal 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;
|
||||
}
|
||||
208
packages/app/src/theme/__tests__/ThemeProvider.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
213
packages/app/src/theme/__tests__/index.test.ts
Normal 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/');
|
||||
});
|
||||
});
|
||||
});
|
||||
275
packages/app/src/theme/index.ts
Normal 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';
|
||||
22
packages/app/src/theme/themes/_base-tokens.scss
Normal 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;
|
||||
}
|
||||
50
packages/app/src/theme/themes/clickstack/Logomark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
packages/app/src/theme/themes/clickstack/Wordmark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
326
packages/app/src/theme/themes/clickstack/_tokens.scss
Normal 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);
|
||||
}
|
||||
21
packages/app/src/theme/themes/clickstack/index.ts
Normal 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
|
||||
},
|
||||
};
|
||||
312
packages/app/src/theme/themes/clickstack/mantineTheme.ts
Normal 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({});
|
||||
|
|
@ -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>
|
||||
28
packages/app/src/theme/themes/hyperdx/Wordmark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
21
packages/app/src/theme/themes/hyperdx/index.ts
Normal 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
|
||||
},
|
||||
};
|
||||
|
|
@ -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',
|
||||
64
packages/app/src/theme/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||