diff --git a/turbo/generators/templates/core-package/ui/AGENTS.md.hbs b/turbo/generators/templates/core-package/ui/AGENTS.md.hbs new file mode 100644 index 0000000..a984ec8 --- /dev/null +++ b/turbo/generators/templates/core-package/ui/AGENTS.md.hbs @@ -0,0 +1,67 @@ +# AGENTS.md — core-ui + +Design-system primitives organized by Atomic Design. Only **generic components** (atoms, molecules, generic organisms) live here. Feature-specific components live in their respective feature packages. + +## Responsibilities + +- **Atoms** — Single HTML elements: Button, Input, Label, Card +- **Molecules** — Combinations: FormField (Label + Input), Badge, Avatar +- **Generic Organisms** — Reusable complex layouts: Modal, Tabs, NavigationMenu, CommandPalette +- **Templates** — Page layouts: AuthLayout, DashboardLayout, LandingLayout +- **Utilities** — `cn()` for className merging, design tokens, Tailwind config + +## Feature-specific boundary + +Feature-specific organisms (e.g., `ArticleCard`, `ArticleList`, `HeaderNavMenu`) live in their feature's `ui/` folder, NOT here. This boundary keeps `core-ui` framework-agnostic and reusable. + +## Must NOT import + +- Any feature package (`@repo/auth`, `@repo/blog`, etc.) +- Any app package +- `@repo/core-api`, `@repo/core-cms`, `@repo/core-trpc` + +> Note: `@repo/core-trpc` is an optional package scaffolded via `pnpm turbo gen core-package trpc`. If not present, this constraint still applies to any future installation. + +## Public exports + +From `package.json`: +- `.` — all atoms, molecules, organisms, templates + +Example usage: +```typescript +import { Button, Input, Label } from "@repo/core-ui"; +import { FormField } from "@repo/core-ui"; +import { Modal, Tabs } from "@repo/core-ui"; +``` + +## Test conventions + +- Tests colocated: `src/atoms/button/button.tsx` → `src/atoms/button/button.test.tsx` +- Vitest environment: `jsdom` (React component testing) +- Alias: `@/` resolves to `src/` +- Run: `pnpm test --filter @repo/core-ui` +- Storybook stories colocated: `src/atoms/button/button.stories.tsx` + +## Structure + +``` +src/ + atoms/ + {name}/ + {name}.tsx # Component + {name}.test.tsx # Tests + {name}.stories.tsx # Storybook story + index.ts # Barrel export + molecules/ + {name}/ + ... + organisms/ + {name}/ + ... + templates/ + {name}/ + ... + lib/ + utils.ts # cn() and helpers + index.ts # Re-exports everything +``` diff --git a/turbo/generators/templates/core-package/ui/eslint.config.js.hbs b/turbo/generators/templates/core-package/ui/eslint.config.js.hbs new file mode 100644 index 0000000..7440d8f --- /dev/null +++ b/turbo/generators/templates/core-package/ui/eslint.config.js.hbs @@ -0,0 +1,3 @@ +import baseConfig from "@repo/core-eslint/base"; + +export default baseConfig; diff --git a/turbo/generators/templates/core-package/ui/package.json.hbs b/turbo/generators/templates/core-package/ui/package.json.hbs new file mode 100644 index 0000000..e9354e2 --- /dev/null +++ b/turbo/generators/templates/core-package/ui/package.json.hbs @@ -0,0 +1,33 @@ +{ + "name": "@repo/core-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./styles/globals.css": "./src/styles/globals.css" + }, + "scripts": { + "build": "tsc --noEmit", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "clsx": "^2.1.1", + "react": "^19.0.0", + "tailwind-merge": "^3.0.0" + }, + "devDependencies": { + "@repo/core-eslint": "workspace:*", + "@repo/core-testing": "workspace:*", + "@repo/core-typescript": "workspace:*", + "@storybook/react": "^8.6.0", + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.0", + "@types/react": "^19.0.0", + "jsdom": "^25.0.0", + "vitest": "^3.0.0" + } +} diff --git a/turbo/generators/templates/core-package/ui/src/atoms/button/button.stories.tsx.hbs b/turbo/generators/templates/core-package/ui/src/atoms/button/button.stories.tsx.hbs new file mode 100644 index 0000000..094736a --- /dev/null +++ b/turbo/generators/templates/core-package/ui/src/atoms/button/button.stories.tsx.hbs @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Button } from "./button"; + +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; +export default meta; + +type Story = StoryObj; + +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" }, +}; diff --git a/turbo/generators/templates/core-package/ui/src/atoms/button/button.test.tsx.hbs b/turbo/generators/templates/core-package/ui/src/atoms/button/button.test.tsx.hbs new file mode 100644 index 0000000..bcf664f --- /dev/null +++ b/turbo/generators/templates/core-package/ui/src/atoms/button/button.test.tsx.hbs @@ -0,0 +1,42 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderWithProviders } from "@repo/core-testing/react"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Button } from "./button"; + +describe("Button", () => { + it("renders children inside a button", () => { + renderWithProviders(); + expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument(); + }); + + it("calls onClick when activated", async () => { + const handleClick = vi.fn(); + renderWithProviders(); + await userEvent.click(screen.getByRole("button", { name: "Go" })); + expect(handleClick).toHaveBeenCalledOnce(); + }); + + it("applies the destructive variant class", () => { + renderWithProviders(); + expect(screen.getByRole("button")).toHaveClass(/destructive/); + }); + + it("applies the lg size class", () => { + renderWithProviders(); + expect(screen.getByRole("button")).toHaveClass(/h-11/); + }); + + it("disabled prop sets the attribute and prevents click handler", async () => { + const handleClick = vi.fn(); + renderWithProviders( + , + ); + const button = screen.getByRole("button"); + expect(button).toBeDisabled(); + await userEvent.click(button); + expect(handleClick).not.toHaveBeenCalled(); + }); +}); diff --git a/turbo/generators/templates/core-package/ui/src/atoms/button/button.tsx.hbs b/turbo/generators/templates/core-package/ui/src/atoms/button/button.tsx.hbs new file mode 100644 index 0000000..38564ed --- /dev/null +++ b/turbo/generators/templates/core-package/ui/src/atoms/button/button.tsx.hbs @@ -0,0 +1,41 @@ +import { forwardRef, type ButtonHTMLAttributes } from "react"; +import { cn } from "../../lib/utils"; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: "default" | "secondary" | "destructive" | "outline" | "ghost"; + size?: "sm" | "default" | "lg"; +} + +const variantStyles: Record, 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, 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( + ({ className, variant = "default", size = "default", ...props }, ref) => { + return ( +