From 9a40a449885e0461f8cf2d937cc93e2eb973f4b0 Mon Sep 17 00:00:00 2001 From: egdev6 Date: Tue, 2 Jun 2026 16:37:40 +0200 Subject: [PATCH 1/3] feat(card-container): add visual container atom --- docs/COMPONENTS.en.md | 47 ++-- docs/COMPONENTS.md | 47 ++-- docs/DESIGN.en.md | 17 +- docs/DESIGN.md | 17 +- src/components/atoms/button/useButton.ts | 2 +- .../card-container/CardContainer.stories.tsx | 203 ++++++++++++++++++ .../card-container/CardContainer.test.tsx | 166 ++++++++++++++ .../atoms/card-container/CardContainer.tsx | 13 ++ src/components/atoms/card-container/index.ts | 2 + src/components/atoms/card-container/types.ts | 109 ++++++++++ .../atoms/card-container/useCardContainer.ts | 31 +++ .../atoms/icon-button/useIconButton.ts | 2 +- src/index.ts | 1 + src/styles/theme.css | 13 ++ 14 files changed, 618 insertions(+), 52 deletions(-) create mode 100644 src/components/atoms/card-container/CardContainer.stories.tsx create mode 100644 src/components/atoms/card-container/CardContainer.test.tsx create mode 100644 src/components/atoms/card-container/CardContainer.tsx create mode 100644 src/components/atoms/card-container/index.ts create mode 100644 src/components/atoms/card-container/types.ts create mode 100644 src/components/atoms/card-container/useCardContainer.ts diff --git a/docs/COMPONENTS.en.md b/docs/COMPONENTS.en.md index 1b0cb080..bafc0660 100644 --- a/docs/COMPONENTS.en.md +++ b/docs/COMPONENTS.en.md @@ -9,7 +9,7 @@ These rules are system-wide and non-negotiable. Every component must comply with all of them. **Rule 1 — `backdrop-filter: blur` only on floating elements.** -Only elements that literally float above page content (navbar, mobile sidebar, modal backdrops, sticky bars) use `backdrop-filter`. Content cards are opaque — they use `background: #0B131E`. `blur` signals "I am floating"; opaque signals "I am content". Never apply `backdrop-filter` to feature cards, release cards, pipeline cards, or any card that lives in normal document flow. +Only elements that literally float above page content (navbar, mobile sidebar, modal backdrops, sticky bars, or an explicitly floating `CardContainer` with `backdropBlur` enabled) use `backdrop-filter`. Content cards are opaque — they use `background: #0B131E`. `blur` signals "I am floating"; opaque signals "I am content". Never apply `backdrop-filter` to feature cards, release cards, pipeline cards, or any card that lives in normal document flow. `CardContainer` defaults to `backdropBlur="none"`; `backdropBlur="sm" | "md" | "lg"` is only for floating/glass treatments above other content. **Rule 2 — Never animate gradient background directly. Use `::before` opacity instead.** A `linear-gradient` cannot be transitioned by the browser. Instead, place the hover gradient on a `::before` pseudo-element with `opacity: 0`, then transition only `opacity` to `1` on hover. This runs on the GPU compositor and produces a smooth fade. The background property on the element itself remains static or uses only simple `background-color` transitions. @@ -638,24 +638,35 @@ z-index: 1; ### 3.10 Card — Frosted -Used only for truly floating elements: navbar, sticky bars, mobile sidebar, modal overlays. +Used only for explicitly floating CardContainer glass surfaces. Normal document-flow cards stay opaque. ```css -background: rgba(6, 12, 19, 0.75); /* --color-navbar-dark */ -backdrop-filter: blur(16px); --webkit-backdrop-filter: blur(16px); -border: 1px solid rgba(255, 255, 255, 0.06); +background: rgba(6, 12, 19, 0.38); /* --color-card-backdrop-dark */ +backdrop-filter: blur(20px); +-webkit-backdrop-filter: blur(20px); +border: 1px solid rgba(255, 0, 54, 0.5); /* --color-red-tint-border */ border-radius: 8px; /* or 12px for larger panels */ ``` **Light mode:** ```css -background: rgba(255, 255, 255, 0.7); /* --color-navbar-light */ -backdrop-filter: blur(16px); --webkit-backdrop-filter: blur(16px); +background: rgba(255, 255, 255, 0.32); /* --color-card-backdrop-light */ +backdrop-filter: blur(20px); +-webkit-backdrop-filter: blur(20px); +border: 1px solid rgba(255, 0, 54, 0.5); /* --color-red-tint-border */ +``` + +**CardContainer backdropBlur levels:** + +```css +backdropBlur="sm"; /* --blur-card-sm: blur(10px) */ +backdropBlur="md"; /* --blur-card-md: blur(20px) */ +backdropBlur="lg"; /* --blur-card-lg: blur(36px) */ ``` +Use these only when the card is visually floating above other content. Leave `backdropBlur="none"` for normal content cards. + **Sticky CTA bar specific (floats below navbar after scroll):** ```css @@ -709,15 +720,10 @@ box-shadow: 0 4px 30px rgba(255, 0, 54, 0.1); ```css /* Dark */ -background: rgba( - 27, - 27, - 29, - 0.6 -); /* reference; DESIGN.md spec: rgba(6,12,19,0.75) */ +background: rgba(6, 12, 19, 0.75); /* --color-navbar-dark */ backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); -border-bottom: 1px solid #1a1a1a; /* --ifm-color-emphasis-200 */ +border-bottom: 1px solid #172230; /* --color-border-dark */ position: sticky; top: 0; z-index: 300; /* --z-navbar */ @@ -726,7 +732,7 @@ z-index: 300; /* --z-navbar */ **Light mode:** ```css -background: rgba(255, 255, 255, 0.7); +background: rgba(255, 255, 255, 0.7); /* --color-navbar-light */ backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); ``` @@ -1260,6 +1266,13 @@ Note: buttons ARE the exception — their `background` gradient transition is pe background: rgba(6, 12, 19, 0.75); backdrop-filter: blur(16px); } + +/* ✅ CardContainer backdropBlur is allowed only when the card is floating */ +.floatingGlassCard { + background: rgba(6, 12, 19, 0.38); + border-color: rgba(255, 0, 54, 0.5); + backdrop-filter: blur(20px); /* CardContainer backdropBlur="md" */ +} ``` Blur on content cards creates a visual hierarchy confusion: it signals "floating" when the card is grounded content. It also has significant GPU cost on long pages with many cards. diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 69c35e05..5ff66e9a 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -9,7 +9,7 @@ These rules are system-wide and non-negotiable. Every component must comply with all of them. **Rule 1 — `backdrop-filter: blur` only on floating elements.** -Only elements that literally float above page content (navbar, mobile sidebar, modal backdrops, sticky bars) use `backdrop-filter`. Content cards are opaque — they use `background: #0B131E`. `blur` signals "I am floating"; opaque signals "I am content". Never apply `backdrop-filter` to feature cards, release cards, pipeline cards, or any card that lives in normal document flow. +Only elements that literally float above page content (navbar, mobile sidebar, modal backdrops, sticky bars, or an explicitly floating `CardContainer` with `backdropBlur` enabled) use `backdrop-filter`. Content cards are opaque — they use `background: #0B131E`. `blur` signals "I am floating"; opaque signals "I am content". Never apply `backdrop-filter` to feature cards, release cards, pipeline cards, or any card that lives in normal document flow. `CardContainer` defaults to `backdropBlur="none"`; `backdropBlur="sm" | "md" | "lg"` is only for floating/glass treatments above other content. **Rule 2 — Never animate gradient background directly. Use `::before` opacity instead.** A `linear-gradient` cannot be transitioned by the browser. Instead, place the hover gradient on a `::before` pseudo-element with `opacity: 0`, then transition only `opacity` to `1` on hover. This runs on the GPU compositor and produces a smooth fade. The background property on the element itself remains static or uses only simple `background-color` transitions. @@ -638,24 +638,35 @@ z-index: 1; ### 3.10 Card — Frosted -Used only for truly floating elements: navbar, sticky bars, mobile sidebar, modal overlays. +Used only for explicitly floating CardContainer glass surfaces. Normal document-flow cards stay opaque. ```css -background: rgba(6, 12, 19, 0.75); /* --color-navbar-dark */ -backdrop-filter: blur(16px); --webkit-backdrop-filter: blur(16px); -border: 1px solid rgba(255, 255, 255, 0.06); +background: rgba(6, 12, 19, 0.38); /* --color-card-backdrop-dark */ +backdrop-filter: blur(20px); +-webkit-backdrop-filter: blur(20px); +border: 1px solid rgba(255, 0, 54, 0.5); /* --color-red-tint-border */ border-radius: 8px; /* or 12px for larger panels */ ``` **Light mode:** ```css -background: rgba(255, 255, 255, 0.7); /* --color-navbar-light */ -backdrop-filter: blur(16px); --webkit-backdrop-filter: blur(16px); +background: rgba(255, 255, 255, 0.32); /* --color-card-backdrop-light */ +backdrop-filter: blur(20px); +-webkit-backdrop-filter: blur(20px); +border: 1px solid rgba(255, 0, 54, 0.5); /* --color-red-tint-border */ +``` + +**CardContainer backdropBlur levels:** + +```css +backdropBlur="sm"; /* --blur-card-sm: blur(10px) */ +backdropBlur="md"; /* --blur-card-md: blur(20px) */ +backdropBlur="lg"; /* --blur-card-lg: blur(36px) */ ``` +Use these only when the card is visually floating above other content. Leave `backdropBlur="none"` for normal content cards. + **Sticky CTA bar specific (floats below navbar after scroll):** ```css @@ -709,15 +720,10 @@ box-shadow: 0 4px 30px rgba(255, 0, 54, 0.1); ```css /* Dark */ -background: rgba( - 27, - 27, - 29, - 0.6 -); /* reference; DESIGN.md spec: rgba(6,12,19,0.75) */ +background: rgba(6, 12, 19, 0.75); /* --color-navbar-dark */ backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); -border-bottom: 1px solid #1a1a1a; /* --ifm-color-emphasis-200 */ +border-bottom: 1px solid #172230; /* --color-border-dark */ position: sticky; top: 0; z-index: 300; /* --z-navbar */ @@ -726,7 +732,7 @@ z-index: 300; /* --z-navbar */ **Light mode:** ```css -background: rgba(255, 255, 255, 0.7); +background: rgba(255, 255, 255, 0.7); /* --color-navbar-light */ backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); ``` @@ -1260,6 +1266,13 @@ Note: buttons ARE the exception — their `background` gradient transition is pe background: rgba(6, 12, 19, 0.75); backdrop-filter: blur(16px); } + +/* ✅ CardContainer backdropBlur is allowed only when the card is floating */ +.floatingGlassCard { + background: rgba(6, 12, 19, 0.38); + border-color: rgba(255, 0, 54, 0.5); + backdrop-filter: blur(20px); /* CardContainer backdropBlur="md" */ +} ``` Blur on content cards creates a visual hierarchy confusion: it signals "floating" when the card is grounded content. It also has significant GPU cost on long pages with many cards. diff --git a/docs/DESIGN.en.md b/docs/DESIGN.en.md index 7b2effa1..f3dabee7 100644 --- a/docs/DESIGN.en.md +++ b/docs/DESIGN.en.md @@ -71,6 +71,7 @@ The brand color. Two values depending on the mode: | `background-surface` | `#0B131E` | — | Solid cards, code blocks | | `background-surface-raised` | `#0F1824` | — | Table headers, subtle emphasis | | `navbar-bg` | `rgba(6,12,19,0.75)` | `rgba(255,255,255,0.7)` | Navbar with backdrop blur | +| `card-backdrop-bg` | `rgba(6,12,19,0.38)` | `rgba(255,255,255,0.32)` | Floating CardContainer with `backdropBlur` | | `sidebar-mobile-bg` | `rgba(6,12,19,1.0)` | `#ffffff` | Mobile sidebar | | `dropdown-bg` | `#0B131E` | `#ffffff` | Dropdown menus | @@ -184,8 +185,8 @@ Unlike a shadow-based elevation system, Stack-and-Flow uses an **opacity and blu |-------|------|-------------|-----| | **Base** | Opaque | `background: #060C13` + subtle grid | Page canvas | | **Raised** | Opaque | `background: #0B131E` + `border: 1px solid #172230` | Solid cards, code blocks, dropdowns | -| **Frosted** | Translucent | `background: rgba(6,12,19,0.75)` + `backdrop-filter: blur(16px)` | Navbar, floating overlays | -| **Frosted Light** | Translucent | `background: rgba(255,255,255,0.7)` + `backdrop-filter: blur(16px)` | Navbar in light mode | +| **Frosted** | Translucent | `background: rgba(6,12,19,0.38)` + `border: rgba(255,0,54,0.5)` + `backdrop-filter: blur(20px)` | `CardContainer backdropBlur="md"` when floating above content | +| **Frosted Light** | Translucent | `background: rgba(255,255,255,0.32)` + `border: rgba(255,0,54,0.5)` + `backdrop-filter: blur(20px)` | `CardContainer backdropBlur="md"` in light mode when floating above content | | **Tinted** | Tinted translucent | `background: rgba(255,0,54,0.06–0.15)` | Secondary buttons, hover states, active menus | | **Overlay** | Dark translucent | `background: rgba(0,0,0,0.6)` + `backdrop-filter: blur(4px)` | Modal backgrounds, sidebar backdrop | @@ -198,12 +199,12 @@ border: 1px solid #172230; border-radius: 8px; ``` -**Frosted card** — use for elements that float above content (navbar, tooltips, popovers): +**Frosted card** — use only when `CardContainer` floats above content (`backdropBlur="sm|md|lg"`): ```css -background: rgba(6, 12, 19, 0.75); -backdrop-filter: blur(16px); --webkit-backdrop-filter: blur(16px); -border: 1px solid rgba(255, 255, 255, 0.06); +background: rgba(6, 12, 19, 0.38); +backdrop-filter: blur(20px); /* md; sm=10px, lg=36px */ +-webkit-backdrop-filter: blur(20px); +border: 1px solid rgba(255, 0, 54, 0.5); border-radius: 8px; ``` @@ -216,7 +217,7 @@ border-radius: 8px; ### Blur rule -Only elements that literally float above others (navbar, mobile sidebar, modals, tooltips) use `backdrop-filter`. Content cards do not — they are opaque. This distinction is intentional: blur means “I am floating,” opaque means “I am content.” +Only elements that literally float above others (navbar, mobile sidebar, modals, tooltips, or a `CardContainer` used as a floating surface) use `backdrop-filter`. Content cards do not — they are opaque. This distinction is intentional: blur means “I am floating,” opaque means “I am content.” In `CardContainer`, keep `backdropBlur="none"` for normal-flow cards and use `sm`, `md`, or `lg` only for glass/floating surfaces. --- diff --git a/docs/DESIGN.md b/docs/DESIGN.md index a6bf1ece..15796bab 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -71,6 +71,7 @@ El color de marca. Dos valores según el modo: | `background-surface` | `#0B131E` | — | Cards sólidas, code blocks | | `background-surface-raised` | `#0F1824` | — | Headers de tabla, énfasis sutil | | `navbar-bg` | `rgba(6,12,19,0.75)` | `rgba(255,255,255,0.7)` | Navbar con backdrop-blur | +| `card-backdrop-bg` | `rgba(6,12,19,0.38)` | `rgba(255,255,255,0.32)` | CardContainer flotante con `backdropBlur` | | `sidebar-mobile-bg` | `rgba(6,12,19,1.0)` | `#ffffff` | Sidebar mobile | | `dropdown-bg` | `#0B131E` | `#ffffff` | Menus desplegables | @@ -184,8 +185,8 @@ A diferencia de un sistema de elevación con sombras, Stack-and-Flow usa un **si |-------|------|-------------|-----| | **Base** | Opaco | `background: #060C13` + grid sutil | Canvas de página | | **Raised** | Opaco | `background: #0B131E` + `border: 1px solid #172230` | Cards sólidas, code blocks, dropdowns | -| **Frosted** | Semitransparente | `background: rgba(6,12,19,0.75)` + `backdrop-filter: blur(16px)` | Navbar, overlays flotantes | -| **Frosted Light** | Semitransparente | `background: rgba(255,255,255,0.7)` + `backdrop-filter: blur(16px)` | Navbar en light mode | +| **Frosted** | Semitransparente | `background: rgba(6,12,19,0.38)` + `border: rgba(255,0,54,0.5)` + `backdrop-filter: blur(20px)` | `CardContainer backdropBlur="md"` cuando flota sobre contenido | +| **Frosted Light** | Semitransparente | `background: rgba(255,255,255,0.32)` + `border: rgba(255,0,54,0.5)` + `backdrop-filter: blur(20px)` | `CardContainer backdropBlur="md"` en light mode cuando flota sobre contenido | | **Tinted** | Semitransparente colored | `background: rgba(255,0,54,0.06–0.15)` | Botones secondary, hover states, menús activos | | **Overlay** | Semitransparente oscuro | `background: rgba(0,0,0,0.6)` + `backdrop-filter: blur(4px)` | Fondos de modal, sidebar backdrop | @@ -198,12 +199,12 @@ border: 1px solid #172230; border-radius: 8px; ``` -**Card frosted** — usa para elementos que flotan sobre contenido (navbar, tooltips, popovers): +**Card frosted** — usá solo cuando el `CardContainer` flota sobre contenido (`backdropBlur="sm|md|lg"`): ```css -background: rgba(6, 12, 19, 0.75); -backdrop-filter: blur(16px); --webkit-backdrop-filter: blur(16px); -border: 1px solid rgba(255, 255, 255, 0.06); +background: rgba(6, 12, 19, 0.38); +backdrop-filter: blur(20px); /* md; sm=10px, lg=36px */ +-webkit-backdrop-filter: blur(20px); +border: 1px solid rgba(255, 0, 54, 0.5); border-radius: 8px; ``` @@ -216,7 +217,7 @@ border-radius: 8px; ### Regla de blur -Solo los elementos que literalmente flotan sobre otros (navbar, mobile sidebar, modals, tooltips) usan `backdrop-filter`. Los cards de contenido no lo usan — son opacos. Esta distinción es intencional: el blur implica "estoy flotando", el opaco implica "soy contenido". +Solo los elementos que literalmente flotan sobre otros (navbar, mobile sidebar, modals, tooltips o un `CardContainer` usado como superficie flotante) usan `backdrop-filter`. Los cards de contenido no lo usan — son opacos. Esta distinción es intencional: el blur implica "estoy flotando", el opaco implica "soy contenido". En `CardContainer`, mantené `backdropBlur="none"` para cards en flujo normal y usá `sm`, `md` o `lg` solo para superficies glass/floating. --- diff --git a/src/components/atoms/button/useButton.ts b/src/components/atoms/button/useButton.ts index c59d81fa..aeeb9300 100644 --- a/src/components/atoms/button/useButton.ts +++ b/src/components/atoms/button/useButton.ts @@ -6,7 +6,7 @@ import { type ButtonEmphasis, type ButtonProps, buttonVariants } from './types'; type UseButtonReturn = Omit & { ariaLabel: string; ariaPressed: ButtonProps['aria-pressed']; - buttonRef: RefObject; + buttonRef: RefObject; className: string; contentClassName: string; disabled: boolean; diff --git a/src/components/atoms/card-container/CardContainer.stories.tsx b/src/components/atoms/card-container/CardContainer.stories.tsx new file mode 100644 index 00000000..7a0d7748 --- /dev/null +++ b/src/components/atoms/card-container/CardContainer.stories.tsx @@ -0,0 +1,203 @@ +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; +import { Button } from '../button'; +import { Text } from '../text'; +import { CardContainer } from './CardContainer'; + +/** + * ## Description + * CardContainer renders a quiet, token-backed surface for grouping related content without owning its internal layout. + * + * ## Dependencies + * Uses `Text` for copy examples and `Button` for nested interactive children inside the card body. + * + * ## Usage Guide + * Use CardContainer as a presentational wrapper for content blocks, settings panels, summaries, and feature highlights. Keep the root non-interactive by default and place links or buttons inside the card when actions are needed. Leave `backdropBlur="none"` for normal document-flow cards; use backdropBlur levels only when the card is intentionally acting as a floating/glass surface above other content. + */ +const meta: Meta = { + title: 'Atoms/CardContainer', + component: CardContainer, + parameters: { + docs: { + autodocs: true + } + }, + tags: ['autodocs'] +}; + +export default meta; + +type Story = StoryObj; + +const renderCardBody = (title: string, description: string) => ( +
+

