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 + + + + + + +
); @@ -170,6 +186,28 @@ export const DisabledStates = () => ( +
+ + Link - Normal vs Disabled + + + + + + + +
+
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; + } +}