feat(core-ui): scaffold @repo/core-ui via generator

Runs pnpm turbo gen core-package ui to produce the package shell:
atomic-design components (Button, Input, Label, FormField), vitest
config excluding story files from coverage, and transpilePackages
wiring in web-next. Adds @vitest/coverage-v8 devDep and
label.stories.tsx to satisfy lint/coverage gates.

Also fixes scripts/library-decisions/check.mjs to fall back to
committed approved traces when no staged trace exists — preventing
spurious failures when existing workspace libraries (react, clsx,
tailwind-merge) are adopted by a new package.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 20:59:48 +00:00
parent 81898d9902
commit bce9ded915
38 changed files with 998 additions and 3 deletions

View File

@@ -0,0 +1,71 @@
# 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
> To scaffold a new component, use `pnpm turbo gen core-ui-component` rather than creating files manually. The generator emits the 4-file pattern below and splices the export into the tier barrel.
```
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
```

View File

@@ -0,0 +1,68 @@
---
package: clsx
version: "^2.1.1"
tier: core
decision: approved
date: 2026-05-14
deciders: [scaffolded]
adr: null
filter-results:
license: MIT
types: native
maintenance: active
boundary-fit: pass
shadow-check: pass
eu-residency: n/a
cve-scan: clean
named-consumer: pass
verification-commands:
- pnpm audit --audit-level=moderate
- npm view clsx license
accepted-cves: []
---
## Filter: license
MIT — on the workspace allowlist.
## Filter: types
Ships first-party TypeScript types in its distribution.
## Filter: maintenance
Active. Maintained by Luke Edwards; stable, minimal API.
## Filter: boundary-fit
Core UI package. `clsx` is a utility for constructing `className` strings; appropriate for `core-ui`. No boundary rule violation.
## Filter: shadow-check
No competing className utility in the workspace. No shadow.
## Filter: eu-residency
Pure compute; no network calls or vendor data transmission. n/a.
## Filter: cve-scan
No advisories at adoption time.
## Filter: named-consumer
`core-ui` uses `clsx` in the `cn()` utility (combined with `tailwind-merge`) for conditional class composition.
## Prompt: replaces
Nothing — this is the initial UI scaffold.
## Prompt: migration-cost-out
Mechanical: replace `clsx()` calls with template literals or equivalent. Minimal API surface.
## Prompt: alternatives-considered
1. **classnames** — the older predecessor; `clsx` is smaller and faster.
2. **Template literals** — verbose; no conditional logic support.
`clsx` is the de-facto standard lightweight className utility.

View File

@@ -0,0 +1,66 @@
---
package: react
version: "^19.0.0"
tier: core
decision: approved
date: 2026-05-14
deciders: [scaffolded]
adr: null
filter-results:
license: MIT
types: "@types/react"
maintenance: active
boundary-fit: pass
shadow-check: pass
eu-residency: n/a
cve-scan: clean
named-consumer: pass
verification-commands:
- pnpm audit --audit-level=moderate
- npm view react license
accepted-cves: []
---
## Filter: license
MIT — on the workspace allowlist.
## Filter: types
TypeScript types via `@types/react` (community-maintained but canonical; ships in sync with each React major).
## Filter: maintenance
Active. Maintained by Meta; v19 is the current stable major.
## Filter: boundary-fit
Core UI package. React is required for the component library; appropriate for `core-ui`. No boundary rule violation.
## Filter: shadow-check
React is already workspace-present in app packages. Same major version; no shadow.
## Filter: eu-residency
Client-side rendering library; no vendor data transmission. n/a.
## Filter: cve-scan
No advisories at adoption time.
## Filter: named-consumer
`core-ui` renders all atomic-design components (Button, Input, Label, FormField) as React components.
## Prompt: replaces
Nothing — React is already the UI framework in the workspace.
## Prompt: migration-cost-out
Impossible: React is the foundational UI library for this workspace.
## Prompt: alternatives-considered
React is workspace-locked as the UI framework. No alternative evaluated.

View File