{title}

+ {description} +
+); + +/** + * Shows the default card surface using the component default variants. + */ +export const Default: Story = { + args: { + children: renderCardBody('Usage summary', 'Group related content inside a quiet surface with native div semantics.') + } +}; + +/** + * Shows the four approved surface variants. + */ +export const Variants: Story = { + render: () => ( +
+ + {renderCardBody('Surface', 'Each variant adjusts surface depth without changing the free-form content model.')} + + + {renderCardBody('Raised', 'Each variant adjusts surface depth without changing the free-form content model.')} + + + {renderCardBody('Outlined', 'Each variant adjusts surface depth without changing the free-form content model.')} + + + {renderCardBody('Tinted', 'Each variant adjusts surface depth without changing the free-form content model.')} + +
+ ) +}; + +/** + * Shows the internal spacing scale from none to large. + */ +export const Padding: Story = { + render: () => ( +
+ + {renderCardBody('Padding none', 'Use the spacing scale to match the density of the parent layout.')} + + + {renderCardBody('Padding sm', 'Use the spacing scale to match the density of the parent layout.')} + + + {renderCardBody('Padding md', 'Use the spacing scale to match the density of the parent layout.')} + + + {renderCardBody('Padding lg', 'Use the spacing scale to match the density of the parent layout.')} + +
+ ) +}; + +/** + * Shows the approved radius options from square to large. + */ +export const Radius: Story = { + render: () => ( +
+ + {renderCardBody('Radius none', 'Radius changes the card silhouette while preserving the same content flow.')} + + + {renderCardBody('Radius xs', 'Radius changes the card silhouette while preserving the same content flow.')} + + + {renderCardBody('Radius sm', 'Radius changes the card silhouette while preserving the same content flow.')} + + + {renderCardBody('Radius md', 'Radius changes the card silhouette while preserving the same content flow.')} + + + {renderCardBody('Radius lg', 'Radius changes the card silhouette while preserving the same content flow.')} + +
+ ) +}; + +/** + * Shows decorative hover lift without implying root interactivity. + */ +export const HoverEffects: Story = { + render: () => ( +
+ +
+

Hover none

+ + The default card stays still and keeps a quiet surface treatment. + +
+
+ +
+

Hover lift

+ + Lift is a subtle 2px decorative movement. It does not make the card keyboard-focusable or clickable. + +
+
+
+ ) +}; + +/** + * Shows the renamed backdropBlur prop across the three approved blur levels over one plain gray background block. + */ +export const BackdropBlur: Story = { + render: () => ( +
+
+
+ + {renderCardBody('backdropBlur=sm', 'A subtle blur over the plain gray background block.')} + + + {renderCardBody('backdropBlur=md', 'The default blur level softens the gray block more clearly.')} + + + {renderCardBody('backdropBlur=lg', 'The strongest blur creates the softest edge over the block.')} + +
+
+ ) +}; + +/** + * Shows semantic article markup provided by the consumer. + */ +export const SemanticArticle: Story = { + render: () => ( + +
+

+ Billing +

+ + Manage invoices, payment methods, and tax details from a single surface. + +
+
+ ) +}; + +/** + * Shows nested design-system content while keeping actions inside the card body. + */ +export const NestedContent: Story = { + render: () => ( + +
+
+

Team workspace

+ + Invite collaborators, review permissions, and manage shared environments from one place. + +
+
+
+
+
+ ) +}; diff --git a/src/components/atoms/card-container/CardContainer.test.tsx b/src/components/atoms/card-container/CardContainer.test.tsx new file mode 100644 index 00000000..135e4435 --- /dev/null +++ b/src/components/atoms/card-container/CardContainer.test.tsx @@ -0,0 +1,166 @@ +import { fireEvent, render, renderHook, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { CardContainer } from './CardContainer'; +import { useCardContainer } from './useCardContainer'; + +describe('useCardContainer — logic', () => { + it('passes native div props through the hook result', () => { + const handleClick = vi.fn(); + + const { result } = renderHook(() => + useCardContainer({ + children: 'Card content', + id: 'card-container', + role: 'article', + onClick: handleClick, + 'aria-labelledby': 'card-title' + }) + ); + + expect(result.current.children).toBe('Card content'); + expect(result.current.cardContainerProps.id).toBe('card-container'); + expect(result.current.cardContainerProps.role).toBe('article'); + expect(result.current.cardContainerProps.onClick).toBe(handleClick); + expect(result.current.cardContainerProps['aria-labelledby']).toBe('card-title'); + }); + + it('keeps the consumer className while generating component classes', () => { + const { result } = renderHook(() => useCardContainer({ children: 'Card content', className: 'custom-card' })); + + expect(result.current.className).toContain('custom-card'); + expect(result.current.className).not.toBe('custom-card'); + }); +}); + +describe('CardContainer — component behavior', () => { + it('renders children', () => { + render(Card content); + + expect(screen.getByText('Card content')).toBeInTheDocument(); + }); + + it('renders a div by default', () => { + render(Card content); + + expect(screen.getByTestId('card-container').tagName).toBe('DIV'); + }); + + it('passes through native id, role, aria-* and data-* props', () => { + render( + +

Billing

+
+ ); + + const card = screen.getByTestId('billing-card'); + expect(card).toHaveAttribute('id', 'billing-card'); + expect(card).toHaveAttribute('role', 'article'); + expect(card).toHaveAttribute('aria-labelledby', 'billing-card-title'); + expect(card).toHaveAttribute('data-testid', 'billing-card'); + }); + + it('passes through native event handlers without adding interactive semantics', () => { + const handleClick = vi.fn(); + render( + + Card content + + ); + + const card = screen.getByTestId('card-container'); + fireEvent.click(card); + + expect(handleClick).toHaveBeenCalledTimes(1); + expect(card).not.toHaveAttribute('role'); + expect(card).not.toHaveAttribute('aria-pressed'); + }); + + it.each(['surface', 'raised', 'outlined', 'tinted'] as const)('supports the %s variant', (variant) => { + render( + + {variant} + + ); + + expect(screen.getByTestId(`card-${variant}`)).toBeInTheDocument(); + }); + + it.each(['none', 'sm', 'md', 'lg'] as const)('supports the %s backdropBlur level', (backdropBlur) => { + render( + + {backdropBlur} + + ); + + expect(screen.getByTestId(`card-backdrop-blur-${backdropBlur}`)).toBeInTheDocument(); + }); + + it.each([ + ['sm', '--blur-card-sm'], + ['md', '--blur-card-md'], + ['lg', '--blur-card-lg'] + ] as const)('maps backdropBlur=%s to the expected glass tokens', (backdropBlur, blurToken) => { + render( + + {backdropBlur} + + ); + + const card = screen.getByTestId('card-container'); + expect(card.className).toContain('!bg-card-backdrop-light'); + expect(card.className).toContain('dark:!bg-card-backdrop-dark'); + expect(card.className).toContain(`[backdrop-filter:var(${blurToken})]`); + expect(card.className).toContain(`[-webkit-backdrop-filter:var(${blurToken})]`); + }); + + it('keeps backdropBlur=none free of glass classes', () => { + render( + + none + + ); + + expect(screen.getByTestId('card-container').className).not.toContain('blur-card'); + }); + + it('accepts className without dropping generated component classes', () => { + render( + + Card content + + ); + + const card = screen.getByTestId('card-container'); + expect(card.className).toContain('custom-card'); + expect(card.className).not.toBe('custom-card'); + }); + + it('is not focusable by default', () => { + render(Card content); + + expect(screen.getByTestId('card-container')).not.toHaveAttribute('tabindex'); + }); + + it('does not add interactive ARIA or roles by default', () => { + render(Card content); + + const card = screen.getByTestId('card-container'); + expect(card).not.toHaveAttribute('role'); + expect(card).not.toHaveAttribute('aria-disabled'); + expect(card).not.toHaveAttribute('aria-expanded'); + expect(card).not.toHaveAttribute('aria-pressed'); + expect(card).not.toHaveAttribute('aria-selected'); + }); + + it('preserves semantic content passed as children', () => { + render( + +

Usage summary

+ +
+ ); + + expect(screen.getByRole('heading', { name: 'Usage summary' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Manage' })).toBeInTheDocument(); + }); +}); diff --git a/src/components/atoms/card-container/CardContainer.tsx b/src/components/atoms/card-container/CardContainer.tsx new file mode 100644 index 00000000..c4bffefa --- /dev/null +++ b/src/components/atoms/card-container/CardContainer.tsx @@ -0,0 +1,13 @@ +import type { FC } from 'react'; +import type { CardContainerProps } from './types'; +import { useCardContainer } from './useCardContainer'; + +export const CardContainer: FC = (props) => { + const { children, className, cardContainerProps } = useCardContainer(props); + + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/atoms/card-container/index.ts b/src/components/atoms/card-container/index.ts new file mode 100644 index 00000000..5796ec80 --- /dev/null +++ b/src/components/atoms/card-container/index.ts @@ -0,0 +1,2 @@ +export { CardContainer } from './CardContainer'; +export type * from './types'; diff --git a/src/components/atoms/card-container/types.ts b/src/components/atoms/card-container/types.ts new file mode 100644 index 00000000..efca7917 --- /dev/null +++ b/src/components/atoms/card-container/types.ts @@ -0,0 +1,109 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import type { ComponentProps, ReactNode } from 'react'; + +export const cardContainerVariants = cva( + [ + 'block overflow-hidden border', + 'text-text-light dark:text-text-dark', + 'transition-[background-color,border-color,box-shadow,translate,transform,color] duration-300 ease-out motion-reduce:transition-none' + ], + { + variants: { + variant: { + surface: 'bg-surface-light border-border-light dark:bg-surface-dark dark:border-border-dark', + raised: + 'bg-surface-raised-light border-border-strong-light dark:bg-surface-raised-dark dark:border-border-strong-dark', + outlined: 'bg-transparent border-border-strong-light dark:border-border-strong-dark', + tinted: 'bg-red-tint-subtle border-red-tint-border' + }, + backdropBlur: { + none: '', + sm: [ + '!bg-card-backdrop-light !border-red-tint-border dark:!bg-card-backdrop-dark dark:!border-red-tint-border', + '[backdrop-filter:var(--blur-card-sm)] [-webkit-backdrop-filter:var(--blur-card-sm)]', + 'dark:[backdrop-filter:var(--blur-card-sm)] dark:[-webkit-backdrop-filter:var(--blur-card-sm)]' + ], + md: [ + '!bg-card-backdrop-light !border-red-tint-border dark:!bg-card-backdrop-dark dark:!border-red-tint-border', + '[backdrop-filter:var(--blur-card-md)] [-webkit-backdrop-filter:var(--blur-card-md)]', + 'dark:[backdrop-filter:var(--blur-card-md)] dark:[-webkit-backdrop-filter:var(--blur-card-md)]' + ], + lg: [ + '!bg-card-backdrop-light !border-red-tint-border dark:!bg-card-backdrop-dark dark:!border-red-tint-border', + '[backdrop-filter:var(--blur-card-lg)] [-webkit-backdrop-filter:var(--blur-card-lg)]', + 'dark:[backdrop-filter:var(--blur-card-lg)] dark:[-webkit-backdrop-filter:var(--blur-card-lg)]' + ] + }, + padding: { + none: 'p-0', + sm: 'p-sm', + md: 'p-md', + lg: 'p-lg' + }, + radius: { + none: 'rounded-none', + xs: 'rounded-xs', + sm: 'rounded-sm', + md: 'rounded-md', + lg: 'rounded-lg' + }, + hoverEffect: { + none: '', + lift: [ + 'motion-safe:hover:-translate-y-0.5 motion-reduce:hover:translate-y-0', + 'hover:border-brand-light dark:hover:border-brand-dark-light', + 'hover:shadow-glow-card-hover-light dark:hover:shadow-glow-card-hover' + ] + } + }, + defaultVariants: { + variant: 'surface', + backdropBlur: 'none', + padding: 'md', + radius: 'md', + hoverEffect: 'none' + } + } +); + +type CardContainerVariantProps = VariantProps; +export type CardContainerVariant = NonNullable; +export type CardContainerBackdropBlur = NonNullable; +export type CardContainerPadding = NonNullable; +export type CardContainerRadius = NonNullable; +export type CardContainerHoverEffect = NonNullable; +type NativeCardContainerProps = Omit, 'children' | 'className'>; + +export type CardContainerProps = NativeCardContainerProps & { + children?: ReactNode; + /** + * @control select + * @default surface + */ + variant?: CardContainerVariant; + /** + * Optional background blur treatment for cards that intentionally act as floating glass surfaces. + * Keep `none` for normal document-flow content cards. + * + * @control select + * @default none + */ + backdropBlur?: CardContainerBackdropBlur; + /** + * @control select + * @default md + */ + padding?: CardContainerPadding; + /** + * @control select + * @default md + */ + radius?: CardContainerRadius; + /** + * @control select + * @default none + */ + hoverEffect?: CardContainerHoverEffect; + /** @control text */ + className?: string; +}; diff --git a/src/components/atoms/card-container/useCardContainer.ts b/src/components/atoms/card-container/useCardContainer.ts new file mode 100644 index 00000000..e289e2a8 --- /dev/null +++ b/src/components/atoms/card-container/useCardContainer.ts @@ -0,0 +1,31 @@ +import type { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; +import { type CardContainerProps, cardContainerVariants } from './types'; + +type CardContainerElementProps = Omit< + CardContainerProps, + 'backdropBlur' | 'children' | 'className' | 'hoverEffect' | 'padding' | 'radius' | 'variant' +>; + +export type UseCardContainerReturn = { + cardContainerProps: CardContainerElementProps; + children?: ReactNode; + className: string; +}; + +export const useCardContainer = ({ + children, + className, + variant = 'surface', + backdropBlur = 'none', + padding = 'md', + radius = 'md', + hoverEffect = 'none', + ...props +}: CardContainerProps): UseCardContainerReturn => ({ + children, + className: cn(cardContainerVariants({ variant, backdropBlur, padding, radius, hoverEffect }), className), + cardContainerProps: { + ...props + } +}); diff --git a/src/components/atoms/icon-button/useIconButton.ts b/src/components/atoms/icon-button/useIconButton.ts index 015be5e1..3afa0c5d 100644 --- a/src/components/atoms/icon-button/useIconButton.ts +++ b/src/components/atoms/icon-button/useIconButton.ts @@ -9,7 +9,7 @@ type UseIconButtonReturn = Omit< > & { ariaLabel: string; ariaPressed: IconButtonProps['aria-pressed']; - buttonRef: RefObject; + buttonRef: RefObject; className: string; disabled: boolean; handleClick: (event: MouseEvent) => void; diff --git a/src/index.ts b/src/index.ts index 2169070d..c61f4076 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,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 { CardContainer } from './components/atoms/card-container'; export { Checkbox } from './components/atoms/checkbox'; export { Chip } from './components/atoms/chip'; export { Divider } from './components/atoms/divider'; diff --git a/src/styles/theme.css b/src/styles/theme.css index 49b4f447..95c2980e 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -49,6 +49,7 @@ /* Fondos especiales con transparencia — dark */ --color-navbar-dark: rgba(6, 12, 19, 0.75); + --color-card-backdrop-dark: rgba(6, 12, 19, 0.38); --color-sidebar-mobile-dark: rgba(6, 12, 19, 1); --color-overlay-dark: rgba(0, 0, 0, 0.6); @@ -61,6 +62,7 @@ /* Fondos especiales con transparencia — light */ --color-navbar-light: rgba(255, 255, 255, 0.7); + --color-card-backdrop-light: rgba(255, 255, 255, 0.32); --color-sidebar-mobile-light: #ffffff; /* ── Texto — Dark ────────────────────────────────────────────── */ @@ -248,6 +250,14 @@ --shadow-glow-chip-danger: var(--glow-chip-danger); --shadow-glow-chip-danger-hover: var(--glow-chip-danger-hover); + /* Glow de hover para cards */ + --glow-card-hover-light: + 0 0 0 1px rgba(219, 20, 60, 0.12), 0 8px 40px rgba(255, 0, 54, 0.1), 0 20px 60px rgba(0, 0, 0, 0.1); + --glow-card-hover: + 0 0 0 1px rgba(255, 0, 54, 0.12), 0 8px 40px rgba(255, 0, 54, 0.12), 0 20px 60px rgba(0, 0, 0, 0.25); + --shadow-glow-card-hover-light: var(--glow-card-hover-light); + --shadow-glow-card-hover: var(--glow-card-hover); + /* 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); @@ -435,6 +445,9 @@ /* ── Backdrop blur ───────────────────────────────────────────── */ --blur-navbar: blur(16px); /* Navbar, sidebars flotantes */ --blur-overlay: blur(4px); /* Backdrop de modal */ + --blur-card-sm: blur(10px); /* Card glass sutil */ + --blur-card-md: blur(20px); /* Card glass estándar */ + --blur-card-lg: blur(36px); /* Card glass fuerte */ /* ── Z-index ─────────────────────────────────────────────────── */ --z-base: 0; From b8612f22133571a16f9f4d81e8b06bb8266ac8e7 Mon Sep 17 00:00:00 2001 From: egdev6 Date: Tue, 2 Jun 2026 16:50:17 +0200 Subject: [PATCH 2/3] fix(card-container): resolve ci validation failures --- src/components/atoms/button/useButton.ts | 2 +- src/components/atoms/card-container/CardContainer.stories.tsx | 4 ++-- src/components/atoms/icon-button/useIconButton.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/atoms/button/useButton.ts b/src/components/atoms/button/useButton.ts index aeeb9300..a876df6a 100644 --- a/src/components/atoms/button/useButton.ts +++ b/src/components/atoms/button/useButton.ts @@ -36,7 +36,7 @@ export const useButton = ({ type = 'button', ...props }: ButtonProps): UseButtonReturn => { - const buttonRef = useRef(null); + const buttonRef = useRef(null) as RefObject; const isDisabled = disabled || isLoading; useRipple(buttonRef); diff --git a/src/components/atoms/card-container/CardContainer.stories.tsx b/src/components/atoms/card-container/CardContainer.stories.tsx index 7a0d7748..0d3d3401 100644 --- a/src/components/atoms/card-container/CardContainer.stories.tsx +++ b/src/components/atoms/card-container/CardContainer.stories.tsx @@ -194,8 +194,8 @@ export const NestedContent: Story = {
-
diff --git a/src/components/atoms/icon-button/useIconButton.ts b/src/components/atoms/icon-button/useIconButton.ts index 3afa0c5d..9d87e83c 100644 --- a/src/components/atoms/icon-button/useIconButton.ts +++ b/src/components/atoms/icon-button/useIconButton.ts @@ -36,7 +36,7 @@ export const useIconButton = ({ 'aria-pressed': ariaPressed, ...props }: IconButtonProps): UseIconButtonReturn => { - const buttonRef = useRef(null); + const buttonRef = useRef(null) as RefObject; useRipple(buttonRef); From 3b49aba8aa3b8a6eb5ea3d53424d9ccbfa085b5e Mon Sep 17 00:00:00 2001 From: egdev6 Date: Tue, 2 Jun 2026 17:55:48 +0200 Subject: [PATCH 3/3] fix(button): keep refs nullable --- src/components/atoms/button/useButton.ts | 6 +++--- src/components/atoms/icon-button/useIconButton.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/atoms/button/useButton.ts b/src/components/atoms/button/useButton.ts index a876df6a..4564ffab 100644 --- a/src/components/atoms/button/useButton.ts +++ b/src/components/atoms/button/useButton.ts @@ -1,4 +1,4 @@ -import { type MouseEvent, type RefObject, useRef } from 'react'; +import { type ComponentProps, type MouseEvent, useRef } from 'react'; import { useRipple } from '@/hooks/useRipple'; import { cn } from '@/lib/utils'; import { type ButtonEmphasis, type ButtonProps, buttonVariants } from './types'; @@ -6,7 +6,7 @@ import { type ButtonEmphasis, type ButtonProps, buttonVariants } from './types'; type UseButtonReturn = Omit & { ariaLabel: string; ariaPressed: ButtonProps['aria-pressed']; - buttonRef: RefObject; + buttonRef: ComponentProps<'button'>['ref']; className: string; contentClassName: string; disabled: boolean; @@ -36,7 +36,7 @@ export const useButton = ({ type = 'button', ...props }: ButtonProps): UseButtonReturn => { - const buttonRef = useRef(null) as RefObject; + const buttonRef = useRef(null); const isDisabled = disabled || isLoading; useRipple(buttonRef); diff --git a/src/components/atoms/icon-button/useIconButton.ts b/src/components/atoms/icon-button/useIconButton.ts index 9d87e83c..48ad30ab 100644 --- a/src/components/atoms/icon-button/useIconButton.ts +++ b/src/components/atoms/icon-button/useIconButton.ts @@ -1,4 +1,4 @@ -import { type MouseEvent, type RefObject, useRef } from 'react'; +import { type ComponentProps, type MouseEvent, useRef } from 'react'; import { useRipple } from '@/hooks/useRipple'; import { cn } from '@/lib/utils'; import { type IconButtonEmphasis, type IconButtonProps, type IconButtonSize, iconButtonVariants } from './types'; @@ -9,7 +9,7 @@ type UseIconButtonReturn = Omit< > & { ariaLabel: string; ariaPressed: IconButtonProps['aria-pressed']; - buttonRef: RefObject; + buttonRef: ComponentProps<'button'>['ref']; className: string; disabled: boolean; handleClick: (event: MouseEvent) => void; @@ -36,7 +36,7 @@ export const useIconButton = ({ 'aria-pressed': ariaPressed, ...props }: IconButtonProps): UseIconButtonReturn => { - const buttonRef = useRef(null) as RefObject; + const buttonRef = useRef(null); useRipple(buttonRef);