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)
This commit is contained in:
Elizabet Oliveira 2026-03-18 18:25:36 +00:00 committed by GitHub
parent 69cf33cbe6
commit 72d4642b6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 202 additions and 9 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: Add `link` variant for Button and ActionIcon components

View file

@ -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) | `<Button variant="primary">Save</Button>` |
| `variant="secondary"` | Secondary actions (Cancel, Clear, auxiliary actions) | `<Button variant="secondary">Cancel</Button>` |
| `variant="danger"` | Destructive actions (Delete, Remove, Rotate API Key) | `<Button variant="danger">Delete</Button>` |
| `variant="link"` | Link-style actions with no background or border (View Details, navigation-style CTAs) | `<Button variant="link">View Details</Button>` |
### DO NOT USE (Forbidden Patterns)
@ -58,11 +59,15 @@ The following patterns are **NOT ALLOWED** for Button and ActionIcon:
<Button variant="primary">Save</Button>
<Button variant="secondary">Cancel</Button>
<Button variant="danger">Delete</Button>
<Button variant="link">View Details</Button>
<ActionIcon variant="primary">...</ActionIcon>
<ActionIcon variant="secondary">...</ActionIcon>
<ActionIcon variant="danger">...</ActionIcon>
<ActionIcon variant="link">...</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

View file

@ -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 (
<div className={font.className}>
<QueryClientProvider client={queryClient}>
<QueryParamProvider adapter={NextAdapter}>
<ThemeWrapper
colorScheme={context.globals.theme || 'light'}
fontFamily={fontFamily}
>
<Story />
</ThemeWrapper>
<AppThemeProvider themeName={brandTheme}>
<ThemeWrapper
colorScheme={context.globals.theme || 'light'}
fontFamily={fontFamily}
>
<Story />
</ThemeWrapper>
</AppThemeProvider>
</QueryParamProvider>
</QueryClientProvider>
</div>

View file

@ -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<typeof ActionIcon> = {
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'danger'],
options: ['primary', 'secondary', 'danger', 'link'],
},
size: {
control: 'select',
@ -109,6 +110,26 @@ export const CustomVariants = () => (
</ActionIcon>
</Group>
</div>
<div>
<Text size="sm" fw={600} mb="xs">
Link
</Text>
<Group>
<ActionIcon variant="link" size="sm">
<IconExternalLink size={16} />
</ActionIcon>
<ActionIcon variant="link" size="md">
<IconExternalLink size={18} />
</ActionIcon>
<ActionIcon variant="link" size="lg">
<IconExternalLink size={20} />
</ActionIcon>
<ActionIcon variant="link" disabled>
<IconExternalLink size={18} />
</ActionIcon>
</Group>
</div>
</Stack>
);
@ -201,6 +222,26 @@ export const DisabledStates = () => (
</Group>
</div>
<div>
<Text size="sm" fw={600} mb="xs">
Link - Normal vs Disabled
</Text>
<Group>
<ActionIcon variant="link" size="md">
<IconExternalLink size={18} />
</ActionIcon>
<ActionIcon variant="link" size="md" disabled>
<IconExternalLink size={18} />
</ActionIcon>
<ActionIcon variant="link" size="lg">
<IconExternalLink size={20} />
</ActionIcon>
<ActionIcon variant="link" size="lg" disabled>
<IconExternalLink size={20} />
</ActionIcon>
</Group>
</div>
<div>
<Text size="sm" fw={600} mb="xs">
Subtle - Normal vs Disabled
@ -269,6 +310,9 @@ export const LoadingStates = () => (
<ActionIcon variant="danger" loading>
<IconTrash size={18} />
</ActionIcon>
<ActionIcon variant="link" loading>
<IconExternalLink size={18} />
</ActionIcon>
<ActionIcon variant="subtle" loading>
<IconSettings size={18} />
</ActionIcon>

View file

@ -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<typeof Button> = {
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'danger'],
options: ['primary', 'secondary', 'danger', 'link'],
},
size: {
control: 'select',
@ -95,6 +96,21 @@ export const CustomVariants = () => (
</Button>
</Group>
</div>
<div>
<Text size="sm" fw={600} mb="xs">
Link
</Text>
<Group>
<Button variant="link">Link</Button>
<Button variant="link" rightSection={<IconExternalLink size={16} />}>
View Details
</Button>
<Button variant="link" disabled>
Disabled
</Button>
</Group>
</div>
</Stack>
);
@ -170,6 +186,28 @@ export const DisabledStates = () => (
</Group>
</div>
<div>
<Text size="sm" fw={600} mb="xs">
Link - Normal vs Disabled
</Text>
<Group>
<Button variant="link">Normal</Button>
<Button variant="link" disabled>
Disabled
</Button>
<Button variant="link" rightSection={<IconExternalLink size={16} />}>
With Icon
</Button>
<Button
variant="link"
rightSection={<IconExternalLink size={16} />}
disabled
>
Disabled with Icon
</Button>
</Group>
</div>
<div>
<Text size="sm" fw={600} mb="xs">
All Sizes - Disabled
@ -215,6 +253,9 @@ export const LoadingStates = () => (
<Button variant="danger" loading>
Danger Loading
</Button>
<Button variant="link" loading>
Link Loading
</Button>
</Group>
</div>

View file

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

View file

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

View file

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