Skip to content

Instantly share code, notes, and snippets.

@matthew-gerstman
Last active December 1, 2025 18:23
Show Gist options
  • Select an option

  • Save matthew-gerstman/ee0d085f4d6d3de4facc94710d0c2e9d to your computer and use it in GitHub Desktop.

Select an option

Save matthew-gerstman/ee0d085f4d6d3de4facc94710d0c2e9d to your computer and use it in GitHub Desktop.
Design System v2: Classname Helpers - Complete Implementation Plan

Design System v2: Classname Helpers

A token-conscious approach using type-safe classname utilities instead of component wrappers for layout primitives.


Core Principle

Components for behavior. Classnames for styling.

Use Components Use Classname Helpers
Dialog, Popover, Form Flex, Grid, Stack, Box
Tooltip, Select, Tabs Spacing, Typography
Anything with state/a11y logic Pure CSS composition

This keeps token count flat while maximizing composability.


Architecture

┌─────────────────────────────────────────────────────┐
│  Application Components                              │
│  (features, pages, domain-specific)                  │
├─────────────────────────────────────────────────────┤
│  Behavioral Components (Radix wrappers)              │
│  Form, Dialog, WithTooltip, ListItem                │
├─────────────────────────────────────────────────────┤
│  Classname Helpers (type-safe utilities)            │
│  flex(), stack(), inline(), box(), grid()           │
├─────────────────────────────────────────────────────┤
│  Foundation                                          │
│  cn(), spacing scale, color tokens                  │
└─────────────────────────────────────────────────────┘

Part 1: Classname Helpers

Type Definitions (Shared)

// dashboard/src/ui/helpers/types.ts

export const spacingScale = ['0', '0.5', '1', '1.5', '2', '3', '4', '6', '8', '10', '12'] as const
export type Spacing = typeof spacingScale[number]

export type Alignment = 'start' | 'center' | 'end' | 'stretch' | 'baseline'
export type Justify = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly'

flex()

// dashboard/src/ui/helpers/flex.ts

import { cn } from '@/lib/utils'
import type { Spacing, Alignment, Justify } from './types'

export interface FlexOptions {
  direction?: 'row' | 'column' | 'row-reverse' | 'column-reverse'
  gap?: Spacing
  align?: Alignment
  justify?: Justify
  wrap?: boolean | 'reverse'
  inline?: boolean
}

export const flex = (opts: FlexOptions = {}): string => {
  const {
    direction,
    gap,
    align,
    justify,
    wrap,
    inline = false,
  } = opts

  return cn(
    inline ? 'inline-flex' : 'flex',
    direction === 'column' && 'flex-col',
    direction === 'row-reverse' && 'flex-row-reverse',
    direction === 'column-reverse' && 'flex-col-reverse',
    // Default: row direction gets items-center (our convention)
    !direction && !align && 'items-center',
    gap !== undefined && `gap-${gap}`,
    align && `items-${align}`,
    justify && `justify-${justify}`,
    wrap === true && 'flex-wrap',
    wrap === 'reverse' && 'flex-wrap-reverse',
  )
}

Usage:

<div className={flex({ gap: '2' })}>
  <Icon />
  <span>Label</span>
</div>

<nav className={cn(flex({ direction: 'column', gap: '1' }), 'p-4')}>
  <NavItem />
  <NavItem />
</nav>

// Semantic elements, no wrapper needed
<header className={flex({ justify: 'between' })}>
  <Logo />
  <UserMenu />
</header>

stack()

Vertical flex with sensible defaults. The most common layout pattern.

// dashboard/src/ui/helpers/stack.ts

import { cn } from '@/lib/utils'
import type { Spacing, Alignment } from './types'

export interface StackOptions {
  gap?: Spacing
  align?: Alignment  // cross-axis (horizontal)
}

export const stack = (opts: StackOptions = {}): string => {
  const { gap = '4', align } = opts

  return cn(
    'flex flex-col',
    `gap-${gap}`,
    align && `items-${align}`,
  )
}

Usage:

<div className={stack()}>
  <Heading />
  <Paragraph />
  <Button />
</div>

<form className={stack({ gap: '6' })}>
  <FieldGroup />
  <FieldGroup />
  <SubmitButton />
</form>

inline()

Inline-flex for icon + text patterns.

// dashboard/src/ui/helpers/inline.ts

import { cn } from '@/lib/utils'
import type { Spacing } from './types'

export interface InlineOptions {
  gap?: Spacing
  align?: 'start' | 'center' | 'end' | 'baseline'
}

export const inline = (opts: InlineOptions = {}): string => {
  const { gap = '1.5', align = 'center' } = opts

  return cn(
    'inline-flex',
    `gap-${gap}`,
    `items-${align}`,
  )
}

Usage:

<span className={inline()}>
  <IconCheck className="size-4" />
  Completed
</span>

<button className={cn(inline({ gap: '2' }), 'px-3 py-1.5')}>
  <IconPlus className="size-4" />
  Add Item
</button>

box()

Padding, sizing, and common box patterns.

// dashboard/src/ui/helpers/box.ts

import { cn } from '@/lib/utils'
import type { Spacing } from './types'

export interface BoxOptions {
  p?: Spacing       // padding all
  px?: Spacing      // padding x
  py?: Spacing      // padding y
  m?: Spacing       // margin all
  mx?: Spacing      // margin x
  my?: Spacing      // margin y
  fill?: boolean    // w-full h-full
  center?: boolean  // flex items-center justify-center
}

export const box = (opts: BoxOptions = {}): string => {
  const { p, px, py, m, mx, my, fill, center } = opts

  return cn(
    p !== undefined && `p-${p}`,
    px !== undefined && `px-${px}`,
    py !== undefined && `py-${py}`,
    m !== undefined && `m-${m}`,
    mx !== undefined && `mx-${mx}`,
    my !== undefined && `my-${my}`,
    fill && 'w-full h-full',
    center && 'flex items-center justify-center',
  )
}

Usage:

<div className={box({ p: '4', fill: true })}>
  <Content />
</div>

<div className={box({ center: true, fill: true })}>
  <Spinner />
</div>

// Combine with other helpers
<section className={cn(box({ px: '6', py: '8' }), stack({ gap: '4' }))}>
  <Heading />
  <Content />
</section>

grid()

CSS Grid layouts.

// dashboard/src/ui/helpers/grid.ts

import { cn } from '@/lib/utils'
import type { Spacing } from './types'

export interface GridOptions {
  cols?: 1 | 2 | 3 | 4 | 5 | 6 | 12 | 'none'
  rows?: 1 | 2 | 3 | 4 | 5 | 6 | 'none'
  gap?: Spacing
  gapX?: Spacing
  gapY?: Spacing
  flow?: 'row' | 'col' | 'dense' | 'row-dense' | 'col-dense'
}

export const grid = (opts: GridOptions = {}): string => {
  const { cols, rows, gap, gapX, gapY, flow } = opts

  return cn(
    'grid',
    cols && `grid-cols-${cols}`,
    rows && `grid-rows-${rows}`,
    gap !== undefined && `gap-${gap}`,
    gapX !== undefined && `gap-x-${gapX}`,
    gapY !== undefined && `gap-y-${gapY}`,
    flow && `grid-flow-${flow}`,
  )
}

Usage:

<div className={grid({ cols: 3, gap: '4' })}>
  <Card />
  <Card />
  <Card />
</div>

<div className={grid({ cols: 12, gap: '6' })}>
  <aside className="col-span-3">Sidebar</aside>
  <main className="col-span-9">Content</main>
</div>

Barrel Export

// dashboard/src/ui/helpers/index.ts

export { flex, type FlexOptions } from './flex'
export { stack, type StackOptions } from './stack'
export { inline, type InlineOptions } from './inline'
export { box, type BoxOptions } from './box'
export { grid, type GridOptions } from './grid'
export { spacingScale, type Spacing, type Alignment, type Justify } from './types'

Part 2: Behavioral Components

These have real behavior that justifies component abstraction.


