diff --git a/apps/shade/.storybook/preview.tsx b/apps/shade/.storybook/preview.tsx index f0e803a1026..2e3ff361296 100644 --- a/apps/shade/.storybook/preview.tsx +++ b/apps/shade/.storybook/preview.tsx @@ -58,8 +58,20 @@ const preview: Preview = { storySort: { method: 'alphabetical', order: [ - 'Introduction', 'Principles', 'Architecture', 'Tokens', 'Contributing', - 'Components', 'Layout', 'Experimental'], + 'Primitives', + 'Components', + 'Layout', + 'Features', + 'Experimental', + 'Introduction', + 'Principles', + 'Architecture', + 'Primitives Guide', + 'Component Rules and Guarantees', + 'Tokens', + 'Contributing', + '*' + ], }, }, docs: { diff --git a/apps/shade/src/components/layout/error-page.tsx b/apps/shade/src/components/layout/error-page.tsx index 65d16124673..55b46989825 100644 --- a/apps/shade/src/components/layout/error-page.tsx +++ b/apps/shade/src/components/layout/error-page.tsx @@ -6,6 +6,9 @@ export interface ErrorPageProps onBackToDashboard?: () => void; } +/** + * @deprecated Prefer composing product-specific error states from primitives and shared components. + */ const ErrorPage = React.forwardRef( ({className, onBackToDashboard, ...props}, ref) => { return ( diff --git a/apps/shade/src/components/layout/header.tsx b/apps/shade/src/components/layout/header.tsx index a2ce49f761f..a08b52c4c97 100644 --- a/apps/shade/src/components/layout/header.tsx +++ b/apps/shade/src/components/layout/header.tsx @@ -1,4 +1,5 @@ import {H1} from './heading'; +import {Inline} from '@/components/primitives'; import {cn} from '@/lib/utils'; import {cva, VariantProps} from 'class-variance-authority'; @@ -10,12 +11,14 @@ type PropsWithChildrenAndClassName = React.PropsWithChildren & { function HeaderAbove({className, children}: PropsWithChildrenAndClassName) { return ( -
{children} -
+ ); } @@ -35,45 +38,54 @@ function HeaderTitle({className, children}: PropsWithChildrenAndClassName) { function HeaderMeta({className, children}: PropsWithChildrenAndClassName) { return ( -
{children} -
+ ); } function HeaderActionGroup({className, children}: PropsWithChildrenAndClassName) { return ( -
{children} -
+ ); } function HeaderActions({className, children}: PropsWithChildrenAndClassName) { return ( -
{children} -
+ ); } function HeaderNav({className, children}: PropsWithChildrenAndClassName) { return ( -
{children} -
+ ); } @@ -99,6 +111,9 @@ type HeaderComponent = React.ForwardRefExoticComponent(function Header({className, children, variant}, ref) { return ( diff --git a/apps/shade/src/components/layout/heading.tsx b/apps/shade/src/components/layout/heading.tsx index 7c559b535f9..6ea91e8f807 100644 --- a/apps/shade/src/components/layout/heading.tsx +++ b/apps/shade/src/components/layout/heading.tsx @@ -1,52 +1,81 @@ +import {Text} from '@/components/primitives'; import {cn} from '@/lib/utils'; import * as React from 'react'; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface HeadingProps extends React.HTMLAttributes {} +/** + * @deprecated Prefer `Text` primitive composition for new heading usage. + */ const H1 = React.forwardRef( ({className, ...props}, ref) => { return ( -

+ } + as='h1' + className={cn('scroll-m-20 leading-[1.1em] tracking-tighter', className)} + size='3xl' + weight='bold' + {...props} + /> ); } ); H1.displayName = 'H1'; +/** + * @deprecated Prefer `Text` primitive composition for new heading usage. + */ const H2 = React.forwardRef( ({className, ...props}, ref) => { return ( -

+ } + as='h2' + className={cn('scroll-m-20 tracking-tighter first:mt-0', className)} + size='2xl' + weight='bold' + {...props} + /> ); } ); H2.displayName = 'H2'; +/** + * @deprecated Prefer `Text` primitive composition for new heading usage. + */ const H3 = React.forwardRef( ({className, ...props}, ref) => { return ( -

+ } + as='h3' + className={cn('scroll-m-20 tracking-tight', className)} + size='xl' + weight='semibold' + {...props} + /> ); } ); H3.displayName = 'H3'; +/** + * @deprecated Prefer `Text` primitive composition for new heading usage. + */ const H4 = React.forwardRef( ({className, ...props}, ref) => { return ( -

+ } + as='h4' + className={cn('scroll-m-20 tracking-tight', className)} + size='lg' + weight='semibold' + {...props} + /> ); } ); @@ -57,13 +86,21 @@ interface HTableProps extends React.HTMLAttributes { className?: string; } +/** + * @deprecated Prefer `Text` primitive composition for metadata/label text. + */ const HTable = React.forwardRef( ({className, ...props}, ref) => { return ( -
+ } + as='div' + className={cn('tracking-wide uppercase', className)} + size='xs' + tone='secondary' + weight='medium' + {...props} + /> ); } ); diff --git a/apps/shade/src/components/layout/list-header.tsx b/apps/shade/src/components/layout/list-header.tsx index 65b9b739f80..c8e190db618 100644 --- a/apps/shade/src/components/layout/list-header.tsx +++ b/apps/shade/src/components/layout/list-header.tsx @@ -1,5 +1,6 @@ import {H1} from './heading'; import {DropdownMenu, DropdownMenuContent, DropdownMenuTrigger} from '@/components/ui/dropdown-menu'; +import {Inline, Stack, Text} from '@/components/primitives'; import {cn} from '@/lib/utils'; import React from 'react'; @@ -15,23 +16,27 @@ type ListHeaderProps = PropsWithChildrenAndClassName & { function ListHeaderLeft({className, children}: PropsWithChildrenAndClassName) { return ( -
{children} -
+ ); } function ListHeaderBreadcrumb({className, children}: PropsWithChildrenAndClassName) { return ( -
{children} -
+ ); } @@ -51,23 +56,29 @@ function ListHeaderTitle({className, children}: PropsWithChildrenAndClassName) { function ListHeaderDescription({className, children}: PropsWithChildrenAndClassName) { return ( -

{children} -

+
); } function ListHeaderCount({className, children}: PropsWithChildrenAndClassName) { return ( - {children} - + ); } @@ -170,39 +181,52 @@ const ListHeaderActionGroup: ListHeaderActionGroupComponent = Object.assign( if (!mobileMenu) { return ( -
{children} -
+ ); } if (!shouldCollapse) { return ( -
-
{desktopChildren} -
-
+ + ); } return ( -
-
{mobileMenu} {primaryAction && ( @@ -210,8 +234,8 @@ const ListHeaderActionGroup: ListHeaderActionGroupComponent = Object.assign( {primaryAction}
)} -
-
+ + ); }, { @@ -224,12 +248,14 @@ const ListHeaderActionGroup: ListHeaderActionGroupComponent = Object.assign( function ListHeaderActions({className, children}: PropsWithChildrenAndClassName) { return ( -
{children} -
+ ); } @@ -243,20 +269,27 @@ type ListHeaderComponent = React.FC & { ActionGroup: ListHeaderActionGroupComponent; }; +/** + * @deprecated Prefer composing list header shells directly from `Inline`, `Stack`, `Grid`, and `Text` primitives. + */ const ListHeader: ListHeaderComponent = Object.assign( function ListHeader({className, children, sticky = true, blurredBackground = true}: ListHeaderProps) { return ( -
{children} -
+ ); }, { diff --git a/apps/shade/src/components/layout/page.tsx b/apps/shade/src/components/layout/page.tsx index e328f7f9b08..3372026329d 100644 --- a/apps/shade/src/components/layout/page.tsx +++ b/apps/shade/src/components/layout/page.tsx @@ -1,15 +1,21 @@ +import {Container} from '@/components/primitives'; import {cn} from '@/lib/utils'; import * as React from 'react'; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface PageProps extends React.HTMLAttributes {} +/** + * @deprecated Prefer composing new surfaces with `Container`, `Stack`, and other primitives directly. + */ const Page = React.forwardRef( ({className, ...props}, ref) => { return ( -
); diff --git a/apps/shade/src/components/layout/view-header.tsx b/apps/shade/src/components/layout/view-header.tsx index b3282046bdd..cb042a9268c 100644 --- a/apps/shade/src/components/layout/view-header.tsx +++ b/apps/shade/src/components/layout/view-header.tsx @@ -1,14 +1,18 @@ +import {Inline} from '@/components/primitives'; import {cn} from '@/lib/utils'; import React from 'react'; // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface ViewHeaderActionsProps extends React.HTMLAttributes {} +/** + * @deprecated Prefer composing inline action rows with the `Inline` primitive. + */ const ViewHeaderActions:React.FC = ({children}) => { return ( -
+ {children} -
+ ); }; @@ -16,15 +20,22 @@ interface ViewHeaderProps extends React.HTMLAttributes { className?: string; } +/** + * @deprecated Prefer composing view header shells with `Inline`, `Stack`, and `Text` primitives. + */ const ViewHeader:React.FC = ({className, children}) => { const [headerComponent, actionsComponent] = React.Children.toArray(children); return (
-
+ {headerComponent} {actionsComponent} -
+
); }; diff --git a/apps/shade/src/components/primitives/box.stories.tsx b/apps/shade/src/components/primitives/box.stories.tsx new file mode 100644 index 00000000000..87013cf8ce6 --- /dev/null +++ b/apps/shade/src/components/primitives/box.stories.tsx @@ -0,0 +1,37 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {Box} from './box'; + +const meta = { + title: 'Primitives / Box', + component: Box, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'Framing primitive for padding and radius without implicit layout behavior.' + } + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + padding: 'lg', + radius: 'lg', + className: 'border border-border-default bg-surface-panel', + children: 'Framed content' + } +}; + +export const AxisPadding: Story = { + args: { + paddingX: 'xl', + paddingY: 'sm', + radius: 'md', + className: 'border border-border-default bg-surface-panel', + children: 'Independent horizontal and vertical spacing' + } +}; diff --git a/apps/shade/src/components/primitives/box.tsx b/apps/shade/src/components/primitives/box.tsx new file mode 100644 index 00000000000..e2548c3ebb5 --- /dev/null +++ b/apps/shade/src/components/primitives/box.tsx @@ -0,0 +1,50 @@ +import {cn} from '@/lib/utils'; +import {PADDING_CLASSES, PADDING_X_CLASSES, PADDING_Y_CLASSES, SpaceStep} from './types'; +import React from 'react'; + +type BoxRadius = 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full'; + +const RADIUS_CLASSES: Record = { + none: 'rounded-none', + sm: 'rounded-sm', + md: 'rounded-md', + lg: 'rounded-lg', + xl: 'rounded-xl', + full: 'rounded-full' +}; + +export interface BoxProps extends React.HTMLAttributes { + padding?: SpaceStep; + paddingX?: SpaceStep; + paddingY?: SpaceStep; + radius?: BoxRadius; +} + +const Box = React.forwardRef( + function Box({ + className, + padding, + paddingX, + paddingY, + radius, + ...props + }: BoxProps, ref) { + return ( +
+ ); + } +); + +Box.displayName = 'Box'; + +export {Box}; diff --git a/apps/shade/src/components/primitives/container.stories.tsx b/apps/shade/src/components/primitives/container.stories.tsx new file mode 100644 index 00000000000..752c81205ec --- /dev/null +++ b/apps/shade/src/components/primitives/container.stories.tsx @@ -0,0 +1,53 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {Container} from './container'; + +const meta = { + title: 'Primitives / Container', + component: Container, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Width-constrained primitive for page and region containers.' + } + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const PageWidth: Story = { + args: { + size: 'page', + paddingX: 'lg', + children: ( +
+ Page-width container content +
+ ) + }, + render: args => ( +
+ +
+ ) +}; + +export const ProseWidth: Story = { + args: { + size: 'prose', + centered: true, + children: ( +
+ A narrower, readable content width intended for copy-heavy regions. +
+ ) + }, + render: args => ( +
+ +
+ ) +}; diff --git a/apps/shade/src/components/primitives/container.tsx b/apps/shade/src/components/primitives/container.tsx new file mode 100644 index 00000000000..5014a0cacd3 --- /dev/null +++ b/apps/shade/src/components/primitives/container.tsx @@ -0,0 +1,74 @@ +import {cn} from '@/lib/utils'; +import {PADDING_X_CLASSES, SpaceStep} from './types'; +import React from 'react'; + +export type ContainerSize = + | 'xs' + | 'sm' + | 'md' + | 'lg' + | 'xl' + | '2xl' + | '3xl' + | '4xl' + | '5xl' + | '6xl' + | '7xl' + | '8xl' + | '9xl' + | 'prose' + | 'page' + | 'page-with-sidebar'; + +const MAX_WIDTH_CLASSES: Record = { + xs: 'max-w-xs', + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-lg', + xl: 'max-w-xl', + '2xl': 'max-w-2xl', + '3xl': 'max-w-3xl', + '4xl': 'max-w-4xl', + '5xl': 'max-w-5xl', + '6xl': 'max-w-6xl', + '7xl': 'max-w-7xl', + '8xl': 'max-w-8xl', + '9xl': 'max-w-9xl', + prose: 'max-w-prose', + page: 'max-w-page', + 'page-with-sidebar': 'max-w-pageminsidebar' +}; + +export interface ContainerProps extends React.HTMLAttributes { + size?: ContainerSize; + centered?: boolean; + paddingX?: SpaceStep; +} + +const Container = React.forwardRef( + function Container({ + className, + size = 'page', + centered = true, + paddingX, + ...props + }: ContainerProps, ref) { + return ( +
+ ); + } +); + +Container.displayName = 'Container'; + +export {Container}; diff --git a/apps/shade/src/components/primitives/grid.stories.tsx b/apps/shade/src/components/primitives/grid.stories.tsx new file mode 100644 index 00000000000..7cf20f63227 --- /dev/null +++ b/apps/shade/src/components/primitives/grid.stories.tsx @@ -0,0 +1,56 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {Grid} from './grid'; + +const meta = { + title: 'Primitives / Grid', + component: Grid, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'Two-dimensional layout primitive for explicit columns and spacing.' + } + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const renderCell = (label: string) => ( +
+ {label} +
+); + +export const TwoColumns: Story = { + args: { + columns: 2, + gap: 'md', + children: ( + <> + {renderCell('Card 1')} + {renderCell('Card 2')} + {renderCell('Card 3')} + {renderCell('Card 4')} + + ) + } +}; + +export const ThreeColumns: Story = { + args: { + columns: 3, + gap: 'lg', + children: ( + <> + {renderCell('A')} + {renderCell('B')} + {renderCell('C')} + {renderCell('D')} + {renderCell('E')} + {renderCell('F')} + + ) + } +}; diff --git a/apps/shade/src/components/primitives/grid.tsx b/apps/shade/src/components/primitives/grid.tsx new file mode 100644 index 00000000000..bf70df78e66 --- /dev/null +++ b/apps/shade/src/components/primitives/grid.tsx @@ -0,0 +1,59 @@ +import {cn} from '@/lib/utils'; +import { + ALIGN_ITEMS_CLASSES, + GAP_CLASSES, + JUSTIFY_CONTENT_CLASSES, + Align, + Justify, + SpaceStep +} from './types'; +import React from 'react'; + +type GridColumns = 1 | 2 | 3 | 4 | 5 | 6 | 12; + +const GRID_COLUMNS_CLASSES: Record = { + 1: 'grid-cols-1', + 2: 'grid-cols-2', + 3: 'grid-cols-3', + 4: 'grid-cols-4', + 5: 'grid-cols-5', + 6: 'grid-cols-6', + 12: 'grid-cols-12' +}; + +export interface GridProps extends React.HTMLAttributes { + columns?: GridColumns; + gap?: SpaceStep; + align?: Align; + justify?: Justify; +} + +const Grid = React.forwardRef( + function Grid({ + className, + columns = 1, + gap = 'md', + align = 'stretch', + justify = 'start', + ...props + }: GridProps, ref) { + return ( +
+ ); + } +); + +Grid.displayName = 'Grid'; + +export {Grid}; diff --git a/apps/shade/src/components/primitives/index.ts b/apps/shade/src/components/primitives/index.ts new file mode 100644 index 00000000000..5037adfe096 --- /dev/null +++ b/apps/shade/src/components/primitives/index.ts @@ -0,0 +1,15 @@ +export {Box} from './box'; +export {Container} from './container'; +export type {ContainerSize} from './container'; +export {Grid} from './grid'; +export {Inline} from './inline'; +export {Stack} from './stack'; +export {Text} from './text'; +export type { + TextElement, + TextLeading, + TextSize, + TextTone, + TextWeight +} from './text'; +export type {Align, Justify, SpaceStep} from './types'; diff --git a/apps/shade/src/components/primitives/inline.stories.tsx b/apps/shade/src/components/primitives/inline.stories.tsx new file mode 100644 index 00000000000..e891f8b26ee --- /dev/null +++ b/apps/shade/src/components/primitives/inline.stories.tsx @@ -0,0 +1,46 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {Inline} from './inline'; + +const meta = { + title: 'Primitives / Inline', + component: Inline, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'Horizontal layout primitive for actions, controls, and inline composition with explicit spacing.' + } + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + gap: 'sm', + children: ( + <> + + + + ) + } +}; + +export const Wrapped: Story = { + args: { + gap: 'sm', + wrap: true, + children: ( + <> + Tag A + Tag B + Tag C + Tag D + Tag E + + ) + } +}; diff --git a/apps/shade/src/components/primitives/inline.tsx b/apps/shade/src/components/primitives/inline.tsx new file mode 100644 index 00000000000..990a4ffd174 --- /dev/null +++ b/apps/shade/src/components/primitives/inline.tsx @@ -0,0 +1,53 @@ +import {cn} from '@/lib/utils'; +import { + ALIGN_ITEMS_CLASSES, + GAP_CLASSES, + JUSTIFY_CONTENT_CLASSES, + Align, + Justify, + SpaceStep +} from './types'; +import React from 'react'; + +type InlineElement = 'div' | 'header' | 'section' | 'footer' | 'nav' | 'span'; + +export interface InlineProps extends React.HTMLAttributes { + as?: InlineElement; + gap?: SpaceStep; + align?: Align; + justify?: Justify; + wrap?: boolean; +} + +const Inline = React.forwardRef( + function Inline({ + as = 'div', + className, + gap = 'md', + align = 'center', + justify = 'start', + wrap = false, + ...props + }: InlineProps, ref: React.Ref) { + const Component = as as React.ElementType; + + return ( + + ); + } +); + +Inline.displayName = 'Inline'; + +export {Inline}; diff --git a/apps/shade/src/components/primitives/stack.stories.tsx b/apps/shade/src/components/primitives/stack.stories.tsx new file mode 100644 index 00000000000..2c5607cc403 --- /dev/null +++ b/apps/shade/src/components/primitives/stack.stories.tsx @@ -0,0 +1,44 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {Stack} from './stack'; + +const meta = { + title: 'Primitives / Stack', + component: Stack, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'Vertical layout primitive for grouping content with explicit spacing and alignment.' + } + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + gap: 'md', + children: ( + <> +
Section A
+
Section B
+
Section C
+ + ) + } +}; + +export const Centered: Story = { + args: { + align: 'center', + gap: 'lg', + children: ( + <> +
Primary action
+
Secondary action
+ + ) + } +}; diff --git a/apps/shade/src/components/primitives/stack.tsx b/apps/shade/src/components/primitives/stack.tsx new file mode 100644 index 00000000000..73f958bd604 --- /dev/null +++ b/apps/shade/src/components/primitives/stack.tsx @@ -0,0 +1,44 @@ +import {cn} from '@/lib/utils'; +import { + ALIGN_ITEMS_CLASSES, + GAP_CLASSES, + JUSTIFY_CONTENT_CLASSES, + Align, + Justify, + SpaceStep +} from './types'; +import React from 'react'; + +export interface StackProps extends React.HTMLAttributes { + gap?: SpaceStep; + align?: Align; + justify?: Justify; +} + +const Stack = React.forwardRef( + function Stack({ + className, + gap = 'md', + align = 'stretch', + justify = 'start', + ...props + }: StackProps, ref) { + return ( +
+ ); + } +); + +Stack.displayName = 'Stack'; + +export {Stack}; diff --git a/apps/shade/src/components/primitives/text.stories.tsx b/apps/shade/src/components/primitives/text.stories.tsx new file mode 100644 index 00000000000..f78ac8ebf93 --- /dev/null +++ b/apps/shade/src/components/primitives/text.stories.tsx @@ -0,0 +1,46 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {Text} from './text'; + +const meta = { + title: 'Primitives / Text', + component: Text, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'Minimal typography primitive with semantic size, tone, weight, and line-height controls.' + } + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: 'Primary body copy with semantic defaults.' + } +}; + +export const HeadingTone: Story = { + args: { + as: 'h2', + size: '2xl', + weight: 'bold', + tone: 'primary', + leading: 'heading', + children: 'Section title' + } +}; + +export const SecondaryAndTruncated: Story = { + args: { + as: 'span', + size: 'sm', + tone: 'secondary', + truncate: true, + className: 'block max-w-64', + children: 'This is a long metadata label that truncates once it reaches the max width.' + } +}; diff --git a/apps/shade/src/components/primitives/text.tsx b/apps/shade/src/components/primitives/text.tsx new file mode 100644 index 00000000000..ad0e40ca089 --- /dev/null +++ b/apps/shade/src/components/primitives/text.tsx @@ -0,0 +1,109 @@ +import {cn} from '@/lib/utils'; +import React from 'react'; + +type TextElement = + | 'p' + | 'span' + | 'div' + | 'label' + | 'small' + | 'strong' + | 'em' + | 'h1' + | 'h2' + | 'h3' + | 'h4' + | 'h5' + | 'h6'; + +type TextSize = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'; +type TextWeight = 'regular' | 'medium' | 'semibold' | 'bold'; +type TextTone = 'primary' | 'secondary' | 'tertiary' | 'inverse'; +type TextLeading = 'none' | 'snug' | 'normal' | 'relaxed' | 'tight' | 'tighter' | 'supertight' | 'body' | 'heading'; + +const TEXT_SIZE_CLASSES: Record = { + '2xs': 'text-2xs', + xs: 'text-xs', + sm: 'text-sm', + md: 'text-md', + lg: 'text-lg', + xl: 'text-xl', + '2xl': 'text-2xl', + '3xl': 'text-3xl' +}; + +const TEXT_WEIGHT_CLASSES: Record = { + regular: 'font-normal', + medium: 'font-medium', + semibold: 'font-semibold', + bold: 'font-bold' +}; + +const TEXT_TONE_CLASSES: Record = { + primary: 'text-text-primary', + secondary: 'text-text-secondary', + tertiary: 'text-text-tertiary', + inverse: 'text-text-inverse' +}; + +const TEXT_LEADING_CLASSES: Record = { + none: 'leading-none', + snug: 'leading-snug', + normal: 'leading-normal', + relaxed: 'leading-relaxed', + tight: 'leading-tight', + tighter: 'leading-tighter', + supertight: 'leading-supertight', + body: 'leading-body', + heading: 'leading-heading' +}; + +export interface TextProps extends React.HTMLAttributes { + as?: TextElement; + size?: TextSize; + weight?: TextWeight; + tone?: TextTone; + leading?: TextLeading; + truncate?: boolean; +} + +const Text = React.forwardRef( + function Text({ + as = 'p', + className, + size = 'md', + weight = 'regular', + tone = 'primary', + leading = 'body', + truncate = false, + ...props + }: TextProps, ref) { + const Component = as as React.ElementType; + + return ( + + ); + } +); + +Text.displayName = 'Text'; + +export {Text}; +export type { + TextElement, + TextLeading, + TextSize, + TextTone, + TextWeight +}; diff --git a/apps/shade/src/components/primitives/types.ts b/apps/shade/src/components/primitives/types.ts new file mode 100644 index 00000000000..304fccf5031 --- /dev/null +++ b/apps/shade/src/components/primitives/types.ts @@ -0,0 +1,62 @@ +export type SpaceStep = 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'; + +export type Align = 'start' | 'center' | 'end' | 'stretch' | 'baseline'; + +export type Justify = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly'; + +export const GAP_CLASSES: Record = { + none: 'gap-0', + xs: 'gap-1', + sm: 'gap-2', + md: 'gap-3', + lg: 'gap-4', + xl: 'gap-6', + '2xl': 'gap-8' +}; + +export const PADDING_CLASSES: Record = { + none: 'p-0', + xs: 'p-1', + sm: 'p-2', + md: 'p-3', + lg: 'p-4', + xl: 'p-6', + '2xl': 'p-8' +}; + +export const PADDING_X_CLASSES: Record = { + none: 'px-0', + xs: 'px-1', + sm: 'px-2', + md: 'px-3', + lg: 'px-4', + xl: 'px-6', + '2xl': 'px-8' +}; + +export const PADDING_Y_CLASSES: Record = { + none: 'py-0', + xs: 'py-1', + sm: 'py-2', + md: 'py-3', + lg: 'py-4', + xl: 'py-6', + '2xl': 'py-8' +}; + +export const ALIGN_ITEMS_CLASSES: Record = { + start: 'items-start', + center: 'items-center', + end: 'items-end', + stretch: 'items-stretch', + baseline: 'items-baseline' +}; + +export const JUSTIFY_CONTENT_CLASSES: Record = { + start: 'justify-start', + center: 'justify-center', + end: 'justify-end', + between: 'justify-between', + around: 'justify-around', + evenly: 'justify-evenly' +}; diff --git a/apps/shade/src/docs/architecture.mdx b/apps/shade/src/docs/architecture.mdx index 61df32a0e25..426045600db 100644 --- a/apps/shade/src/docs/architecture.mdx +++ b/apps/shade/src/docs/architecture.mdx @@ -8,6 +8,23 @@ import { Meta } from '@storybook/addon-docs/blocks';

