diff --git a/.changeset/link-button-variant.md b/.changeset/link-button-variant.md
new file mode 100644
index 00000000..d43dbe98
--- /dev/null
+++ b/.changeset/link-button-variant.md
@@ -0,0 +1,5 @@
+---
+"@hyperdx/app": patch
+---
+
+feat: Add `link` variant for Button and ActionIcon components
diff --git a/agent_docs/code_style.md b/agent_docs/code_style.md
index 2e79b82a..6d42f333 100644
--- a/agent_docs/code_style.md
+++ b/agent_docs/code_style.md
@@ -36,6 +36,7 @@ The project uses Mantine UI with **custom variants** defined in `packages/app/sr
| `variant="primary"` | Primary actions (Submit, Save, Create, Run) | `` |
| `variant="secondary"` | Secondary actions (Cancel, Clear, auxiliary actions) | `` |
| `variant="danger"` | Destructive actions (Delete, Remove, Rotate API Key) | `` |
+| `variant="link"` | Link-style actions with no background or border (View Details, navigation-style CTAs) | `` |
### DO NOT USE (Forbidden Patterns)
@@ -58,11 +59,15 @@ The following patterns are **NOT ALLOWED** for Button and ActionIcon:
+
...
...
...
+...
```
+**Link variant details**: Renders with no background, no border, and muted text color. On hover, text brightens to full contrast. Use for link-style CTAs that should blend into surrounding content (e.g., "View Details", "View Full Trace").
+
**Note**: `variant="filled"` is still valid for **form inputs** (Select, TextInput, etc.), just not for Button/ActionIcon.
### Icon-Only Buttons → ActionIcon
diff --git a/packages/app/.storybook/preview.tsx b/packages/app/.storybook/preview.tsx
index 6ac5a540..1f9777fc 100644
--- a/packages/app/.storybook/preview.tsx
+++ b/packages/app/.storybook/preview.tsx
@@ -7,6 +7,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ibmPlexMono, inter, roboto, robotoMono } from '../src/fonts';
import { meHandler } from '../src/mocks/handlers';
+import { AppThemeProvider } from '../src/theme/ThemeProvider';
+import { ThemeName } from '../src/theme/types';
import { ThemeWrapper } from '../src/ThemeWrapper';
import '@mantine/core/styles.css';
@@ -39,6 +41,19 @@ export const globalTypes = {
],
},
},
+ brand: {
+ name: 'Brand',
+ description: 'Brand theme',
+ defaultValue: 'hyperdx',
+ toolbar: {
+ icon: 'paintbrush',
+ title: 'Brand',
+ items: [
+ { value: 'hyperdx', title: 'HyperDX' },
+ { value: 'clickstack', title: 'ClickStack' },
+ ],
+ },
+ },
font: {
name: 'Font',
description: 'App font family',
@@ -79,23 +94,25 @@ const createQueryClient = () =>
const preview: Preview = {
decorators: [
(Story, context) => {
- // Create a fresh QueryClient for each story render
const [queryClient] = React.useState(() => createQueryClient());
const selectedFont = context.globals.font || 'inter';
const font = fontMap[selectedFont as keyof typeof fontMap] || inter;
const fontFamily = font.style.fontFamily;
+ const brandTheme = (context.globals.brand || 'hyperdx') as ThemeName;
return (
diff --git a/packages/app/src/stories/ActionIcon.stories.tsx b/packages/app/src/stories/ActionIcon.stories.tsx
index 3700a292..f8a87e16 100644
--- a/packages/app/src/stories/ActionIcon.stories.tsx
+++ b/packages/app/src/stories/ActionIcon.stories.tsx
@@ -3,6 +3,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs';
import {
IconCheck,
IconEdit,
+ IconExternalLink,
IconLoader2,
IconPlus,
IconSettings,
@@ -19,7 +20,7 @@ const meta: Meta = {
argTypes: {
variant: {
control: 'select',
- options: ['primary', 'secondary', 'danger'],
+ options: ['primary', 'secondary', 'danger', 'link'],
},
size: {
control: 'select',
@@ -109,6 +110,26 @@ export const CustomVariants = () => (
+
+
+
+ Link
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
@@ -201,6 +222,26 @@ export const DisabledStates = () => (
+
+
+ Link - Normal vs Disabled
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Subtle - Normal vs Disabled
@@ -269,6 +310,9 @@ export const LoadingStates = () => (
+
+
+
diff --git a/packages/app/src/stories/Button.stories.tsx b/packages/app/src/stories/Button.stories.tsx
index 051199cf..6171a93d 100644
--- a/packages/app/src/stories/Button.stories.tsx
+++ b/packages/app/src/stories/Button.stories.tsx
@@ -3,6 +3,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs';
import {
IconArrowRight,
IconCheck,
+ IconExternalLink,
IconLoader2,
IconPlus,
IconTrash,
@@ -17,7 +18,7 @@ const meta: Meta = {
argTypes: {
variant: {
control: 'select',
- options: ['primary', 'secondary', 'danger'],
+ options: ['primary', 'secondary', 'danger', 'link'],
},
size: {
control: 'select',
@@ -95,6 +96,21 @@ export const CustomVariants = () => (
+
+
+
+ Link
+
+
+
+ }>
+ View Details
+
+
+
+
);
@@ -170,6 +186,28 @@ export const DisabledStates = () => (
+
+
+ Link - Normal vs Disabled
+
+
+
+
+ }>
+ With Icon
+
+ }
+ disabled
+ >
+ Disabled with Icon
+
+
+
+
All Sizes - Disabled
@@ -215,6 +253,9 @@ export const LoadingStates = () => (
+
diff --git a/packages/app/src/theme/themes/clickstack/mantineTheme.ts b/packages/app/src/theme/themes/clickstack/mantineTheme.ts
index 499c1106..ee6d6d91 100644
--- a/packages/app/src/theme/themes/clickstack/mantineTheme.ts
+++ b/packages/app/src/theme/themes/clickstack/mantineTheme.ts
@@ -12,6 +12,8 @@ import {
Tooltip,
} from '@mantine/core';
+import variantClasses from '../../../../styles/variants.module.scss';
+
/**
* ClickStack Theme
*
@@ -218,6 +220,12 @@ export const makeTheme = ({
defaultProps: {
variant: 'primary',
},
+ classNames: (_theme, props) => {
+ if (props.variant === 'link') {
+ return { root: variantClasses.buttonLink };
+ }
+ return {};
+ },
vars: (_theme, props) => {
const baseVars: Record = {};
@@ -248,6 +256,14 @@ export const makeTheme = ({
baseVars['--button-color'] = 'var(--mantine-color-red-light-color)';
}
+ if (props.variant === 'link') {
+ baseVars['--button-bg'] = 'transparent';
+ baseVars['--button-hover'] = 'transparent';
+ baseVars['--button-color'] = 'var(--color-text-secondary)';
+ baseVars['--button-bd'] = 'none';
+ baseVars['--button-padding-x'] = '0';
+ }
+
return { root: baseVars };
},
}),
@@ -273,6 +289,12 @@ export const makeTheme = ({
variant: 'subtle',
color: 'gray',
},
+ classNames: (_theme, props) => {
+ if (props.variant === 'link') {
+ return { root: variantClasses.actionIconLink };
+ }
+ return {};
+ },
vars: (_theme, props) => {
const baseVars: Record = {};
@@ -308,6 +330,13 @@ export const makeTheme = ({
baseVars['--ai-color'] = 'var(--mantine-color-red-light-color)';
}
+ if (props.variant === 'link') {
+ baseVars['--ai-bg'] = 'transparent';
+ baseVars['--ai-hover'] = 'transparent';
+ baseVars['--ai-color'] = 'var(--color-text-secondary)';
+ baseVars['--ai-bd'] = 'none';
+ }
+
return { root: baseVars };
},
}),
diff --git a/packages/app/src/theme/themes/hyperdx/mantineTheme.ts b/packages/app/src/theme/themes/hyperdx/mantineTheme.ts
index 9b3bb2be..44b2147b 100644
--- a/packages/app/src/theme/themes/hyperdx/mantineTheme.ts
+++ b/packages/app/src/theme/themes/hyperdx/mantineTheme.ts
@@ -13,6 +13,7 @@ import {
} from '@mantine/core';
import focusClasses from '../../../../styles/focus.module.scss';
+import variantClasses from '../../../../styles/variants.module.scss';
export const makeTheme = ({
fontFamily = '"IBM Plex Sans", monospace',
@@ -235,6 +236,12 @@ export const makeTheme = ({
defaultProps: {
variant: 'primary',
},
+ classNames: (_theme, props) => {
+ if (props.variant === 'link') {
+ return { root: variantClasses.buttonLink };
+ }
+ return {};
+ },
vars: (_theme, props) => {
const baseVars: Record = {};
@@ -265,6 +272,14 @@ export const makeTheme = ({
baseVars['--button-color'] = 'var(--mantine-color-red-light-color)';
}
+ if (props.variant === 'link') {
+ baseVars['--button-bg'] = 'transparent';
+ baseVars['--button-hover'] = 'transparent';
+ baseVars['--button-color'] = 'var(--color-text-secondary)';
+ baseVars['--button-bd'] = 'none';
+ baseVars['--button-padding-x'] = '0';
+ }
+
return { root: baseVars };
},
}),
@@ -290,6 +305,12 @@ export const makeTheme = ({
variant: 'subtle',
color: 'gray',
},
+ classNames: (_theme, props) => {
+ if (props.variant === 'link') {
+ return { root: variantClasses.actionIconLink };
+ }
+ return {};
+ },
vars: (_theme, props) => {
const baseVars: Record = {};
@@ -325,6 +346,13 @@ export const makeTheme = ({
baseVars['--ai-color'] = 'var(--mantine-color-red-light-color)';
}
+ if (props.variant === 'link') {
+ baseVars['--ai-bg'] = 'transparent';
+ baseVars['--ai-hover'] = 'transparent';
+ baseVars['--ai-color'] = 'var(--color-text-secondary)';
+ baseVars['--ai-bd'] = 'none';
+ }
+
return { root: baseVars };
},
}),
diff --git a/packages/app/styles/variants.module.scss b/packages/app/styles/variants.module.scss
new file mode 100644
index 00000000..1ed9c0fc
--- /dev/null
+++ b/packages/app/styles/variants.module.scss
@@ -0,0 +1,24 @@
+// Hover: brighten text to full contrast.
+// Both Button and ActionIcon use SCSS for hover color because
+// Mantine only provides --button-color-hover (no ActionIcon equivalent).
+// Using SCSS for both keeps the approach consistent.
+.buttonLink:hover:not(:disabled, [data-disabled]) {
+ --button-color: var(--color-text);
+}
+
+.actionIconLink:hover:not(:disabled, [data-disabled]) {
+ --ai-color: var(--color-text);
+}
+
+// Disabled: override Mantine's built-in disabled background.
+// !important is required because Mantine applies disabled styles
+// via high-specificity internal selectors that CSS modules cannot beat.
+.buttonLink,
+.actionIconLink {
+ &:disabled,
+ &[data-disabled] {
+ background-color: transparent !important;
+ border: none !important;
+ opacity: 0.4;
+ }
+}