Skip to content

Commit 72d4642

Browse files
authored
feat: add link variant for Button and ActionIcon (#1938)
## Summary - Adds a new `variant="link"` for `Button` and `ActionIcon` that renders as a plain text link — no background, no border, no padding. Text is muted by default and brightens on hover (adapts to light/dark mode). - Adds a **Brand** theme switcher to the Storybook toolbar so stories can be previewed in both HyperDX and ClickStack themes. - Documents the new variant in `code_style.md` and adds comprehensive Storybook stories. ## Changes | File | What | |------|------| | `themes/hyperdx/mantineTheme.ts` | `link` variant for Button (`vars` + `classNames`) and ActionIcon (`vars` + `classNames`) | | `themes/clickstack/mantineTheme.ts` | Same for ClickStack theme | | `styles/variants.module.scss` | Hover color transition and transparent disabled state for link variants | | `stories/Button.stories.tsx` | Link variant in CustomVariants, DisabledStates, LoadingStates | | `stories/ActionIcon.stories.tsx` | Link variant in CustomVariants, DisabledStates, LoadingStates | | `.storybook/preview.tsx` | Brand theme switcher (HyperDX / ClickStack) in toolbar | | `agent_docs/code_style.md` | Documented link variant usage and guidelines | ## Test plan - [ ] Verify `variant="link"` renders without background/border/padding in Storybook - [ ] Verify hover brightens text in both light and dark modes - [ ] Verify disabled state shows reduced opacity with no background - [ ] Switch brand theme in Storybook toolbar and confirm both HyperDX and ClickStack render correctly Made with [Cursor](https://cursor.com)
1 parent 69cf33c commit 72d4642

8 files changed

Lines changed: 202 additions & 9 deletions

File tree

.changeset/link-button-variant.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/app": patch
3+
---
4+
5+
feat: Add `link` variant for Button and ActionIcon components

agent_docs/code_style.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ The project uses Mantine UI with **custom variants** defined in `packages/app/sr
3636
| `variant="primary"` | Primary actions (Submit, Save, Create, Run) | `<Button variant="primary">Save</Button>` |
3737
| `variant="secondary"` | Secondary actions (Cancel, Clear, auxiliary actions) | `<Button variant="secondary">Cancel</Button>` |
3838
| `variant="danger"` | Destructive actions (Delete, Remove, Rotate API Key) | `<Button variant="danger">Delete</Button>` |
39+
| `variant="link"` | Link-style actions with no background or border (View Details, navigation-style CTAs) | `<Button variant="link">View Details</Button>` |
3940

4041
### DO NOT USE (Forbidden Patterns)
4142

@@ -58,11 +59,15 @@ The following patterns are **NOT ALLOWED** for Button and ActionIcon:
5859
<Button variant="primary">Save</Button>
5960
<Button variant="secondary">Cancel</Button>
6061
<Button variant="danger">Delete</Button>
62+
<Button variant="link">View Details</Button>
6163
<ActionIcon variant="primary">...</ActionIcon>
6264
<ActionIcon variant="secondary">...</ActionIcon>
6365
<ActionIcon variant="danger">...</ActionIcon>
66+
<ActionIcon variant="link">...</ActionIcon>
6467
```
6568

69+
**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").
70+
6671
**Note**: `variant="filled"` is still valid for **form inputs** (Select, TextInput, etc.), just not for Button/ActionIcon.
6772

6873
### Icon-Only Buttons → ActionIcon

packages/app/.storybook/preview.tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
77

88
import { ibmPlexMono, inter, roboto, robotoMono } from '../src/fonts';
99
import { meHandler } from '../src/mocks/handlers';
10+
import { AppThemeProvider } from '../src/theme/ThemeProvider';
11+
import { ThemeName } from '../src/theme/types';
1012
import { ThemeWrapper } from '../src/ThemeWrapper';
1113

1214
import '@mantine/core/styles.css';
@@ -39,6 +41,19 @@ export const globalTypes = {
3941
],
4042
},
4143
},
44+
brand: {
45+
name: 'Brand',
46+
description: 'Brand theme',
47+
defaultValue: 'hyperdx',
48+
toolbar: {
49+
icon: 'paintbrush',
50+
title: 'Brand',
51+
items: [
52+
{ value: 'hyperdx', title: 'HyperDX' },
53+
{ value: 'clickstack', title: 'ClickStack' },
54+
],
55+
},
56+
},
4257
font: {
4358
name: 'Font',
4459
description: 'App font family',
@@ -79,23 +94,25 @@ const createQueryClient = () =>
7994
const preview: Preview = {
8095
decorators: [
8196
(Story, context) => {
82-
// Create a fresh QueryClient for each story render
8397
const [queryClient] = React.useState(() => createQueryClient());
8498

8599
const selectedFont = context.globals.font || 'inter';
86100
const font = fontMap[selectedFont as keyof typeof fontMap] || inter;
87101
const fontFamily = font.style.fontFamily;
102+
const brandTheme = (context.globals.brand || 'hyperdx') as ThemeName;
88103

89104
return (
90105
<div className={font.className}>
91106
<QueryClientProvider client={queryClient}>
92107
<QueryParamProvider adapter={NextAdapter}>
93-
<ThemeWrapper
94-
colorScheme={context.globals.theme || 'light'}
95-
fontFamily={fontFamily}
96-
>
97-
<Story />
98-
</ThemeWrapper>
108+
<AppThemeProvider themeName={brandTheme}>
109+
<ThemeWrapper
110+
colorScheme={context.globals.theme || 'light'}
111+
fontFamily={fontFamily}
112+
>
113+
<Story />
114+
</ThemeWrapper>
115+
</AppThemeProvider>
99116
</QueryParamProvider>
100117
</QueryClientProvider>
101118
</div>

packages/app/src/stories/ActionIcon.stories.tsx

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs';
33
import {
44
IconCheck,
55
IconEdit,
6+
IconExternalLink,
67
IconLoader2,
78
IconPlus,
89
IconSettings,
@@ -19,7 +20,7 @@ const meta: Meta<typeof ActionIcon> = {
1920
argTypes: {
2021
variant: {
2122
control: 'select',
22-
options: ['primary', 'secondary', 'danger'],
23+
options: ['primary', 'secondary', 'danger', 'link'],
2324
},
2425
size: {
2526
control: 'select',
@@ -109,6 +110,26 @@ export const CustomVariants = () => (
109110
</ActionIcon>
110111
</Group>
111112
</div>
113+
114+
<div>
115+
<Text size="sm" fw={600} mb="xs">
116+
Link
117+
</Text>
118+
<Group>
119+
<ActionIcon variant="link" size="sm">
120+
<IconExternalLink size={16} />
121+
</ActionIcon>
122+
<ActionIcon variant="link" size="md">
123+
<IconExternalLink size={18} />
124+
</ActionIcon>
125+
<ActionIcon variant="link" size="lg">
126+
<IconExternalLink size={20} />
127+
</ActionIcon>
128+
<ActionIcon variant="link" disabled>
129+
<IconExternalLink size={18} />
130+
</ActionIcon>
131+
</Group>
132+
</div>
112133
</Stack>
113134
);
114135

@@ -201,6 +222,26 @@ export const DisabledStates = () => (
201222
</Group>
202223
</div>
203224

225+
<div>
226+
<Text size="sm" fw={600} mb="xs">
227+
Link - Normal vs Disabled
228+
</Text>
229+
<Group>
230+
<ActionIcon variant="link" size="md">
231+
<IconExternalLink size={18} />
232+
</ActionIcon>
233+
<ActionIcon variant="link" size="md" disabled>
234+
<IconExternalLink size={18} />
235+
</ActionIcon>
236+
<ActionIcon variant="link" size="lg">
237+
<IconExternalLink size={20} />
238+
</ActionIcon>
239+
<ActionIcon variant="link" size="lg" disabled>
240+
<IconExternalLink size={20} />
241+
</ActionIcon>
242+
</Group>
243+
</div>
244+
204245
<div>
205246
<Text size="sm" fw={600} mb="xs">
206247
Subtle - Normal vs Disabled
@@ -269,6 +310,9 @@ export const LoadingStates = () => (
269310
<ActionIcon variant="danger" loading>
270311
<IconTrash size={18} />
271312
</ActionIcon>
313+
<ActionIcon variant="link" loading>
314+
<IconExternalLink size={18} />
315+
</ActionIcon>
272316
<ActionIcon variant="subtle" loading>
273317
<IconSettings size={18} />
274318
</ActionIcon>

packages/app/src/stories/Button.stories.tsx

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs';
33
import {
44
IconArrowRight,
55
IconCheck,
6+
IconExternalLink,
67
IconLoader2,
78
IconPlus,
89
IconTrash,
@@ -17,7 +18,7 @@ const meta: Meta<typeof Button> = {
1718
argTypes: {
1819
variant: {
1920
control: 'select',
20-
options: ['primary', 'secondary', 'danger'],
21+
options: ['primary', 'secondary', 'danger', 'link'],
2122
},
2223
size: {
2324
control: 'select',
@@ -95,6 +96,21 @@ export const CustomVariants = () => (
9596
</Button>
9697
</Group>
9798
</div>
99+
100+
<div>
101+
<Text size="sm" fw={600} mb="xs">
102+
Link
103+
</Text>
104+
<Group>
105+
<Button variant="link">Link</Button>
106+
<Button variant="link" rightSection={<IconExternalLink size={16} />}>
107+
View Details
108+
</Button>
109+
<Button variant="link" disabled>
110+
Disabled
111+
</Button>
112+
</Group>
113+
</div>
98114
</Stack>
99115
);
100116

@@ -170,6 +186,28 @@ export const DisabledStates = () => (
170186
</Group>
171187
</div>
172188

189+
<div>
190+
<Text size="sm" fw={600} mb="xs">
191+
Link - Normal vs Disabled
192+
</Text>
193+
<Group>
194+
<Button variant="link">Normal</Button>
195+
<Button variant="link" disabled>
196+
Disabled
197+
</Button>
198+
<Button variant="link" rightSection={<IconExternalLink size={16} />}>
199+
With Icon
200+
</Button>
201+
<Button
202+
variant="link"
203+
rightSection={<IconExternalLink size={16} />}
204+
disabled
205+
>
206+
Disabled with Icon
207+
</Button>
208+
</Group>
209+
</div>
210+
173211
<div>
174212
<Text size="sm" fw={600} mb="xs">
175213
All Sizes - Disabled
@@ -215,6 +253,9 @@ export const LoadingStates = () => (
215253
<Button variant="danger" loading>
216254
Danger Loading
217255
</Button>
256+
<Button variant="link" loading>
257+
Link Loading
258+
</Button>
218259
</Group>
219260
</div>
220261

packages/app/src/theme/themes/clickstack/mantineTheme.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
Tooltip,
1313
} from '@mantine/core';
1414

15+
import variantClasses from '../../../../styles/variants.module.scss';
16+
1517
/**
1618
* ClickStack Theme
1719
*
@@ -218,6 +220,12 @@ export const makeTheme = ({
218220
defaultProps: {
219221
variant: 'primary',
220222
},
223+
classNames: (_theme, props) => {
224+
if (props.variant === 'link') {
225+
return { root: variantClasses.buttonLink };
226+
}
227+
return {};
228+
},
221229
vars: (_theme, props) => {
222230
const baseVars: Record<string, string> = {};
223231

@@ -248,6 +256,14 @@ export const makeTheme = ({
248256
baseVars['--button-color'] = 'var(--mantine-color-red-light-color)';
249257
}
250258

259+
if (props.variant === 'link') {
260+
baseVars['--button-bg'] = 'transparent';
261+
baseVars['--button-hover'] = 'transparent';
262+
baseVars['--button-color'] = 'var(--color-text-secondary)';
263+
baseVars['--button-bd'] = 'none';
264+
baseVars['--button-padding-x'] = '0';
265+
}
266+
251267
return { root: baseVars };
252268
},
253269
}),
@@ -273,6 +289,12 @@ export const makeTheme = ({
273289
variant: 'subtle',
274290
color: 'gray',
275291
},
292+
classNames: (_theme, props) => {
293+
if (props.variant === 'link') {
294+
return { root: variantClasses.actionIconLink };
295+
}
296+
return {};
297+
},
276298
vars: (_theme, props) => {
277299
const baseVars: Record<string, string> = {};
278300

@@ -308,6 +330,13 @@ export const makeTheme = ({
308330
baseVars['--ai-color'] = 'var(--mantine-color-red-light-color)';
309331
}
310332

333+
if (props.variant === 'link') {
334+
baseVars['--ai-bg'] = 'transparent';
335+
baseVars['--ai-hover'] = 'transparent';
336+
baseVars['--ai-color'] = 'var(--color-text-secondary)';
337+
baseVars['--ai-bd'] = 'none';
338+
}
339+
311340
return { root: baseVars };
312341
},
313342
}),

packages/app/src/theme/themes/hyperdx/mantineTheme.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '@mantine/core';
1414

1515
import focusClasses from '../../../../styles/focus.module.scss';
16+
import variantClasses from '../../../../styles/variants.module.scss';
1617

1718
export const makeTheme = ({
1819
fontFamily = '"IBM Plex Sans", monospace',
@@ -235,6 +236,12 @@ export const makeTheme = ({
235236
defaultProps: {
236237
variant: 'primary',
237238
},
239+
classNames: (_theme, props) => {
240+
if (props.variant === 'link') {
241+
return { root: variantClasses.buttonLink };
242+
}
243+
return {};
244+
},
238245
vars: (_theme, props) => {
239246
const baseVars: Record<string, string> = {};
240247

@@ -265,6 +272,14 @@ export const makeTheme = ({
265272
baseVars['--button-color'] = 'var(--mantine-color-red-light-color)';
266273
}
267274

275+
if (props.variant === 'link') {
276+
baseVars['--button-bg'] = 'transparent';
277+
baseVars['--button-hover'] = 'transparent';
278+
baseVars['--button-color'] = 'var(--color-text-secondary)';
279+
baseVars['--button-bd'] = 'none';
280+
baseVars['--button-padding-x'] = '0';
281+
}
282+
268283
return { root: baseVars };
269284
},
270285
}),
@@ -290,6 +305,12 @@ export const makeTheme = ({
290305
variant: 'subtle',
291306
color: 'gray',
292307
},
308+
classNames: (_theme, props) => {
309+
if (props.variant === 'link') {
310+
return { root: variantClasses.actionIconLink };
311+
}
312+
return {};
313+
},
293314
vars: (_theme, props) => {
294315
const baseVars: Record<string, string> = {};
295316

@@ -325,6 +346,13 @@ export const makeTheme = ({
325346
baseVars['--ai-color'] = 'var(--mantine-color-red-light-color)';
326347
}
327348

349+
if (props.variant === 'link') {
350+
baseVars['--ai-bg'] = 'transparent';
351+
baseVars['--ai-hover'] = 'transparent';
352+
baseVars['--ai-color'] = 'var(--color-text-secondary)';
353+
baseVars['--ai-bd'] = 'none';
354+
}
355+
328356
return { root: baseVars };
329357
},
330358
}),

0 commit comments

Comments
 (0)