Shade is built as a layered system, with each layer building upon the previous to create a complete design system. This document outlines how these layers work together.

+## Read This First (60 seconds) + +If you are not sure where code belongs, use this: + +- `tokens`: visual values only (color, type, radius, motion) +- `primitives`: layout structure only (`Stack`, `Inline`, `Box`, `Container`, `Grid`, `Text`) +- `components`: reusable generic controls (button, input, dialog) +- `patterns`: repeated product workflows (filters, resource flows) +- `app`: app shell and transitional domain exports +- `utils`: design-system-safe generic helpers only + +Fast decision rule: + +- If it changes spacing/alignment/layout, use `primitives`. +- If it is a generic control, use `components`. +- If it encodes product workflow, use `patterns`. + ## File Structure ``` @@ -44,21 +61,54 @@ The root entrypoint now keeps compatibility for DS layers only (`tokens`, `primi ## Layer Ownership Rules -- `tokens`: token contracts and token helpers only (CSS variables, token names, token lookup helpers). +- `tokens`: token definitions and helpers only (CSS variables, token names, token lookup helpers). - `primitives`: structure/layout building blocks with no product/domain behavior. - `components`: reusable UI controls and visual assets/icons. -- `patterns`: feature-level reusable compositions and interaction contracts. +- `patterns`: feature-level reusable compositions and interaction rules. - `app`: app shell/provider/context APIs and transitional domain exports. - `utils`: DS-safe helpers, generic hooks, and third-party namespaces. -Promotion guidance: +## Human and AI Decision Protocol + +Run this order before implementing UI: + +1. Choose the layer (`tokens` -> `primitives` -> `components` -> `patterns`). +2. Reuse existing API first. +3. If no fit, keep code local and add it to Shade only after repeated use. +4. Keep shared APIs product-agnostic. + +Stop and re-evaluate when: -- Keep code local first, then promote when it is reused across surfaces. -- Promote to `primitives` when the abstraction is structural. -- Promote to `components` when the abstraction is a reusable control. -- Promote to `patterns` when repeated product workflows emerge. +- a shared component needs workflow-specific props +- a layout abstraction starts carrying business logic +- ownership between `components` and `patterns` is unclear + +When to add code to Shade: + +- Keep code local first, then add it to Shade when it is reused across surfaces. +- Add to `primitives` when the abstraction is structural. +- Add to `components` when the abstraction is a reusable control. +- Add to `patterns` when repeated product workflows emerge. - Do not place domain/product helpers in `utils`; route them through `app` during migration and then toward app-local/pattern ownership. +## Primitive Composition Rules and Guarantees + +Primitive composition has these fixed rules: + +- Primitive set: `Stack`, `Inline`, `Box`, `Container`, `Grid`, `Text`. +- Spacing API: semantic steps `none | xs | sm | md | lg | xl | 2xl`. +- Text depth: minimal `Text` primitive with compatibility heading wrappers (`H1`-`H4`, `HTable`). +- Migration scope: `apps/shade/src/components/layout/*` only. +- Compatibility policy: keep existing `@tryghost/shade/primitives` exports available during migration and deprecate without hard removals. + +## Component Rules and Guarantees Layer + +This layer keeps shared control APIs explicit and stable in `components/ui`: + +- Define explicit API and state rules for Wave A controls (`Button`, `Input`, `Field`, `Select`, `Tabs`, `Table`, `Dialog`, `DropdownMenu`, `Card`). +- Keep shared controls product-agnostic. +- Preserve compatibility through deprecation notes, not hard removals. + ## Component Types ### Base Components (UI) diff --git a/apps/shade/src/docs/component-contracts.mdx b/apps/shade/src/docs/component-contracts.mdx new file mode 100644 index 00000000000..274c85fd361 --- /dev/null +++ b/apps/shade/src/docs/component-contracts.mdx @@ -0,0 +1,154 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +
+ +# Component Rules and Guarantees + +

