Files
agentic-dev/docs/superpowers/plans/2026-04-06-plan-5-ui-system.md

15 KiB

Plan 5: UI System — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Set up @repo/ui with Atomic Design folder structure, Tailwind CSS v4, shadcn/ui, base components (atoms + a molecule), and apps/storybook with Storybook 8.

Architecture: @repo/ui uses Atomic Design (atoms/molecules/organisms/templates). Tailwind v4 uses CSS-first config (@import "tailwindcss" + @theme). shadcn/ui components land in atoms/ by default. Storybook runs as a separate app pulling stories from the UI package via @storybook/react-vite with @tailwindcss/vite plugin.

Tech Stack: Tailwind CSS v4, shadcn/ui, clsx, tailwind-merge, Storybook 8, @storybook/react-vite


Task 1: Set up @repo/ui with Tailwind v4 + Atomic Design structure

Files:

  • Modify: packages/ui/package.json

  • Modify: packages/ui/tsconfig.json

  • Create: packages/ui/src/styles/globals.css

  • Create: packages/ui/src/lib/utils.ts

  • Create: packages/ui/src/atoms/index.ts

  • Create: packages/ui/src/molecules/index.ts

  • Create: packages/ui/src/organisms/index.ts

  • Create: packages/ui/src/templates/index.ts

  • Modify: packages/ui/src/index.ts

  • Step 1: Update packages/ui/package.json

{
  "name": "@repo/ui",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "scripts": {
    "build": "echo 'typechecked by consuming app bundler'",
    "lint": "eslint .",
    "test": "vitest run",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "clsx": "^2.1.0",
    "tailwind-merge": "^3.0.0",
    "react": "^19.0.0"
  },
  "devDependencies": {
    "@repo/eslint-config": "workspace:*",
    "@repo/typescript-config": "workspace:*",
    "@types/react": "^19.0.0",
    "tailwindcss": "^4.1.0"
  }
}
  • Step 2: Create src/styles/globals.css

Tailwind v4 CSS-first config — no tailwind.config.ts needed.

@import "tailwindcss";

@theme {
  --color-background: hsl(0 0% 100%);
  --color-foreground: hsl(240 10% 3.9%);
  --color-card: hsl(0 0% 100%);
  --color-card-foreground: hsl(240 10% 3.9%);
  --color-popover: hsl(0 0% 100%);
  --color-popover-foreground: hsl(240 10% 3.9%);
  --color-primary: hsl(240 5.9% 10%);
  --color-primary-foreground: hsl(0 0% 98%);
  --color-secondary: hsl(240 4.8% 95.9%);
  --color-secondary-foreground: hsl(240 5.9% 10%);
  --color-muted: hsl(240 4.8% 95.9%);
  --color-muted-foreground: hsl(240 3.8% 46.1%);
  --color-accent: hsl(240 4.8% 95.9%);
  --color-accent-foreground: hsl(240 5.9% 10%);
  --color-destructive: hsl(0 84.2% 60.2%);
  --color-destructive-foreground: hsl(0 0% 98%);
  --color-border: hsl(240 5.9% 90%);
  --color-input: hsl(240 5.9% 90%);
  --color-ring: hsl(240 5.9% 10%);
  --radius-sm: 0.25rem;
  --radius-md: 0.375rem;
  --radius-lg: 0.5rem;
}
  • Step 3: Create src/lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
  • Step 4: Create atomic design barrel files
// packages/ui/src/atoms/index.ts
// Atom components are exported from here
export {};
// packages/ui/src/molecules/index.ts
// Molecule components are exported from here
export {};
// packages/ui/src/organisms/index.ts
// Organism components are exported from here
export {};
// packages/ui/src/templates/index.ts
// Template components are exported from here
export {};
  • Step 5: Update src/index.ts
export { cn } from "./lib/utils.js";
export * from "./atoms/index.js";
export * from "./molecules/index.js";
export * from "./organisms/index.js";
export * from "./templates/index.js";
  • Step 6: Run pnpm install and commit
pnpm install
git add packages/ui/ pnpm-lock.yaml
git commit -m "feat(ui): set up Atomic Design structure with Tailwind v4"

Task 2: Add Button atom

Files:

  • Create: packages/ui/src/atoms/button/button.tsx

  • Create: packages/ui/src/atoms/button/button.stories.tsx

  • Create: packages/ui/src/atoms/button/index.ts

  • Modify: packages/ui/src/atoms/index.ts

  • Step 1: Create button.tsx

import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cn } from "../../lib/utils.js";

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: "default" | "secondary" | "destructive" | "outline" | "ghost";
  size?: "sm" | "default" | "lg";
}