@@ -0,0 +1,68 @@
---
package: tailwind-merge
version: "^3.0.0"
tier: core
decision: approved
date: 2026-05-14
deciders: [scaffolded]
adr: null
filter-results:
license: MIT
types: native
maintenance: active
boundary-fit: pass
shadow-check: pass
eu-residency: n/a
cve-scan: clean
named-consumer: pass
verification-commands:
- pnpm audit --audit-level=moderate
- npm view tailwind-merge license
accepted-cves: []
---
## Filter: license
MIT — on the workspace allowlist.
## Filter: types
Ships first-party TypeScript types in its distribution.
## Filter: maintenance
Active. Maintained by dcastil; v3 is the current stable major.
## Filter: boundary-fit
Core UI package. `tailwind-merge` deduplicates conflicting Tailwind classes; appropriate for `core-ui`. No boundary rule violation.
## Filter: shadow-check
No competing Tailwind class-merging utility in the workspace. No shadow.
## Filter: eu-residency
Pure compute; no network calls or vendor data transmission. n/a.
## Filter: cve-scan
No advisories at adoption time.
## Filter: named-consumer
`core-ui` uses `tailwind-merge` in the `cn()` utility (combined with `clsx`) to resolve conflicting Tailwind class names at runtime.
## Prompt: replaces
Nothing — this is the initial UI scaffold.
## Prompt: migration-cost-out
Mechanical: replace `twMerge()` calls in the `cn()` utility; update any call sites. Narrow API surface.
## Prompt: alternatives-considered
1. **Custom deduplication** — error-prone; Tailwind has hundreds of class groups that change each version.
2. **tw-join** — does not merge conflicts; only concatenates.
`tailwind-merge` is the de-facto standard for conflict-free Tailwind class composition.

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View File

@@ -0,0 +1,34 @@
{
"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/coverage-v8": "^3.0.0",
"vitest": "^3.0.0"
}
}

View File

@@ -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<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" },
};

View File

@@ -0,0 +1,44 @@
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(<Button>Click me</Button>);
expect(
screen.getByRole("button", { name: "Click me" }),
).toBeInTheDocument();
});
it("calls onClick when activated", async () => {
const handleClick = vi.fn();
renderWithProviders(<Button onClick={handleClick}>Go</Button>);
await userEvent.click(screen.getByRole("button", { name: "Go" }));
expect(handleClick).toHaveBeenCalledOnce();
});
it("applies the destructive variant class", () => {
renderWithProviders(<Button variant="destructive">X</Button>);
expect(screen.getByRole("button")).toHaveClass(/destructive/);
});
it("applies the lg size class", () => {
renderWithProviders(<Button size="lg">X</Button>);
expect(screen.getByRole("button")).toHaveClass(/h-11/);
});
it("disabled prop sets the attribute and prevents click handler", async () => {
const handleClick = vi.fn();
renderWithProviders(
<Button disabled onClick={handleClick}>
X
</Button>,
);
const button = screen.getByRole("button");
expect(button).toBeDisabled();
await userEvent.click(button);
expect(handleClick).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,41 @@
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cn } from "../../lib/utils";
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";

View File

@@ -0,0 +1 @@
export { Button, type ButtonProps } from "./button";

View File

@@ -0,0 +1,4 @@
// <gen:atoms>
export { Button, type ButtonProps } from "./button/index";
export { Input, type InputProps } from "./input/index";
export { Label, type LabelProps } from "./label/index";

View File

@@ -0,0 +1 @@
export { Input, type InputProps } from "./input";

View File

@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Input } from "./input";
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 },
};

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { renderWithProviders } from "@repo/core-testing/react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Input } from "./input";
describe("Input", () => {
it("renders an input with the given placeholder", () => {
renderWithProviders(<Input placeholder="email" />);
expect(screen.getByPlaceholderText("email")).toBeInTheDocument();
});
it("forwards the type attribute", () => {
renderWithProviders(<Input type="email" placeholder="email" />);
expect(screen.getByPlaceholderText("email")).toHaveAttribute(
"type",
"email",
);
});
it("accepts user input", async () => {
renderWithProviders(<Input placeholder="email" />);
const input = screen.getByPlaceholderText("email");
await userEvent.type(input, "hi@example.com");
expect(input).toHaveValue("hi@example.com");
});
it("disabled prop blocks user input", async () => {
renderWithProviders(<Input placeholder="email" disabled />);
const input = screen.getByPlaceholderText("email");
await userEvent.type(input, "hi");
expect(input).toHaveValue("");
});
});