This page defines clear rules and guarantees for shared controls: what they do, what they do not do, and which states must always work.

+ +## Plain-English Definition + +In this doc, “contract” means “rules and guarantees”. +Those rules answer 5 questions: + +- What problem does this component solve? +- What API can consumers use safely? +- Which states must always work? +- What is intentionally out of scope? +- What compatibility promise do we make? + +If these answers are missing, teams guess. Guessing causes component drift. + +## Why This Matters + +For humans: + +- Faster code reviews (less ambiguity) +- Easier reuse decisions (`components` vs `patterns`) +- Fewer accidental breaking changes + +For AI agents: + +- Reliable constraints for code generation +- Fewer invented props and variants +- Predictable output shape across components + +## Scope (Wave A) + +Current Wave A rules coverage targets: + +- `Button` +- `Input` +- `Field` +- `Select` +- `Tabs` +- `Table` +- `Dialog` +- `DropdownMenu` +- `Card` + +Out of scope for Wave A: + +- workflow-level compositions (`filters`, app-specific orchestration) +- very large mixed-behavior surfaces (`sidebar`, `multi-select-combobox`, chart variants) + +## Human Workflow (Fast) + +Use this when adding or changing a shared component: + +1. Write a 1-2 sentence purpose and non-goals. +2. List public props and slots. +3. Confirm required states: `default`, `hover`, `focus-visible`, `disabled`. +4. Add Storybook stories that prove those states. +5. Add compatibility notes if API changed. + +If you cannot explain a prop in one sentence, the API is probably too broad. + +## AI Agent Workflow (Deterministic) + +When generating or editing a shared component, produce this structure first, then implement: + +1. `Component scope` +2. `Public API` +3. `State matrix` +4. `Compatibility policy` +5. `Deprecation plan` (if needed) + +Hard rules for agents: + +- Do not add product-specific props in shared `components`. +- Do not skip required states. +- Do not remove existing public API without compatibility notes. +- If behavior is workflow-specific, move it to `patterns`. + +## Rules Template + +```md +## Component scope +- Purpose: +- Non-goals: + +## Public API +- Props: +- Slots/subcomponents: +- Variants: +- Events/callbacks: + +## State matrix +- default: +- hover: +- focus-visible: +- disabled: +- optional states (if applicable): active/loading/error/empty + +## Compatibility policy +- Backward-compatible additions: +- Breaking changes: +- Migration notes: + +## Deprecation plan +- Alias period: +- Removal target: +``` + +## Good vs Bad Example + +Good: + +- `variant="destructive"`: visual intent is explicit and reusable. +- `size="sm|md|lg"`: bounded scale. + +Bad: + +- `isMembersPage`: product-specific in a shared control. +- `layoutMode="toolbarWithFilterAndStats"`: workflow logic leaking into base component. + +## Required State Coverage + +For each required state, define: + +- visuals (semantic tokens) +- interaction behavior +- accessibility semantics + +Missing required states is a blocker. + +## Ownership Rules + +Choose `components` when the abstraction is a generic reusable control. +Choose `patterns` when the abstraction encodes product workflow semantics. + +If a shared control starts accumulating workflow props, stop and extract a pattern wrapper. + +## Review Checklist + +Before merge: + +- [ ] Rules and guarantees are documented and match implementation. +- [ ] Required states are implemented and visible in stories. +- [ ] API remains product-agnostic. +- [ ] Compatibility/deprecation notes are present for API changes. + +
diff --git a/apps/shade/src/docs/contributing.mdx b/apps/shade/src/docs/contributing.mdx index c780a8e7821..b4256e0010b 100644 --- a/apps/shade/src/docs/contributing.mdx +++ b/apps/shade/src/docs/contributing.mdx @@ -8,6 +8,21 @@ import { Meta } from '@storybook/addon-docs/blocks';