WithTooltip

Convenience wrapper for adding tooltips to any element.

// dashboard/src/ui/tooltip.tsx (add to existing)

interface WithTooltipProps {
  content: ReactNode
  side?: 'top' | 'right' | 'bottom' | 'left'
  sideOffset?: number
  disabled?: boolean
  children: ReactElement
}

function WithTooltip({ 
  content, 
  side = 'top', 
  sideOffset = 4, 
  disabled, 
  children 
}: WithTooltipProps) {
  if (disabled) return children
  
  return (
    <Tooltip>
      <TooltipTrigger asChild>{children}</TooltipTrigger>
      <TooltipContent side={side} sideOffset={sideOffset}>
        {content}
      </TooltipContent>
    </Tooltip>
  )
}

Usage:

<WithTooltip content="Delete item" side="bottom">
  <Button variant="ghost"><IconTrash /></Button>
</WithTooltip>

// Disabled state skips tooltip entirely
<WithTooltip content="Not available" disabled={!hasPermission}>
  <Button>Action</Button>
</WithTooltip>

Solves: 387 tooltip usages with boilerplate


Action

Semantic clickable primitive. Auto-detects button vs link based on props.

// dashboard/src/ui/action/action.tsx

import { Slot } from '@radix-ui/react-slot'
import { Link } from 'react-router-dom'

interface ActionProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  href?: string
  external?: boolean
  variant?: 'default' | 'subtle' | 'ghost'
  asChild?: boolean
}

function Action({ 
  children, 
  onClick, 
  href, 
  external, 
  variant = 'ghost', 
  disabled, 
  className,
  asChild,
  ...props 
}: ActionProps) {
  const classes = cn(
    'cursor-pointer inline-flex items-center',
    'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
    'disabled:cursor-not-allowed disabled:opacity-50',
    variant === 'default' && 'hover:bg-darken-200 active:bg-darken-300 rounded-md',
    variant === 'subtle' && 'hover:bg-darken-100 active:bg-darken-200 rounded-md',
    className
  )

  if (asChild) {
    return <Slot className={classes} {...props}>{children}</Slot>
  }

  if (href) {
    const isExternal = external || href.startsWith('http')
    if (isExternal) {
      return (
        <a 
          href={href} 
          target="_blank" 
          rel="noopener noreferrer" 
          className={classes} 
          {...props}
        >
          {children}
        </a>
      )
    }
    return <Link to={href} className={classes} {...props}>{children}</Link>
  }

  return (
    <button 
      type="button" 
      onClick={onClick} 
      disabled={disabled} 
      className={classes} 
      {...props}
    >
      {children}
    </button>
  )
}

Usage:

// Button behavior
<Action onClick={handleClick}>Click me</Action>

// Internal link
<Action href="/settings">Go to settings</Action>

// External link (auto-detected or explicit)
<Action href="https://example.com">External link</Action>
<Action href="/api/download" external>Download</Action>

// With asChild for custom elements
<Action asChild variant="subtle">
  <div>Custom clickable</div>
</Action>

Solves: 136 cursor-pointer divs


Form (Radix Wrapper)

Wraps @radix-ui/react-form with our styling conventions.

// dashboard/src/ui/form.tsx

import * as FormPrimitive from '@radix-ui/react-form'

const Form = FormPrimitive.Root

const FormField = React.forwardRef<
  React.ElementRef<typeof FormPrimitive.Field>,
  React.ComponentPropsWithoutRef<typeof FormPrimitive.Field>
>(({ className, ...props }, ref) => (
  <FormPrimitive.Field 
    ref={ref} 
    className={cn('flex flex-col gap-1.5', className)} 
    {...props} 
  />
))
FormField.displayName = 'FormField'

const FormLabel = React.forwardRef<
  React.ElementRef<typeof FormPrimitive.Label>,
  React.ComponentPropsWithoutRef<typeof FormPrimitive.Label> & { required?: boolean }
