;
+/**
+ * Shows the default inline link for body copy, release notes, and low-emphasis navigation.
+ */
export const Default: Story = {
args: {
- variant: 'regular',
- href: '',
- target: '_blank',
- size: 'md',
- children: 'Lorem ipsum',
- className: '',
- title: ''
- }
+ children: 'Read the release notes',
+ href: 'https://github.com/egdev6'
+ },
+ render: (args) => (
+
+
+ Follow the product update in for migration notes and release context.
+
+
+ )
};
/**
- * - Regular: Default link style.
- * - ⚠️ This variant uses a transparent background by default. To ensure proper visibility, place it inside a container with a solid or plain background.
+ * Shows the three visual hierarchy levels: inline, secondary CTA, and primary CTA.
*/
-
-export const Regular: Story = {
+export const Variants: Story = {
render: () => (
-
-
- Lorem Ipsum
-
-
- Lorem Ipsum
-
-
- Lorem Ipsum
-
-
+
+
+ Inline link
+
+ Secondary CTA
+
+
+ Primary CTA
+
+
+
)
};
/**
- * - Outlined: Link with outlined style.
- * - ⚠️ This variant uses a transparent background by default. To ensure proper visibility, place it inside a container with a solid or plain background.
+ * Shows CTA-style links with their default glow and with glow disabled for quieter layouts.
+ * Regular inline links stay glowless regardless of this option.
*/
-
-export const Outlined: Story = {
+export const Shadow: Story = {
render: () => (
-
-
- Lorem Ipsum
-
-
- Lorem Ipsum
-
-
- Lorem Ipsum
-
-
+
+
+
+
+ Outlined with glow
+
+
+ Outlined without glow
+
+
+
+
+ Primary with glow
+
+
+ Primary without glow
+
+
+
+
)
};
+
/**
- * - Button: Link styled as a button.
+ * Shows how size changes typography and horizontal rhythm while preserving each variant treatment.
*/
-
-export const Button: Story = {
+export const Sizes: Story = {
render: () => (
-
-
- Lorem Ipsum
-
-
- Lorem Ipsum
-
-
- Lorem Ipsum
-
-
+
+
+
+
+ Small inline
+
+
+ Medium inline
+
+
+ Large inline
+
+
+
+
+ Small outlined
+
+
+ Medium outlined
+
+
+ Large outlined
+
+
+
+
+ Small button
+
+
+ Medium button
+
+
+ Large button
+
+
+
+
)
};
/**
- * - With Icon: Link with an icon.
+ * Shows icons inheriting current text color and keeping alignment consistent across variants.
*/
-
export const WithIcon: Story = {
render: () => (
-
-
- Lorem Ipsum
-
-
- Lorem Ipsum
-
-
- Lorem Ipsum
-
-
+
+
+
+ External link
+
+
+ Continue
+
+
+ Download
+
+
+
)
};
/**
- * - You can change the target of the link to open in a new tab or the same tab.
- * - Use the `target` prop to specify the link behavior.
+ * Shows disabled links preserving their color treatment while reducing affordance through opacity and cursor state.
*/
-export const Target: Story = {
+export const Disabled: Story = {
render: () => (
-
-
- Open in new tab
-
-
- Open in same tab
-
-
- Open in parent frame
-
-
- Open in top frame
-
-
+
+
+
+ Inline disabled
+
+
+ Outlined disabled
+
+
+ Button disabled
+
+
+
)
};
/**
- * - Accessibility: The `title` prop is used to provide a tooltip or description for the link.
- * - Use the `title` attribute to provide additional context for screen readers.
+ * Shows target values changing navigation behavior while preserving accessible link semantics.
*/
+export const Target: Story = {
+ render: () => (
+
+
+
+ New tab
+
+
+ Same tab
+
+
+ Parent frame
+
+
+ Top frame
+
+
+
+ )
+};
+/**
+ * Shows title-backed accessible labels when visible text alone is not enough.
+ */
export const Accessibility: Story = {
render: () => (
-
-
- GitHub Profile
-
-
+
+
+
+ GitHub profile
+
+
+ Documentation
+
+
+
)
};
/**
- * - Custom Class: You can add custom classes to the link for additional styling.
- * - You need to override the default classes using `!important` to ensure your styles take precedence.
+ * Shows local action behavior when no `href` is provided; these controls expose button semantics and support pointer plus keyboard activation.
*/
-
-export const CustomClass: Story = {
+export const ActionSemantics: Story = {
render: () => (
-
-
- Custom Class Link
-
-
- Custom Class Link
-
-
+
+
+
+ Run local action
+
+
+ Secondary action
+
+
+
)
};
diff --git a/src/components/atoms/link/Link.test.tsx b/src/components/atoms/link/Link.test.tsx
new file mode 100644
index 00000000..c87b626e
--- /dev/null
+++ b/src/components/atoms/link/Link.test.tsx
@@ -0,0 +1,271 @@
+/**
+ * Link.test.tsx — behavior tests for Stack-and-Flow Design System
+ *
+ * Strategy:
+ * - Hook (useLink): tested with renderHook for derived attributes and state.
+ * - Component (Link): tested with render/screen/userEvent for observable behavior.
+ *
+ * We test behavior, not internal CSS class strings.
+ */
+
+import { render, renderHook, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+// --- Mocks (declared before component import) ---
+
+vi.mock('lucide-react/dynamic', () => ({
+ // biome-ignore lint/style/useNamingConvention: must match library export name
+ DynamicIcon: ({ name }: { name: string }) =>
+}));
+
+// --- Imports after mocks ---
+
+import { Link } from './Link';
+import { useLink } from './useLink';
+
+// ─────────────────────────────────────────────
+// HOOK TESTS — useLink
+// ─────────────────────────────────────────────
+
+describe('useLink — logic', () => {
+ it('returns target _blank by default', () => {
+ const { result } = renderHook(() => useLink({ children: 'Docs' }));
+ expect(result.current.target).toBe('_blank');
+ });
+
+ it('marks _blank links as external', () => {
+ const { result } = renderHook(() => useLink({ children: 'Docs', target: '_blank' }));
+ expect(result.current.isExternal).toBe(true);
+ });
+
+ it('does not mark _self links as external', () => {
+ const { result } = renderHook(() => useLink({ children: 'Docs', target: '_self' }));
+ expect(result.current.isExternal).toBe(false);
+ });
+
+ it('uses title as accessible label when provided', () => {
+ const { result } = renderHook(() => useLink({ children: 'Docs', title: 'Open docs' }));
+ expect(result.current.ariaLabel).toBe('Open docs');
+ });
+
+ it('falls back to string children as accessible label', () => {
+ const { result } = renderHook(() => useLink({ children: 'Docs' }));
+ expect(result.current.ariaLabel).toBe('Docs');
+ });
+
+ it('returns disabled false by default', () => {
+ const { result } = renderHook(() => useLink({ children: 'Docs' }));
+ expect(result.current.disabled).toBe(false);
+ });
+
+ it('keeps navigational visual variants exposed as links when href is present', () => {
+ const { result } = renderHook(() => useLink({ children: 'Docs', href: 'https://example.com', variant: 'button' }));
+ expect(result.current.role).toBe('link');
+ });
+
+ it('exposes button role only when no href is present', () => {
+ const { result } = renderHook(() => useLink({ children: 'Docs', variant: 'button' }));
+ expect(result.current.role).toBe('button');
+ });
+
+ it('makes action-style links keyboard-focusable by default', () => {
+ const { result } = renderHook(() => useLink({ children: 'Docs', variant: 'button' }));
+ expect(result.current.tabIndex).toBe(0);
+ });
+});
+
+// ─────────────────────────────────────────────
+// COMPONENT TESTS — Link
+// ─────────────────────────────────────────────
+
+describe('Link — component behavior', () => {
+ it('renders an anchor with link role by default', () => {
+ render( Docs);
+ expect(screen.getByRole('link', { name: 'Docs' })).toBeInTheDocument();
+ });
+
+ it('sets rel for external links opened in a new tab', () => {
+ render(
+
+ Docs
+
+ );
+ expect(screen.getByRole('link', { name: 'Docs' })).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+
+ it('does not set rel for same-tab links when no rel is provided', () => {
+ render(
+
+ Docs
+
+ );
+ expect(screen.getByRole('link', { name: 'Docs' })).not.toHaveAttribute('rel');
+ });
+
+ it('preserves caller rel for same-tab links', () => {
+ render(
+
+ Docs
+
+ );
+ expect(screen.getByRole('link', { name: 'Docs' })).toHaveAttribute('rel', 'nofollow');
+ });
+
+ it('preserves caller rel tokens when securing new-tab links', () => {
+ render(
+
+ Docs
+
+ );
+ expect(screen.getByRole('link', { name: 'Docs' })).toHaveAttribute('rel', 'nofollow noopener noreferrer');
+ });
+
+ it('preserves caller aria-label over derived labels', () => {
+ render(
+
+ Docs
+
+ );
+ const link = screen.getByRole('link', { name: 'Read migration guide' });
+ expect(link).toHaveAttribute('title', 'Open docs');
+ });
+
+ it('uses title as aria-label and title attribute', () => {
+ render(
+
+ Docs
+
+ );
+ const link = screen.getByRole('link', { name: 'Open docs' });
+ expect(link).toHaveAttribute('title', 'Open docs');
+ });
+
+ it('renders an icon when icon prop is provided', () => {
+ render(
+
+ Docs
+
+ );
+ expect(screen.getByRole('link', { name: 'Docs' })).toBeInTheDocument();
+ expect(screen.getByTestId('link-icon')).toHaveAttribute('data-icon', 'image');
+ });
+
+ it('uses caller aria-label for icon-only links', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByRole('link', { name: 'Open image gallery' })).toBeInTheDocument();
+ });
+
+ it('does not leak visual variant, size, or shadow props to the DOM', () => {
+ render(
+
+ Docs
+
+ );
+
+ const link = screen.getByRole('link', { name: 'Docs' });
+ expect(link).not.toHaveAttribute('variant');
+ expect(link).not.toHaveAttribute('size');
+ expect(link).not.toHaveAttribute('shadow');
+ });
+
+ it('keeps button-styled links exposed as links when href is present', () => {
+ render(
+
+ Docs
+
+ );
+ expect(screen.getByRole('link', { name: 'Docs' })).toBeInTheDocument();
+ });
+
+ it('uses button semantics only when no href is present', () => {
+ render( Action);
+ expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
+ });
+
+ it('keeps action-style links in the tab order when enabled', async () => {
+ const user = userEvent.setup();
+ render( Action);
+
+ const actionLink = screen.getByRole('button', { name: 'Action' });
+ await user.tab();
+
+ expect(actionLink).toHaveFocus();
+ });
+
+ it('calls onClick for action-style links when clicked', async () => {
+ const user = userEvent.setup();
+ const handleClick = vi.fn();
+ render(
+
+ Action
+
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Action' }));
+
+ expect(handleClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('activates action-style links with Enter and Space', async () => {
+ const user = userEvent.setup();
+ const handleClick = vi.fn();
+ render(
+
+ Action
+
+ );
+
+ const actionLink = screen.getByRole('button', { name: 'Action' });
+ actionLink.focus();
+
+ await user.keyboard('{Enter}');
+ await user.keyboard(' ');
+
+ expect(handleClick).toHaveBeenCalledTimes(2);
+ });
+
+ it('calls onClick when clicked and enabled', async () => {
+ const user = userEvent.setup();
+ const handleClick = vi.fn();
+ render(
+
+ Docs
+
+ );
+
+ await user.click(screen.getByRole('link', { name: 'Docs' }));
+
+ expect(handleClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('marks disabled links with aria-disabled and removes them from tab order', () => {
+ render(
+
+ Docs
+
+ );
+
+ const link = screen.getByRole('link', { name: 'Docs' });
+ expect(link).toHaveAttribute('aria-disabled', 'true');
+ expect(link).toHaveAttribute('tabindex', '-1');
+ });
+
+ it('does not call onClick when disabled', async () => {
+ const user = userEvent.setup();
+ const handleClick = vi.fn();
+ render(
+
+ Docs
+
+ );
+
+ await user.click(screen.getByRole('link', { name: 'Docs' }));
+
+ expect(handleClick).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/atoms/link/Link.tsx b/src/components/atoms/link/Link.tsx
index 57b07a72..fb0b4259 100644
--- a/src/components/atoms/link/Link.tsx
+++ b/src/components/atoms/link/Link.tsx
@@ -1,31 +1,47 @@
-import type { VariantProps } from 'class-variance-authority';
import { DynamicIcon } from 'lucide-react/dynamic';
-import type { ComponentProps, FC } from 'react';
-import { cn } from '@/lib/utils';
-import { type LinkProps, linkVariants } from './types';
+import type { FC } from 'react';
+import type { LinkProps } from './types';
import { useLink } from './useLink';
-const Link: FC & ComponentProps<'a'>> = ({ ...props }) => {
- const { href, target, isExternal, title, children, variant, size, className, icon, iconWidth, ...rest } =
- useLink(props);
+export const Link: FC = (props) => {
+ const {
+ href,
+ target,
+ rel,
+ title,
+ children,
+ className,
+ icon,
+ iconWidth,
+ disabled,
+ isExternal: _isExternal,
+ ariaLabel,
+ role,
+ tabIndex,
+ handleClick,
+ handleKeyDown,
+ ...rest
+ } = useLink(props);
return (
- {icon && }
+ {icon && }
{children}
);
};
-
-export default Link;
diff --git a/src/components/atoms/link/index.ts b/src/components/atoms/link/index.ts
index 5f689a0d..8f12bf40 100644
--- a/src/components/atoms/link/index.ts
+++ b/src/components/atoms/link/index.ts
@@ -1,2 +1,2 @@
-export { default as Link } from './Link';
-export * from './types';
+export { Link } from './Link';
+export * from './types';
diff --git a/src/components/atoms/link/types.ts b/src/components/atoms/link/types.ts
index 6d63ab02..8dcc07f5 100644
--- a/src/components/atoms/link/types.ts
+++ b/src/components/atoms/link/types.ts
@@ -1,92 +1,99 @@
-import { cva } from 'class-variance-authority';
+import { cva, type VariantProps } from 'class-variance-authority';
+import type { ComponentProps, ReactNode } from 'react';
import type { DynamicIconName } from '@/types';
export const linkVariants = cva(
[
- 'link w-auto relative cursor-pointer',
- 'flex gap-1 items-center justify-start',
- 'font-medium whitespace-nowrap line-clamp-1 leading-[1.2]',
- 'focus-visible:outline-none focus-visible:shadow-glow-focus-light dark:focus-visible:shadow-glow-focus-dark'
+ 'link relative w-auto cursor-pointer font-medium leading-normal',
+ 'focus-visible:outline-none focus-visible:shadow-glow-focus-light dark:focus-visible:shadow-glow-focus-dark',
+ 'data-[disabled=true]:pointer-events-none data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-40'
],
{
variants: {
variant: {
regular: [
- 'font-bold transition-[color,border-color] duration-200 ease-[ease]',
- 'text-brand-dark-lighter dark:text-brand-dark-lighter',
- 'hover:text-brand-dark-lightest dark:hover:text-brand-dark-lightest',
- 'border-b border-[rgba(255,77,109,0.4)] hover:border-[rgba(255,128,153,0.7)]',
- 'no-underline'
+ 'inline border-b font-bold no-underline',
+ 'border-brand-light/60 text-brand-light dark:border-brand-dark-dark dark:text-brand-dark-dark',
+ 'transition-[color,border-color] duration-200 ease-[ease]',
+ 'hover:border-brand-light-dark/80 hover:text-brand-light-dark dark:hover:border-brand-dark-light dark:hover:text-brand-dark-light',
+ '[&>svg]:mr-1 [&>svg]:inline-block [&>svg]:align-[-0.125em]'
],
button: [
- 'min-h-[44px]',
- 'transition-[box-shadow,background,border-color] duration-[250ms] ease-[ease]',
- 'px-4 py-2',
- 'rounded-md',
- 'border',
- 'text-white',
- 'bg-[image:var(--background-image-btn-primary)]',
- 'border-transparent',
- 'shadow-glow-btn-primary-light dark:shadow-glow-btn-primary',
- 'hover:bg-[image:var(--background-image-btn-primary-hover)]',
- 'hover:shadow-glow-btn-primary-hover-light dark:hover:shadow-glow-btn-primary-hover'
+ 'inline-flex items-center justify-center gap-1 whitespace-nowrap rounded-pill border border-transparent font-semibold tracking-ui text-white no-underline',
+ 'bg-btn-primary shadow-glow-btn-primary-light dark:shadow-glow-btn-primary',
+ 'transition-[box-shadow,background,transform] duration-250 ease-[ease]',
+ 'hover:bg-btn-primary-hover hover:text-white hover:shadow-glow-btn-primary-hover-light dark:hover:shadow-glow-btn-primary-hover',
+ 'motion-safe:active:scale-[0.98]'
],
outlined: [
- 'min-h-[44px]',
- 'transition-[box-shadow,background,border-color] duration-[250ms] ease-[ease]',
- 'px-4 py-2',
- 'rounded-md',
- 'border',
- 'text-brand-light dark:text-brand-dark',
- 'border-brand-light dark:border-brand-dark',
- 'bg-transparent',
- 'hover:text-white dark:hover:text-white',
- 'hover:bg-[image:var(--background-image-btn-primary)]',
- 'hover:border-transparent',
- 'hover:shadow-glow-btn-primary-hover-light dark:hover:shadow-glow-btn-primary-hover'
+ 'inline-flex items-center justify-center gap-1 whitespace-nowrap rounded-pill border font-semibold tracking-ui no-underline',
+ 'border-red-tint-border bg-red-tint-subtle text-brand-light shadow-glow-btn-secondary-light dark:text-text-dark dark:shadow-glow-btn-secondary',
+ 'transition-[box-shadow,background,border-color,transform] duration-250 ease-[ease]',
+ 'hover:border-brand-light/80 hover:bg-red-tint-low hover:text-brand-light-darkest hover:shadow-glow-btn-secondary-hover-light',
+ 'dark:hover:border-brand-dark-light dark:hover:bg-red-tint-active dark:hover:text-text-dark dark:hover:shadow-glow-btn-secondary-hover',
+ 'motion-safe:active:scale-[0.98]'
]
},
size: {
- md: 'px-md fs-base',
- lg: 'px-lg fs-h6',
- sm: 'px-sm fs-small'
+ sm: 'fs-small',
+ md: 'fs-base',
+ lg: 'fs-h5'
+ },
+ shadow: {
+ true: '',
+ false: 'shadow-none hover:shadow-none dark:shadow-none dark:hover:shadow-none'
}
},
+ compoundVariants: [
+ { variant: ['button', 'outlined'], size: 'sm', class: 'h-9 px-sm' },
+ { variant: ['button', 'outlined'], size: 'md', class: 'h-11 px-md' },
+ { variant: ['button', 'outlined'], size: 'lg', class: 'h-12 px-lg' }
+ ],
defaultVariants: {
variant: 'regular',
- size: 'md'
+ size: 'md',
+ shadow: true
}
}
);
-type ButtonVariants = 'regular' | 'button' | 'outlined';
-type TargetVariants = '_blank' | '_self' | '_parent' | '_top';
-type LinkSizes = 'sm' | 'md' | 'lg';
+export type LinkVariant = 'regular' | 'button' | 'outlined';
+export type LinkTarget = '_blank' | '_self' | '_parent' | '_top';
+export type LinkSize = 'sm' | 'md' | 'lg';
-export type LinkProps = {
- /**
- * @control select
- * @default regular
- * */
- variant?: ButtonVariants;
- /** @control text */
- href?: string;
- /**
- * @control text
- * @default _blank
- * */
- target?: TargetVariants;
- /**
- * @control select
- * @default md
- * */
- size: LinkSizes;
- /** @control text */
- icon?: DynamicIconName;
- /** @control text */
- title?: string;
- /** @control text */
- className?: string;
- /** @control text */
- children: string;
-};
+export type LinkProps = Omit, 'children' | 'disabled' | 'target'> &
+ VariantProps & {
+ /**
+ * @control text
+ */
+ children: ReactNode;
+ /**
+ * @control select
+ * @default regular
+ */
+ variant?: LinkVariant;
+ /**
+ * @control select
+ * @default md
+ */
+ size?: LinkSize;
+ /**
+ * @control select
+ * @default _blank
+ */
+ target?: LinkTarget;
+ /**
+ * @control text
+ */
+ icon?: DynamicIconName;
+ /**
+ * @control boolean
+ * @default true
+ */
+ shadow?: boolean;
+ /**
+ * @control boolean
+ * @default false
+ */
+ disabled?: boolean;
+ };
diff --git a/src/components/atoms/link/useLink.ts b/src/components/atoms/link/useLink.ts
index 96ddf2f8..bf3bb293 100644
--- a/src/components/atoms/link/useLink.ts
+++ b/src/components/atoms/link/useLink.ts
@@ -1,20 +1,96 @@
-import type { VariantProps } from 'class-variance-authority';
-import type { ComponentProps } from 'react';
-import type { LinkProps, linkVariants } from './types';
+import type { KeyboardEvent, MouseEvent } from 'react';
+import { cn } from '@/lib/utils';
+import { type LinkProps, linkVariants } from './types';
+
+type UseLinkReturn = Omit & {
+ ariaLabel: string | undefined;
+ className: string;
+ disabled: boolean;
+ handleClick: (event: MouseEvent) => void;
+ handleKeyDown: (event: KeyboardEvent) => void;
+ iconWidth: number;
+ isExternal: boolean;
+ rel: string | undefined;
+ role: 'button' | 'link';
+ tabIndex: number | undefined;
+ target: NonNullable;
+};
export const useLink = ({
children,
icon = undefined,
variant = 'regular',
size = 'md',
+ shadow = true,
target = '_blank',
className,
href,
title,
+ rel,
+ 'aria-label': ariaLabelProp,
+ disabled = false,
+ onClick,
+ onKeyDown,
...rest
-}: LinkProps & VariantProps & ComponentProps<'a'>) => {
- const iconWidth = { sm: 18, md: 20, lg: 24 }[size] || 22;
+}: LinkProps): UseLinkReturn => {
+ const iconWidth = { sm: 18, md: 20, lg: 24 }[size] ?? 20;
const isExternal = target === '_blank';
+ const ariaLabel = ariaLabelProp ?? title ?? (typeof children === 'string' ? children : undefined);
+ const isAction = !href;
+ const role = isAction ? 'button' : 'link';
+
+ const handleClick = (event: MouseEvent) => {
+ if (disabled) {
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
+ onClick?.(event);
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (disabled) {
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
+ onKeyDown?.(event);
+
+ if (event.defaultPrevented || !isAction || (event.key !== 'Enter' && event.key !== ' ')) {
+ return;
+ }
+
+ event.preventDefault();
+ event.currentTarget.click();
+ };
+
+ return {
+ ...rest,
+ href,
+ target,
+ rel: getSafeRel(rel, isExternal),
+ isExternal,
+ title,
+ children,
+ className: cn(linkVariants({ variant, size, shadow }), className),
+ icon,
+ iconWidth,
+ disabled,
+ ariaLabel,
+ role,
+ tabIndex: disabled ? -1 : (rest.tabIndex ?? (isAction ? 0 : undefined)),
+ handleClick,
+ handleKeyDown
+ };
+};
+
+const getSafeRel = (rel: string | undefined, isExternal: boolean): string | undefined => {
+ if (!isExternal) {
+ return rel;
+ }
- return { href, target, isExternal, title, children, variant, size, className, icon, iconWidth, ...rest };
+ const tokens = new Set([...(rel?.split(/\s+/).filter(Boolean) ?? []), 'noopener', 'noreferrer']);
+ return Array.from(tokens).join(' ');
};
diff --git a/src/styles/theme.css b/src/styles/theme.css
index a9bee269..4359ba31 100644
--- a/src/styles/theme.css
+++ b/src/styles/theme.css
@@ -223,6 +223,16 @@
--glow-chip-danger-hover:
0 2px 4px rgba(0, 0, 0, 0.14), 0 5px 12px color-mix(in srgb, var(--color-red-600) 36%, transparent);
+ /* Tailwind shadow utility aliases — shadow-glow-* classes */
+ --shadow-glow-btn-primary: var(--glow-btn-primary);
+ --shadow-glow-btn-primary-hover: var(--glow-btn-primary-hover);
+ --shadow-glow-btn-primary-light: var(--glow-btn-primary-light);
+ --shadow-glow-btn-primary-hover-light: var(--glow-btn-primary-hover-light);
+ --shadow-glow-btn-secondary: var(--glow-btn-secondary);
+ --shadow-glow-btn-secondary-hover: var(--glow-btn-secondary-hover);
+ --shadow-glow-btn-secondary-light: var(--glow-btn-secondary-light);
+ --shadow-glow-btn-secondary-hover-light: var(--glow-btn-secondary-hover-light);
+
/* Glow de focus ring */
--glow-focus-dark: 0 0 0 3px rgba(255, 0, 54, 0.4);
--glow-focus-light: 0 0 0 3px rgba(219, 20, 60, 0.35);