This guide explains how to contribute to the Shade design system, including adding or modifying components and documentation.

+## Quick Start (Humans and Agents) + +Use this sequence before writing code: + +1. Pick the right layer (`tokens`, `primitives`, `components`, `patterns`). +2. Reuse existing APIs first. +3. If no good fit exists, keep code local first. +4. Add shared abstractions to Shade only after clear repetition. +5. Add stories that prove intended usage and states. + +If unsure between `components` and `patterns`: + +- choose `components` for generic controls +- choose `patterns` for product workflow composition + ## Repository Layout ``` @@ -35,16 +50,39 @@ For new code, import from a layer-specific Shade subpath instead of the root bar Use the root `@tryghost/shade` entrypoint only for DS-layer compatibility (`tokens`/`primitives`/`components`/`patterns`). Do not import `utils` or `app` symbols from root. -## Ownership and Promotion Rules +## Ownership and "Add to Shade" Rules -- Put token names/helpers and CSS token contracts in `tokens`. +- Put token names/helpers and CSS token definitions in `tokens`. - Put layout/structure-only abstractions in `primitives`. - Put reusable controls and visual assets in `components`. -- Put reusable feature-level compositions/contracts in `patterns`. +- Put reusable feature-level compositions/rules in `patterns`. - Put app shell/provider/context APIs in `app`. - Keep `utils` design-system-safe only (generic helpers/hooks + third-party namespaces). - Do not add new domain/product helpers to `utils`; place them in app-local code or transitional `app` exports. +## Primitive Composition Guardrails + +- Prefer `Stack`, `Inline`, `Box`, `Container`, `Grid`, and `Text` over anonymous wrappers that only carry layout utility classes. +- Layout-shell migration scope is limited to `apps/shade/src/components/layout/*`. +- Keep existing `@tryghost/shade/primitives` consumers compatible during migration; do not remove legacy exports during compatibility windows. +- Use semantic spacing props (`none | xs | sm | md | lg | xl | 2xl`) for primitive composition APIs. + +## Component Rules and Guarantees + +- Shared controls in `apps/shade/src/components/ui/*` must be product-agnostic. +- Define explicit public API and state rules before expanding variants. +- Required interactive states for shared controls: `default`, `hover`, `focus-visible`, `disabled`. +- If behavior becomes workflow-specific, move it into a `patterns` wrapper instead of growing the base control API. + +## AI Agent Output Format + +When an AI agent proposes or changes a shared component, include: + +1. `Scope` (purpose + non-goals) +2. `Public API` (props, slots, variants) +3. `States` (`default`, `hover`, `focus-visible`, `disabled` at minimum) +4. `Compatibility notes` (what changed, what remains stable) + ## Naming Conventions - **File names:** kebab-case, e.g. `dropdown-menu.tsx` (ShadCN-generated files keep kebab-case) diff --git a/apps/shade/src/docs/migration-root-imports.mdx b/apps/shade/src/docs/migration-root-imports.mdx index d65e56bef46..66f4f115dfd 100644 --- a/apps/shade/src/docs/migration-root-imports.mdx +++ b/apps/shade/src/docs/migration-root-imports.mdx @@ -4,13 +4,13 @@ import {Meta} from '@storybook/addon-docs/blocks';
-# Root Import Migration (MS-1) +# Root Import Migration Use layer-specific subpaths instead of the root `@tryghost/shade` entrypoint. ## Cutoff -MS-1 cutoff: **April 2, 2026**. +Cutoff: **April 2, 2026**. - Root `@tryghost/shade` is a compatibility lane for DS layers only. - `app` and `utils` lanes are no longer exported from root. @@ -22,7 +22,7 @@ MS-1 cutoff: **April 2, 2026**. | --- | --- | | `@tryghost/shade` (UI controls/assets) | `@tryghost/shade/components` | | `@tryghost/shade` (layout/structure) | `@tryghost/shade/primitives` | -| `@tryghost/shade` (feature contracts/compositions) | `@tryghost/shade/patterns` | +| `@tryghost/shade` (feature rules/compositions) | `@tryghost/shade/patterns` | | `@tryghost/shade` (DS-safe helpers + `LucideIcon` + `Recharts`) | `@tryghost/shade/utils` | | `@tryghost/shade` (app shell + domain/transitional helpers) | `@tryghost/shade/app` | | `@tryghost/shade` token helpers | `@tryghost/shade/tokens` | @@ -42,4 +42,25 @@ import {cn} from '@tryghost/shade/utils'; import {formatQueryDate, ShadeApp} from '@tryghost/shade/app'; ``` +## Primitive Composition Update + +`@tryghost/shade/primitives` now includes low-level composition primitives: + +- `Stack` +- `Inline` +- `Box` +- `Container` +- `Grid` +- `Text` + +Legacy layout shells in `@tryghost/shade/primitives` (for example `Header`, `ListHeader`, `ViewHeader`, `Page`, and heading wrappers) remain available as a compatibility lane but are deprecated for new work. + +Prefer building new structures directly from primitives and shared components. + +```ts +// Preferred for new composition +import {Container, Stack, Inline, Text} from '@tryghost/shade/primitives'; +import {Button} from '@tryghost/shade/components'; +``` +
diff --git a/apps/shade/src/docs/primitives-guide.mdx b/apps/shade/src/docs/primitives-guide.mdx new file mode 100644 index 00000000000..e5e427f846f --- /dev/null +++ b/apps/shade/src/docs/primitives-guide.mdx @@ -0,0 +1,202 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +
+ +# Primitives Guide + +