>(({ className, required, children, ...props }, ref) => (
  <FormPrimitive.Label 
    ref={ref} 
    className={cn('text-sm font-medium', className)} 
    {...props}
  >
    {children}
    {required && <span className="text-destructive ml-0.5">*</span>}
  </FormPrimitive.Label>
))
FormLabel.displayName = 'FormLabel'

const FormControl = React.forwardRef<
  React.ElementRef<typeof FormPrimitive.Control>,
  React.ComponentPropsWithoutRef<typeof FormPrimitive.Control>
>(({ className, ...props }, ref) => (
  <FormPrimitive.Control 
    ref={ref} 
    className={cn(
      'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1',
      'text-sm shadow-sm transition-colors',
      'placeholder:text-muted-foreground',
      'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
      'disabled:cursor-not-allowed disabled:opacity-50',
      className
    )} 
    {...props} 
  />
))
FormControl.displayName = 'FormControl'

const FormMessage = React.forwardRef<
  React.ElementRef<typeof FormPrimitive.Message>,
  React.ComponentPropsWithoutRef<typeof FormPrimitive.Message>
>(({ className, ...props }, ref) => (
  <FormPrimitive.Message 
    ref={ref} 
    className={cn('text-xs text-destructive', className)} 
    {...props} 
  />
))
FormMessage.displayName = 'FormMessage'

const FormDescription = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
  <p ref={ref} className={cn('text-xs text-text-tertiary', className)} {...props} />
))
FormDescription.displayName = 'FormDescription'

const FormSubmit = FormPrimitive.Submit
const FormValidityState = FormPrimitive.ValidityState

export { 
  Form, 
  FormField, 
  FormLabel, 
  FormControl, 
  FormMessage, 
  FormDescription,
  FormSubmit,
  FormValidityState 
}

Usage:

<Form onSubmit={handleSubmit}>
  <FormField name="email">
    <FormLabel required>Email</FormLabel>
    <FormControl type="email" required placeholder="[email protected]" />
    <FormMessage match="valueMissing">Email is required</FormMessage>
    <FormMessage match="typeMismatch">Please enter a valid email</FormMessage>
  </FormField>
  
  <FormField name="password">
    <FormLabel required>Password</FormLabel>
    <FormControl type="password" required minLength={8} />
    <FormMessage match="valueMissing">Password is required</FormMessage>
    <FormMessage match="tooShort">Password must be at least 8 characters</FormMessage>
    <FormDescription>Must be at least 8 characters</FormDescription>
  </FormField>
  
  <FormSubmit asChild>
    <Button>Sign Up</Button>
  </FormSubmit>
</Form>

What Radix Form handles:

  • Label-input linking (accessibility)
  • Native HTML5 validation with nice API
  • Built-in validity matchers: valueMissing, typeMismatch, tooShort, tooLong, patternMismatch, etc.
  • Server-side validation support (serverInvalid prop)
  • Custom async validators

Badge

Semantic badge for status, categories, or counts.

// dashboard/src/ui/badge/badge.tsx

type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info'

const variantStyles: Record<BadgeVariant, string> = {
  default: 'bg-darken-100 text-text-secondary',
  success: 'bg-green-50 text-green-700',
  warning: 'bg-yellow-50 text-yellow-700',
  error: 'bg-red-50 text-red-700',
  info: 'bg-blue-50 text-blue-700',
}

interface BadgeProps {
  variant?: BadgeVariant
  size?: 'sm' | 'md'
  icon?: ReactNode
  className?: string
  children?: ReactNode
}

function Badge({ 
  variant = 'default', 
  size = 'sm', 
  icon, 
  children, 
  className 
}: BadgeProps) {
  return (
    <span
      className={cn(
        inline({ gap: '1' }),
        'rounded-full font-medium',
        size === 'sm' && 'px-2 py-0.5 text-xs',
        size === 'md' && 'px-2.5 py-1 text-sm',
        variantStyles[variant],
        className
      )}
    >
      {icon}
      {children}
    </span>
  )
}

Usage:

<Badge>Default</Badge>
<Badge variant="success">Active</Badge>
<Badge variant="error" icon={<IconAlert className="size-3" />}>Failed</Badge>
<Badge variant="info" size="md">12 items</Badge>