const variantStyles: Record<NonNullable<ButtonProps["variant"]>, string> = {
  default: "bg-primary text-primary-foreground hover:bg-primary/90",
  secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
  destructive:
    "bg-destructive text-destructive-foreground hover:bg-destructive/90",
  outline:
    "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
  ghost: "hover:bg-accent hover:text-accent-foreground",
};

const sizeStyles: Record<NonNullable<ButtonProps["size"]>, string> = {
  sm: "h-9 px-3 text-sm",
  default: "h-10 px-4 py-2",
  lg: "h-11 px-8 text-lg",
};

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant = "default", size = "default", ...props }, ref) => {
    return (
      <button
        className={cn(
          "inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
          variantStyles[variant],
          sizeStyles[size],
          className
        )}
        ref={ref}
        {...props}
      />
    );
  }
);
Button.displayName = "Button";
  • Step 2: Create button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./button.js";

const meta = {
  title: "Atoms/Button",
  component: Button,
  tags: ["autodocs"],
  argTypes: {
    variant: {
      control: "select",
      options: ["default", "secondary", "destructive", "outline", "ghost"],
    },
    size: { control: "select", options: ["sm", "default", "lg"] },
  },
} satisfies Meta<typeof Button>;
export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
  args: { children: "Button", variant: "default" },
};

export const Secondary: Story = {
  args: { children: "Secondary", variant: "secondary" },
};

export const Destructive: Story = {
  args: { children: "Destructive", variant: "destructive" },
};

export const Outline: Story = {
  args: { children: "Outline", variant: "outline" },
};

export const Ghost: Story = {
  args: { children: "Ghost", variant: "ghost" },
};
  • Step 3: Create button/index.ts and update atoms/index.ts
// packages/ui/src/atoms/button/index.ts
export { Button, type ButtonProps } from "./button.js";
// packages/ui/src/atoms/index.ts
export { Button, type ButtonProps } from "./button/index.js";
  • Step 4: Commit
git add packages/ui/src/atoms/button/ packages/ui/src/atoms/index.ts
git commit -m "feat(ui): add Button atom with Storybook story"

Task 3: Add Input atom

Files:

  • Create: packages/ui/src/atoms/input/input.tsx

  • Create: packages/ui/src/atoms/input/input.stories.tsx

  • Create: packages/ui/src/atoms/input/index.ts

  • Modify: packages/ui/src/atoms/index.ts

  • Step 1: Create input.tsx

import { forwardRef, type InputHTMLAttributes } from "react";
import { cn } from "../../lib/utils.js";

export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {}

export const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ className, type, ...props }, ref) => {
    return (
      <input
        type={type}
        className={cn(
          "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
          className
        )}
        ref={ref}
        {...props}
      />
    );
  }
);
Input.displayName = "Input";
  • Step 2: Create input.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { Input } from "./input.js";

const meta = {
  title: "Atoms/Input",
  component: Input,
  tags: ["autodocs"],
} satisfies Meta<typeof Input>;
export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
  args: { placeholder: "Enter text..." },
};

export const Disabled: Story = {
  args: { placeholder: "Disabled", disabled: true },
};
  • Step 3: Create index and update atoms barrel
// packages/ui/src/atoms/input/index.ts
export { Input, type InputProps } from "./input.js";

Update atoms/index.ts to add:

export { Input, type InputProps } from "./input/index.js";
  • Step 4: Commit
git add packages/ui/src/atoms/input/ packages/ui/src/atoms/index.ts
git commit -m "feat(ui): add Input atom with Storybook story"

Task 4: Add Label atom

Files:

  • Create: packages/ui/src/atoms/label/label.tsx

  • Create: packages/ui/src/atoms/label/index.ts

  • Modify: packages/ui/src/atoms/index.ts

  • Step 1: Create label.tsx

import { forwardRef, type LabelHTMLAttributes } from "react";
import { cn } from "../../lib/utils.js";

export interface LabelProps extends LabelHTMLAttributes<HTMLLabelElement> {}

export const Label = forwardRef<HTMLLabelElement, LabelProps>(
  ({ className, ...props }, ref) => {
    return (
      <label
        className={cn(
          "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
          className
        )}
        ref={ref}
        {...props}
      />
    );
  }
);
Label.displayName = "Label";
  • Step 2: Create label/index.ts and update atoms barrel
// packages/ui/src/atoms/label/index.ts
export { Label, type LabelProps } from "./label.js";

Update atoms/index.ts to add:

export { Label, type LabelProps } from "./label/index.js";
  • Step 3: Commit