Use primitives to make layout intent explicit. This page explains when to use each primitive, which props matter, and how to migrate from raw layout classes.

+ +## Why Primitives + +Primitives solve a simple problem: repeated `flex/grid/gap` class strings hide intent. + +Use primitives so that: + +- humans can read structure quickly +- AI agents can generate consistent layout trees +- spacing/alignment decisions stay explicit + +## Primitive Selection + +- `Stack`: vertical grouping and vertical spacing +- `Inline`: horizontal grouping, action rows, wrapped inline controls +- `Box`: padding and radius framing without layout semantics +- `Container`: width constraints and horizontal page padding +- `Grid`: two-dimensional column layout +- `Text`: semantic text element + typography rules + +Fast rule: + +- vertical composition -> `Stack` +- horizontal composition -> `Inline` +- framed region -> `Box` +- max width shell -> `Container` +- columns/cards -> `Grid` +- copy/headings/labels -> `Text` + +## Prop Reference + +### Stack + +| Prop | Type | Default | Purpose | +| --- | --- | --- | --- | +| `gap` | `none|xs|sm|md|lg|xl|2xl` | `md` | Vertical spacing between children | +| `align` | `start|center|end|stretch|baseline` | `stretch` | Cross-axis alignment | +| `justify` | `start|center|end|between|around|evenly` | `start` | Main-axis distribution | + +### Inline + +| Prop | Type | Default | Purpose | +| --- | --- | --- | --- | +| `as` | `div|header|section|footer|nav|span` | `div` | Render element | +| `gap` | `none|xs|sm|md|lg|xl|2xl` | `md` | Horizontal spacing | +| `align` | `start|center|end|stretch|baseline` | `center` | Cross-axis alignment | +| `justify` | `start|center|end|between|around|evenly` | `start` | Main-axis distribution | +| `wrap` | `boolean` | `false` | Wrap items to multiple rows | + +### Box + +| Prop | Type | Default | Purpose | +| --- | --- | --- | --- | +| `padding` | `none|xs|sm|md|lg|xl|2xl` | unset | Uniform padding | +| `paddingX` | `none|xs|sm|md|lg|xl|2xl` | unset | Horizontal padding only | +| `paddingY` | `none|xs|sm|md|lg|xl|2xl` | unset | Vertical padding only | +| `radius` | `none|sm|md|lg|xl|full` | unset | Border radius | + +### Container + +| Prop | Type | Default | Purpose | +| --- | --- | --- | --- | +| `size` | `xs...9xl|prose|page|page-with-sidebar` | `page` | Maximum width | +| `centered` | `boolean` | `true` | Apply horizontal centering | +| `paddingX` | `none|xs|sm|md|lg|xl|2xl` | unset | Horizontal shell padding | + +### Grid + +| Prop | Type | Default | Purpose | +| --- | --- | --- | --- | +| `columns` | `1|2|3|4|5|6|12` | `1` | Column count | +| `gap` | `none|xs|sm|md|lg|xl|2xl` | `md` | Grid spacing | +| `align` | `start|center|end|stretch|baseline` | `stretch` | Cross-axis alignment | +| `justify` | `start|center|end|between|around|evenly` | `start` | Main-axis distribution | + +### Text + +| Prop | Type | Default | Purpose | +| --- | --- | --- | --- | +| `as` | `p|span|div|label|small|strong|em|h1..h6` | `p` | Semantic element | +| `size` | `2xs|xs|sm|md|lg|xl|2xl|3xl` | `md` | Font size token | +| `weight` | `regular|medium|semibold|bold` | `regular` | Font weight | +| `tone` | `primary|secondary|tertiary|inverse` | `primary` | Text tone token | +| `leading` | `none|snug|normal|relaxed|tight|tighter|supertight|body|heading` | `body` | Line-height token | +| `truncate` | `boolean` | `false` | Single-line truncation | + +## Composition Examples + +### Page Shell + +```tsx +import {Button} from '@tryghost/shade/components'; +import {Container, Inline, Stack, Text} from '@tryghost/shade/primitives'; + + + + + Members + + + + + Core content goes here. + + + +``` + +### Header Shell + +```tsx +import {Button} from '@tryghost/shade/components'; +import {Inline, Stack, Text} from '@tryghost/shade/primitives'; + + + Posts + + 2,132 total + + + + + + +``` + +### List Shell + +```tsx +import {Box, Grid, Stack, Text} from '@tryghost/shade/primitives'; + + + Drafts + + + Draft row content + + + Another draft row + + + +``` + +## Migration Examples + +### Before -> After: Vertical Layout + +```tsx +// Before +
+
+