StatusDot

Small colored dot indicating status. Optionally shows a label.

// dashboard/src/ui/status-dot/status-dot.tsx

type Status = 'success' | 'warning' | 'error' | 'info' | 'offline' | 'neutral'

const statusColors: Record<Status, string> = {
  success: 'bg-green-500',
  warning: 'bg-yellow-500',
  error: 'bg-red-500',
  info: 'bg-blue-500',
  offline: 'bg-gray-400',
  neutral: 'bg-gray-300',
}

const sizes = {
  sm: 'size-1.5',
  md: 'size-2',
  lg: 'size-2.5',
}

interface StatusDotProps {
  status: Status
  size?: 'sm' | 'md' | 'lg'
  pulse?: boolean
  label?: string
  className?: string
}

function StatusDot({ 
  status, 
  size = 'md', 
  pulse, 
  label, 
  className 
}: StatusDotProps) {
  const dot = (
    <span 
      className={cn(
        'rounded-full inline-block flex-shrink-0',
        sizes[size],
        statusColors[status],
        pulse && 'animate-pulse',
        className
      )} 
    />
  )
  
  if (label) {
    return (
      <span className={inline({ gap: '1.5' })}>
        {dot}
        <span className="text-sm text-text-secondary">{label}</span>
      </span>
    )
  }
  
  return dot
}

Usage:

<StatusDot status="success" />
<StatusDot status="error" pulse />
<StatusDot status="success" label="Online" />
<StatusDot status="offline" size="lg" label="Disconnected" />

Skeleton

Loading placeholder with shimmer animation.

// dashboard/src/ui/skeleton/skeleton.tsx

interface SkeletonProps {
  variant?: 'text' | 'circular' | 'rectangular'
  width?: string | number
  height?: string | number
  className?: string
}

function Skeleton({ 
  variant = 'text', 
  width, 
  height, 
  className 
}: SkeletonProps) {
  return (
    <div
      className={cn(
        'animate-pulse bg-darken-200',
        variant === 'text' && 'h-4 rounded',
        variant === 'circular' && 'rounded-full',
        variant === 'rectangular' && 'rounded-md',
        className
      )}
      style={{ width, height }}
    />
  )
}

Usage:

// Text placeholder
<Skeleton width="60%" />

// Avatar placeholder
<Skeleton variant="circular" width={40} height={40} />

// Card placeholder
<Skeleton variant="rectangular" width="100%" height={120} />

EmptyState

Centered empty state with icon, title, description, and action.

// dashboard/src/ui/empty-state/empty-state.tsx

interface EmptyStateProps {
  icon?: ReactNode
  title: string
  description?: string
  action?: ReactNode
  className?: string
}

function EmptyState({ 
  icon, 
  title, 
  description, 
  action, 
  className 
}: EmptyStateProps) {
  return (
    <div 
      className={cn(
        box({ center: true, fill: true }), 
        stack({ gap: '4', align: 'center' }),
        'py-12 px-6',
        className
      )}
    >
      {icon && (
        <div className={cn(box({ center: true }), 'size-12 rounded-full bg-darken-100 text-text-secondary')}>
          {icon}
        </div>
      )}
      <div className={cn(stack({ gap: '1', align: 'center' }), 'text-center max-w-sm')}>
        <h3 className="text-base font-semibold">{title}</h3>
        {description && (
          <p className="text-sm text-text-secondary">{description}</p>
        )}
      </div>
      {action}
    </div>
  )
}

Usage:

<EmptyState 
  icon={<IconFolder className="size-6" />}
  title="No files"
  description="Upload your first file to get started"
  action={<Button>Upload File</Button>}
/>

// Minimal
<EmptyState title="No results found" />

ListItem

Clickable row with optional selection state. Compound component.

// dashboard/src/ui/list-item/list-item.tsx

interface ListItemProps {
  selected?: boolean
  disabled?: boolean
  onClick?: () => void
  className?: string
  children?: ReactNode
}

