From bce9ded9150f88b6988d4b44bbd8af79fe19f7ad Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 19 May 2026 20:59:48 +0000 Subject: [PATCH] feat(core-ui): scaffold @repo/core-ui via generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web-next/next.config.mjs | 1 + docs/library-decisions/2026-05-14-clsx.md | 68 ++++++++++++++++++ .../2026-05-14-tailwind-merge.md | 68 ++++++++++++++++++ packages/core-ui/AGENTS.md | 71 +++++++++++++++++++ .../docs/library-decisions/2026-05-14-clsx.md | 68 ++++++++++++++++++ .../library-decisions/2026-05-14-react.md | 66 +++++++++++++++++ .../2026-05-14-tailwind-merge.md | 68 ++++++++++++++++++ packages/core-ui/eslint.config.js | 3 + packages/core-ui/package.json | 34 +++++++++ .../src/atoms/button/button.stories.tsx | 38 ++++++++++ .../core-ui/src/atoms/button/button.test.tsx | 44 ++++++++++++ packages/core-ui/src/atoms/button/button.tsx | 41 +++++++++++ packages/core-ui/src/atoms/button/index.ts | 1 + packages/core-ui/src/atoms/index.ts | 4 ++ packages/core-ui/src/atoms/input/index.ts | 1 + .../core-ui/src/atoms/input/input.stories.tsx | 19 +++++ .../core-ui/src/atoms/input/input.test.tsx | 34 +++++++++ packages/core-ui/src/atoms/input/input.tsx | 21 ++++++ packages/core-ui/src/atoms/label/index.ts | 1 + .../core-ui/src/atoms/label/label.stories.tsx | 19 +++++ .../core-ui/src/atoms/label/label.test.tsx | 16 +++++ packages/core-ui/src/atoms/label/label.tsx | 20 ++++++ packages/core-ui/src/index.ts | 5 ++ packages/core-ui/src/lib/utils.ts | 6 ++ .../form-field/form-field.stories.tsx | 31 ++++++++ .../molecules/form-field/form-field.test.tsx | 35 +++++++++ .../src/molecules/form-field/form-field.tsx | 31 ++++++++ .../core-ui/src/molecules/form-field/index.ts | 1 + packages/core-ui/src/molecules/index.ts | 2 + packages/core-ui/src/organisms/index.ts | 2 + packages/core-ui/src/styles/globals.css | 26 +++++++ packages/core-ui/src/templates/index.ts | 1 + packages/core-ui/tsconfig.json | 13 ++++ packages/core-ui/turbo.json | 4 ++ packages/core-ui/vitest.config.ts | 20 ++++++ pnpm-lock.yaml | 54 ++++++++++++++ scripts/library-decisions/check.mjs | 34 ++++++++- scripts/library-decisions/check.test.mjs | 30 ++++++++ 38 files changed, 998 insertions(+), 3 deletions(-) create mode 100644 docs/library-decisions/2026-05-14-clsx.md create mode 100644 docs/library-decisions/2026-05-14-tailwind-merge.md create mode 100644 packages/core-ui/AGENTS.md create mode 100644 packages/core-ui/docs/library-decisions/2026-05-14-clsx.md create mode 100644 packages/core-ui/docs/library-decisions/2026-05-14-react.md create mode 100644 packages/core-ui/docs/library-decisions/2026-05-14-tailwind-merge.md create mode 100644 packages/core-ui/eslint.config.js create mode 100644 packages/core-ui/package.json create mode 100644 packages/core-ui/src/atoms/button/button.stories.tsx create mode 100644 packages/core-ui/src/atoms/button/button.test.tsx create mode 100644 packages/core-ui/src/atoms/button/button.tsx create mode 100644 packages/core-ui/src/atoms/button/index.ts create mode 100644 packages/core-ui/src/atoms/index.ts create mode 100644 packages/core-ui/src/atoms/input/index.ts create mode 100644 packages/core-ui/src/atoms/input/input.stories.tsx create mode 100644 packages/core-ui/src/atoms/input/input.test.tsx create mode 100644 packages/core-ui/src/atoms/input/input.tsx create mode 100644 packages/core-ui/src/atoms/label/index.ts create mode 100644 packages/core-ui/src/atoms/label/label.stories.tsx create mode 100644 packages/core-ui/src/atoms/label/label.test.tsx create mode 100644 packages/core-ui/src/atoms/label/label.tsx create mode 100644 packages/core-ui/src/index.ts create mode 100644 packages/core-ui/src/lib/utils.ts create mode 100644 packages/core-ui/src/molecules/form-field/form-field.stories.tsx create mode 100644 packages/core-ui/src/molecules/form-field/form-field.test.tsx create mode 100644 packages/core-ui/src/molecules/form-field/form-field.tsx create mode 100644 packages/core-ui/src/molecules/form-field/index.ts create mode 100644 packages/core-ui/src/molecules/index.ts create mode 100644 packages/core-ui/src/organisms/index.ts create mode 100644 packages/core-ui/src/styles/globals.css create mode 100644 packages/core-ui/src/templates/index.ts create mode 100644 packages/core-ui/tsconfig.json create mode 100644 packages/core-ui/turbo.json create mode 100644 packages/core-ui/vitest.config.ts diff --git a/apps/web-next/next.config.mjs b/apps/web-next/next.config.mjs index 6eba1e2..9deda63 100644 --- a/apps/web-next/next.config.mjs +++ b/apps/web-next/next.config.mjs @@ -12,6 +12,7 @@ const nextConfig = { "@repo/core-consent", "@repo/core-dsr", "@repo/core-shared", + "@repo/core-ui", "@repo/marketing-pages", "@repo/media", "@repo/navigation", diff --git a/docs/library-decisions/2026-05-14-clsx.md b/docs/library-decisions/2026-05-14-clsx.md new file mode 100644 index 0000000..239d0a7 --- /dev/null +++ b/docs/library-decisions/2026-05-14-clsx.md @@ -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. diff --git a/docs/library-decisions/2026-05-14-tailwind-merge.md b/docs/library-decisions/2026-05-14-tailwind-merge.md new file mode 100644 index 0000000..e43efd4 --- /dev/null +++ b/docs/library-decisions/2026-05-14-tailwind-merge.md @@ -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. diff --git a/packages/core-ui/AGENTS.md b/packages/core-ui/AGENTS.md new file mode 100644 index 0000000..e8981a4 --- /dev/null +++ b/packages/core-ui/AGENTS.md @@ -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 +``` diff --git a/packages/core-ui/docs/library-decisions/2026-05-14-clsx.md b/packages/core-ui/docs/library-decisions/2026-05-14-clsx.md new file mode 100644 index 0000000..239d0a7 --- /dev/null +++ b/packages/core-ui/docs/library-decisions/2026-05-14-clsx.md @@ -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. diff --git a/packages/core-ui/docs/library-decisions/2026-05-14-react.md b/packages/core-ui/docs/library-decisions/2026-05-14-react.md new file mode 100644 index 0000000..9911012 --- /dev/null +++ b/packages/core-ui/docs/library-decisions/2026-05-14-react.md @@ -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. diff --git a/packages/core-ui/docs/library-decisions/2026-05-14-tailwind-merge.md b/packages/core-ui/docs/library-decisions/2026-05-14-tailwind-merge.md new file mode 100644 index 0000000..e43efd4 --- /dev/null +++ b/packages/core-ui/docs/library-decisions/2026-05-14-tailwind-merge.md @@ -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. diff --git a/packages/core-ui/eslint.config.js b/packages/core-ui/eslint.config.js new file mode 100644 index 0000000..7440d8f --- /dev/null +++ b/packages/core-ui/eslint.config.js @@ -0,0 +1,3 @@ +import baseConfig from "@repo/core-eslint/base"; + +export default baseConfig; diff --git a/packages/core-ui/package.json b/packages/core-ui/package.json new file mode 100644 index 0000000..a790ed5 --- /dev/null +++ b/packages/core-ui/package.json @@ -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" + } +} diff --git a/packages/core-ui/src/atoms/button/button.stories.tsx b/packages/core-ui/src/atoms/button/button.stories.tsx new file mode 100644 index 0000000..094736a --- /dev/null +++ b/packages/core-ui/src/atoms/button/button.stories.tsx @@ -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/packages/core-ui/src/atoms/button/button.test.tsx b/packages/core-ui/src/atoms/button/button.test.tsx new file mode 100644 index 0000000..21e3169 --- /dev/null +++ b/packages/core-ui/src/atoms/button/button.test.tsx @@ -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(); + 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/packages/core-ui/src/atoms/button/button.tsx b/packages/core-ui/src/atoms/button/button.tsx new file mode 100644 index 0000000..f521c0d --- /dev/null +++ b/packages/core-ui/src/atoms/button/button.tsx @@ -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 ( +