Members

+

Manage your audience

+
+
...
+
+ +// After + + + Members + Manage your audience + + ... + +``` + +### Before -> After: Framed Grid + +```tsx +// Before +
+
A
+
B
+
C
+
+ +// After + + A + B + C + +``` + +## Practical Guardrails + +- Do not add anonymous wrappers that only carry `flex/grid/gap` utilities. +- Prefer semantic spacing (`sm`, `md`, `lg`) over ad-hoc spacing choices. +- Keep primitives layout-focused; do not put product workflow logic in primitives. + +
diff --git a/apps/shade/src/docs/tokens.mdx b/apps/shade/src/docs/tokens.mdx index d5795072514..0b21a28f0c6 100644 --- a/apps/shade/src/docs/tokens.mdx +++ b/apps/shade/src/docs/tokens.mdx @@ -56,9 +56,9 @@ Theme-aware colors using CSS variables: } ``` -### Semantic Contract (MS-2) +### Semantic Contract -The semantic visual contract is defined in: +The semantic visual rules and guarantees are defined in: - `theme-variables.css` (runtime values + dark mode overrides) - `tailwind.theme.css` (Tailwind aliases) diff --git a/apps/shade/src/primitives.ts b/apps/shade/src/primitives.ts index b8a20491df8..42d2f070e2d 100644 --- a/apps/shade/src/primitives.ts +++ b/apps/shade/src/primitives.ts @@ -1,4 +1,8 @@ -// Layout/structure primitives +// Composition primitives +export * from './components/primitives'; + +// Legacy layout/structure compatibility exports +// Deprecated in MS-3: keep for compatibility during primitive-layer migration. export * from './components/layout/page'; export {ErrorPage} from './components/layout/error-page'; export * from './components/layout/heading'; diff --git a/apps/shade/test/unit/components/primitives/primitives.test.tsx b/apps/shade/test/unit/components/primitives/primitives.test.tsx new file mode 100644 index 00000000000..fc02f3187f8 --- /dev/null +++ b/apps/shade/test/unit/components/primitives/primitives.test.tsx @@ -0,0 +1,153 @@ +import assert from 'assert/strict'; +import {describe, it} from 'vitest'; +import { + Box, + Container, + Grid, + Inline, + Stack, + Text +} from '../../../../src/components/primitives'; +import {screen} from '../../utils/test-utils'; +import {render} from '../../utils/test-utils'; + +describe('Primitives', () => { + it('maps Stack props to expected classes with defaults', () => { + render(A); + const stack = screen.getByTestId('stack'); + + assert.equal(stack.tagName.toLowerCase(), 'div'); + assert.ok(stack.className.includes('flex')); + assert.ok(stack.className.includes('flex-col')); + assert.ok(stack.className.includes('gap-3')); + assert.ok(stack.className.includes('items-stretch')); + assert.ok(stack.className.includes('justify-start')); + }); + + it('maps Stack custom gap/align/justify props to classes', () => { + render(A); + const stack = screen.getByTestId('stack'); + + assert.ok(stack.className.includes('gap-6')); + assert.ok(stack.className.includes('items-center')); + assert.ok(stack.className.includes('justify-between')); + }); + + it('maps Inline props and defaults to expected classes', () => { + render(A); + const inline = screen.getByTestId('inline'); + + assert.equal(inline.tagName.toLowerCase(), 'div'); + assert.ok(inline.className.includes('flex')); + assert.ok(inline.className.includes('flex-row')); + assert.ok(inline.className.includes('flex-nowrap')); + assert.ok(inline.className.includes('gap-3')); + assert.ok(inline.className.includes('items-center')); + assert.ok(inline.className.includes('justify-start')); + }); + + it('renders Inline polymorphically with as and maps wrap', () => { + render(A); + const inline = screen.getByTestId('inline'); + + assert.equal(inline.tagName.toLowerCase(), 'nav'); + assert.ok(inline.className.includes('flex-wrap')); + assert.ok(inline.className.includes('gap-2')); + }); + + it('maps Box spacing and radius props to classes', () => { + render( + + A + + ); + const box = screen.getByTestId('box'); + + assert.ok(box.className.includes('p-4')); + assert.ok(box.className.includes('px-2')); + assert.ok(box.className.includes('py-6')); + assert.ok(box.className.includes('rounded-full')); + }); + + it('maps Container defaults and custom props to classes', () => { + render(A); + const container = screen.getByTestId('container'); + + assert.ok(container.className.includes('w-full')); + assert.ok(container.className.includes('max-w-page')); + assert.ok(container.className.includes('mx-auto')); + + render( + + B + + ); + const custom = screen.getByTestId('container-custom'); + + assert.ok(custom.className.includes('max-w-prose')); + assert.ok(custom.className.includes('px-3')); + assert.ok(!custom.className.includes('mx-auto')); + }); + + it('maps Grid props to classes with defaults', () => { + render(A); + const grid = screen.getByTestId('grid'); + + assert.ok(grid.className.includes('grid')); + assert.ok(grid.className.includes('grid-cols-1')); + assert.ok(grid.className.includes('gap-3')); + assert.ok(grid.className.includes('items-stretch')); + assert.ok(grid.className.includes('justify-start')); + + render( + + B + + ); + const custom = screen.getByTestId('grid-custom'); + + assert.ok(custom.className.includes('grid-cols-3')); + assert.ok(custom.className.includes('gap-8')); + assert.ok(custom.className.includes('items-end')); + assert.ok(custom.className.includes('justify-evenly')); + }); + + it('maps Text defaults and polymorphic props to classes', () => { + render(Hello); + const text = screen.getByTestId('text'); + + assert.equal(text.tagName.toLowerCase(), 'p'); + assert.ok(text.className.includes('text-md')); + assert.ok(text.className.includes('font-normal')); + assert.ok(text.className.includes('text-text-primary')); + assert.ok(text.className.includes('leading-body')); + + render( + + Heading + + ); + const custom = screen.getByTestId('text-custom'); + + assert.equal(custom.tagName.toLowerCase(), 'h2'); + assert.ok(custom.className.includes('text-2xl')); + assert.ok(custom.className.includes('font-bold')); + assert.ok(custom.className.includes('text-text-secondary')); + assert.ok(custom.className.includes('leading-heading')); + assert.ok(custom.className.includes('truncate')); + }); +});