git add packages/ui/src/atoms/label/ packages/ui/src/atoms/index.ts
git commit -m "feat(ui): add Label atom"

Task 5: Add FormField molecule

Files:

  • Create: packages/ui/src/molecules/form-field/form-field.tsx

  • Create: packages/ui/src/molecules/form-field/form-field.stories.tsx

  • Create: packages/ui/src/molecules/form-field/index.ts

  • Modify: packages/ui/src/molecules/index.ts

  • Step 1: Create form-field.tsx

import { type ReactNode } from "react";
import { Label } from "../../atoms/label/index.js";
import { Input, type InputProps } from "../../atoms/input/index.js";
import { cn } from "../../lib/utils.js";

export interface FormFieldProps extends InputProps {
  label: string;
  error?: string;
  description?: string;
}

export function FormField({
  label,
  error,
  description,
  className,
  id,
  ...inputProps
}: FormFieldProps) {
  const fieldId = id ?? label.toLowerCase().replace(/\s+/g, "-");

  return (
    <div className={cn("space-y-2", className)}>
      <Label htmlFor={fieldId}>{label}</Label>
      <Input id={fieldId} {...inputProps} />
      {description && (
        <p className="text-sm text-muted-foreground">{description}</p>
      )}
      {error && <p className="text-sm text-destructive">{error}</p>}
    </div>
  );
}
  • Step 2: Create form-field.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { FormField } from "./form-field.js";

const meta = {
  title: "Molecules/FormField",
  component: FormField,
  tags: ["autodocs"],
} satisfies Meta<typeof FormField>;
export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
  args: { label: "Email", placeholder: "you@example.com", type: "email" },
};

export const WithDescription: Story = {
  args: {
    label: "Username",
    placeholder: "johndoe",
    description: "Must be 3-31 characters",
  },
};

export const WithError: Story = {
  args: {
    label: "Password",
    type: "password",
    error: "Password must be at least 6 characters",
  },
};
  • Step 3: Create index and update molecules barrel
// packages/ui/src/molecules/form-field/index.ts
export { FormField, type FormFieldProps } from "./form-field.js";
// packages/ui/src/molecules/index.ts
export { FormField, type FormFieldProps } from "./form-field/index.js";
  • Step 4: Commit
git add packages/ui/src/molecules/
git commit -m "feat(ui): add FormField molecule (Label + Input + error)"

Task 6: Set up apps/storybook

Files:

  • Modify: apps/storybook/package.json

  • Create: apps/storybook/.storybook/main.ts

  • Create: apps/storybook/.storybook/preview.ts

  • Step 1: Update apps/storybook/package.json

{
  "name": "@repo/storybook",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "build": "storybook build",
    "dev": "storybook dev -p 6006",
    "lint": "eslint ."
  },
  "dependencies": {
    "@repo/ui": "workspace:*"
  },
  "devDependencies": {
    "@repo/eslint-config": "workspace:*",
    "@repo/typescript-config": "workspace:*",
    "@storybook/addon-essentials": "^8.6.0",
    "@storybook/react-vite": "^8.6.0",
    "@tailwindcss/vite": "^4.1.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "storybook": "^8.6.0",
    "tailwindcss": "^4.1.0",
    "vite": "^6.3.0"
  }
}
  • Step 2: Create .storybook/main.ts
import type { StorybookConfig } from "@storybook/react-vite";

const config: StorybookConfig = {
  framework: "@storybook/react-vite",
  stories: [
    "../../../packages/ui/src/**/*.stories.@(ts|tsx)",
  ],
  addons: ["@storybook/addon-essentials"],
  docs: {
    autodocs: "tag",
  },
  async viteFinal(config) {
    const { mergeConfig } = await import("vite");
    const tailwindPlugin = await import("@tailwindcss/vite");

    return mergeConfig(config, {
      plugins: [tailwindPlugin.default()],
    });
  },
};

export default config;
  • Step 3: Create .storybook/preview.ts
import type { Preview } from "@storybook/react";
import "../../../packages/ui/src/styles/globals.css";

const preview: Preview = {
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
};

export default preview;
  • Step 4: Run pnpm install and commit
pnpm install
git add apps/storybook/ pnpm-lock.yaml
git commit -m "feat(storybook): add Storybook 8 with Tailwind v4 pulling stories from @repo/ui"

Task 7: Verify build

  • Step 1: Run pnpm build

Run: pnpm build Expected: All workspaces pass.

  • Step 2: Verify core tests still pass

Run: cd packages/core && pnpm vitest run Expected: 22 tests pass.

  • Step 3: Commit any remaining fixes
git add -A
git commit -m "chore: finalize Plan 5 — UI system with Atomic Design"