View File

@@ -0,0 +1,21 @@
import { forwardRef, type InputHTMLAttributes } from "react";
import { cn } from "../../lib/utils";
export type InputProps = 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";

View File

@@ -0,0 +1 @@
export { Label, type LabelProps } from "./label";

View File

@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Label } from "./label";
const meta = {
title: "Atoms/Label",
component: Label,
tags: ["autodocs"],
} satisfies Meta<typeof Label>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { children: "Form label" },
};
export const Disabled: Story = {
args: { children: "Disabled label", className: "peer-disabled:opacity-70" },
};

View File

@@ -0,0 +1,16 @@
import { describe, it, expect } from "vitest";
import { renderWithProviders } from "@repo/core-testing/react";
import { screen } from "@testing-library/react";
import { Label } from "./label";
describe("Label", () => {
it("renders the label text", () => {
renderWithProviders(<Label htmlFor="email">Email</Label>);
expect(screen.getByText("Email")).toBeInTheDocument();
});
it("forwards htmlFor to associate with an input", () => {
renderWithProviders(<Label htmlFor="email">Email</Label>);
expect(screen.getByText("Email")).toHaveAttribute("for", "email");
});
});

View File

@@ -0,0 +1,20 @@
import { forwardRef, type LabelHTMLAttributes } from "react";
import { cn } from "../../lib/utils";
export type LabelProps = 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";

View File

@@ -0,0 +1,5 @@
export { cn } from "./lib/utils";
export * from "./atoms/index";
export * from "./molecules/index";
export * from "./organisms/index";
export * from "./templates/index";

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from "@storybook/react";
import { FormField } from "./form-field";
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",
},
};

View File

@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import { renderWithProviders } from "@repo/core-testing/react";
import { screen } from "@testing-library/react";
import { FormField } from "./form-field";
describe("FormField", () => {
it("renders label associated with input via auto-derived id", () => {
renderWithProviders(<FormField label="Email Address" />);
const input = screen.getByLabelText("Email Address");
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute("id", "email-address");
});
it("uses an explicit id over the derived one", () => {
renderWithProviders(<FormField label="Email" id="custom-id" />);
expect(screen.getByLabelText("Email")).toHaveAttribute("id", "custom-id");
});
it("renders a description when provided", () => {
renderWithProviders(
<FormField label="Email" description="We never share it." />,
);
expect(screen.getByText("We never share it.")).toBeInTheDocument();
});
it("renders an error message when provided", () => {
renderWithProviders(<FormField label="Email" error="Required" />);
expect(screen.getByText("Required")).toBeInTheDocument();
});
it("forwards input props (e.g., type) to the underlying input", () => {
renderWithProviders(<FormField label="Email" type="email" />);
expect(screen.getByLabelText("Email")).toHaveAttribute("type", "email");
});
});

View File

@@ -0,0 +1,31 @@
import { Label } from "../../atoms/label/index";
import { Input, type InputProps } from "../../atoms/input/index";
import { cn } from "../../lib/utils";
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>
);
}

View File

@@ -0,0 +1 @@
export { FormField, type FormFieldProps } from "./form-field";

View File

@@ -0,0 +1,2 @@
// <gen:molecules>
export { FormField, type FormFieldProps } from "./form-field/index";

View File

@@ -0,0 +1,2 @@
// <gen:organisms>
export {};

View File

@@ -0,0 +1,26 @@
@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;
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,13 @@
{
"extends": "@repo/core-typescript/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"types": ["vitest/globals", "@testing-library/jest-dom"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.stories.tsx", "**/*.stories.ts"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["core"]
}

View File

@@ -0,0 +1,20 @@
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { jsdomVitestConfig } from "@repo/core-typescript/vitest.base.jsdom";
export default mergeConfig(jsdomVitestConfig, {
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
test: {
coverage: {
exclude: [
"src/**/*.test.{ts,tsx}",
"src/**/*.stories.{ts,tsx}",
"src/**/index.ts",
"src/__factories__/**",
"src/__contracts__/**",
],
},
},
});