Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/feat-ide-themes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@hyperdx/app': minor
---

feat: add IDE-inspired themes (Nord, Catppuccin, One Dark)

- Add Nord theme with Polar Night dark and Snow Storm light palettes
- Add Catppuccin theme with Mocha dark and Latte light palettes
- Add One Dark theme with One Dark dark and One Light palettes
- Extract shared chart color palette into a reusable `@mixin chart-tokens` in `_shared-chart-tokens.scss`
- Add `themes/_shared/Logomark` and `Wordmark` components for IDE themes β€” adaptive black/white based on color mode
- Extend `ThemeName` union and theme registry to support new theme names
- Add theming contributor guide at `agent_docs/theming.md`
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ directory:
- `agent_docs/development.md` - Development workflows, testing, and common tasks
- `agent_docs/code_style.md` - Code patterns and best practices (read only when
actively coding)
- `agent_docs/theming.md` - Theme system, CSS variable conventions, and how to
add new themes (read when working on UI theming)

**After finishing all code edits**, run `yarn lint:fix` to auto-fix formatting
and lint issues across all packages. Pre-commit hooks handle this when
Expand Down
1 change: 1 addition & 0 deletions agent_docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Instead of stuffing all instructions into `CLAUDE.md` (which goes into every con
- **`tech_stack.md`** - Technology choices, UI component patterns, library usage
- **`development.md`** - Development workflows, testing strategy, common tasks, debugging
- **`code_style.md`** - Code patterns and best practices (read only when actively coding)
- **`theming.md`** - Theme system overview, CSS variable conventions, and step-by-step guide for adding new themes

## Usage Pattern

Expand Down
235 changes: 235 additions & 0 deletions agent_docs/theming.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
# Theming

## Two independent theming concepts

The app has **two separate, orthogonal theming systems** that must not be confused:

| Concept | What it controls | Who sets it | Where it lives |
|---|---|---|---|
| **Color mode** | Dark / light appearance | User preference | `useUserPreferences().colorMode`, stored in `hdx-user-preferences` localStorage |
| **Brand theme** | Accent colors, logos, favicons | Deployment config | `NEXT_PUBLIC_THEME` env var, `hdx-dev-theme` localStorage (dev only) |

A single deployment is always one brand theme. Users can freely toggle light/dark within that brand. The CSS system handles both simultaneously via scoped selectors:

```css
.theme-nord[data-mantine-color-scheme='dark'] { … }
.theme-nord[data-mantine-color-scheme='light'] { … }
```

---

## File structure

```
packages/app/src/theme/
β”œβ”€β”€ types.ts # ThemeName union, ThemeConfig interface
β”œβ”€β”€ index.ts # Theme registry, Zod validation, getTheme()
β”œβ”€β”€ ThemeProvider.tsx # AppThemeProvider context + useAppTheme() hook
└── themes/
β”œβ”€β”€ _base-tokens.scss # SSR fallback β€” @use all theme token modules
β”œβ”€β”€ _shared-chart-tokens.scss # Shared @mixin chart-tokens (reused by all themes)
β”œβ”€β”€ _shared/
β”‚ β”œβ”€β”€ Logomark.tsx # Generic hex+bolt logo (text-colored, light/dark adaptive)
β”‚ └── Wordmark.tsx # Wordmark using the shared Logomark
β”œβ”€β”€ hyperdx/ # HyperDX brand (green accent)
β”‚ β”œβ”€β”€ _tokens.scss # dark-mode-tokens / light-mode-tokens mixins + scoped rules
β”‚ β”œβ”€β”€ mantineTheme.ts # MantineThemeOverride (primaryColor: 'green')
β”‚ β”œβ”€β”€ Logomark.tsx # Green hex+bolt logo
β”‚ β”œβ”€β”€ Wordmark.tsx
β”‚ └── index.ts # ThemeConfig export: hyperdxTheme
β”œβ”€β”€ clickstack/ # ClickStack brand (yellow accent, distinct logo)
β”‚ └── … # Same structure; unique Logomark/Wordmark
β”œβ”€β”€ nord/ # Nord IDE theme (blue accent)
β”‚ └── …
β”œβ”€β”€ catppuccin/ # Catppuccin Mocha/Latte (mauve accent)
β”‚ └── …
└── onedark/ # One Dark/Light (blue accent)
└── …
```

---

## How CSS variables are applied

1. `pages/_document.tsx` sets the initial `<html class="theme-{name}">` during SSR using `NEXT_PUBLIC_THEME`, so CSS variables are populated before JS runs.
2. Mantine sets `data-mantine-color-scheme="dark|light"` on `<html>` based on user preference.
3. Each theme's `_tokens.scss` declares two scoped blocks that match both attributes together:

```scss
.theme-nord[data-mantine-color-scheme='dark'] { @include dark-mode-tokens; }
.theme-nord[data-mantine-color-scheme='light'] { @include light-mode-tokens; }
```

4. `_base-tokens.scss` `@use`s every theme module so their scoped CSS blocks land in the compiled bundle. It also provides unscoped `[data-mantine-color-scheme]` fallback rules (using HyperDX tokens) for SSR before JS attaches the theme class.
5. `ThemeProvider.tsx` swaps the `theme-*` class on `document.documentElement` when the theme changes at runtime.

---

## Semantic CSS variables

Components must use the semantic `--color-*` custom properties, not raw Mantine palette values. All themes define the same variable names so components are theme-agnostic.

Key groups (defined in every `_tokens.scss`):

| Group | Example variables |
|---|---|
| Backgrounds | `--color-bg-body`, `--color-bg-surface`, `--color-bg-muted`, `--color-bg-hover`, `--color-bg-field`, `--color-bg-brand` |
| Text | `--color-text`, `--color-text-primary`, `--color-text-muted`, `--color-text-brand`, `--color-text-danger` |
| Borders | `--color-border`, `--color-border-emphasis`, `--color-border-muted` |
| Icons | `--color-icon-primary`, `--color-icon-muted` |
| States | `--color-state-hover`, `--color-state-selected`, `--color-outline-focus` |
| Primary button | `--color-primary-button-bg`, `--color-primary-button-bg-hover`, `--color-primary-button-text` |
| Sidenav | `--color-bg-sidenav`, `--color-bg-sidenav-link-active`, `--color-text-sidenav-link-active` |
| Slider | `--color-slider-bar`, `--color-slider-thumb`, `--color-slider-dot` |
| Code/kbd | `--color-bg-code`, `--color-border-code`, `--color-bg-kbd` |
| JSON highlighting | `--color-json-key`, `--color-json-string`, `--color-json-number`, … |
| Charts | `--color-chart-1` … `--color-chart-10`, `--color-chart-success`, `--color-chart-error`, … |

Full canonical list: `packages/app/src/theme/semanticColorsGrouped.ts`.

---

## Shared chart tokens

`themes/_shared-chart-tokens.scss` exports `@mixin chart-tokens` β€” the Observable 10 categorical palette used by every theme except HyperDX (which uses a green-first variant). Include it inside each mode's mixin to avoid duplication:

```scss
@use '../shared-chart-tokens' as chart;

@mixin dark-mode-tokens {
/* … theme-specific tokens … */
@include chart.chart-tokens;
}
```

---

## Logos

Three logo variants exist:

| Theme | Logomark | Source |
|---|---|---|
| `hyperdx` | Hex + bolt, filled with `--color-bg-brand` (green) | `themes/hyperdx/Logomark.tsx` |
| `clickstack` | Distinct bar-chart icon, `currentColor` | `themes/clickstack/Logomark.tsx` |
| All others | Hex + bolt, filled with `--color-text` (white/black by color mode) | `themes/_shared/Logomark.tsx` |

When adding a new IDE-inspired theme, import from `themes/_shared/`.

---

## Adding a new theme β€” checklist

### 1. Create the theme directory

```
themes/{name}/
β”œβ”€β”€ _tokens.scss
β”œβ”€β”€ mantineTheme.ts
└── index.ts
```

No separate Logomark/Wordmark needed β€” import from `themes/_shared/` unless this is a distinct brand with its own identity.

### 2. Write `_tokens.scss`

Define `@mixin dark-mode-tokens` and `@mixin light-mode-tokens` then apply them to scoped selectors:

```scss
@use '../shared-chart-tokens' as chart;

@mixin dark-mode-tokens {
--color-bg-body: #1e1e2e;
/* … all required --color-* vars … */
@include chart.chart-tokens;
--mantine-color-body: var(--color-bg-body) !important;
--mantine-color-text: var(--color-text);
}

@mixin light-mode-tokens {
/* … */
}

.theme-{name}[data-mantine-color-scheme='dark'] { @include dark-mode-tokens; }
.theme-{name}[data-mantine-color-scheme='light'] { @include light-mode-tokens; }
```

Use `_tokens.scss` from an existing IDE theme (`nord`, `catppuccin`, etc.) as a template β€” every required variable is already listed there.

### 3. Write `mantineTheme.ts`

Copy `themes/hyperdx/mantineTheme.ts` and adjust:
- `primaryColor` β€” the Mantine palette key that matches your accent (`'blue'`, `'teal'`, `'violet'`, etc.)
- `primaryShade` β€” `{ dark: N, light: N }` targeting your accent shade
- `colors` β€” optionally override the palette for exact hex values

The component overrides (`Button`, `ActionIcon`, `Tabs`, etc.) reference `--color-*` vars and can be copied verbatim.

### 4. Write `index.ts`

```ts
import { ThemeConfig } from '../../types';
import Logomark from '../_shared/Logomark';
import Wordmark from '../_shared/Wordmark';
import { theme } from './mantineTheme';

export const myTheme: ThemeConfig = {
name: 'mytheme', // must match ThemeName union
displayName: 'My Theme',
mantineTheme: theme,
Wordmark,
Logomark,
cssClass: 'theme-mytheme',
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: '#1e1e2e', // dominant dark-mode background
},
};
```

### 5. Register the theme in three places

`THEME_NAMES` in `types.ts` is the single source of truth β€” the `ThemeName` union, the Zod `z.enum(...)` in `theme/index.ts`, and the SSR allowlist in `pages/_document.tsx` all derive from it.

**`types.ts`** β€” add the name to the `THEME_NAMES` tuple:
```ts
export const THEME_NAMES = [
'hyperdx',
'clickstack',
…,
'mytheme',
] as const;
```

**`theme/index.ts`** β€” import and add to the registry:
```ts
import { myTheme } from './themes/mytheme';

export const themes: Record<ThemeName, ThemeConfig> = {
…
mytheme: myTheme,
};
```

**`themes/_base-tokens.scss`** β€” add the `@use` so the scoped CSS is bundled:
```scss
@use './mytheme/tokens';
```

---

## Switching themes in development

The brand theme is deployment-configured (`NEXT_PUBLIC_THEME`), but in dev/local mode it can be overridden without restarting:

```js
// Browser console
window.__HDX_THEME.set('nord') // switch to a theme
window.__HDX_THEME.toggle() // cycle between themes
window.__HDX_THEME.clear() // revert to env default
```

Or use the **Brand Theme** selector in the User Preferences modal (only visible when `NODE_ENV === 'development'` or `NEXT_PUBLIC_IS_LOCAL_MODE === 'true'`).
12 changes: 1 addition & 11 deletions packages/app/pages/_document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,7 @@ import { Head, Html, Main, NextScript } from 'next/document';

import { IS_CLICKHOUSE_BUILD } from '@/config';
import { ibmPlexMono, inter, roboto, robotoMono } from '@/fonts';

// Get theme class for SSR - must match ThemeProvider's resolution
// This ensures CSS variables are applied during server-side rendering
// to prevent hydration mismatch with button styling
function getThemeClass(): string {
const envTheme = process.env.NEXT_PUBLIC_THEME;
// Default to hyperdx if not set or invalid
const themeName =
envTheme === 'hyperdx' || envTheme === 'clickstack' ? envTheme : 'hyperdx';
return `theme-${themeName}`;
}
import { getThemeClass } from '@/theme/ssr';

export default function Document() {
const fontClasses = [
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/theme/__tests__/ThemeProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import React from 'react';
import { renderHook } from '@testing-library/react';

import { DEFAULT_THEME, themes } from '../index';
import { DEFAULT_THEME, THEME_NAMES, themes } from '../index';
import {
AppThemeProvider,
useAppTheme,
Expand Down Expand Up @@ -77,7 +77,7 @@ describe('ThemeProvider', () => {
const { result } = renderHook(() => useAppTheme(), { wrapper });

expect(result.current.availableThemes).toEqual(
expect.arrayContaining(['hyperdx', 'clickstack']),
expect.arrayContaining([...THEME_NAMES]),
);
});

Expand Down
34 changes: 33 additions & 1 deletion packages/app/src/theme/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
safeLocalStorageGet,
safeLocalStorageRemove,
safeLocalStorageSet,
THEME_NAMES,
THEME_STORAGE_KEY,
themes,
} from '../index';
Expand Down Expand Up @@ -44,6 +45,37 @@ describe('theme/index', () => {
expect(themes.clickstack.displayName).toBe('ClickStack');
});

it('should contain nord theme', () => {
expect(themes.nord).toBeDefined();
expect(themes.nord.name).toBe('nord');
expect(themes.nord.displayName).toBe('Nord');
expect(themes.nord.cssClass).toBe('theme-nord');
});

it('should contain catppuccin theme', () => {
expect(themes.catppuccin).toBeDefined();
expect(themes.catppuccin.name).toBe('catppuccin');
expect(themes.catppuccin.displayName).toBe('Catppuccin');
expect(themes.catppuccin.cssClass).toBe('theme-catppuccin');
});

it('should contain onedark theme', () => {
expect(themes.onedark).toBeDefined();
expect(themes.onedark.name).toBe('onedark');
expect(themes.onedark.displayName).toBe('One Dark');
expect(themes.onedark.cssClass).toBe('theme-onedark');
});

it('THEME_NAMES should match themes registry keys', () => {
expect([...THEME_NAMES].sort()).toEqual(Object.keys(themes).sort());
});

it('each theme cssClass should be `theme-${name}`', () => {
THEME_NAMES.forEach(name => {
expect(themes[name].cssClass).toBe(`theme-${name}`);
});
});

it('should have required properties for each theme', () => {
Object.values(themes).forEach(theme => {
expect(theme.name).toBeDefined();
Expand Down Expand Up @@ -75,7 +107,7 @@ describe('theme/index', () => {

describe('DEFAULT_THEME', () => {
it('should be a valid theme name', () => {
expect(['hyperdx', 'clickstack']).toContain(DEFAULT_THEME);
expect(THEME_NAMES).toContain(DEFAULT_THEME);
});

it('should exist in themes registry', () => {
Expand Down
Loading