diff --git a/public/images/design-system.png b/public/images/design-system.png new file mode 100644 index 00000000..933590a9 Binary files /dev/null and b/public/images/design-system.png differ diff --git a/public/images/favicon.svg b/public/images/favicon.svg new file mode 100644 index 00000000..67947813 --- /dev/null +++ b/public/images/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/logo-background.png b/public/images/logo-background.png new file mode 100644 index 00000000..b4da147d Binary files /dev/null and b/public/images/logo-background.png differ diff --git a/public/images/logo-only.svg b/public/images/logo-only.svg new file mode 100644 index 00000000..67947813 --- /dev/null +++ b/public/images/logo-only.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/logo.svg b/public/images/logo.svg new file mode 100644 index 00000000..9b42cc55 --- /dev/null +++ b/public/images/logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/transparent-logo.svg b/public/images/transparent-logo.svg new file mode 100644 index 00000000..9b42cc55 --- /dev/null +++ b/public/images/transparent-logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/transparent.png b/public/images/transparent.png new file mode 100644 index 00000000..5d4342b2 Binary files /dev/null and b/public/images/transparent.png differ diff --git a/src/components/atoms/calendar/Calendar.stories.tsx b/src/components/atoms/calendar/Calendar.stories.tsx index 5d7c79d0..a77d1ae8 100644 --- a/src/components/atoms/calendar/Calendar.stories.tsx +++ b/src/components/atoms/calendar/Calendar.stories.tsx @@ -1,5 +1,4 @@ -import { CalendarDate } from '@internationalized/date'; -import { startOfWeek } from '@internationalized/date'; +import { CalendarDate, startOfWeek } from '@internationalized/date'; import type { Meta, StoryObj } from '@storybook/react'; import { useEffect, useState } from 'react'; import { Calendar } from './index'; @@ -368,6 +367,7 @@ export const Outlined: Story = { export const WithSelectedDate: Story = { args: { selectedDate: new Date(2025, 7, 19), + variant: 'outlined', size: 'md', show: true, disabled: false, @@ -464,6 +464,7 @@ export const WithCalendarDate: Story = { onDateChange={() => { /* noop */ }} + variant='outlined' size='md' show={true} locale={locale.startsWith('es') ? 'es' : 'en'} @@ -508,18 +509,24 @@ export const VisibleMonths: Story = { render: () => { const [visibleMonths, setVisibleMonths] = useState(2); const [isDark, setIsDark] = useState(() => { - if (typeof document !== 'undefined') { - return document.body.classList.contains('dark') || document.body.getAttribute('data-theme') === 'dark'; + if (typeof document !== 'undefined' && document.documentElement) { + return ( + document.documentElement.classList.contains('dark') || + document.documentElement.getAttribute('data-theme') === 'dark' + ); } return false; }); useEffect(() => { const handler = () => { - setIsDark(document.body.classList.contains('dark') || document.body.getAttribute('data-theme') === 'dark'); + setIsDark( + document.documentElement.classList.contains('dark') || + document.documentElement.getAttribute('data-theme') === 'dark' + ); }; window.addEventListener('themechange', handler); const observer = new MutationObserver(handler); - observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'data-theme'] }); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] }); return () => { window.removeEventListener('themechange', handler); observer.disconnect(); @@ -529,6 +536,7 @@ export const VisibleMonths: Story = { const textColor = isDark ? '#fff' : '#222'; return (
+ @@ -613,14 +622,9 @@ export const HighlightedDates: Story = { render: () => { function getContrastText(bgColor: string): string { if (!bgColor) { - return '#222'; + return '#111827'; } - const hex = bgColor.replace('#', ''); - const r = parseInt(hex.substring(0, 2), 16); - const g = parseInt(hex.substring(2, 4), 16); - const b = parseInt(hex.substring(4, 6), 16); - const yiq = (r * 299 + g * 587 + b * 114) / 1000; - return yiq >= 128 ? '#222' : '#fff'; + return '#111827'; } const today = new Date(); @@ -639,7 +643,12 @@ export const HighlightedDates: Story = { } ]; - return ; + return ( +
+ + +
+ ); }, parameters: { docs: { @@ -654,19 +663,25 @@ export const Sizes: Story = { render: () => { const [size, setSize] = useState<'sm' | 'md' | 'lg'>('md'); const [isDark, setIsDark] = useState(() => { - if (typeof document !== 'undefined') { - return document.body.classList.contains('dark') || document.body.getAttribute('data-theme') === 'dark'; + if (typeof document !== 'undefined' && document.documentElement) { + return ( + document.documentElement.classList.contains('dark') || + document.documentElement.getAttribute('data-theme') === 'dark' + ); } return false; }); useEffect(() => { const handler = () => { - setIsDark(document.body.classList.contains('dark') || document.body.getAttribute('data-theme') === 'dark'); + setIsDark( + document.documentElement.classList.contains('dark') || + document.documentElement.getAttribute('data-theme') === 'dark' + ); }; window.addEventListener('themechange', handler); const observer = new MutationObserver(handler); - observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'data-theme'] }); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] }); return () => { window.removeEventListener('themechange', handler); observer.disconnect(); @@ -676,6 +691,7 @@ export const Sizes: Story = { const textColor = isDark ? '#fff' : '#222'; return (
+ @@ -768,18 +785,24 @@ export const WithRadius: Story = { render: () => { const [radius, setRadius] = useState('md'); const [isDark, setIsDark] = useState(() => { - if (typeof document !== 'undefined') { - return document.body.classList.contains('dark') || document.body.getAttribute('data-theme') === 'dark'; + if (typeof document !== 'undefined' && document.documentElement) { + return ( + document.documentElement.classList.contains('dark') || + document.documentElement.getAttribute('data-theme') === 'dark' + ); } return false; }); useEffect(() => { const handler = () => { - setIsDark(document.body.classList.contains('dark') || document.body.getAttribute('data-theme') === 'dark'); + setIsDark( + document.documentElement.classList.contains('dark') || + document.documentElement.getAttribute('data-theme') === 'dark' + ); }; window.addEventListener('themechange', handler); const observer = new MutationObserver(handler); - observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'data-theme'] }); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] }); return () => { window.removeEventListener('themechange', handler); observer.disconnect(); @@ -789,6 +812,7 @@ export const WithRadius: Story = { const textColor = isDark ? '#fff' : '#222'; return (
+ diff --git a/src/components/atoms/calendar/Calendar.tsx b/src/components/atoms/calendar/Calendar.tsx index 59a3c7cd..f71917bf 100644 --- a/src/components/atoms/calendar/Calendar.tsx +++ b/src/components/atoms/calendar/Calendar.tsx @@ -293,7 +293,8 @@ export const Calendar: React.FC = ({ isDisabled: day.isDisabled, isInRange: day.isInRange || isDragInRange, isRangeStart: day.isRangeStart || isDragRangeStart, - isRangeEnd: day.isRangeEnd || isDragRangeEnd + isRangeEnd: day.isRangeEnd || isDragRangeEnd, + theme }), highlightClass, readOnly ? 'pointer-events-none select-none' : '', diff --git a/src/components/atoms/chip/Chip.stories.tsx b/src/components/atoms/chip/Chip.stories.tsx new file mode 100644 index 00000000..34670480 --- /dev/null +++ b/src/components/atoms/chip/Chip.stories.tsx @@ -0,0 +1,289 @@ +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; +import { Check, Trash2 } from 'lucide-react'; +import React from 'react'; +import { Avatar } from '../avatar'; +import Icon from '../icon/Icon'; +import { Chip } from './Chip'; + +/** + * ## DESCRIPTION + * Chip component is a compact element used to display statuses, keywords, or quick actions. + * + * Common use cases include tags, filters, and state indicators in dense interfaces. + * + * - Customizable in color, size, variant, radius and animation. + * - Supports `startContent` / `endContent` (icons or text), optional avatar, and `dot` indicator. + * - Optional interactivity: clickable (`onClick`/`selectable`), selectable (controlled or uncontrolled), and closable. + * - `as` is a preference, not an absolute guarantee: when `closable` + interactive are combined, the chip uses a split-actions group with sibling buttons. + * - Accessible via `ariaLabel` for chips without readable text; close action uses contextual label (`Remove
+ ) +}; + +/** With text → shows a circular indicator before the label. */ +export const DotWithText: Story = { + args: { variant: 'dot', color: 'primary', children: 'Pending' }, + render: (args) => ( +
+ +
+ ) +}; + +/** Dot only → provide `ariaLabel` for accessibility. */ +export const DotOnlyAccessible: Story = { + args: { variant: 'dot', color: 'primary', ariaLabel: 'Online status' } +}; + +/** You can override slot styles with `classNames`. */ +export const WithClassNamesOverrides: Story = { + args: { + children: 'Custom Slots', + classNames: { + base: 'bg-primary text-text-dark hover:bg-primary-hover focus-visible:shadow-glow-focus-light dark:focus-visible:shadow-glow-focus-dark', + content: 'tracking-wide', + closeButton: 'bg-white-tint-high hover:bg-white-tint-strong' + }, + closable: true, + animation: 'bounce', + as: 'div' + } +}; + +/** Stress test for long labels */ +export const Stress: Story = { + render: () => ( +
+ + + + } + endContent={ + + + + } + > + Truncated: Very very very long label that should nicely + +
+ ) +}; + +export const ClosableAndClickable: Story = { + parameters: { + docs: { + description: { + story: + 'When `closable` and click behavior are combined, the chip uses a split-actions group (two sibling buttons). Primary action triggers `onClick`; close action triggers `onClose` only.' + } + } + }, + args: { + children: 'Closable + click', + closable: true, + onClose: action('chip.close'), + onClick: action('chip.click') + } +}; + +export const DisabledAndClosable: Story = { + args: { + children: 'Disabled + closable', + closable: true, + disabled: true, + onClose: action('chip.close') + } +}; + +export const ButtonAndClosable: Story = { + parameters: { + docs: { + description: { + story: + '`as="button"` is treated as a preference. In `closable` + interactive mode the chip renders split-actions markup to avoid nested interactive controls.' + } + } + }, + args: { + children: 'Button + closable', + as: 'button', + closable: true, + onClose: action('chip.close'), + onClick: action('chip.click') + } +}; diff --git a/src/components/atoms/chip/Chip.test.tsx b/src/components/atoms/chip/Chip.test.tsx new file mode 100644 index 00000000..b4e1fddd --- /dev/null +++ b/src/components/atoms/chip/Chip.test.tsx @@ -0,0 +1,319 @@ +/** + * Chip.test.tsx — Behavior tests for Stack-and-Flow Design System + * + * STRATEGY: + * - Hook (useChip): tested with renderHook → pure decision logic + * - Component (Chip): tested with render + screen + userEvent → observable behavior + * + * WHAT we test: accessibility, keyboard interaction, controlled/uncontrolled selection, + * closable/clickable behavior and disabled guards. + * WHAT we do NOT test: internal class strings or implementation details. + */ + +import { fireEvent, render, renderHook, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../icon/Icon', () => ({ + default: () => null +})); + +import { Chip } from './Chip'; +import { useChip } from './useChip'; + +describe('Chip — interactive and accessibility behavior', () => { + it('renders as button when interactive and not closable', () => { + const { container } = render( + undefined}> + Clickable + + ); + + expect(container.firstElementChild?.tagName).toBe('BUTTON'); + }); + + it('returns defaults from useChip', () => { + const { result } = renderHook(() => useChip({})); + + expect(result.current.componentTag).toBe('div'); + expect(result.current.isDisabled).toBe(false); + expect(result.current.interactive).toBe(false); + expect(result.current.propsBase['aria-label']).toBe('Chip'); + expect(result.current.propsBase['data-interactive']).toBe('false'); + }); + + it('handles disabled and isDisabled alias in useChip', () => { + const { result: disabledResult } = renderHook(() => useChip({ disabled: true })); + expect(disabledResult.current.isDisabled).toBe(true); + + const { result: aliasResult } = renderHook(() => useChip({ isDisabled: true })); + expect(aliasResult.current.isDisabled).toBe(true); + + const { result: precedenceResult } = renderHook(() => useChip({ disabled: false, isDisabled: true })); + expect(precedenceResult.current.isDisabled).toBe(false); + }); + + it('marks root as interactive and calls onClick', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + + render(Interactive); + + const root = screen.getByRole('button', { name: 'Interactive' }); + + expect(root).toHaveAttribute('data-interactive', 'true'); + await user.click(root); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('uses split-actions group when closable + interactive is requested', () => { + const { container } = render( + undefined}> + Closable + + ); + + expect(container.firstElementChild?.tagName).toBe('DIV'); + expect(container.firstElementChild).toHaveAttribute('role', 'group'); + }); + + it('uses contextual close label when chip has readable text', () => { + render(React); + + expect(screen.getByRole('button', { name: 'Remove React' })).toBeInTheDocument(); + }); + + it('uses fallback chip labels for non-textual content', () => { + render(•} />); + + expect(screen.getByLabelText('Chip item (closable)')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove chip' })).toBeInTheDocument(); + }); + + it('calls onClose without triggering onClick when close button is clicked', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + const onClose = vi.fn(); + + render( + + React + + ); + + await user.click(screen.getByRole('button', { name: 'Remove React' })); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClick).not.toHaveBeenCalled(); + expect(onClose.mock.calls[0][0]?.type).toBe('click'); + }); + + it('calls both onClick (root) and onClose (close button) in closable + clickable mode', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + const onClose = vi.fn(); + + render( + + React + + ); + + const primary = screen.getByRole('button', { name: 'React' }); + await user.click(primary); + expect(onClick).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole('button', { name: 'Remove React' })); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('does not nest buttons in closable + clickable mode', () => { + const { container } = render( + undefined} onClose={() => undefined}> + React + + ); + + const primary = screen.getByRole('button', { name: 'React' }); + + expect(primary).toBeInTheDocument(); + expect(primary.querySelector('button')).toBeNull(); + expect(container.firstElementChild).toHaveAttribute('role', 'group'); + }); + + it('calls onClose on Delete key when closable and enabled', () => { + const onClose = vi.fn(); + const { container } = render( + + React + + ); + + fireEvent.keyDown(container.firstElementChild as HTMLElement, { key: 'Delete' }); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose.mock.calls[0][0]?.type).toBe('keydown'); + }); + + it('calls onClose on Backspace key when closable and enabled', () => { + const onClose = vi.fn(); + const { container } = render( + + React + + ); + + fireEvent.keyDown(container.firstElementChild as HTMLElement, { key: 'Backspace' }); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose.mock.calls[0][0]?.type).toBe('keydown'); + }); + + it('calls onClose on Delete key in split-actions mode (closable + interactive)', () => { + const onClose = vi.fn(); + + render( + undefined} onClose={onClose}> + React + + ); + + const primaryAction = screen.getByRole('button', { name: 'React' }); + fireEvent.keyDown(primaryAction, { key: 'Delete' }); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose.mock.calls[0][0]?.type).toBe('keydown'); + }); + + it('calls onClose on Backspace key in split-actions mode (closable + interactive)', () => { + const onClose = vi.fn(); + + render( + undefined} onClose={onClose}> + React + + ); + + const primaryAction = screen.getByRole('button', { name: 'React' }); + fireEvent.keyDown(primaryAction, { key: 'Backspace' }); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose.mock.calls[0][0]?.type).toBe('keydown'); + }); + + it('does not trigger onClick or onClose when disabled', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + const onClose = vi.fn(); + + render( + + React + + ); + + const root = screen.getByText('React').closest('div'); + + expect(root).toBeInTheDocument(); + if (!root) { + throw new Error('Chip root not found'); + } + expect(root).toHaveAttribute('aria-disabled', 'true'); + expect(root).toHaveAttribute('data-disabled', 'true'); + expect(root).toHaveAttribute('data-interactive', 'false'); + + const closeButton = screen.getByRole('button', { name: 'Remove React' }); + expect(closeButton).toBeDisabled(); + + await user.click(root); + await user.click(closeButton); + + expect(onClick).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('falls back to non-interactive root and disabled close action when disabled + closable + onClick', () => { + render( + undefined} onClose={() => undefined}> + React + + ); + + expect(screen.queryByRole('button', { name: 'React' })).not.toBeInTheDocument(); + + const root = screen.getByText('React').closest('div'); + expect(root).toHaveAttribute('aria-disabled', 'true'); + expect(root).toHaveAttribute('data-interactive', 'false'); + + const closeButton = screen.getByRole('button', { name: 'Remove React' }); + + expect(closeButton).toBeDisabled(); + }); + + it('supports uncontrolled selectable state', async () => { + const user = userEvent.setup(); + + render( + + Filter + + ); + + const chip = screen.getByRole('button', { name: 'Filter' }); + expect(chip).toHaveAttribute('aria-pressed', 'false'); + + await user.click(chip); + expect(chip).toHaveAttribute('aria-pressed', 'true'); + }); + + it('supports controlled selectable mode through onSelectedChange', async () => { + const user = userEvent.setup(); + const onSelectedChange = vi.fn(); + + render( + + Controlled + + ); + + const chip = screen.getByRole('button', { name: 'Controlled' }); + expect(chip).toHaveAttribute('aria-pressed', 'false'); + + await user.click(chip); + + expect(onSelectedChange).toHaveBeenCalledTimes(1); + expect(onSelectedChange).toHaveBeenCalledWith(true); + // Controlled mode: visual pressed state only changes if parent updates `selected`. + expect(chip).toHaveAttribute('aria-pressed', 'false'); + }); + + it('activates interactive div with Enter and Space keyboard keys', () => { + const onClick = vi.fn(); + render( + + Keyboard + + ); + + const root = screen.getByRole('button', { name: 'Keyboard' }); + + fireEvent.keyDown(root, { key: 'Enter' }); + fireEvent.keyDown(root, { key: ' ' }); + + expect(onClick).toHaveBeenCalledTimes(2); + }); + + it('prefers explicit ariaLabel when provided', () => { + render(); + + expect(screen.getByLabelText('Online status')).toBeInTheDocument(); + }); + + it('uses generic accessibility labels when chip has no readable text', () => { + render(•} />); + + expect(screen.getByLabelText('Chip item (closable)')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove chip' })).toBeInTheDocument(); + }); +}); diff --git a/src/components/atoms/chip/Chip.tsx b/src/components/atoms/chip/Chip.tsx new file mode 100644 index 00000000..59b9b574 --- /dev/null +++ b/src/components/atoms/chip/Chip.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import Icon from '../icon/Icon'; +import type { ChipProps } from './types'; +import { useChip } from './useChip'; + +type ChipElement = HTMLDivElement | HTMLButtonElement; + +export const Chip = React.forwardRef((props, ref) => { + const { + componentTag, + slots, + splitActions, + isDot, + hasChildren, + propsBase, + primaryActionProps, + pieces, + closable, + isDisabled, + handleClose, + closeIconSize, + closeButtonAriaLabel + } = useChip(props); + const { avatar, startContent, endContent, children } = pieces; + + const content = ( + <> + {avatar && {avatar}} + + {startContent && ( + + {startContent} + + )} + + {isDot &&
) @@ -193,12 +197,13 @@ export const CustomTrigger: Story = { render: () => (
- - Lorem ipsum - + - + + + Open image menu +
) @@ -211,7 +216,7 @@ export const CustomWidth: Story = { render: () => (
-
) @@ -224,7 +229,7 @@ export const CustomOffset: Story = { render: () => (
-
) @@ -237,16 +242,16 @@ export const Position: Story = { render: () => (
-
) @@ -259,13 +264,13 @@ export const Alignment: Story = { render: () => (
-
) diff --git a/src/components/atoms/dropdown/Dropdown.tsx b/src/components/atoms/dropdown/Dropdown.tsx index e9f295e9..95801652 100644 --- a/src/components/atoms/dropdown/Dropdown.tsx +++ b/src/components/atoms/dropdown/Dropdown.tsx @@ -112,7 +112,7 @@ const Dropdown: FC = ({ ...props }) => { return ( -
{props.children}
+
& {...props} ref={iconButtonRef} type='button' - role={ariaPressed !== undefined ? 'switch' : 'button'} className={cn('w-auto', iconButtonVariants({ variant, rounded, shadow }), className)} disabled={disabled} aria-disabled={disabled || undefined} diff --git a/src/components/atoms/link/Link.stories.tsx b/src/components/atoms/link/Link.stories.tsx index 64514716..a28affef 100644 --- a/src/components/atoms/link/Link.stories.tsx +++ b/src/components/atoms/link/Link.stories.tsx @@ -1,5 +1,5 @@ -import { fn } from '@storybook/test'; import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; import Link from './Link'; /** @@ -172,7 +172,7 @@ export const CustomClass: Story = { size='md' variant='outlined' href='https://github.com/egdev6' - className='!border-blue !bg-blue hover:!bg-blue-dark hover:!border-blue-dark hover:!shadow-blue-dark' + className='!border-blue-dark !bg-blue-dark !text-white hover:!bg-blue hover:!border-blue hover:!shadow-blue-dark' > Custom Class Link diff --git a/src/components/molecules/snippet/Snippet.stories.tsx b/src/components/molecules/snippet/Snippet.stories.tsx index d926282c..fa008666 100644 --- a/src/components/molecules/snippet/Snippet.stories.tsx +++ b/src/components/molecules/snippet/Snippet.stories.tsx @@ -257,6 +257,7 @@ export const CustomClassName: Story = { */ export const WithAriaControls: Story = { args: { + id: 'custom-snippet', children: 'npm install @your/package', 'aria-label': 'Copy install command', 'aria-controls': 'custom-snippet' diff --git a/src/components/molecules/snippet/types.ts b/src/components/molecules/snippet/types.ts index 05014678..5ee41c97 100644 --- a/src/components/molecules/snippet/types.ts +++ b/src/components/molecules/snippet/types.ts @@ -157,6 +157,7 @@ export type SizeButton = Record; */ export type SnippetProps = SnippetVariants & { children: ReactNode; + id?: string; className?: string; disableCopy?: boolean; onCopy?: () => void; diff --git a/src/index.ts b/src/index.ts index 5e870741..249a64cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export { Avatar } from './components/atoms/avatar'; export { Badge } from './components/atoms/badge'; export { Button } from './components/atoms/button'; export { Calendar } from './components/atoms/calendar'; +export { Chip } from './components/atoms/chip'; export { Divider } from './components/atoms/divider'; export { Dropdown } from './components/atoms/dropdown'; export { Header } from './components/atoms/header'; diff --git a/src/styles/base.css b/src/styles/base.css index b8bdff7d..17dc08ba 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -24,41 +24,41 @@ /* ── Typography scale ──────────────────────────────────────────── */ .fs-h1 { - font-size: var(--text-h1); + font-size: var(--text-h1-compact); line-height: 1.2; font-weight: 800; } .fs-h2 { - font-size: var(--text-h2); + font-size: var(--text-h2-compact); line-height: 1.2; font-weight: 700; } .fs-h3 { - font-size: var(--text-h3); + font-size: var(--text-h3-compact); line-height: 1.2; font-weight: 700; } .fs-h4 { - font-size: var(--text-h4); + font-size: var(--text-h4-compact); line-height: 1.2; font-weight: 600; } .fs-h5 { - font-size: var(--text-h5); + font-size: var(--text-h5-compact); line-height: 1.2; font-weight: 600; } .fs-h6 { - font-size: var(--text-h6); + font-size: var(--text-h6-compact); line-height: 1.2; font-weight: 600; } .fs-base { - font-size: var(--text-body); + font-size: var(--text-body-compact); line-height: 1.5; } .fs-small { - font-size: var(--text-small); + font-size: var(--text-small-compact); line-height: 1.4; } @@ -67,30 +67,30 @@ font-family: var(--font-primary); } - @media (max-width: theme("screens.md")) { + @media (min-width: 48rem) { .fs-h1 { - font-size: var(--text-h1-tablet); + font-size: var(--text-h1); } .fs-h2 { - font-size: var(--text-h2-tablet); + font-size: var(--text-h2); } .fs-h3 { - font-size: var(--text-h3-tablet); + font-size: var(--text-h3); } .fs-h4 { - font-size: var(--text-h4-tablet); + font-size: var(--text-h4); } .fs-h5 { - font-size: var(--text-h5-tablet); + font-size: var(--text-h5); } .fs-h6 { - font-size: var(--text-h6-tablet); + font-size: var(--text-h6); } .fs-base { - font-size: var(--text-body-tablet); + font-size: var(--text-body); } .fs-small { - font-size: var(--text-small-tablet); + font-size: var(--text-small); } } diff --git a/src/styles/theme.css b/src/styles/theme.css index 34af3f86..3d5bc5aa 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -153,6 +153,11 @@ --color-amber: #fbbf24; --color-amber-hover: #ffd700; + /* ── Chip sizing tokens ───────────────────────────────────────── */ + --size-chip-avatar-sm: 18px; + --size-chip-avatar-md: 22px; + --size-chip-avatar-lg: 26px; + /* ── Glow / Shadow ───────────────────────────────────────────── */ --glow-base: 0 4px 20px rgba(219, 20, 60, 0.15); /* Light */ --glow-active: 0 8px 30px rgba(219, 20, 60, 0.3); /* Light */ @@ -185,6 +190,39 @@ --glow-btn-secondary-light: 0 0 0 1px rgba(219, 20, 60, 0.35), 0 2px 8px rgba(219, 20, 60, 0.12); --glow-btn-secondary-hover-light: 0 0 0 1px rgba(219, 20, 60, 0.55), 0 4px 12px rgba(219, 20, 60, 0.18); + /* Glow de chip shadow variants */ + --glow-chip-primary-light: + 0 1px 2px rgba(0, 0, 0, 0.12), 0 3px 8px color-mix(in srgb, var(--color-primary) 26%, transparent); + --glow-chip-primary-hover-light: + 0 2px 4px rgba(0, 0, 0, 0.14), 0 5px 12px color-mix(in srgb, var(--color-primary) 34%, transparent); + --glow-chip-primary: + 0 1px 2px rgba(0, 0, 0, 0.28), 0 3px 9px color-mix(in srgb, var(--color-primary) 22%, transparent); + --glow-chip-primary-hover: + 0 2px 4px rgba(0, 0, 0, 0.32), 0 5px 12px color-mix(in srgb, var(--color-primary) 30%, transparent); + + --glow-chip-secondary-light: + 0 1px 2px rgba(0, 0, 0, 0.12), 0 3px 8px color-mix(in srgb, var(--color-surface-raised-light) 24%, transparent); + --glow-chip-secondary-hover-light: + 0 2px 4px rgba(0, 0, 0, 0.14), 0 5px 12px color-mix(in srgb, var(--color-surface-raised-light) 32%, transparent); + --glow-chip-secondary: + 0 1px 2px rgba(0, 0, 0, 0.28), 0 3px 9px color-mix(in srgb, var(--color-surface-raised-dark) 28%, transparent); + --glow-chip-secondary-hover: + 0 2px 4px rgba(0, 0, 0, 0.32), 0 5px 12px color-mix(in srgb, var(--color-surface-raised-dark) 36%, transparent); + + --glow-chip-success: 0 1px 2px rgba(0, 0, 0, 0.12), 0 3px 8px color-mix(in srgb, var(--color-green) 24%, transparent); + --glow-chip-success-hover: + 0 2px 4px rgba(0, 0, 0, 0.14), 0 5px 12px color-mix(in srgb, var(--color-green) 32%, transparent); + + --glow-chip-warning: + 0 1px 2px rgba(0, 0, 0, 0.12), 0 3px 8px color-mix(in srgb, var(--color-yellow) 24%, transparent); + --glow-chip-warning-hover: + 0 2px 4px rgba(0, 0, 0, 0.14), 0 5px 12px color-mix(in srgb, var(--color-yellow) 32%, transparent); + + --glow-chip-danger: + 0 1px 2px rgba(0, 0, 0, 0.12), 0 3px 8px color-mix(in srgb, var(--color-red-600) 28%, transparent); + --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); + /* 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); @@ -250,16 +288,16 @@ --text-xs: 0.75rem; /* 12px — badges, tags micro */ --text-code: 93%; /* relativo — code blocks */ - /* Tamaños tablet */ - --text-display-tablet: 2.75rem; - --text-h1-tablet: 2.4rem; - --text-h2-tablet: 2rem; - --text-h3-tablet: 1.75rem; - --text-h4-tablet: 1.375rem; - --text-h5-tablet: 1.125rem; - --text-h6-tablet: 1rem; - --text-body-tablet: 0.9375rem; - --text-small-tablet: 0.8125rem; + /* Tamaños compactos (base < md) */ + --text-display-compact: 2.75rem; + --text-h1-compact: 2.4rem; + --text-h2-compact: 2rem; + --text-h3-compact: 1.75rem; + --text-h4-compact: 1.375rem; + --text-h5-compact: 1.125rem; + --text-h6-compact: 1rem; + --text-body-compact: 0.9375rem; + --text-small-compact: 0.8125rem; /* Line heights */ --leading-tight: 1.1;