function ListItem({ 
  selected, 
  disabled, 
  onClick, 
  className, 
  children,
  ...props 
}: ListItemProps) {
  return (
    <div
      role={onClick ? 'button' : undefined}
      tabIndex={onClick && !disabled ? 0 : undefined}
      onClick={disabled ? undefined : onClick}
      className={cn(
        flex({ gap: '3' }),
        'px-2 py-2 rounded-lg transition-colors',
        onClick && 'cursor-pointer hover:bg-surface-hover',
        selected && 'bg-surface-hover',
        disabled && 'opacity-50 cursor-not-allowed',
        className
      )}
      {...props}
    >
      {children}
    </div>
  )
}

function ListItemContent({ className, children }: { className?: string; children?: ReactNode }) {
  return <div className={cn('flex-1 min-w-0', className)}>{children}</div>
}

function ListItemTitle({ className, children }: { className?: string; children?: ReactNode }) {
  return <div className={cn('text-sm font-medium truncate', className)}>{children}</div>
}

function ListItemSubtitle({ className, children }: { className?: string; children?: ReactNode }) {
  return <div className={cn('text-xs text-text-secondary truncate', className)}>{children}</div>
}

function ListItemAction({ className, children }: { className?: string; children?: ReactNode }) {
  return <div className={cn('flex-shrink-0', className)}>{children}</div>
}

ListItem.Content = ListItemContent
ListItem.Title = ListItemTitle
ListItem.Subtitle = ListItemSubtitle
ListItem.Action = ListItemAction

Usage:

<ListItem onClick={handleClick} selected={isActive}>
  <Avatar src={user.avatar} />
  <ListItem.Content>
    <ListItem.Title>{user.name}</ListItem.Title>
    <ListItem.Subtitle>Last active 2h ago</ListItem.Subtitle>
  </ListItem.Content>
  <ListItem.Action>
    <IconChevron className="size-4" />
  </ListItem.Action>
</ListItem>

ButtonGroup

Groups buttons together, optionally attached with shared borders.

// dashboard/src/ui/button-group/button-group.tsx

interface ButtonGroupProps {
  attached?: boolean
  className?: string
  children?: ReactNode
}

function ButtonGroup({ attached, className, children }: ButtonGroupProps) {
  return (
    <div 
      className={cn(
        flex({ gap: attached ? '0' : '1' }),
        attached && [
          '[&>*:not(:first-child)]:rounded-l-none',
          '[&>*:not(:last-child)]:rounded-r-none',
          '[&>*:not(:first-child)]:-ml-px',
        ],
        className
      )}
    >
      {children}
    </div>
  )
}

Usage:

// Spaced
<ButtonGroup>
  <Button>One</Button>
  <Button>Two</Button>
  <Button>Three</Button>
</ButtonGroup>

// Attached
<ButtonGroup attached>
  <Button variant="outline">Left</Button>
  <Button variant="outline">Center</Button>
  <Button variant="outline">Right</Button>
</ButtonGroup>

Text (Enhanced)

Text component with semantic variants. Enhanced with truncation.

// dashboard/src/ui/text.tsx (enhance existing)

interface TextProps {
  variant?: 'default' | 'title' | 'title2' | 'title3' | 'subheadline' | 'body' | 'callout' | 'caption'
  color?: 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'danger'
  weight?: 'normal' | 'medium' | 'semibold' | 'bold'
  truncate?: boolean | number  // true = single line, number = line clamp
  as?: 'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
  emphasized?: boolean
  className?: string
  children?: ReactNode
}

function Text({ 
  variant = 'default',
  color = 'default',
  weight,
  truncate,
  as: Component = 'span',
  emphasized,
  className,
  children,
  ...props
}: TextProps) {
  return (
    <Component
      className={cn(
        variantStyles[variant],
        colorStyles[color],
        weight && weightStyles[weight],
        emphasized && 'italic',
        truncate === true && 'truncate',
        typeof truncate === 'number' && `line-clamp-${truncate}`,
        className
      )}
      {...props}
    >
      {children}
    </Component>
  )
}

Usage:

<Text variant="title">Page Title</Text>
<Text variant="body" color="secondary">Description text</Text>
<Text truncate>This will truncate if too long...</Text>
<Text truncate={2}>This will clamp to 2 lines...</Text>

Implementation Plan

Phase 1: Foundation (3 PRs)

PR 1: Layout Helpers

  • flex(), stack(), inline(), box(), grid()
  • Shared types and spacing scale
  • Unit tests for all helpers
  • Update AGENTS.md with usage patterns

PR 2: WithTooltip

  • Convenience wrapper around Radix Tooltip
  • Solves 387 tooltip usages with boilerplate

PR 3: Action Primitive

  • Semantic clickable (button/link polymorphism)
  • Uses @radix-ui/react-slot for asChild
  • Solves 136 cursor-pointer divs

Phase 2: Forms + Feedback (3 PRs)

PR 4: Radix Form Wrapper

  • Install @radix-ui/react-form
  • FormField, FormLabel, FormControl, FormMessage, FormDescription, FormSubmit
  • Uses stack() internally for field layout

PR 5: Badge + StatusDot

  • Badge with variants: default, success, warning, error, info
  • StatusDot with optional label and pulse animation
  • Both use inline() internally

PR 6: Skeleton + EmptyState

  • Skeleton with variants: text, circular, rectangular
  • EmptyState uses stack() + box({ center: true })

Phase 3: Lists + Polish (3 PRs)

PR 7: ListItem Compound Component

  • Uses flex() internally
  • Compound: ListItem.Content, .Title, .Subtitle, .Action
  • Selection and disabled states

PR 8: ButtonGroup + Text Enhancement

  • ButtonGroup with optional attached styling
  • Text enhanced with truncate prop (single line or line-clamp)

PR 9: Migration + Docs

  • Migrate top 50 flex items-center patterns
  • Complete AGENTS.md documentation
  • Storybook examples for all helpers and components

File Structure

dashboard/src/ui/
├── helpers/
│   ├── flex.ts
│   ├── flex.test.ts
│   ├── stack.ts
│   ├── stack.test.ts
│   ├── inline.ts
│   ├── inline.test.ts
│   ├── box.ts
│   ├── box.test.ts
│   ├── grid.ts
│   ├── grid.test.ts
│   ├── types.ts
│   └── index.ts
├── action/
│   ├── action.tsx
│   ├── action.test.tsx
│   └── action.stories.tsx
├── badge/
│   ├── badge.tsx
│   ├── badge.test.tsx
│   └── badge.stories.tsx
├── button-group/
│   ├── button-group.tsx
│   ├── button-group.test.tsx
│   └── button-group.stories.tsx
├── empty-state/
│   ├── empty-state.tsx
│   ├── empty-state.test.tsx
│   └── empty-state.stories.tsx
├── list-item/
│   ├── list-item.tsx
│   ├── list-item.test.tsx
│   └── list-item.stories.tsx
├── skeleton/
│   ├── skeleton.tsx
│   ├── skeleton.test.tsx
│   └── skeleton.stories.tsx
├── status-dot/
│   ├── status-dot.tsx
│   ├── status-dot.test.tsx
│   └── status-dot.stories.tsx
├── form.tsx           # Radix Form wrapper
├── text.tsx           # Enhanced with truncate
├── tooltip.tsx        # + WithTooltip
└── index.ts

Dependencies

No new dependencies for helpers. They're pure functions using cn().

Add for Form:

npm install @radix-ui/react-form

Success Metrics

Metric Target
Helper bundle size < 1KB gzipped
Type coverage 100%
Token count vs components ≤ 105% (no worse than 5% more)
Raw flex items-center < 400 (from 1,200)
cursor-pointer divs < 20 (from 136)
Storybook coverage 100%

PR Checklist

For each PR:

  • Implementation with full TypeScript types
  • Unit tests (vitest)
  • Storybook stories
  • Export from ui/index.ts or ui/helpers/index.ts
  • AGENTS.md updated
  • Lint passes
  • className escape hatch on all components
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment