diff --git a/apps/web/package.json b/apps/web/package.json
index 19005cc..e056c7d 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,36 +1,37 @@
{
- "name": "@tpmjs/web",
- "version": "0.0.0",
- "private": true,
- "scripts": {
- "dev": "next dev",
- "build": "next build",
- "start": "next start",
- "lint": "next lint",
- "type-check": "tsc --noEmit",
- "clean": "rm -rf .next .turbo"
- },
- "dependencies": {
- "@tpmjs/env": "workspace:*",
- "@tpmjs/types": "workspace:*",
- "@tpmjs/ui": "workspace:*",
- "@tpmjs/utils": "workspace:*",
- "next": "^16.0.4",
- "react": "^19.0.0",
- "react-dom": "^19.0.0",
- "zod": "^3.24.1"
- },
- "devDependencies": {
- "@tpmjs/eslint-config": "workspace:*",
- "@tpmjs/tailwind-config": "workspace:*",
- "@tpmjs/tsconfig": "workspace:*",
- "@types/node": "^22.10.2",
- "@types/react": "^19.0.2",
- "@types/react-dom": "^19.0.2",
- "autoprefixer": "^10.4.20",
- "eslint-config-next": "^16.0.4",
- "postcss": "^8.5.1",
- "tailwindcss": "^3.4.17",
- "typescript": "^5.9.3"
- }
+ "name": "@tpmjs/web",
+ "version": "0.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "type-check": "tsc --noEmit",
+ "clean": "rm -rf .next .turbo"
+ },
+ "dependencies": {
+ "@tpmjs/env": "workspace:*",
+ "@tpmjs/types": "workspace:*",
+ "@tpmjs/ui": "workspace:*",
+ "@tpmjs/utils": "workspace:*",
+ "next": "^16.0.4",
+ "next-themes": "^0.4.6",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "zod": "^3.24.1"
+ },
+ "devDependencies": {
+ "@tpmjs/eslint-config": "workspace:*",
+ "@tpmjs/tailwind-config": "workspace:*",
+ "@tpmjs/tsconfig": "workspace:*",
+ "@types/node": "^22.10.2",
+ "@types/react": "^19.0.2",
+ "@types/react-dom": "^19.0.2",
+ "autoprefixer": "^10.4.20",
+ "eslint-config-next": "^16.0.4",
+ "postcss": "^8.5.1",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.9.3"
+ }
}
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 5cf7456..4254162 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -3,28 +3,285 @@
@tailwind utilities;
@layer base {
+ /* Light mode (default) */
:root {
- --background: 0 0% 100%;
- --foreground: 222.2 84% 4.9%;
- --card: 0 0% 100%;
- --card-foreground: 222.2 84% 4.9%;
- --primary: 222.2 47.4% 11.2%;
- --primary-foreground: 210 40% 98%;
- --secondary: 210 40% 96.1%;
- --secondary-foreground: 222.2 47.4% 11.2%;
- --muted: 210 40% 96.1%;
- --muted-foreground: 215.4 16.3% 46.9%;
- --accent: 210 40% 96.1%;
- --accent-foreground: 222.2 47.4% 11.2%;
- --destructive: 0 84.2% 60.2%;
+ /* Backgrounds & Surfaces */
+ --background: 0 0% 100%; /* Pure white */
+ --surface: 210 20% 98%; /* Off-white */
+ --surface-elevated: 0 0% 100%; /* White (elevated) */
+ --surface-overlay: 0 0% 98%; /* Light gray */
+
+ /* Foreground (Text) */
+ --foreground: 222 47% 11%; /* Almost black */
+ --foreground-secondary: 215 16% 47%; /* Medium gray */
+ --foreground-tertiary: 215 16% 65%; /* Light gray */
+ --foreground-muted: 215 16% 75%; /* Very light gray */
+
+ /* Borders */
+ --border: 214 32% 91%; /* Light gray */
+ --border-strong: 214 32% 80%; /* Medium gray */
+ --border-subtle: 214 20% 95%; /* Very light gray */
+
+ /* Interactive States */
+ --primary: 222 47% 11%; /* Dark for light mode */
+ --primary-foreground: 210 40% 98%; /* Light text */
+ --secondary: 210 40% 96%; /* Light secondary */
+ --secondary-foreground: 222 47% 11%; /* Dark text */
+ --accent: 210 40% 96%; /* Accent bg */
+ --accent-foreground: 222 47% 11%; /* Accent text */
+ --muted: 210 40% 96%; /* Muted bg */
+ --muted-foreground: 215 16% 47%; /* Muted text */
+
+ /* Status Colors */
+ --success: 142 71% 45%;
+ --success-foreground: 142 76% 15%;
+ --error: 0 72% 51%;
+ --error-foreground: 0 86% 17%;
+ --warning: 38 92% 50%;
+ --warning-foreground: 48 96% 19%;
+ --info: 217 91% 60%;
+ --info-foreground: 214 95% 23%;
+
+ /* Destructive (legacy) */
+ --destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%;
- --border: 214.3 31.8% 91.4%;
- --input: 214.3 31.8% 91.4%;
- --ring: 222.2 84% 4.9%;
- --radius: 0.5rem;
+
+ /* Form Elements */
+ --input: 214 32% 91%;
+ --ring: 222 84% 5%;
+ --ring-offset: 0 0% 100%;
+
+ /* Grid/Blueprint Pattern */
+ --grid-color: 214 32% 95%;
+ --grid-size: 24px; /* Grid cell size */
+
+ /* Card */
+ --card: 0 0% 100%;
+ --card-foreground: 222 47% 11%;
+
+ /* Border Radii */
+ --radius-sm: 4px;
+ --radius-md: 6px;
+ --radius-lg: 8px;
+ --radius-xl: 12px;
+ --radius: var(--radius-md);
+
+ /* Typography */
+ --font-sans: 'Geist', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
+ --font-mono: 'Geist Mono', 'SF Mono', Consolas, monospace;
+ }
+
+ /* Dark mode (opt-in) - Vercel/Cursor/Perplexity aesthetic */
+ .dark {
+ /* Backgrounds & Surfaces */
+ --background: 210 10% 5%; /* #0d0d0f - Almost black */
+ --surface: 210 10% 8%; /* #14141a - Slightly lighter */
+ --surface-elevated: 210 10% 12%; /* #1c1c24 - Cards, modals */
+ --surface-overlay: 210 10% 16%; /* #25252f - Overlays, popovers */
+
+ /* Foreground (Text) */
+ --foreground: 210 10% 90%; /* #e3e3e8 - Primary text */
+ --foreground-secondary: 210 8% 60%; /* #949499 - Secondary text */
+ --foreground-tertiary: 210 6% 40%; /* #64646a - Tertiary text */
+ --foreground-muted: 210 6% 30%; /* #4a4a50 - Muted text */
+
+ /* Borders (1px, subtle) */
+ --border: 210 10% 20%; /* #30303a - Default border */
+ --border-strong: 210 12% 30%; /* #494954 - Emphasized border */
+ --border-subtle: 210 8% 15%; /* #22222c - Subtle border */
+
+ /* Interactive States */
+ --primary: 210 10% 98%; /* White-ish for dark mode */
+ --primary-foreground: 210 10% 5%; /* Dark text on light bg */
+ --secondary: 210 10% 15%; /* Dark secondary */
+ --secondary-foreground: 210 10% 90%; /* Light text on dark bg */
+ --accent: 210 10% 18%; /* Accent background */
+ --accent-foreground: 210 10% 95%; /* Accent text */
+ --muted: 210 10% 12%; /* Muted background */
+ --muted-foreground: 210 8% 60%; /* Muted text */
+
+ /* Status Colors (desaturated for dark mode) */
+ --success: 142 71% 45%; /* #10b981 - Green */
+ --success-foreground: 142 76% 95%; /* Light green text */
+ --error: 0 72% 51%; /* #ef4444 - Red */
+ --error-foreground: 0 86% 97%; /* Light red text */
+ --warning: 38 92% 50%; /* #f59e0b - Amber */
+ --warning-foreground: 48 96% 89%; /* Light amber text */
+ --info: 217 91% 60%; /* #3b82f6 - Blue */
+ --info-foreground: 214 95% 93%; /* Light blue text */
+
+ /* Destructive (legacy support) */
+ --destructive: 0 72% 51%;
+ --destructive-foreground: 0 86% 97%;
+
+ /* Form Elements */
+ --input: 210 10% 20%; /* Input border */
+ --ring: 210 10% 90%; /* Focus ring */
+ --ring-offset: 210 10% 5%; /* Focus ring offset */
+
+ /* Grid/Blueprint Pattern */
+ --grid-color: 210 10% 15%; /* Grid line color */
+
+ /* Card */
+ --card: 210 10% 8%;
+ --card-foreground: 210 10% 90%;
+ }
+
+ /* Base element styles */
+ * {
+ @apply border-border;
+ }
+
+ body {
+ @apply bg-background text-foreground;
+ font-family: var(--font-sans);
+ font-feature-settings: 'rlig' 1, 'calt' 1;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+
+ /* Grid/Blueprint background patterns */
+ .grid-background {
+ background-image:
+ linear-gradient(hsl(var(--grid-color)) 1px, transparent 1px),
+ linear-gradient(90deg, hsl(var(--grid-color)) 1px, transparent 1px);
+ background-size: var(--grid-size) var(--grid-size);
+ background-position: -1px -1px;
+ }
+
+ .dotted-grid-background {
+ background-image:
+ radial-gradient(circle, hsl(var(--grid-color)) 1px, transparent 1px);
+ background-size: var(--grid-size) var(--grid-size);
+ }
+
+ .blueprint-background {
+ background-color: hsl(var(--background));
+ background-image:
+ /* Major grid lines (4x size) */
+ linear-gradient(hsl(var(--grid-color)) 1px, transparent 1px),
+ linear-gradient(90deg, hsl(var(--grid-color)) 1px, transparent 1px),
+ /* Minor grid lines (1x size) */
+ linear-gradient(hsl(var(--grid-color) / 0.3) 1px, transparent 1px),
+ linear-gradient(90deg, hsl(var(--grid-color) / 0.3) 1px, transparent 1px);
+ background-size:
+ calc(var(--grid-size) * 4) calc(var(--grid-size) * 4),
+ calc(var(--grid-size) * 4) calc(var(--grid-size) * 4),
+ var(--grid-size) var(--grid-size),
+ var(--grid-size) var(--grid-size);
+ }
+
+ /* Animated grid (subtle pulse) */
+ .grid-background-animated {
+ @apply grid-background;
+ animation: grid-pulse 8s ease-in-out infinite;
+ }
+
+ @keyframes grid-pulse {
+ 0%, 100% { opacity: 0.4; }
+ 50% { opacity: 0.6; }
}
}
-body {
- @apply bg-background text-foreground;
+@layer utilities {
+ /* Border utilities */
+ .border-1 {
+ border-width: 1px;
+ }
+
+ .border-dotted-1 {
+ border-width: 1px;
+ border-style: dotted;
+ }
+
+ .border-dashed-1 {
+ border-width: 1px;
+ border-style: dashed;
+ }
+
+ /* Directional dotted borders */
+ .border-t-dotted {
+ border-top-width: 1px;
+ border-top-style: dotted;
+ }
+
+ .border-b-dotted {
+ border-bottom-width: 1px;
+ border-bottom-style: dotted;
+ }
+
+ .border-l-dotted {
+ border-left-width: 1px;
+ border-left-style: dotted;
+ }
+
+ .border-r-dotted {
+ border-right-width: 1px;
+ border-right-style: dotted;
+ }
+
+ /* Transition presets */
+ .transition-base {
+ transition-property: background-color, border-color, color, fill, stroke;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 200ms;
+ }
+
+ .transition-transform {
+ transition-property: transform;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 200ms;
+ }
+
+ .transition-all-smooth {
+ transition-property: all;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 300ms;
+ }
+
+ /* Hover effects */
+ .hover-lift {
+ @apply transition-transform hover:-translate-y-0.5 hover:shadow-md;
+ }
+
+ /* Focus ring (modern, accessible) */
+ .focus-ring {
+ @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background;
+ }
+
+ /* Custom animations */
+ @keyframes slide-up {
+ from {
+ transform: translateY(10px);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+ }
+
+ @keyframes shimmer {
+ 0% {
+ background-position: -1000px 0;
+ }
+ 100% {
+ background-position: 1000px 0;
+ }
+ }
+
+ .animate-slide-up {
+ animation: slide-up 300ms cubic-bezier(0.4, 0, 0.2, 1);
+ }
+
+ .animate-shimmer {
+ animation: shimmer 2s infinite linear;
+ background: linear-gradient(
+ 90deg,
+ hsl(var(--muted)) 0%,
+ hsl(var(--accent)) 50%,
+ hsl(var(--muted)) 100%
+ );
+ background-size: 1000px 100%;
+ }
}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 0575b4d..15c805b 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -1,19 +1,30 @@
-import type { Metadata } from 'next';
-import './globals.css';
+import type { Metadata } from "next";
+import { ThemeProvider } from "../components/providers/ThemeProvider";
+import "./globals.css";
export const metadata: Metadata = {
- title: 'TPMJS - Tool Package Manager for AI Agents',
- description: 'The registry for AI tools. Discover, share, and integrate tools that give your agents superpowers.',
+ title: "TPMJS - Tool Package Manager for AI Agents",
+ description:
+ "The registry for AI tools. Discover, share, and integrate tools that give your agents superpowers.",
};
export default function RootLayout({
- children,
+ children,
}: {
- children: React.ReactNode;
+ children: React.ReactNode;
}): React.ReactElement {
- return (
-
-
{children}
-
- );
+ return (
+
+
+
+ {children}
+
+
+
+ );
}
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index 2805671..3532566 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -1,38 +1,319 @@
-import { Button } from '@tpmjs/ui/Button/Button';
-import { Card, CardHeader, CardTitle, CardContent } from '@tpmjs/ui/Card/Card';
-import { createElement } from 'react';
+import { Badge } from "@tpmjs/ui/Badge/Badge";
+import { Button } from "@tpmjs/ui/Button/Button";
+import { Card, CardContent } from "@tpmjs/ui/Card/Card";
+import { Container } from "@tpmjs/ui/Container/Container";
+import { Header } from "@tpmjs/ui/Header/Header";
+import { Icon } from "@tpmjs/ui/Icon/Icon";
+import { Input } from "@tpmjs/ui/Input/Input";
+import { createElement } from "react";
+import { ThemeToggle } from "../components/ThemeToggle";
+import { categories, featuredTools, statistics } from "../data/homePageData";
export default function HomePage(): React.ReactElement {
- return (
-
-
-
TPMJS
-
- Tool Package Manager for AI Agents
-
+ return createElement(
+ "div",
+ { className: "min-h-screen flex flex-col" },
+ // Header
+ createElement(Header, {
+ title: "TPMJS",
+ size: "md",
+ sticky: true,
+ actions: createElement(
+ "div",
+ { className: "flex items-center gap-4" },
+ createElement(Button, { variant: "ghost", size: "sm" }, "Pro"),
+ createElement(Button, { variant: "ghost", size: "sm" }, "Teams"),
+ createElement(Button, { variant: "ghost", size: "sm" }, "Pricing"),
+ createElement(
+ Button,
+ { variant: "ghost", size: "sm" },
+ "Documentation",
+ ),
+ createElement(Button, { variant: "outline", size: "sm" }, "Sign In"),
+ createElement(Button, { size: "sm" }, "Sign Up"),
+ createElement(ThemeToggle, {}),
+ ),
+ }),
- {createElement(
- Card,
- {},
- createElement(
- CardHeader,
- {},
- createElement(CardTitle, {}, 'Welcome to TPMJS')
- ),
- createElement(
- CardContent,
- { className: 'space-y-4' },
- createElement('p', {},
- 'The registry for AI tools. Discover, share, and integrate tools that give your agents superpowers.'
- ),
- createElement(
- Button,
- { className: 'w-full' },
- 'Get Started'
- )
- )
- )}
-
-
- );
+ createElement(
+ "main",
+ { className: "flex-1" },
+ // Hero Section
+ createElement(
+ "section",
+ { className: "relative py-24 overflow-hidden" },
+ createElement("div", {
+ className:
+ "absolute inset-0 bg-gradient-to-r from-orange-500 via-amber-500 to-red-500 opacity-10",
+ }),
+ createElement(
+ Container,
+ { size: "xl", padding: "lg" },
+ createElement(
+ "div",
+ {
+ className:
+ "max-w-3xl mx-auto text-center space-y-8 relative z-10",
+ },
+ createElement(
+ "h1",
+ { className: "text-5xl md:text-6xl font-bold tracking-tight" },
+ "Tool Registry for AI Agents",
+ ),
+ createElement(
+ "p",
+ { className: "text-xl text-foreground-secondary" },
+ "Discover, share, and integrate tools that give your agents superpowers. The registry for AI tools.",
+ ),
+ createElement(
+ "div",
+ { className: "flex gap-3 items-center max-w-2xl mx-auto" },
+ createElement(Input, {
+ size: "lg",
+ placeholder: "Search tools...",
+ className: "flex-1",
+ }),
+ createElement(
+ Button,
+ { size: "lg", className: "px-8" },
+ "Search",
+ ),
+ ),
+ ),
+ ),
+ ),
+
+ // Featured Tools Section
+ createElement(
+ "section",
+ { className: "py-16 bg-surface" },
+ createElement(
+ Container,
+ { size: "xl", padding: "lg" },
+ createElement(
+ "div",
+ { className: "flex items-center justify-between mb-8" },
+ createElement(
+ "h2",
+ { className: "text-3xl font-semibold" },
+ "Featured Tools",
+ ),
+ createElement(Button, { variant: "ghost" }, "View all tools →"),
+ ),
+ createElement(
+ "div",
+ {
+ className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6",
+ },
+ ...featuredTools.map((tool) =>
+ createElement(
+ Card,
+ {
+ key: tool.id,
+ className: "hover:shadow-lg transition-shadow",
+ },
+ createElement(
+ CardContent,
+ { className: "p-6 space-y-4" },
+ createElement(
+ "div",
+ { className: "flex items-start gap-4" },
+ createElement(Icon, {
+ icon: tool.icon,
+ size: "lg",
+ className: "text-primary",
+ }),
+ createElement(
+ "div",
+ { className: "flex-1 space-y-2" },
+ createElement(
+ "h3",
+ { className: "text-lg font-semibold" },
+ tool.name,
+ ),
+ createElement(
+ "p",
+ { className: "text-sm text-foreground-secondary" },
+ tool.description,
+ ),
+ createElement(
+ "div",
+ { className: "flex items-center gap-2" },
+ createElement(
+ Badge,
+ { variant: tool.categoryVariant, size: "sm" },
+ tool.category,
+ ),
+ createElement(
+ "span",
+ { className: "text-xs text-foreground-tertiary" },
+ tool.weeklyUsage,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+
+ // Categories Section
+ createElement(
+ "section",
+ { className: "py-16" },
+ createElement(
+ Container,
+ { size: "xl", padding: "lg" },
+ createElement(
+ "h2",
+ { className: "text-3xl font-semibold mb-8" },
+ "Browse by Category",
+ ),
+ createElement(
+ "div",
+ {
+ className: "grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4",
+ },
+ ...categories.map((category) =>
+ createElement(
+ Card,
+ {
+ key: category.id,
+ className: "hover:shadow-lg transition-shadow cursor-pointer",
+ },
+ createElement(
+ CardContent,
+ { className: `p-6 ${category.colorClass}` },
+ createElement(
+ "div",
+ { className: "space-y-3" },
+ createElement(Icon, {
+ icon: category.icon,
+ size: "lg",
+ }),
+ createElement(
+ "h3",
+ { className: "font-semibold" },
+ category.name,
+ ),
+ createElement(
+ "p",
+ { className: "text-sm opacity-90" },
+ `${category.toolCount} tools`,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+
+ // Statistics Section
+ createElement(
+ "section",
+ { className: "py-16 bg-surface" },
+ createElement(
+ Container,
+ { size: "xl", padding: "lg" },
+ createElement(
+ "h2",
+ { className: "text-3xl font-semibold mb-8 text-center" },
+ "Platform Statistics",
+ ),
+ createElement(
+ "div",
+ {
+ className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6",
+ },
+ ...statistics.map((stat, index) =>
+ createElement(
+ Card,
+ { key: index },
+ createElement(
+ CardContent,
+ { className: "p-6 text-center space-y-3" },
+ createElement(Icon, {
+ icon: stat.icon,
+ size: "lg",
+ className: "mx-auto text-primary",
+ }),
+ createElement(
+ "div",
+ { className: "text-4xl font-bold" },
+ stat.value,
+ ),
+ createElement(
+ "div",
+ { className: "text-sm font-medium" },
+ stat.label,
+ ),
+ stat.subtext &&
+ createElement(
+ "div",
+ { className: "text-xs text-foreground-tertiary" },
+ stat.subtext,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+
+ // Footer
+ createElement(
+ "footer",
+ { className: "py-8 border-t border-border bg-surface" },
+ createElement(
+ Container,
+ { size: "xl", padding: "lg" },
+ createElement(
+ "div",
+ {
+ className:
+ "flex flex-col md:flex-row items-center justify-between gap-4",
+ },
+ createElement(
+ "p",
+ { className: "text-sm text-foreground-secondary" },
+ "© 2025 TPMJS. All rights reserved.",
+ ),
+ createElement(
+ "div",
+ { className: "flex items-center gap-4 text-sm" },
+ createElement(
+ "a",
+ {
+ href: "#",
+ className: "text-foreground-secondary hover:text-foreground",
+ },
+ "Privacy",
+ ),
+ createElement("span", { className: "text-border" }, "·"),
+ createElement(
+ "a",
+ {
+ href: "#",
+ className: "text-foreground-secondary hover:text-foreground",
+ },
+ "Terms",
+ ),
+ createElement("span", { className: "text-border" }, "·"),
+ createElement(
+ "a",
+ {
+ href: "#",
+ className: "text-foreground-secondary hover:text-foreground",
+ },
+ "Contact",
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
}
diff --git a/apps/web/src/app/tool/tool-search/page.tsx b/apps/web/src/app/tool/tool-search/page.tsx
new file mode 100644
index 0000000..2f3ea1d
--- /dev/null
+++ b/apps/web/src/app/tool/tool-search/page.tsx
@@ -0,0 +1,304 @@
+"use client";
+
+import { Badge } from "@tpmjs/ui/Badge/Badge";
+import { Button } from "@tpmjs/ui/Button/Button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@tpmjs/ui/Card/Card";
+import { CodeBlock } from "@tpmjs/ui/CodeBlock/CodeBlock";
+import { Container } from "@tpmjs/ui/Container/Container";
+import { Header } from "@tpmjs/ui/Header/Header";
+import { Icon } from "@tpmjs/ui/Icon/Icon";
+import { Input } from "@tpmjs/ui/Input/Input";
+import { ProgressBar } from "@tpmjs/ui/ProgressBar/ProgressBar";
+import { Tabs } from "@tpmjs/ui/Tabs/Tabs";
+import { createElement, useState } from "react";
+import { getFeaturedTools, mockTools } from "../../../data/toolData";
+
+/**
+ * Tool Registry Search Page
+ *
+ * Demonstrates all UI components in a realistic tool registry layout.
+ * Built with .ts-only React using createElement.
+ */
+export default function ToolSearchPage(): React.ReactElement {
+ const [activeTab, setActiveTab] = useState("all");
+ const [searchQuery, setSearchQuery] = useState("");
+
+ // Get tools based on active tab
+ const displayedTools =
+ activeTab === "featured" ? getFeaturedTools() : mockTools;
+
+ // Filter by search query
+ const filteredTools = searchQuery
+ ? displayedTools.filter(
+ (tool) =>
+ tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ tool.description.toLowerCase().includes(searchQuery.toLowerCase()),
+ )
+ : displayedTools;
+
+ return createElement("div", { className: "min-h-screen bg-black" }, [
+ // Header
+ createElement(Header, {
+ key: "header",
+ title: createElement("div", { className: "flex items-center gap-2" }, [
+ createElement(
+ "span",
+ { key: "title", className: "text-2xl font-bold" },
+ "TPMJS",
+ ),
+ createElement(
+ Badge,
+ { key: "badge", variant: "outline", size: "sm" },
+ "Beta",
+ ),
+ ]),
+ actions: createElement("div", { className: "flex items-center gap-3" }, [
+ createElement(
+ Button,
+ { key: "docs", variant: "ghost", size: "sm" },
+ "Docs",
+ ),
+ createElement(
+ "a",
+ {
+ key: "github",
+ href: "https://github.com/tpmjs/tpmjs",
+ target: "_blank",
+ rel: "noopener noreferrer",
+ className: "text-zinc-400 hover:text-zinc-100 transition-colors",
+ },
+ createElement(Icon, { icon: "github", size: "md" }),
+ ),
+ createElement(
+ Button,
+ { key: "publish", variant: "default", size: "sm" },
+ "Publish Tool",
+ ),
+ ]),
+ sticky: true,
+ size: "md",
+ }),
+
+ // Main content
+ createElement(
+ Container,
+ { key: "container", size: "xl", padding: "md", className: "py-8" },
+ [
+ // Page header
+ createElement(
+ "div",
+ { key: "page-header", className: "space-y-4 mb-8" },
+ [
+ createElement(
+ "h1",
+ { key: "title", className: "text-4xl font-bold text-zinc-100" },
+ "Tool Registry",
+ ),
+ createElement(
+ "p",
+ { key: "description", className: "text-lg text-zinc-400" },
+ "Discover, share, and integrate tools that give your AI agents superpowers.",
+ ),
+ ],
+ ),
+
+ // Search input
+ createElement(Input, {
+ key: "search",
+ placeholder: "Search tools...",
+ value: searchQuery,
+ onChange: (e) => setSearchQuery(e.target.value),
+ className: "mb-6",
+ }),
+
+ // Tabs
+ createElement(Tabs, {
+ key: "tabs",
+ tabs: [
+ { id: "all", label: "All Tools", count: mockTools.length },
+ {
+ id: "featured",
+ label: "Featured",
+ count: getFeaturedTools().length,
+ },
+ ],
+ activeTab,
+ onTabChange: setActiveTab,
+ size: "md",
+ className: "mb-8",
+ }),
+
+ // Tool grid
+ createElement(
+ "div",
+ {
+ key: "tools-grid",
+ className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6",
+ },
+ filteredTools.length > 0
+ ? filteredTools.map((tool) =>
+ createElement(
+ Card,
+ { key: tool.id, className: "flex flex-col" },
+ [
+ createElement(CardHeader, { key: "header" }, [
+ createElement(
+ "div",
+ {
+ key: "title-row",
+ className: "flex items-start justify-between gap-2",
+ },
+ [
+ createElement(CardTitle, { key: "title" }, tool.name),
+ createElement(
+ "a",
+ {
+ key: "link",
+ href: tool.repository,
+ target: "_blank",
+ rel: "noopener noreferrer",
+ className:
+ "text-zinc-400 hover:text-zinc-100 transition-colors",
+ },
+ createElement(Icon, {
+ icon: "externalLink",
+ size: "sm",
+ }),
+ ),
+ ],
+ ),
+ createElement(
+ CardDescription,
+ { key: "description" },
+ tool.description,
+ ),
+ ]),
+ createElement(
+ CardContent,
+ { key: "content", className: "flex-1 space-y-4" },
+ [
+ // Category badge
+ createElement(
+ "div",
+ {
+ key: "category",
+ className: "flex items-center gap-2",
+ },
+ [
+ createElement(
+ Badge,
+ {
+ key: "badge",
+ variant: "secondary",
+ size: "sm",
+ },
+ tool.category,
+ ),
+ createElement(
+ "span",
+ {
+ key: "version",
+ className: "text-xs text-zinc-500",
+ },
+ `v${tool.version}`,
+ ),
+ ],
+ ),
+
+ // Tags
+ createElement(
+ "div",
+ { key: "tags", className: "flex flex-wrap gap-2" },
+ tool.tags.map((tag) =>
+ createElement(
+ Badge,
+ { key: tag, variant: "outline", size: "sm" },
+ tag,
+ ),
+ ),
+ ),
+
+ // Usage progress
+ createElement(
+ "div",
+ { key: "usage", className: "space-y-2" },
+ [
+ createElement(
+ "div",
+ {
+ key: "label",
+ className:
+ "flex items-center justify-between text-sm",
+ },
+ [
+ createElement(
+ "span",
+ { key: "text", className: "text-zinc-400" },
+ "Usage",
+ ),
+ createElement(
+ "span",
+ { key: "stars", className: "text-zinc-500" },
+ `⭐ ${tool.stars.toLocaleString()}`,
+ ),
+ ],
+ ),
+ createElement(ProgressBar, {
+ key: "progress",
+ value: tool.usage,
+ variant:
+ tool.usage >= 70
+ ? "success"
+ : tool.usage >= 50
+ ? "primary"
+ : "warning",
+ size: "sm",
+ showLabel: true,
+ }),
+ ],
+ ),
+
+ // Install command
+ createElement(CodeBlock, {
+ key: "install",
+ code: tool.installCommand,
+ language: "bash",
+ size: "sm",
+ showCopy: true,
+ }),
+ ],
+ ),
+ createElement(
+ CardFooter,
+ { key: "footer" },
+ createElement(
+ Button,
+ { variant: "outline", size: "sm", className: "w-full" },
+ "View Details",
+ ),
+ ),
+ ],
+ ),
+ )
+ : [
+ createElement(
+ "div",
+ {
+ key: "no-results",
+ className: "col-span-full text-center py-12 text-zinc-500",
+ },
+ `No tools found matching "${searchQuery}"`,
+ ),
+ ],
+ ),
+ ],
+ ),
+ ]);
+}
diff --git a/apps/web/src/components/ThemeToggle.tsx b/apps/web/src/components/ThemeToggle.tsx
new file mode 100644
index 0000000..d9dfd24
--- /dev/null
+++ b/apps/web/src/components/ThemeToggle.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { Button } from "@tpmjs/ui/Button/Button";
+import { Icon } from "@tpmjs/ui/Icon/Icon";
+import { useTheme } from "next-themes";
+import { createElement, useEffect, useState } from "react";
+
+export function ThemeToggle(): React.ReactElement {
+ const { theme, setTheme } = useTheme();
+ const [mounted, setMounted] = useState(false);
+
+ // useEffect only runs on the client, so we can safely show the UI
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ if (!mounted) {
+ // Return a placeholder with the same dimensions to avoid layout shift
+ return createElement(Button, {
+ variant: "ghost",
+ size: "sm",
+ className: "w-9 h-9 p-0",
+ "aria-label": "Toggle theme",
+ });
+ }
+
+ const isDark = theme === "dark";
+
+ return createElement(
+ Button,
+ {
+ variant: "ghost",
+ size: "sm",
+ className: "w-9 h-9 p-0",
+ onClick: () => setTheme(isDark ? "light" : "dark"),
+ "aria-label": `Switch to ${isDark ? "light" : "dark"} mode`,
+ title: `Switch to ${isDark ? "light" : "dark"} mode`,
+ },
+ createElement(Icon, {
+ icon: isDark ? "sun" : "moon",
+ size: "md",
+ className: "transition-transform duration-300",
+ }),
+ );
+}
diff --git a/apps/web/src/components/providers/ThemeProvider.tsx b/apps/web/src/components/providers/ThemeProvider.tsx
new file mode 100644
index 0000000..39ccc70
--- /dev/null
+++ b/apps/web/src/components/providers/ThemeProvider.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import { ThemeProvider as NextThemesProvider } from "next-themes";
+import { type ComponentProps, createElement } from "react";
+
+export function ThemeProvider({
+ children,
+ ...props
+}: ComponentProps) {
+ return createElement(NextThemesProvider, props, children);
+}
diff --git a/apps/web/src/data/homePageData.ts b/apps/web/src/data/homePageData.ts
new file mode 100644
index 0000000..4736148
--- /dev/null
+++ b/apps/web/src/data/homePageData.ts
@@ -0,0 +1,230 @@
+import type { IconName } from "@tpmjs/ui/Icon/Icon";
+
+export interface ToolCard {
+ id: string;
+ name: string;
+ icon: IconName;
+ description: string;
+ category: string;
+ categoryVariant:
+ | "default"
+ | "secondary"
+ | "outline"
+ | "success"
+ | "error"
+ | "warning"
+ | "info";
+ weeklyUsage: string;
+ href: string;
+}
+
+export interface Category {
+ id: string;
+ name: string;
+ icon: IconName;
+ colorClass: string;
+ toolCount: number;
+ href: string;
+}
+
+export interface Statistic {
+ icon: IconName;
+ value: string;
+ label: string;
+ subtext?: string;
+}
+
+export const featuredTools: ToolCard[] = [
+ {
+ id: "tool-search",
+ name: "tool-search",
+ icon: "check",
+ description:
+ "Meta-tool that lets AI agents discover and load tools on-demand using semantic search.",
+ category: "Meta-Tools",
+ categoryVariant: "warning",
+ weeklyUsage: "847K/week",
+ href: "/tool/tool-search",
+ },
+ {
+ id: "web-scraper",
+ name: "web-scraper",
+ icon: "externalLink",
+ description:
+ "Extract structured data from any website with automatic schema detection and pagination support.",
+ category: "Web & APIs",
+ categoryVariant: "info",
+ weeklyUsage: "524K/week",
+ href: "/tool/web-scraper",
+ },
+ {
+ id: "github-manager",
+ name: "github-manager",
+ icon: "github",
+ description:
+ "Manage GitHub repositories, issues, pull requests, and workflows programmatically.",
+ category: "Code & Git",
+ categoryVariant: "default",
+ weeklyUsage: "392K/week",
+ href: "/tool/github-manager",
+ },
+ {
+ id: "sql-query",
+ name: "sql-query",
+ icon: "copy",
+ description:
+ "Execute SQL queries across PostgreSQL, MySQL, and SQLite databases with connection pooling.",
+ category: "Databases",
+ categoryVariant: "success",
+ weeklyUsage: "287K/week",
+ href: "/tool/sql-query",
+ },
+ {
+ id: "pdf-parser",
+ name: "pdf-parser",
+ icon: "copy",
+ description:
+ "Extract text, tables, and metadata from PDF documents with OCR support for scanned pages.",
+ category: "Documents",
+ categoryVariant: "outline",
+ weeklyUsage: "156K/week",
+ href: "/tool/pdf-parser",
+ },
+ {
+ id: "email-sender",
+ name: "email-sender",
+ icon: "check",
+ description:
+ "Send transactional emails with templates, attachments, and delivery tracking.",
+ category: "Email",
+ categoryVariant: "secondary",
+ weeklyUsage: "203K/week",
+ href: "/tool/email-sender",
+ },
+];
+
+export const categories: Category[] = [
+ {
+ id: "web-apis",
+ name: "Web & APIs",
+ icon: "externalLink",
+ colorClass: "bg-blue-500 text-white",
+ toolCount: 423,
+ href: "/category/web-apis",
+ },
+ {
+ id: "databases",
+ name: "Databases",
+ icon: "copy",
+ colorClass: "bg-emerald-500 text-white",
+ toolCount: 198,
+ href: "/category/databases",
+ },
+ {
+ id: "documents",
+ name: "Documents",
+ icon: "copy",
+ colorClass: "bg-amber-500 text-white",
+ toolCount: 156,
+ href: "/category/documents",
+ },
+ {
+ id: "images",
+ name: "Images",
+ icon: "check",
+ colorClass: "bg-pink-500 text-white",
+ toolCount: 134,
+ href: "/category/images",
+ },
+ {
+ id: "email",
+ name: "Email",
+ icon: "check",
+ colorClass: "bg-indigo-500 text-white",
+ toolCount: 89,
+ href: "/category/email",
+ },
+ {
+ id: "calendar",
+ name: "Calendar",
+ icon: "check",
+ colorClass: "bg-orange-500 text-white",
+ toolCount: 67,
+ href: "/category/calendar",
+ },
+ {
+ id: "search",
+ name: "Search",
+ icon: "check",
+ colorClass: "bg-red-500 text-white",
+ toolCount: 112,
+ href: "/category/search",
+ },
+ {
+ id: "code-execution",
+ name: "Code Execution",
+ icon: "github",
+ colorClass: "bg-zinc-700 text-white",
+ toolCount: 245,
+ href: "/category/code-execution",
+ },
+ {
+ id: "communication",
+ name: "Communication",
+ icon: "check",
+ colorClass: "bg-cyan-500 text-white",
+ toolCount: 178,
+ href: "/category/communication",
+ },
+ {
+ id: "analytics",
+ name: "Analytics",
+ icon: "check",
+ colorClass: "bg-violet-500 text-white",
+ toolCount: 203,
+ href: "/category/analytics",
+ },
+ {
+ id: "security",
+ name: "Security",
+ icon: "check",
+ colorClass: "bg-slate-600 text-white",
+ toolCount: 91,
+ href: "/category/security",
+ },
+ {
+ id: "workflows",
+ name: "Workflows",
+ icon: "check",
+ colorClass: "bg-teal-500 text-white",
+ toolCount: 167,
+ href: "/category/workflows",
+ },
+];
+
+export const statistics: Statistic[] = [
+ {
+ icon: "copy",
+ value: "2,847",
+ label: "Published Tools",
+ subtext: "Across 24 categories",
+ },
+ {
+ icon: "github",
+ value: "48K+",
+ label: "Active Developers",
+ subtext: "Building with TPMJS",
+ },
+ {
+ icon: "check",
+ value: "12M+",
+ label: "Weekly Invocations",
+ subtext: "Across all tools",
+ },
+ {
+ icon: "chevronDown",
+ value: "47ms",
+ label: "Average Response",
+ subtext: "95th percentile latency",
+ },
+];
diff --git a/apps/web/src/data/toolData.ts b/apps/web/src/data/toolData.ts
new file mode 100644
index 0000000..e1ad7ec
--- /dev/null
+++ b/apps/web/src/data/toolData.ts
@@ -0,0 +1,238 @@
+/**
+ * Mock data for tool registry
+ * Represents tools available in the TPMJS registry
+ */
+
+export interface Tool {
+ id: string;
+ name: string;
+ description: string;
+ category: string;
+ installCommand: string;
+ usage: number; // 0-100 percentage
+ stars: number;
+ version: string;
+ tags: string[];
+ homepage?: string;
+ repository?: string;
+}
+
+export const toolCategories = [
+ "Web & APIs",
+ "Databases",
+ "Documents",
+ "Images",
+ "Email",
+ "Calendar",
+ "Search",
+ "Code Execution",
+ "Communication",
+ "Analytics",
+ "Security",
+ "Workflows",
+] as const;
+
+export const mockTools: Tool[] = [
+ {
+ id: "web-fetch",
+ name: "web-fetch",
+ description:
+ "Fetch and parse web content with automatic HTML-to-markdown conversion",
+ category: "Web & APIs",
+ installCommand: "npm install @tpmjs/web-fetch",
+ usage: 87,
+ stars: 12453,
+ version: "2.1.4",
+ tags: ["http", "scraping", "markdown"],
+ homepage: "https://tpmjs.com/tools/web-fetch",
+ repository: "https://github.com/tpmjs/web-fetch",
+ },
+ {
+ id: "database-query",
+ name: "database-query",
+ description:
+ "Execute SQL queries across multiple database engines with type-safe results",
+ category: "Databases",
+ installCommand: "npm install @tpmjs/database-query",
+ usage: 72,
+ stars: 8921,
+ version: "3.0.1",
+ tags: ["sql", "postgres", "mysql", "sqlite"],
+ homepage: "https://tpmjs.com/tools/database-query",
+ repository: "https://github.com/tpmjs/database-query",
+ },
+ {
+ id: "pdf-extract",
+ name: "pdf-extract",
+ description:
+ "Extract text, images, and metadata from PDF documents with OCR support",
+ category: "Documents",
+ installCommand: "npm install @tpmjs/pdf-extract",
+ usage: 65,
+ stars: 7832,
+ version: "1.9.2",
+ tags: ["pdf", "ocr", "document-processing"],
+ homepage: "https://tpmjs.com/tools/pdf-extract",
+ repository: "https://github.com/tpmjs/pdf-extract",
+ },
+ {
+ id: "image-transform",
+ name: "image-transform",
+ description:
+ "Resize, crop, and optimize images with automatic format conversion",
+ category: "Images",
+ installCommand: "npm install @tpmjs/image-transform",
+ usage: 58,
+ stars: 6745,
+ version: "2.4.0",
+ tags: ["images", "resize", "optimization"],
+ homepage: "https://tpmjs.com/tools/image-transform",
+ repository: "https://github.com/tpmjs/image-transform",
+ },
+ {
+ id: "email-send",
+ name: "email-send",
+ description:
+ "Send transactional emails with template support and delivery tracking",
+ category: "Email",
+ installCommand: "npm install @tpmjs/email-send",
+ usage: 51,
+ stars: 5623,
+ version: "1.7.3",
+ tags: ["email", "smtp", "templates"],
+ homepage: "https://tpmjs.com/tools/email-send",
+ repository: "https://github.com/tpmjs/email-send",
+ },
+ {
+ id: "calendar-sync",
+ name: "calendar-sync",
+ description:
+ "Sync events across Google Calendar, Outlook, and Apple Calendar",
+ category: "Calendar",
+ installCommand: "npm install @tpmjs/calendar-sync",
+ usage: 43,
+ stars: 4891,
+ version: "2.0.5",
+ tags: ["calendar", "events", "sync"],
+ homepage: "https://tpmjs.com/tools/calendar-sync",
+ repository: "https://github.com/tpmjs/calendar-sync",
+ },
+ {
+ id: "semantic-search",
+ name: "semantic-search",
+ description:
+ "Vector-based semantic search with support for embeddings and similarity",
+ category: "Search",
+ installCommand: "npm install @tpmjs/semantic-search",
+ usage: 79,
+ stars: 11234,
+ version: "3.2.1",
+ tags: ["search", "embeddings", "vector", "ai"],
+ homepage: "https://tpmjs.com/tools/semantic-search",
+ repository: "https://github.com/tpmjs/semantic-search",
+ },
+ {
+ id: "code-execute",
+ name: "code-execute",
+ description:
+ "Safely execute code in sandboxed environments with timeout controls",
+ category: "Code Execution",
+ installCommand: "npm install @tpmjs/code-execute",
+ usage: 68,
+ stars: 9567,
+ version: "1.5.8",
+ tags: ["sandbox", "execution", "security"],
+ homepage: "https://tpmjs.com/tools/code-execute",
+ repository: "https://github.com/tpmjs/code-execute",
+ },
+ {
+ id: "slack-notify",
+ name: "slack-notify",
+ description:
+ "Send rich notifications to Slack channels with interactive components",
+ category: "Communication",
+ installCommand: "npm install @tpmjs/slack-notify",
+ usage: 54,
+ stars: 6234,
+ version: "2.3.2",
+ tags: ["slack", "notifications", "webhooks"],
+ homepage: "https://tpmjs.com/tools/slack-notify",
+ repository: "https://github.com/tpmjs/slack-notify",
+ },
+ {
+ id: "analytics-track",
+ name: "analytics-track",
+ description:
+ "Track user events and metrics across multiple analytics platforms",
+ category: "Analytics",
+ installCommand: "npm install @tpmjs/analytics-track",
+ usage: 61,
+ stars: 7123,
+ version: "1.8.4",
+ tags: ["analytics", "tracking", "metrics"],
+ homepage: "https://tpmjs.com/tools/analytics-track",
+ repository: "https://github.com/tpmjs/analytics-track",
+ },
+ {
+ id: "secret-vault",
+ name: "secret-vault",
+ description:
+ "Securely store and retrieve secrets with encryption and access control",
+ category: "Security",
+ installCommand: "npm install @tpmjs/secret-vault",
+ usage: 76,
+ stars: 10432,
+ version: "2.6.0",
+ tags: ["security", "secrets", "encryption"],
+ homepage: "https://tpmjs.com/tools/secret-vault",
+ repository: "https://github.com/tpmjs/secret-vault",
+ },
+ {
+ id: "workflow-orchestrate",
+ name: "workflow-orchestrate",
+ description:
+ "Orchestrate complex workflows with conditional logic and error handling",
+ category: "Workflows",
+ installCommand: "npm install @tpmjs/workflow-orchestrate",
+ usage: 47,
+ stars: 5789,
+ version: "3.1.2",
+ tags: ["workflows", "orchestration", "automation"],
+ homepage: "https://tpmjs.com/tools/workflow-orchestrate",
+ repository: "https://github.com/tpmjs/workflow-orchestrate",
+ },
+];
+
+/**
+ * Get tools by category
+ */
+export function getToolsByCategory(category: string): Tool[] {
+ return mockTools.filter((tool) => tool.category === category);
+}
+
+/**
+ * Get featured tools (top 6 by usage)
+ */
+export function getFeaturedTools(): Tool[] {
+ return [...mockTools].sort((a, b) => b.usage - a.usage).slice(0, 6);
+}
+
+/**
+ * Get tool by ID
+ */
+export function getToolById(id: string): Tool | undefined {
+ return mockTools.find((tool) => tool.id === id);
+}
+
+/**
+ * Search tools by name or description
+ */
+export function searchTools(query: string): Tool[] {
+ const lowerQuery = query.toLowerCase();
+ return mockTools.filter(
+ (tool) =>
+ tool.name.toLowerCase().includes(lowerQuery) ||
+ tool.description.toLowerCase().includes(lowerQuery) ||
+ tool.tags.some((tag) => tag.toLowerCase().includes(lowerQuery)),
+ );
+}
diff --git a/packages/config/tailwind/base.ts b/packages/config/tailwind/base.ts
index 11d29b3..cd1f673 100644
--- a/packages/config/tailwind/base.ts
+++ b/packages/config/tailwind/base.ts
@@ -1,42 +1,150 @@
-import type { Config } from 'tailwindcss';
+import type { Config } from "tailwindcss";
export default {
- content: [],
- theme: {
- extend: {
- colors: {
- border: 'hsl(var(--border))',
- input: 'hsl(var(--input))',
- ring: 'hsl(var(--ring))',
- background: 'hsl(var(--background))',
- foreground: 'hsl(var(--foreground))',
- primary: {
- DEFAULT: 'hsl(var(--primary))',
- foreground: 'hsl(var(--primary-foreground))',
- },
- secondary: {
- DEFAULT: 'hsl(var(--secondary))',
- foreground: 'hsl(var(--secondary-foreground))',
- },
- destructive: {
- DEFAULT: 'hsl(var(--destructive))',
- foreground: 'hsl(var(--destructive-foreground))',
- },
- muted: {
- DEFAULT: 'hsl(var(--muted))',
- foreground: 'hsl(var(--muted-foreground))',
- },
- accent: {
- DEFAULT: 'hsl(var(--accent))',
- foreground: 'hsl(var(--accent-foreground))',
- },
- },
- borderRadius: {
- lg: 'var(--radius)',
- md: 'calc(var(--radius) - 2px)',
- sm: 'calc(var(--radius) - 4px)',
- },
- },
- },
- plugins: [],
+ content: [],
+ theme: {
+ extend: {
+ colors: {
+ // Backgrounds & Surfaces
+ background: "hsl(var(--background))",
+ surface: "hsl(var(--surface))",
+ "surface-elevated": "hsl(var(--surface-elevated))",
+ "surface-overlay": "hsl(var(--surface-overlay))",
+
+ // Foreground (Text)
+ foreground: {
+ DEFAULT: "hsl(var(--foreground))",
+ secondary: "hsl(var(--foreground-secondary))",
+ tertiary: "hsl(var(--foreground-tertiary))",
+ muted: "hsl(var(--foreground-muted))",
+ },
+
+ // Borders
+ border: {
+ DEFAULT: "hsl(var(--border))",
+ strong: "hsl(var(--border-strong))",
+ subtle: "hsl(var(--border-subtle))",
+ },
+
+ // Interactive States
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+
+ // Status Colors
+ success: {
+ DEFAULT: "hsl(var(--success))",
+ foreground: "hsl(var(--success-foreground))",
+ },
+ error: {
+ DEFAULT: "hsl(var(--error))",
+ foreground: "hsl(var(--error-foreground))",
+ },
+ warning: {
+ DEFAULT: "hsl(var(--warning))",
+ foreground: "hsl(var(--warning-foreground))",
+ },
+ info: {
+ DEFAULT: "hsl(var(--info))",
+ foreground: "hsl(var(--info-foreground))",
+ },
+
+ // Destructive (legacy support)
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+
+ // Form Elements
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+
+ // Card
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+
+ // Grid/Blueprint
+ grid: "hsl(var(--grid-color))",
+ },
+
+ borderRadius: {
+ sm: "var(--radius-sm)",
+ md: "var(--radius-md)",
+ lg: "var(--radius-lg)",
+ xl: "var(--radius-xl)",
+ },
+
+ fontFamily: {
+ sans: "var(--font-sans)",
+ mono: "var(--font-mono)",
+ },
+
+ keyframes: {
+ "slide-up": {
+ from: { transform: "translateY(10px)", opacity: "0" },
+ to: { transform: "translateY(0)", opacity: "1" },
+ },
+ "slide-down": {
+ from: { transform: "translateY(-10px)", opacity: "0" },
+ to: { transform: "translateY(0)", opacity: "1" },
+ },
+ "slide-left": {
+ from: { transform: "translateX(10px)", opacity: "0" },
+ to: { transform: "translateX(0)", opacity: "1" },
+ },
+ "slide-right": {
+ from: { transform: "translateX(-10px)", opacity: "0" },
+ to: { transform: "translateX(0)", opacity: "1" },
+ },
+ "scale-in": {
+ from: { transform: "scale(0.95)", opacity: "0" },
+ to: { transform: "scale(1)", opacity: "1" },
+ },
+ "scale-out": {
+ from: { transform: "scale(1)", opacity: "1" },
+ to: { transform: "scale(0.95)", opacity: "0" },
+ },
+ shimmer: {
+ "0%": { backgroundPosition: "-1000px 0" },
+ "100%": { backgroundPosition: "1000px 0" },
+ },
+ pulse: {
+ "0%, 100%": { opacity: "1" },
+ "50%": { opacity: "0.5" },
+ },
+ "grid-pulse": {
+ "0%, 100%": { opacity: "0.4" },
+ "50%": { opacity: "0.6" },
+ },
+ },
+
+ animation: {
+ "slide-up": "slide-up 300ms cubic-bezier(0.4, 0, 0.2, 1)",
+ "slide-down": "slide-down 300ms cubic-bezier(0.4, 0, 0.2, 1)",
+ "slide-left": "slide-left 300ms cubic-bezier(0.4, 0, 0.2, 1)",
+ "slide-right": "slide-right 300ms cubic-bezier(0.4, 0, 0.2, 1)",
+ "scale-in": "scale-in 200ms cubic-bezier(0.4, 0, 0.2, 1)",
+ "scale-out": "scale-out 200ms cubic-bezier(0.4, 0, 0.2, 1)",
+ shimmer: "shimmer 2s infinite linear",
+ pulse: "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
+ "grid-pulse": "grid-pulse 8s ease-in-out infinite",
+ },
+ },
+ },
+ plugins: [],
} satisfies Config;
diff --git a/packages/storybook/stories/Badge.stories.ts b/packages/storybook/stories/Badge.stories.ts
new file mode 100644
index 0000000..91704eb
--- /dev/null
+++ b/packages/storybook/stories/Badge.stories.ts
@@ -0,0 +1,54 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Badge } from "@tpmjs/ui/Badge/Badge";
+import { createElement } from "react";
+
+const meta = {
+ title: "Components/Badge",
+ component: Badge,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "centered",
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => createElement(Badge, {}, "Default"),
+};
+
+export const AllVariants: Story = {
+ render: () =>
+ createElement(
+ "div",
+ {
+ className: "flex flex-wrap items-center gap-3",
+ },
+ createElement(Badge, { variant: "default" }, "Default"),
+ createElement(Badge, { variant: "secondary" }, "Secondary"),
+ createElement(Badge, { variant: "outline" }, "Outline"),
+ createElement(Badge, { variant: "success" }, "Success"),
+ createElement(Badge, { variant: "error" }, "Error"),
+ createElement(Badge, { variant: "warning" }, "Warning"),
+ createElement(Badge, { variant: "info" }, "Info"),
+ ),
+};
+
+export const AllSizes: Story = {
+ render: () =>
+ createElement(
+ "div",
+ {
+ className: "flex items-center gap-3",
+ },
+ createElement(Badge, { size: "sm" }, "Small"),
+ createElement(Badge, { size: "md" }, "Medium"),
+ createElement(Badge, { size: "lg" }, "Large"),
+ ),
+};
+
+export const WithLongText: Story = {
+ render: () =>
+ createElement(Badge, {}, "This is a very long badge with lots of text"),
+};
diff --git a/packages/storybook/stories/CodeBlock.stories.ts b/packages/storybook/stories/CodeBlock.stories.ts
new file mode 100644
index 0000000..d0e8008
--- /dev/null
+++ b/packages/storybook/stories/CodeBlock.stories.ts
@@ -0,0 +1,79 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { CodeBlock } from "@tpmjs/ui/CodeBlock/CodeBlock";
+import { createElement } from "react";
+
+const meta = {
+ title: "Components/CodeBlock",
+ component: CodeBlock,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "centered",
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () =>
+ createElement(CodeBlock, {
+ code: `console.log('Hello, World!');`,
+ }),
+};
+
+export const BashCommand: Story = {
+ render: () =>
+ createElement(CodeBlock, {
+ code: "npm install @tpmjs/ui",
+ language: "bash",
+ }),
+};
+
+export const TypeScriptCode: Story = {
+ render: () =>
+ createElement(CodeBlock, {
+ code: `import { Button } from '@tpmjs/ui/Button/Button';
+import { createElement } from 'react';
+
+export function MyComponent() {
+ return createElement(Button, {
+ variant: 'outline',
+ onClick: () => console.log('clicked'),
+ }, 'Click me');
+}`,
+ language: "typescript",
+ }),
+};
+
+export const JSONData: Story = {
+ render: () =>
+ createElement(CodeBlock, {
+ code: `{
+ "name": "@tpmjs/ui",
+ "version": "0.0.1",
+ "description": "UI components for TPMJS"
+}`,
+ language: "json",
+ }),
+};
+
+export const LongCode: Story = {
+ render: () =>
+ createElement(
+ "div",
+ { className: "w-96" },
+ createElement(CodeBlock, {
+ code: `export const buttonVariants = createVariants({
+ base: 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2',
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+ destructive: 'bg-error text-error-foreground hover:bg-error/90',
+ outline: 'border border-border bg-background hover:bg-accent hover:text-accent-foreground',
+ },
+ },
+});`,
+ language: "typescript",
+ }),
+ ),
+};
diff --git a/packages/storybook/stories/Icon.stories.ts b/packages/storybook/stories/Icon.stories.ts
new file mode 100644
index 0000000..9afa905
--- /dev/null
+++ b/packages/storybook/stories/Icon.stories.ts
@@ -0,0 +1,102 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Icon } from "@tpmjs/ui/Icon/Icon";
+import { createElement } from "react";
+
+const meta = {
+ title: "Components/Icon",
+ component: Icon,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "centered",
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const AllIcons: Story = {
+ render: () =>
+ createElement(
+ "div",
+ {
+ className: "grid grid-cols-4 gap-8 p-8",
+ },
+ ...(
+ [
+ "copy",
+ "externalLink",
+ "github",
+ "check",
+ "x",
+ "chevronDown",
+ "sun",
+ "moon",
+ ] as const
+ ).map((iconName) =>
+ createElement(
+ "div",
+ {
+ key: iconName,
+ className: "flex flex-col items-center gap-2",
+ },
+ createElement(Icon, { icon: iconName, size: "lg" }),
+ createElement(
+ "span",
+ { className: "text-sm text-foreground-secondary" },
+ iconName,
+ ),
+ ),
+ ),
+ ),
+};
+
+export const AllSizes: Story = {
+ render: () =>
+ createElement(
+ "div",
+ {
+ className: "flex items-center gap-6",
+ },
+ ...(["sm", "md", "lg"] as const).map((size) =>
+ createElement(
+ "div",
+ {
+ key: size,
+ className: "flex flex-col items-center gap-2",
+ },
+ createElement(Icon, { icon: "github", size }),
+ createElement(
+ "span",
+ { className: "text-sm text-foreground-secondary" },
+ size,
+ ),
+ ),
+ ),
+ ),
+};
+
+export const WithColors: Story = {
+ render: () =>
+ createElement(
+ "div",
+ {
+ className: "flex items-center gap-6",
+ },
+ createElement(Icon, {
+ icon: "check",
+ size: "lg",
+ className: "text-success",
+ }),
+ createElement(Icon, { icon: "x", size: "lg", className: "text-error" }),
+ createElement(Icon, {
+ icon: "github",
+ size: "lg",
+ className: "text-info",
+ }),
+ createElement(Icon, {
+ icon: "sun",
+ size: "lg",
+ className: "text-warning",
+ }),
+ ),
+};
diff --git a/packages/storybook/stories/Input.stories.ts b/packages/storybook/stories/Input.stories.ts
new file mode 100644
index 0000000..8d5f474
--- /dev/null
+++ b/packages/storybook/stories/Input.stories.ts
@@ -0,0 +1,94 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Input } from "@tpmjs/ui/Input/Input";
+import { Label } from "@tpmjs/ui/Label/Label";
+import { createElement } from "react";
+
+const meta = {
+ title: "Components/Input",
+ component: Input,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "centered",
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => createElement(Input, { placeholder: "Enter text..." }),
+};
+
+export const WithLabel: Story = {
+ render: () =>
+ createElement(
+ "div",
+ { className: "flex flex-col gap-2 w-80" },
+ createElement(Label, { htmlFor: "email" }, "Email address"),
+ createElement(Input, {
+ id: "email",
+ type: "email",
+ placeholder: "name@example.com",
+ }),
+ ),
+};
+
+export const AllStates: Story = {
+ render: () =>
+ createElement(
+ "div",
+ { className: "flex flex-col gap-4 w-80" },
+ createElement(
+ "div",
+ { className: "flex flex-col gap-2" },
+ createElement(Label, {}, "Normal"),
+ createElement(Input, { placeholder: "Normal state" }),
+ ),
+ createElement(
+ "div",
+ { className: "flex flex-col gap-2" },
+ createElement(Label, {}, "Success"),
+ createElement(Input, {
+ placeholder: "Success state",
+ state: "success",
+ }),
+ ),
+ createElement(
+ "div",
+ { className: "flex flex-col gap-2" },
+ createElement(Label, {}, "Error"),
+ createElement(Input, { placeholder: "Error state", state: "error" }),
+ ),
+ createElement(
+ "div",
+ { className: "flex flex-col gap-2" },
+ createElement(Label, {}, "Disabled"),
+ createElement(Input, { placeholder: "Disabled state", disabled: true }),
+ ),
+ ),
+};
+
+export const AllSizes: Story = {
+ render: () =>
+ createElement(
+ "div",
+ { className: "flex flex-col gap-4 w-80" },
+ createElement(Input, { size: "sm", placeholder: "Small size" }),
+ createElement(Input, { size: "md", placeholder: "Medium size" }),
+ createElement(Input, { size: "lg", placeholder: "Large size" }),
+ ),
+};
+
+export const PasswordInput: Story = {
+ render: () =>
+ createElement(
+ "div",
+ { className: "flex flex-col gap-2 w-80" },
+ createElement(Label, { htmlFor: "password" }, "Password"),
+ createElement(Input, {
+ id: "password",
+ type: "password",
+ placeholder: "Enter password",
+ }),
+ ),
+};
diff --git a/packages/storybook/stories/ProgressBar.stories.ts b/packages/storybook/stories/ProgressBar.stories.ts
new file mode 100644
index 0000000..36e570e
--- /dev/null
+++ b/packages/storybook/stories/ProgressBar.stories.ts
@@ -0,0 +1,74 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { ProgressBar } from "@tpmjs/ui/ProgressBar/ProgressBar";
+import { createElement } from "react";
+
+const meta = {
+ title: "Components/ProgressBar",
+ component: ProgressBar,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "centered",
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () =>
+ createElement(
+ "div",
+ { className: "w-80" },
+ createElement(ProgressBar, { value: 50 }),
+ ),
+};
+
+export const AllVariants: Story = {
+ render: () =>
+ createElement(
+ "div",
+ { className: "flex flex-col gap-4 w-80" },
+ createElement(ProgressBar, { variant: "primary", value: 75 }),
+ createElement(ProgressBar, { variant: "success", value: 85 }),
+ createElement(ProgressBar, { variant: "warning", value: 60 }),
+ createElement(ProgressBar, { variant: "danger", value: 30 }),
+ ),
+};
+
+export const AllSizes: Story = {
+ render: () =>
+ createElement(
+ "div",
+ { className: "flex flex-col gap-4 w-80" },
+ createElement(ProgressBar, { size: "sm", value: 65 }),
+ createElement(ProgressBar, { size: "md", value: 65 }),
+ createElement(ProgressBar, { size: "lg", value: 65 }),
+ ),
+};
+
+export const WithLabel: Story = {
+ render: () =>
+ createElement(
+ "div",
+ { className: "w-80" },
+ createElement(ProgressBar, { value: 75, showLabel: true }),
+ ),
+};
+
+export const ZeroPercent: Story = {
+ render: () =>
+ createElement(
+ "div",
+ { className: "w-80" },
+ createElement(ProgressBar, { value: 0, showLabel: true }),
+ ),
+};
+
+export const HundredPercent: Story = {
+ render: () =>
+ createElement(
+ "div",
+ { className: "w-80" },
+ createElement(ProgressBar, { value: 100, showLabel: true }),
+ ),
+};
diff --git a/packages/storybook/stories/Tabs.stories.ts b/packages/storybook/stories/Tabs.stories.ts
new file mode 100644
index 0000000..bdb66d5
--- /dev/null
+++ b/packages/storybook/stories/Tabs.stories.ts
@@ -0,0 +1,91 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Tabs } from "@tpmjs/ui/Tabs/Tabs";
+import type { Tab } from "@tpmjs/ui/Tabs/types";
+import { createElement } from "react";
+
+const meta = {
+ title: "Components/Tabs",
+ component: Tabs,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "centered",
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const mockTabs: Tab[] = [
+ { id: "overview", label: "Overview" },
+ { id: "usage", label: "Usage" },
+ { id: "api", label: "API" },
+];
+
+export const Default: Story = {
+ render: () =>
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "overview",
+ onTabChange: (id: string) => console.log("Tab changed:", id),
+ }),
+};
+
+export const AllSizes: Story = {
+ render: () =>
+ createElement(
+ "div",
+ { className: "flex flex-col gap-8 w-full" },
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "overview",
+ size: "sm",
+ onTabChange: () => {},
+ }),
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "overview",
+ size: "md",
+ onTabChange: () => {},
+ }),
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "overview",
+ size: "lg",
+ onTabChange: () => {},
+ }),
+ ),
+};
+
+export const WithCounts: Story = {
+ render: () =>
+ createElement(Tabs, {
+ tabs: [
+ { id: "all", label: "All Tools", count: 1234 },
+ { id: "featured", label: "Featured", count: 42 },
+ { id: "recent", label: "Recent", count: 18 },
+ ],
+ activeTab: "all",
+ onTabChange: () => {},
+ }),
+};
+
+export const ManyTabs: Story = {
+ render: () =>
+ createElement(
+ "div",
+ { className: "w-96" },
+ createElement(Tabs, {
+ tabs: [
+ { id: "tab1", label: "Overview" },
+ { id: "tab2", label: "Documentation" },
+ { id: "tab3", label: "Examples" },
+ { id: "tab4", label: "API Reference" },
+ { id: "tab5", label: "Configuration" },
+ { id: "tab6", label: "Changelog" },
+ { id: "tab7", label: "Contributing" },
+ ],
+ activeTab: "tab1",
+ onTabChange: () => {},
+ }),
+ ),
+};
diff --git a/packages/storybook/styles/globals.css b/packages/storybook/styles/globals.css
index ed3b6aa..4254162 100644
--- a/packages/storybook/styles/globals.css
+++ b/packages/storybook/styles/globals.css
@@ -3,24 +3,285 @@
@tailwind utilities;
@layer base {
+ /* Light mode (default) */
:root {
- --background: 0 0% 100%;
- --foreground: 222.2 84% 4.9%;
- --card: 0 0% 100%;
- --card-foreground: 222.2 84% 4.9%;
- --primary: 222.2 47.4% 11.2%;
- --primary-foreground: 210 40% 98%;
- --secondary: 210 40% 96.1%;
- --secondary-foreground: 222.2 47.4% 11.2%;
- --muted: 210 40% 96.1%;
- --muted-foreground: 215.4 16.3% 46.9%;
- --accent: 210 40% 96.1%;
- --accent-foreground: 222.2 47.4% 11.2%;
- --destructive: 0 84.2% 60.2%;
+ /* Backgrounds & Surfaces */
+ --background: 0 0% 100%; /* Pure white */
+ --surface: 210 20% 98%; /* Off-white */
+ --surface-elevated: 0 0% 100%; /* White (elevated) */
+ --surface-overlay: 0 0% 98%; /* Light gray */
+
+ /* Foreground (Text) */
+ --foreground: 222 47% 11%; /* Almost black */
+ --foreground-secondary: 215 16% 47%; /* Medium gray */
+ --foreground-tertiary: 215 16% 65%; /* Light gray */
+ --foreground-muted: 215 16% 75%; /* Very light gray */
+
+ /* Borders */
+ --border: 214 32% 91%; /* Light gray */
+ --border-strong: 214 32% 80%; /* Medium gray */
+ --border-subtle: 214 20% 95%; /* Very light gray */
+
+ /* Interactive States */
+ --primary: 222 47% 11%; /* Dark for light mode */
+ --primary-foreground: 210 40% 98%; /* Light text */
+ --secondary: 210 40% 96%; /* Light secondary */
+ --secondary-foreground: 222 47% 11%; /* Dark text */
+ --accent: 210 40% 96%; /* Accent bg */
+ --accent-foreground: 222 47% 11%; /* Accent text */
+ --muted: 210 40% 96%; /* Muted bg */
+ --muted-foreground: 215 16% 47%; /* Muted text */
+
+ /* Status Colors */
+ --success: 142 71% 45%;
+ --success-foreground: 142 76% 15%;
+ --error: 0 72% 51%;
+ --error-foreground: 0 86% 17%;
+ --warning: 38 92% 50%;
+ --warning-foreground: 48 96% 19%;
+ --info: 217 91% 60%;
+ --info-foreground: 214 95% 23%;
+
+ /* Destructive (legacy) */
+ --destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%;
- --border: 214.3 31.8% 91.4%;
- --input: 214.3 31.8% 91.4%;
- --ring: 222.2 84% 4.9%;
- --radius: 0.5rem;
+
+ /* Form Elements */
+ --input: 214 32% 91%;
+ --ring: 222 84% 5%;
+ --ring-offset: 0 0% 100%;
+
+ /* Grid/Blueprint Pattern */
+ --grid-color: 214 32% 95%;
+ --grid-size: 24px; /* Grid cell size */
+
+ /* Card */
+ --card: 0 0% 100%;
+ --card-foreground: 222 47% 11%;
+
+ /* Border Radii */
+ --radius-sm: 4px;
+ --radius-md: 6px;
+ --radius-lg: 8px;
+ --radius-xl: 12px;
+ --radius: var(--radius-md);
+
+ /* Typography */
+ --font-sans: 'Geist', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
+ --font-mono: 'Geist Mono', 'SF Mono', Consolas, monospace;
+ }
+
+ /* Dark mode (opt-in) - Vercel/Cursor/Perplexity aesthetic */
+ .dark {
+ /* Backgrounds & Surfaces */
+ --background: 210 10% 5%; /* #0d0d0f - Almost black */
+ --surface: 210 10% 8%; /* #14141a - Slightly lighter */
+ --surface-elevated: 210 10% 12%; /* #1c1c24 - Cards, modals */
+ --surface-overlay: 210 10% 16%; /* #25252f - Overlays, popovers */
+
+ /* Foreground (Text) */
+ --foreground: 210 10% 90%; /* #e3e3e8 - Primary text */
+ --foreground-secondary: 210 8% 60%; /* #949499 - Secondary text */
+ --foreground-tertiary: 210 6% 40%; /* #64646a - Tertiary text */
+ --foreground-muted: 210 6% 30%; /* #4a4a50 - Muted text */
+
+ /* Borders (1px, subtle) */
+ --border: 210 10% 20%; /* #30303a - Default border */
+ --border-strong: 210 12% 30%; /* #494954 - Emphasized border */
+ --border-subtle: 210 8% 15%; /* #22222c - Subtle border */
+
+ /* Interactive States */
+ --primary: 210 10% 98%; /* White-ish for dark mode */
+ --primary-foreground: 210 10% 5%; /* Dark text on light bg */
+ --secondary: 210 10% 15%; /* Dark secondary */
+ --secondary-foreground: 210 10% 90%; /* Light text on dark bg */
+ --accent: 210 10% 18%; /* Accent background */
+ --accent-foreground: 210 10% 95%; /* Accent text */
+ --muted: 210 10% 12%; /* Muted background */
+ --muted-foreground: 210 8% 60%; /* Muted text */
+
+ /* Status Colors (desaturated for dark mode) */
+ --success: 142 71% 45%; /* #10b981 - Green */
+ --success-foreground: 142 76% 95%; /* Light green text */
+ --error: 0 72% 51%; /* #ef4444 - Red */
+ --error-foreground: 0 86% 97%; /* Light red text */
+ --warning: 38 92% 50%; /* #f59e0b - Amber */
+ --warning-foreground: 48 96% 89%; /* Light amber text */
+ --info: 217 91% 60%; /* #3b82f6 - Blue */
+ --info-foreground: 214 95% 93%; /* Light blue text */
+
+ /* Destructive (legacy support) */
+ --destructive: 0 72% 51%;
+ --destructive-foreground: 0 86% 97%;
+
+ /* Form Elements */
+ --input: 210 10% 20%; /* Input border */
+ --ring: 210 10% 90%; /* Focus ring */
+ --ring-offset: 210 10% 5%; /* Focus ring offset */
+
+ /* Grid/Blueprint Pattern */
+ --grid-color: 210 10% 15%; /* Grid line color */
+
+ /* Card */
+ --card: 210 10% 8%;
+ --card-foreground: 210 10% 90%;
+ }
+
+ /* Base element styles */
+ * {
+ @apply border-border;
+ }
+
+ body {
+ @apply bg-background text-foreground;
+ font-family: var(--font-sans);
+ font-feature-settings: 'rlig' 1, 'calt' 1;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+
+ /* Grid/Blueprint background patterns */
+ .grid-background {
+ background-image:
+ linear-gradient(hsl(var(--grid-color)) 1px, transparent 1px),
+ linear-gradient(90deg, hsl(var(--grid-color)) 1px, transparent 1px);
+ background-size: var(--grid-size) var(--grid-size);
+ background-position: -1px -1px;
+ }
+
+ .dotted-grid-background {
+ background-image:
+ radial-gradient(circle, hsl(var(--grid-color)) 1px, transparent 1px);
+ background-size: var(--grid-size) var(--grid-size);
+ }
+
+ .blueprint-background {
+ background-color: hsl(var(--background));
+ background-image:
+ /* Major grid lines (4x size) */
+ linear-gradient(hsl(var(--grid-color)) 1px, transparent 1px),
+ linear-gradient(90deg, hsl(var(--grid-color)) 1px, transparent 1px),
+ /* Minor grid lines (1x size) */
+ linear-gradient(hsl(var(--grid-color) / 0.3) 1px, transparent 1px),
+ linear-gradient(90deg, hsl(var(--grid-color) / 0.3) 1px, transparent 1px);
+ background-size:
+ calc(var(--grid-size) * 4) calc(var(--grid-size) * 4),
+ calc(var(--grid-size) * 4) calc(var(--grid-size) * 4),
+ var(--grid-size) var(--grid-size),
+ var(--grid-size) var(--grid-size);
+ }
+
+ /* Animated grid (subtle pulse) */
+ .grid-background-animated {
+ @apply grid-background;
+ animation: grid-pulse 8s ease-in-out infinite;
+ }
+
+ @keyframes grid-pulse {
+ 0%, 100% { opacity: 0.4; }
+ 50% { opacity: 0.6; }
+ }
+}
+
+@layer utilities {
+ /* Border utilities */
+ .border-1 {
+ border-width: 1px;
+ }
+
+ .border-dotted-1 {
+ border-width: 1px;
+ border-style: dotted;
+ }
+
+ .border-dashed-1 {
+ border-width: 1px;
+ border-style: dashed;
+ }
+
+ /* Directional dotted borders */
+ .border-t-dotted {
+ border-top-width: 1px;
+ border-top-style: dotted;
+ }
+
+ .border-b-dotted {
+ border-bottom-width: 1px;
+ border-bottom-style: dotted;
+ }
+
+ .border-l-dotted {
+ border-left-width: 1px;
+ border-left-style: dotted;
+ }
+
+ .border-r-dotted {
+ border-right-width: 1px;
+ border-right-style: dotted;
+ }
+
+ /* Transition presets */
+ .transition-base {
+ transition-property: background-color, border-color, color, fill, stroke;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 200ms;
+ }
+
+ .transition-transform {
+ transition-property: transform;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 200ms;
+ }
+
+ .transition-all-smooth {
+ transition-property: all;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 300ms;
+ }
+
+ /* Hover effects */
+ .hover-lift {
+ @apply transition-transform hover:-translate-y-0.5 hover:shadow-md;
+ }
+
+ /* Focus ring (modern, accessible) */
+ .focus-ring {
+ @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background;
+ }
+
+ /* Custom animations */
+ @keyframes slide-up {
+ from {
+ transform: translateY(10px);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+ }
+
+ @keyframes shimmer {
+ 0% {
+ background-position: -1000px 0;
+ }
+ 100% {
+ background-position: 1000px 0;
+ }
+ }
+
+ .animate-slide-up {
+ animation: slide-up 300ms cubic-bezier(0.4, 0, 0.2, 1);
+ }
+
+ .animate-shimmer {
+ animation: shimmer 2s infinite linear;
+ background: linear-gradient(
+ 90deg,
+ hsl(var(--muted)) 0%,
+ hsl(var(--accent)) 50%,
+ hsl(var(--muted)) 100%
+ );
+ background-size: 1000px 100%;
}
}
diff --git a/packages/ui/package.json b/packages/ui/package.json
index c6e0277..1414caf 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,52 +1,87 @@
{
- "name": "@tpmjs/ui",
- "version": "0.1.2",
- "type": "module",
- "exports": {
- "./Button/Button": {
- "types": "./dist/Button/Button.d.ts",
- "default": "./dist/Button/Button.js"
- },
- "./Card/Card": {
- "types": "./dist/Card/Card.d.ts",
- "default": "./dist/Card/Card.js"
- }
- },
- "files": [
- "dist"
- ],
- "scripts": {
- "build": "tsup",
- "dev": "tsup --watch",
- "test": "vitest run",
- "test:ui": "vitest --ui",
- "type-check": "tsc --noEmit",
- "lint": "eslint .",
- "clean": "rm -rf dist .turbo"
- },
- "peerDependencies": {
- "react": "^18.0.0 || ^19.0.0",
- "react-dom": "^18.0.0 || ^19.0.0"
- },
- "dependencies": {
- "@tpmjs/utils": "workspace:*"
- },
- "devDependencies": {
- "@testing-library/jest-dom": "^6.9.1",
- "@testing-library/react": "^16.3.0",
- "@tpmjs/eslint-config": "workspace:*",
- "@tpmjs/test": "workspace:*",
- "@tpmjs/tsconfig": "workspace:*",
- "@types/react": "^19.0.2",
- "@types/react-dom": "^19.0.2",
- "happy-dom": "^15.11.7",
- "react": "^19.0.0",
- "react-dom": "^19.0.0",
- "tsup": "^8.3.5",
- "typescript": "^5.9.3",
- "vitest": "^2.1.8"
- },
- "publishConfig": {
- "access": "public"
- }
+ "name": "@tpmjs/ui",
+ "version": "0.1.2",
+ "type": "module",
+ "exports": {
+ "./Button/Button": {
+ "types": "./dist/Button/Button.d.ts",
+ "default": "./dist/Button/Button.js"
+ },
+ "./Card/Card": {
+ "types": "./dist/Card/Card.d.ts",
+ "default": "./dist/Card/Card.js"
+ },
+ "./Input/Input": {
+ "types": "./dist/Input/Input.d.ts",
+ "default": "./dist/Input/Input.js"
+ },
+ "./Label/Label": {
+ "types": "./dist/Label/Label.d.ts",
+ "default": "./dist/Label/Label.js"
+ },
+ "./Badge/Badge": {
+ "types": "./dist/Badge/Badge.d.ts",
+ "default": "./dist/Badge/Badge.js"
+ },
+ "./Container/Container": {
+ "types": "./dist/Container/Container.d.ts",
+ "default": "./dist/Container/Container.js"
+ },
+ "./Icon/Icon": {
+ "types": "./dist/Icon/Icon.d.ts",
+ "default": "./dist/Icon/Icon.js"
+ },
+ "./ProgressBar/ProgressBar": {
+ "types": "./dist/ProgressBar/ProgressBar.d.ts",
+ "default": "./dist/ProgressBar/ProgressBar.js"
+ },
+ "./CodeBlock/CodeBlock": {
+ "types": "./dist/CodeBlock/CodeBlock.d.ts",
+ "default": "./dist/CodeBlock/CodeBlock.js"
+ },
+ "./Tabs/Tabs": {
+ "types": "./dist/Tabs/Tabs.d.ts",
+ "default": "./dist/Tabs/Tabs.js"
+ },
+ "./Header/Header": {
+ "types": "./dist/Header/Header.d.ts",
+ "default": "./dist/Header/Header.js"
+ }
+ },
+ "files": ["dist"],
+ "scripts": {
+ "build": "tsup",
+ "dev": "tsup --watch",
+ "test": "vitest run",
+ "test:ui": "vitest --ui",
+ "type-check": "tsc --noEmit",
+ "lint": "eslint .",
+ "clean": "rm -rf dist .turbo"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "dependencies": {
+ "@tpmjs/utils": "workspace:*"
+ },
+ "devDependencies": {
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.0",
+ "@tpmjs/eslint-config": "workspace:*",
+ "@tpmjs/test": "workspace:*",
+ "@tpmjs/tsconfig": "workspace:*",
+ "@types/react": "^19.0.2",
+ "@types/react-dom": "^19.0.2",
+ "glob": "^13.0.0",
+ "happy-dom": "^15.11.7",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "tsup": "^8.3.5",
+ "typescript": "^5.9.3",
+ "vitest": "^2.1.8"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
}
diff --git a/packages/ui/src/Badge/Badge.test.ts b/packages/ui/src/Badge/Badge.test.ts
new file mode 100644
index 0000000..5c9ad97
--- /dev/null
+++ b/packages/ui/src/Badge/Badge.test.ts
@@ -0,0 +1,542 @@
+import { render, screen } from "@testing-library/react";
+import { createElement } from "react";
+import { describe, expect, it } from "vitest";
+import { Badge } from "./Badge";
+
+describe("Badge", () => {
+ describe("Rendering", () => {
+ it("renders a badge element", () => {
+ render(createElement(Badge, { "data-testid": "badge" }, "Badge"));
+ const badge = screen.getByTestId("badge");
+ expect(badge).toBeInTheDocument();
+ expect(badge.tagName).toBe("DIV");
+ });
+
+ it("renders children text", () => {
+ render(createElement(Badge, null, "Active"));
+ expect(screen.getByText("Active")).toBeInTheDocument();
+ });
+ });
+
+ describe("Variants", () => {
+ it("applies default variant classes", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ variant: "default",
+ "data-testid": "badge",
+ },
+ "Default",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge.className).toContain("bg-primary");
+ expect(badge.className).toContain("text-primary-foreground");
+ expect(badge.className).toContain("border-primary");
+ });
+
+ it("applies secondary variant classes", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ variant: "secondary",
+ "data-testid": "badge",
+ },
+ "Secondary",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge.className).toContain("bg-secondary");
+ expect(badge.className).toContain("text-secondary-foreground");
+ });
+
+ it("applies outline variant classes", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ variant: "outline",
+ "data-testid": "badge",
+ },
+ "Outline",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge.className).toContain("bg-transparent");
+ expect(badge.className).toContain("text-foreground");
+ expect(badge.className).toContain("border-border");
+ });
+
+ it("applies success variant classes", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ variant: "success",
+ "data-testid": "badge",
+ },
+ "Success",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge.className).toContain("bg-success");
+ expect(badge.className).toContain("text-success-foreground");
+ });
+
+ it("applies error variant classes", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ variant: "error",
+ "data-testid": "badge",
+ },
+ "Error",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge.className).toContain("bg-error");
+ expect(badge.className).toContain("text-error-foreground");
+ });
+
+ it("applies warning variant classes", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ variant: "warning",
+ "data-testid": "badge",
+ },
+ "Warning",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge.className).toContain("bg-warning");
+ expect(badge.className).toContain("text-warning-foreground");
+ });
+
+ it("applies info variant classes", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ variant: "info",
+ "data-testid": "badge",
+ },
+ "Info",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge.className).toContain("bg-info");
+ expect(badge.className).toContain("text-info-foreground");
+ });
+ });
+
+ describe("Sizes", () => {
+ it("applies small size classes", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ size: "sm",
+ "data-testid": "badge",
+ },
+ "Small",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge.className).toContain("px-2");
+ expect(badge.className).toContain("py-0.5");
+ expect(badge.className).toContain("text-xs");
+ });
+
+ it("applies medium size classes (default)", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ size: "md",
+ "data-testid": "badge",
+ },
+ "Medium",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge.className).toContain("px-2.5");
+ expect(badge.className).toContain("py-1");
+ expect(badge.className).toContain("text-sm");
+ });
+
+ it("applies large size classes", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ size: "lg",
+ "data-testid": "badge",
+ },
+ "Large",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge.className).toContain("px-3");
+ expect(badge.className).toContain("py-1.5");
+ expect(badge.className).toContain("text-base");
+ });
+
+ it("uses medium size by default", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ "data-testid": "badge",
+ },
+ "Default",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge.className).toContain("text-sm");
+ });
+ });
+
+ describe("HTML Attributes", () => {
+ it("passes through id attribute", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ id: "badge-id",
+ "data-testid": "badge",
+ },
+ "Badge",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge).toHaveAttribute("id", "badge-id");
+ });
+
+ it("passes through title attribute", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ title: "Badge tooltip",
+ "data-testid": "badge",
+ },
+ "Badge",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge).toHaveAttribute("title", "Badge tooltip");
+ });
+
+ it("passes through onClick handler", () => {
+ let clicked = false;
+ render(
+ createElement(
+ Badge,
+ {
+ onClick: () => {
+ clicked = true;
+ },
+ "data-testid": "badge",
+ },
+ "Clickable",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ badge.click();
+ expect(clicked).toBe(true);
+ });
+
+ it("passes through role attribute", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ role: "status",
+ "data-testid": "badge",
+ },
+ "Status",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge).toHaveAttribute("role", "status");
+ });
+ });
+
+ describe("ARIA Attributes", () => {
+ it("passes through aria-label", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ "aria-label": "Status badge",
+ "data-testid": "badge",
+ },
+ "Active",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge).toHaveAttribute("aria-label", "Status badge");
+ });
+
+ it("passes through aria-describedby", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ "aria-describedby": "description-id",
+ "data-testid": "badge",
+ },
+ "Badge",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge).toHaveAttribute("aria-describedby", "description-id");
+ });
+
+ it("passes through aria-live for dynamic status", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ "aria-live": "polite",
+ "data-testid": "badge",
+ },
+ "Updating",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge).toHaveAttribute("aria-live", "polite");
+ });
+ });
+
+ describe("Custom className", () => {
+ it("merges custom className with variant classes", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ className: "custom-badge",
+ "data-testid": "badge",
+ },
+ "Custom",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge.className).toContain("custom-badge");
+ expect(badge.className).toContain("rounded-full");
+ expect(badge.className).toContain("bg-primary");
+ });
+ });
+
+ describe("Ref Forwarding", () => {
+ it("forwards ref to badge element", () => {
+ let ref: HTMLDivElement | null = null;
+ render(
+ createElement(
+ Badge,
+ {
+ ref: (el: HTMLDivElement | null) => {
+ ref = el;
+ },
+ },
+ "Badge",
+ ),
+ );
+ expect(ref).toBeInstanceOf(HTMLDivElement);
+ expect(ref?.tagName).toBe("DIV");
+ });
+ });
+
+ describe("Base Classes", () => {
+ it("always includes base classes", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ "data-testid": "badge",
+ },
+ "Badge",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge.className).toContain("inline-flex");
+ expect(badge.className).toContain("items-center");
+ expect(badge.className).toContain("font-semibold");
+ expect(badge.className).toContain("whitespace-nowrap");
+ expect(badge.className).toContain("rounded-full");
+ });
+
+ it("includes transition classes", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ "data-testid": "badge",
+ },
+ "Badge",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge.className).toContain("transition-base");
+ });
+ });
+
+ describe("Compound Scenarios", () => {
+ it("works correctly with success variant and small size", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ variant: "success",
+ size: "sm",
+ "data-testid": "badge",
+ },
+ "Success",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge.className).toContain("bg-success");
+ expect(badge.className).toContain("text-xs");
+ });
+
+ it("works correctly with error variant and large size", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ variant: "error",
+ size: "lg",
+ "data-testid": "badge",
+ },
+ "Error",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge.className).toContain("bg-error");
+ expect(badge.className).toContain("text-base");
+ });
+
+ it("works correctly with outline variant and custom className", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ variant: "outline",
+ className: "hover:bg-accent",
+ "data-testid": "badge",
+ },
+ "Outlined",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge.className).toContain("bg-transparent");
+ expect(badge.className).toContain("hover:bg-accent");
+ });
+
+ it("works correctly with warning variant and onClick", () => {
+ let clicked = false;
+ render(
+ createElement(
+ Badge,
+ {
+ variant: "warning",
+ onClick: () => {
+ clicked = true;
+ },
+ "data-testid": "badge",
+ },
+ "Warning",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge.className).toContain("bg-warning");
+ badge.click();
+ expect(clicked).toBe(true);
+ });
+ });
+
+ describe("Content Types", () => {
+ it("renders with text content", () => {
+ render(createElement(Badge, null, "Text"));
+ expect(screen.getByText("Text")).toBeInTheDocument();
+ });
+
+ it("renders with numeric content", () => {
+ render(createElement(Badge, null, "42"));
+ expect(screen.getByText("42")).toBeInTheDocument();
+ });
+
+ it("renders with multiple children", () => {
+ render(
+ createElement(
+ Badge,
+ null,
+ "Count: ",
+ createElement("strong", null, "5"),
+ ),
+ );
+ expect(screen.getByText("Count:")).toBeInTheDocument();
+ expect(screen.getByText("5")).toBeInTheDocument();
+ });
+ });
+
+ describe("Semantic Usage", () => {
+ it("can be used as status indicator with role", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ variant: "success",
+ role: "status",
+ "aria-label": "Online status",
+ "data-testid": "badge",
+ },
+ "Online",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge).toHaveAttribute("role", "status");
+ expect(badge).toHaveAttribute("aria-label", "Online status");
+ expect(badge.className).toContain("bg-success");
+ });
+
+ it("can be used as notification count", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ variant: "error",
+ size: "sm",
+ "aria-label": "3 unread messages",
+ "data-testid": "badge",
+ },
+ "3",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge).toHaveAttribute("aria-label", "3 unread messages");
+ expect(screen.getByText("3")).toBeInTheDocument();
+ });
+
+ it("can be used as tag/label", () => {
+ render(
+ createElement(
+ Badge,
+ {
+ variant: "outline",
+ "data-testid": "badge",
+ },
+ "TypeScript",
+ ),
+ );
+ const badge = screen.getByTestId("badge");
+ expect(badge.className).toContain("bg-transparent");
+ expect(screen.getByText("TypeScript")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/ui/src/Badge/Badge.ts b/packages/ui/src/Badge/Badge.ts
new file mode 100644
index 0000000..c11a58d
--- /dev/null
+++ b/packages/ui/src/Badge/Badge.ts
@@ -0,0 +1,41 @@
+import { cn } from "@tpmjs/utils/cn";
+import { createElement, forwardRef } from "react";
+import type { BadgeProps } from "./types";
+import { badgeVariants } from "./variants";
+
+/**
+ * Badge component
+ *
+ * A versatile badge component for status indicators, tags, and labels.
+ * Built with .ts-only React using createElement.
+ *
+ * @example
+ * ```typescript
+ * import { Badge } from '@tpmjs/ui/Badge/Badge';
+ * import { createElement } from 'react';
+ *
+ * function MyComponent() {
+ * return createElement(Badge, {
+ * variant: 'success',
+ * children: 'Active',
+ * });
+ * }
+ * ```
+ */
+export const Badge = forwardRef(
+ ({ className, variant = "default", size = "md", ...props }, ref) => {
+ return createElement("div", {
+ className: cn(
+ badgeVariants({
+ variant,
+ size,
+ }),
+ className,
+ ),
+ ref,
+ ...props,
+ });
+ },
+);
+
+Badge.displayName = "Badge";
diff --git a/packages/ui/src/Badge/tokens.ts b/packages/ui/src/Badge/tokens.ts
new file mode 100644
index 0000000..219ab17
--- /dev/null
+++ b/packages/ui/src/Badge/tokens.ts
@@ -0,0 +1,41 @@
+import { borderRadius, spacing } from "../tokens";
+
+/**
+ * Badge component design tokens
+ * Maps global tokens to badge-specific semantics
+ */
+export const badgeTokens = {
+ /** Horizontal padding for each size */
+ padding: {
+ x: {
+ sm: spacing[2], // 0.5rem (8px)
+ md: spacing[2.5], // 0.625rem (10px)
+ lg: spacing[3], // 0.75rem (12px)
+ },
+ y: {
+ sm: spacing[0.5], // 0.125rem (2px)
+ md: spacing[1], // 0.25rem (4px)
+ lg: spacing[1.5], // 0.375rem (6px)
+ },
+ },
+
+ /** Font sizes for each size */
+ fontSize: {
+ sm: "0.75rem", // 12px
+ md: "0.875rem", // 14px
+ lg: "1rem", // 16px
+ },
+
+ /** Line heights for each size */
+ lineHeight: {
+ sm: "1rem", // 16px
+ md: "1.25rem", // 20px
+ lg: "1.5rem", // 24px
+ },
+
+ /** Border radius */
+ borderRadius: borderRadius.full,
+
+ /** Border width */
+ borderWidth: "1px",
+} as const;
diff --git a/packages/ui/src/Badge/types.ts b/packages/ui/src/Badge/types.ts
new file mode 100644
index 0000000..0264f6f
--- /dev/null
+++ b/packages/ui/src/Badge/types.ts
@@ -0,0 +1,30 @@
+import type { HTMLAttributes } from "react";
+
+/**
+ * Badge component props
+ */
+export interface BadgeProps extends HTMLAttributes {
+ /**
+ * Visual variant of the badge
+ * @default 'default'
+ */
+ variant?:
+ | "default"
+ | "secondary"
+ | "outline"
+ | "success"
+ | "error"
+ | "warning"
+ | "info";
+
+ /**
+ * Size of the badge
+ * @default 'md'
+ */
+ size?: "sm" | "md" | "lg";
+}
+
+/**
+ * Badge ref type
+ */
+export type BadgeRef = HTMLDivElement;
diff --git a/packages/ui/src/Badge/variants.ts b/packages/ui/src/Badge/variants.ts
new file mode 100644
index 0000000..35dff41
--- /dev/null
+++ b/packages/ui/src/Badge/variants.ts
@@ -0,0 +1,66 @@
+import { createVariants } from "../system/variants";
+
+/**
+ * Badge variant definitions
+ * Uses custom variant system for type-safe class composition
+ */
+export const badgeVariants = createVariants({
+ base: [
+ // Layout
+ "inline-flex items-center",
+ // Typography
+ "font-semibold",
+ "whitespace-nowrap",
+ // Borders & Radius
+ "rounded-full",
+ // Transitions
+ "transition-base",
+ ].join(" "),
+
+ variants: {
+ variant: {
+ default: [
+ "bg-primary text-primary-foreground",
+ "border border-primary",
+ ].join(" "),
+
+ secondary: [
+ "bg-secondary text-secondary-foreground",
+ "border border-secondary",
+ ].join(" "),
+
+ outline: ["bg-transparent text-foreground", "border border-border"].join(
+ " ",
+ ),
+
+ success: [
+ "bg-success text-success-foreground",
+ "border border-success",
+ ].join(" "),
+
+ error: ["bg-error text-error-foreground", "border border-error"].join(
+ " ",
+ ),
+
+ warning: [
+ "bg-warning text-warning-foreground",
+ "border border-warning",
+ ].join(" "),
+
+ info: ["bg-info text-info-foreground", "border border-info"].join(" "),
+ },
+
+ size: {
+ sm: "px-2 py-0.5 text-xs",
+ md: "px-2.5 py-1 text-sm",
+ lg: "px-3 py-1.5 text-base",
+ },
+ },
+
+ compoundVariants: [],
+
+ defaultVariants: {
+ variant: "default",
+ size: "md",
+ },
+});
diff --git a/packages/ui/src/Button/Button.test.ts b/packages/ui/src/Button/Button.test.ts
index 4cfe148..4a9b2b2 100644
--- a/packages/ui/src/Button/Button.test.ts
+++ b/packages/ui/src/Button/Button.test.ts
@@ -1,23 +1,383 @@
-import { describe, it, expect } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import { Button } from './Button';
-import { createElement } from 'react';
+import { render, screen } from "@testing-library/react";
+import { createElement } from "react";
+import { describe, expect, it } from "vitest";
+import { Button } from "./Button";
-describe('Button', () => {
- it('renders with default variant', () => {
- render(createElement(Button, { children: 'Click me' }));
- expect(screen.getByRole('button')).toHaveTextContent('Click me');
- });
+describe("Button", () => {
+ describe("Rendering", () => {
+ it("renders with default variant and size", () => {
+ render(createElement(Button, {}, "Click me"));
+ const button = screen.getByRole("button");
+ expect(button).toBeInTheDocument();
+ expect(button).toHaveTextContent("Click me");
+ });
- it('applies variant classes', () => {
- render(createElement(Button, { variant: 'destructive', children: 'Delete' }));
- const button = screen.getByRole('button');
- expect(button.className).toContain('bg-destructive');
- });
+ it("renders as a button element", () => {
+ render(createElement(Button, {}, "Test"));
+ expect(screen.getByRole("button").tagName).toBe("BUTTON");
+ });
+ });
- it('applies size classes', () => {
- render(createElement(Button, { size: 'lg', children: 'Large' }));
- const button = screen.getByRole('button');
- expect(button.className).toContain('h-11');
- });
+ describe("Variants", () => {
+ it("applies default variant classes", () => {
+ render(
+ createElement(Button, {
+ variant: "default",
+ "data-testid": "button",
+ children: "Default",
+ }),
+ );
+ const button = screen.getByTestId("button");
+ expect(button.className).toContain("bg-primary");
+ expect(button.className).toContain("text-primary-foreground");
+ });
+
+ it("applies destructive variant classes", () => {
+ render(
+ createElement(Button, {
+ variant: "destructive",
+ "data-testid": "button",
+ children: "Delete",
+ }),
+ );
+ const button = screen.getByTestId("button");
+ expect(button.className).toContain("bg-error");
+ expect(button.className).toContain("text-error-foreground");
+ });
+
+ it("applies outline variant classes", () => {
+ render(
+ createElement(Button, {
+ variant: "outline",
+ "data-testid": "button",
+ children: "Outline",
+ }),
+ );
+ const button = screen.getByTestId("button");
+ expect(button.className).toContain("border");
+ expect(button.className).toContain("bg-transparent");
+ });
+
+ it("applies secondary variant classes", () => {
+ render(
+ createElement(Button, {
+ variant: "secondary",
+ "data-testid": "button",
+ children: "Secondary",
+ }),
+ );
+ const button = screen.getByTestId("button");
+ expect(button.className).toContain("bg-secondary");
+ expect(button.className).toContain("text-secondary-foreground");
+ });
+
+ it("applies ghost variant classes", () => {
+ render(
+ createElement(Button, {
+ variant: "ghost",
+ "data-testid": "button",
+ children: "Ghost",
+ }),
+ );
+ const button = screen.getByTestId("button");
+ expect(button.className).toContain("hover:bg-accent");
+ });
+
+ it("applies link variant classes", () => {
+ render(
+ createElement(Button, {
+ variant: "link",
+ "data-testid": "button",
+ children: "Link",
+ }),
+ );
+ const button = screen.getByTestId("button");
+ expect(button.className).toContain("text-primary");
+ expect(button.className).toContain("underline-offset-4");
+ });
+ });
+
+ describe("Sizes", () => {
+ it("applies small size classes", () => {
+ render(
+ createElement(Button, {
+ size: "sm",
+ "data-testid": "button",
+ children: "Small",
+ }),
+ );
+ const button = screen.getByTestId("button");
+ expect(button.className).toContain("h-9");
+ expect(button.className).toContain("px-3");
+ });
+
+ it("applies medium size classes (default)", () => {
+ render(
+ createElement(Button, {
+ size: "md",
+ "data-testid": "button",
+ children: "Medium",
+ }),
+ );
+ const button = screen.getByTestId("button");
+ expect(button.className).toContain("h-10");
+ expect(button.className).toContain("px-4");
+ });
+
+ it("applies large size classes", () => {
+ render(
+ createElement(Button, {
+ size: "lg",
+ "data-testid": "button",
+ children: "Large",
+ }),
+ );
+ const button = screen.getByTestId("button");
+ expect(button.className).toContain("h-11");
+ expect(button.className).toContain("px-8");
+ });
+
+ it("applies icon size classes", () => {
+ render(
+ createElement(Button, {
+ size: "icon",
+ "data-testid": "button",
+ "aria-label": "Icon button",
+ }),
+ );
+ const button = screen.getByTestId("button");
+ expect(button.className).toContain("h-10");
+ expect(button.className).toContain("w-10");
+ });
+ });
+
+ describe("Compound Variants", () => {
+ it("applies compound variant for outline + small", () => {
+ render(
+ createElement(Button, {
+ variant: "outline",
+ size: "sm",
+ "data-testid": "button",
+ children: "Small Outline",
+ }),
+ );
+ const button = screen.getByTestId("button");
+ expect(button.className).toContain("border");
+ expect(button.className).toContain("h-9");
+ });
+
+ it("applies compound variant for link variants (removes padding)", () => {
+ render(
+ createElement(Button, {
+ variant: "link",
+ size: "md",
+ "data-testid": "button",
+ children: "Link",
+ }),
+ );
+ const button = screen.getByTestId("button");
+ expect(button.className).toContain("px-0");
+ });
+ });
+
+ describe("Loading State", () => {
+ it("shows loading spinner when loading is true", () => {
+ render(createElement(Button, { loading: true, children: "Loading" }));
+ const button = screen.getByRole("button");
+
+ // Should have loading indicator
+ const spinner = button.querySelector('[aria-hidden="true"]');
+ expect(spinner).toBeInTheDocument();
+ expect(spinner?.className).toContain("animate-spin");
+ });
+
+ it("disables button when loading", () => {
+ render(createElement(Button, { loading: true, children: "Loading" }));
+ const button = screen.getByRole("button");
+ expect(button).toBeDisabled();
+ expect(button).toHaveAttribute("aria-busy", "true");
+ });
+
+ it("applies cursor-wait class when loading", () => {
+ render(
+ createElement(Button, {
+ loading: true,
+ "data-testid": "button",
+ children: "Loading",
+ }),
+ );
+ const button = screen.getByTestId("button");
+ expect(button.className).toContain("cursor-wait");
+ });
+
+ it("still shows children text when loading", () => {
+ render(createElement(Button, { loading: true, children: "Processing" }));
+ expect(screen.getByText("Processing")).toBeInTheDocument();
+ });
+ });
+
+ describe("Disabled State", () => {
+ it("disables button when disabled prop is true", () => {
+ render(createElement(Button, { disabled: true, children: "Disabled" }));
+ const button = screen.getByRole("button");
+ expect(button).toBeDisabled();
+ expect(button).toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("applies disabled opacity class", () => {
+ render(
+ createElement(Button, {
+ disabled: true,
+ "data-testid": "button",
+ children: "Disabled",
+ }),
+ );
+ const button = screen.getByTestId("button");
+ expect(button.className).toContain("disabled:opacity-50");
+ expect(button.className).toContain("disabled:pointer-events-none");
+ });
+
+ it("is disabled when both disabled and loading are true", () => {
+ render(
+ createElement(Button, {
+ disabled: true,
+ loading: true,
+ children: "Both",
+ }),
+ );
+ const button = screen.getByRole("button");
+ expect(button).toBeDisabled();
+ });
+ });
+
+ describe("Custom className", () => {
+ it("merges custom className with variant classes", () => {
+ render(
+ createElement(Button, {
+ className: "custom-class",
+ "data-testid": "button",
+ children: "Custom",
+ }),
+ );
+ const button = screen.getByTestId("button");
+ expect(button.className).toContain("custom-class");
+ expect(button.className).toContain("bg-primary");
+ });
+ });
+
+ describe("Ref Forwarding", () => {
+ it("forwards ref to button element", () => {
+ let ref: HTMLButtonElement | null = null;
+ render(
+ createElement(Button, {
+ ref: (el: HTMLButtonElement | null) => {
+ ref = el;
+ },
+ children: "Ref Test",
+ }),
+ );
+ expect(ref).toBeInstanceOf(HTMLButtonElement);
+ expect(ref?.tagName).toBe("BUTTON");
+ });
+ });
+
+ describe("HTML Attributes", () => {
+ it("passes through HTML button attributes", () => {
+ render(
+ createElement(Button, {
+ type: "submit",
+ name: "test-button",
+ value: "test-value",
+ "data-testid": "button",
+ children: "Submit",
+ }),
+ );
+ const button = screen.getByTestId("button");
+ expect(button).toHaveAttribute("type", "submit");
+ expect(button).toHaveAttribute("name", "test-button");
+ expect(button).toHaveAttribute("value", "test-value");
+ });
+
+ it("supports onClick handler", () => {
+ let clicked = false;
+ render(
+ createElement(Button, {
+ onClick: () => {
+ clicked = true;
+ },
+ children: "Click",
+ }),
+ );
+ const button = screen.getByRole("button");
+ button.click();
+ expect(clicked).toBe(true);
+ });
+ });
+
+ describe("Accessibility", () => {
+ it("has proper ARIA attributes for loading state", () => {
+ render(createElement(Button, { loading: true, children: "Loading" }));
+ const button = screen.getByRole("button");
+ expect(button).toHaveAttribute("aria-busy", "true");
+ expect(button).toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("has proper ARIA attributes for disabled state", () => {
+ render(createElement(Button, { disabled: true, children: "Disabled" }));
+ const button = screen.getByRole("button");
+ expect(button).toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("hides loading spinner from screen readers", () => {
+ render(createElement(Button, { loading: true, children: "Loading" }));
+ const button = screen.getByRole("button");
+ const spinner = button.querySelector('[aria-hidden="true"]');
+ expect(spinner).toHaveAttribute("aria-hidden", "true");
+ });
+
+ it("supports aria-label for icon buttons", () => {
+ render(
+ createElement(Button, {
+ size: "icon",
+ "aria-label": "Close dialog",
+ children: "X",
+ }),
+ );
+ const button = screen.getByRole("button");
+ expect(button).toHaveAccessibleName("Close dialog");
+ });
+ });
+
+ describe("Base Classes", () => {
+ it("always includes base classes", () => {
+ render(
+ createElement(Button, { "data-testid": "button", children: "Base" }),
+ );
+ const button = screen.getByTestId("button");
+ expect(button.className).toContain("inline-flex");
+ expect(button.className).toContain("items-center");
+ expect(button.className).toContain("justify-center");
+ expect(button.className).toContain("font-medium");
+ expect(button.className).toContain("rounded-md");
+ });
+
+ it("includes focus ring classes", () => {
+ render(
+ createElement(Button, { "data-testid": "button", children: "Focus" }),
+ );
+ const button = screen.getByTestId("button");
+ expect(button.className).toContain("focus-ring");
+ });
+
+ it("includes transition classes", () => {
+ render(
+ createElement(Button, {
+ "data-testid": "button",
+ children: "Transition",
+ }),
+ );
+ const button = screen.getByTestId("button");
+ expect(button.className).toContain("transition-base");
+ });
+ });
});
diff --git a/packages/ui/src/Button/Button.ts b/packages/ui/src/Button/Button.ts
index eabd578..b725a0b 100644
--- a/packages/ui/src/Button/Button.ts
+++ b/packages/ui/src/Button/Button.ts
@@ -1,40 +1,74 @@
-import { createElement, forwardRef, type ButtonHTMLAttributes } from 'react';
-import { cn } from '@tpmjs/utils/cn';
-
-export interface ButtonProps extends ButtonHTMLAttributes {
- variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
- size?: 'default' | 'sm' | 'lg' | 'icon';
-}
-
-const buttonVariants = {
- default: 'bg-primary text-primary-foreground hover:bg-primary/90',
- destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
- outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
- secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
- ghost: 'hover:bg-accent hover:text-accent-foreground',
- link: 'text-primary underline-offset-4 hover:underline',
-};
-
-const buttonSizes = {
- default: 'h-10 px-4 py-2',
- sm: 'h-9 rounded-md px-3',
- lg: 'h-11 rounded-md px-8',
- icon: 'h-10 w-10',
-};
+import { cn } from "@tpmjs/utils/cn";
+import { createElement, forwardRef } from "react";
+import type { ButtonProps } from "./types";
+import { buttonVariants } from "./variants";
+/**
+ * Button component
+ *
+ * A versatile button component with multiple variants, sizes, and states.
+ * Built with .ts-only React using createElement.
+ *
+ * @example
+ * ```typescript
+ * import { Button } from '@tpmjs/ui/Button/Button';
+ * import { createElement } from 'react';
+ *
+ * function MyComponent() {
+ * return createElement(Button, {
+ * variant: 'outline',
+ * size: 'lg',
+ * onClick: () => console.log('clicked'),
+ * children: 'Click me',
+ * });
+ * }
+ * ```
+ */
export const Button = forwardRef(
- ({ className, variant = 'default', size = 'default', ...props }, ref) => {
- return createElement('button', {
- className: cn(
- 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
- buttonVariants[variant],
- buttonSizes[size],
- className
- ),
- ref,
- ...props,
- });
- }
+ (
+ {
+ className,
+ variant = "default",
+ size = "md",
+ loading = false,
+ disabled = false,
+ children,
+ ...props
+ },
+ ref,
+ ) => {
+ return createElement(
+ "button",
+ {
+ type: "button",
+ className: cn(
+ buttonVariants({
+ variant,
+ size,
+ loading: loading ? "true" : "false",
+ }),
+ className,
+ ),
+ ref,
+ disabled: disabled || loading,
+ "aria-disabled": disabled || loading ? "true" : undefined,
+ "aria-busy": loading ? "true" : undefined,
+ ...props,
+ },
+ loading
+ ? createElement(
+ "span",
+ { className: "flex items-center gap-2" },
+ createElement("span", {
+ className:
+ "h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent",
+ "aria-hidden": "true",
+ }),
+ createElement("span", null, children),
+ )
+ : children,
+ );
+ },
);
-Button.displayName = 'Button';
+Button.displayName = "Button";
diff --git a/packages/ui/src/Button/tokens.ts b/packages/ui/src/Button/tokens.ts
new file mode 100644
index 0000000..151b915
--- /dev/null
+++ b/packages/ui/src/Button/tokens.ts
@@ -0,0 +1,33 @@
+import { borderRadius, spacing } from "../tokens";
+
+/**
+ * Button component design tokens
+ * Maps global tokens to button-specific semantics
+ */
+export const buttonTokens = {
+ /** Button heights for each size */
+ height: {
+ sm: spacing[9], // 2.25rem (36px)
+ md: spacing[10], // 2.5rem (40px)
+ lg: spacing[11], // 2.75rem (44px)
+ icon: spacing[10], // 2.5rem (40px) - square
+ },
+
+ /** Horizontal padding for each size */
+ padding: {
+ x: {
+ sm: spacing[3], // 0.75rem (12px)
+ md: spacing[4], // 1rem (16px)
+ lg: spacing[8], // 2rem (32px)
+ },
+ },
+
+ /** Gap between icon and text */
+ gap: spacing[2], // 0.5rem (8px)
+
+ /** Border radius */
+ borderRadius: borderRadius.md,
+
+ /** Transition duration */
+ transitionDuration: "200ms",
+} as const;
diff --git a/packages/ui/src/Button/types.ts b/packages/ui/src/Button/types.ts
new file mode 100644
index 0000000..f26aef8
--- /dev/null
+++ b/packages/ui/src/Button/types.ts
@@ -0,0 +1,35 @@
+import type { ButtonHTMLAttributes } from "react";
+
+/**
+ * Button component props
+ */
+export interface ButtonProps extends ButtonHTMLAttributes {
+ /**
+ * Visual variant of the button
+ * @default 'default'
+ */
+ variant?:
+ | "default"
+ | "destructive"
+ | "outline"
+ | "secondary"
+ | "ghost"
+ | "link";
+
+ /**
+ * Size of the button
+ * @default 'md'
+ */
+ size?: "sm" | "md" | "lg" | "icon";
+
+ /**
+ * Loading state - disables button and shows loading indicator
+ * @default false
+ */
+ loading?: boolean;
+}
+
+/**
+ * Button ref type
+ */
+export type ButtonRef = HTMLButtonElement;
diff --git a/packages/ui/src/Button/variants.ts b/packages/ui/src/Button/variants.ts
new file mode 100644
index 0000000..66454f3
--- /dev/null
+++ b/packages/ui/src/Button/variants.ts
@@ -0,0 +1,99 @@
+import { createVariants } from "../system/variants";
+
+/**
+ * Button variant definitions
+ * Uses custom variant system for type-safe class composition
+ */
+export const buttonVariants = createVariants({
+ base: [
+ // Layout
+ "inline-flex items-center justify-center gap-2",
+ // Typography
+ "font-medium text-sm",
+ "whitespace-nowrap",
+ // Borders & Radius
+ "rounded-md",
+ // Transitions
+ "transition-base",
+ // Focus
+ "focus-ring",
+ // Disabled state
+ "disabled:pointer-events-none disabled:opacity-50",
+ ].join(" "),
+
+ variants: {
+ variant: {
+ default: [
+ "bg-primary text-primary-foreground",
+ "hover:bg-primary/90",
+ "active:bg-primary/80",
+ ].join(" "),
+
+ destructive: [
+ "bg-error text-error-foreground",
+ "hover:bg-error/90",
+ "active:bg-error/80",
+ ].join(" "),
+
+ outline: [
+ "border border-border bg-transparent",
+ "hover:bg-accent hover:text-accent-foreground hover:border-border-strong",
+ "active:bg-accent/80",
+ ].join(" "),
+
+ secondary: [
+ "bg-secondary text-secondary-foreground",
+ "hover:bg-secondary/80",
+ "active:bg-secondary/70",
+ ].join(" "),
+
+ ghost: [
+ "hover:bg-accent hover:text-accent-foreground",
+ "active:bg-accent/80",
+ ].join(" "),
+
+ link: [
+ "text-primary underline-offset-4",
+ "hover:underline",
+ "active:text-primary/80",
+ ].join(" "),
+ },
+
+ size: {
+ sm: "h-9 px-3 text-sm",
+ md: "h-10 px-4 text-base",
+ lg: "h-11 px-8 text-lg",
+ icon: "h-10 w-10 p-0",
+ },
+
+ loading: {
+ true: "cursor-wait",
+ false: "",
+ },
+ },
+
+ compoundVariants: [
+ {
+ conditions: { variant: "outline", size: "sm" },
+ className: "border-1",
+ },
+ {
+ conditions: { variant: "link", size: "sm" },
+ className: "px-0",
+ },
+ {
+ conditions: { variant: "link", size: "md" },
+ className: "px-0",
+ },
+ {
+ conditions: { variant: "link", size: "lg" },
+ className: "px-0",
+ },
+ ],
+
+ defaultVariants: {
+ variant: "default",
+ size: "md",
+ loading: "false",
+ },
+});
diff --git a/packages/ui/src/Card/Card.test.ts b/packages/ui/src/Card/Card.test.ts
new file mode 100644
index 0000000..d3a2457
--- /dev/null
+++ b/packages/ui/src/Card/Card.test.ts
@@ -0,0 +1,876 @@
+import { render, screen } from "@testing-library/react";
+import { createElement } from "react";
+import { describe, expect, it } from "vitest";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "./Card";
+
+describe("Card", () => {
+ describe("Rendering", () => {
+ it("renders a card element", () => {
+ render(createElement(Card, { "data-testid": "card" }, "Card content"));
+ const card = screen.getByTestId("card");
+ expect(card).toBeInTheDocument();
+ expect(card.tagName).toBe("DIV");
+ });
+
+ it("renders with all sub-components", () => {
+ render(
+ createElement(
+ Card,
+ { "data-testid": "card" },
+ createElement(
+ CardHeader,
+ { "data-testid": "header" },
+ createElement(CardTitle, { "data-testid": "title" }, "Title"),
+ createElement(
+ CardDescription,
+ { "data-testid": "description" },
+ "Description",
+ ),
+ ),
+ createElement(CardContent, { "data-testid": "content" }, "Content"),
+ createElement(CardFooter, { "data-testid": "footer" }, "Footer"),
+ ),
+ );
+
+ expect(screen.getByTestId("card")).toBeInTheDocument();
+ expect(screen.getByTestId("header")).toBeInTheDocument();
+ expect(screen.getByTestId("title")).toBeInTheDocument();
+ expect(screen.getByTestId("description")).toBeInTheDocument();
+ expect(screen.getByTestId("content")).toBeInTheDocument();
+ expect(screen.getByTestId("footer")).toBeInTheDocument();
+ });
+ });
+
+ describe("Card Variants", () => {
+ it("applies default variant classes", () => {
+ render(
+ createElement(
+ Card,
+ {
+ variant: "default",
+ "data-testid": "card",
+ },
+ "Default",
+ ),
+ );
+ const card = screen.getByTestId("card");
+ expect(card.className).toContain("border");
+ expect(card.className).toContain("bg-card");
+ expect(card.className).toContain("shadow-sm");
+ });
+
+ it("applies elevated variant classes", () => {
+ render(
+ createElement(
+ Card,
+ {
+ variant: "elevated",
+ "data-testid": "card",
+ },
+ "Elevated",
+ ),
+ );
+ const card = screen.getByTestId("card");
+ expect(card.className).toContain("bg-surface-elevated");
+ expect(card.className).toContain("shadow-md");
+ });
+
+ it("applies outline variant classes", () => {
+ render(
+ createElement(
+ Card,
+ {
+ variant: "outline",
+ "data-testid": "card",
+ },
+ "Outline",
+ ),
+ );
+ const card = screen.getByTestId("card");
+ expect(card.className).toContain("border-2");
+ expect(card.className).toContain("bg-transparent");
+ });
+
+ it("applies ghost variant classes", () => {
+ render(
+ createElement(
+ Card,
+ {
+ variant: "ghost",
+ "data-testid": "card",
+ },
+ "Ghost",
+ ),
+ );
+ const card = screen.getByTestId("card");
+ expect(card.className).toContain("bg-transparent");
+ });
+ });
+
+ describe("Card Padding", () => {
+ it("applies no padding by default", () => {
+ render(
+ createElement(
+ Card,
+ {
+ "data-testid": "card",
+ },
+ "No padding",
+ ),
+ );
+ const card = screen.getByTestId("card");
+ expect(card.className).toContain("p-0");
+ });
+
+ it("applies small padding", () => {
+ render(
+ createElement(
+ Card,
+ {
+ padding: "sm",
+ "data-testid": "card",
+ },
+ "Small padding",
+ ),
+ );
+ const card = screen.getByTestId("card");
+ expect(card.className).toContain("p-4");
+ });
+
+ it("applies medium padding", () => {
+ render(
+ createElement(
+ Card,
+ {
+ padding: "md",
+ "data-testid": "card",
+ },
+ "Medium padding",
+ ),
+ );
+ const card = screen.getByTestId("card");
+ expect(card.className).toContain("p-6");
+ });
+
+ it("applies large padding", () => {
+ render(
+ createElement(
+ Card,
+ {
+ padding: "lg",
+ "data-testid": "card",
+ },
+ "Large padding",
+ ),
+ );
+ const card = screen.getByTestId("card");
+ expect(card.className).toContain("p-8");
+ });
+ });
+
+ describe("CardHeader", () => {
+ it("renders with default medium padding", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(CardHeader, { "data-testid": "header" }, "Header"),
+ ),
+ );
+ const header = screen.getByTestId("header");
+ expect(header.className).toContain("p-6");
+ });
+
+ it("applies small padding", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardHeader,
+ { padding: "sm", "data-testid": "header" },
+ "Header",
+ ),
+ ),
+ );
+ const header = screen.getByTestId("header");
+ expect(header.className).toContain("p-4");
+ });
+
+ it("applies large padding", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardHeader,
+ { padding: "lg", "data-testid": "header" },
+ "Header",
+ ),
+ ),
+ );
+ const header = screen.getByTestId("header");
+ expect(header.className).toContain("p-8");
+ });
+
+ it("applies no padding", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardHeader,
+ { padding: "none", "data-testid": "header" },
+ "Header",
+ ),
+ ),
+ );
+ const header = screen.getByTestId("header");
+ expect(header.className).toContain("p-0");
+ });
+ });
+
+ describe("CardTitle", () => {
+ it("renders as h3 by default", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardHeader,
+ null,
+ createElement(CardTitle, { "data-testid": "title" }, "Title"),
+ ),
+ ),
+ );
+ const title = screen.getByTestId("title");
+ expect(title.tagName).toBe("H3");
+ });
+
+ it("renders as h1 when specified", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardHeader,
+ null,
+ createElement(
+ CardTitle,
+ { as: "h1", "data-testid": "title" },
+ "Title",
+ ),
+ ),
+ ),
+ );
+ const title = screen.getByTestId("title");
+ expect(title.tagName).toBe("H1");
+ });
+
+ it("renders as h2 when specified", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardHeader,
+ null,
+ createElement(
+ CardTitle,
+ { as: "h2", "data-testid": "title" },
+ "Title",
+ ),
+ ),
+ ),
+ );
+ const title = screen.getByTestId("title");
+ expect(title.tagName).toBe("H2");
+ });
+
+ it("applies title classes", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardHeader,
+ null,
+ createElement(CardTitle, { "data-testid": "title" }, "Title"),
+ ),
+ ),
+ );
+ const title = screen.getByTestId("title");
+ expect(title.className).toContain("text-2xl");
+ expect(title.className).toContain("font-semibold");
+ expect(title.className).toContain("leading-none");
+ });
+ });
+
+ describe("CardDescription", () => {
+ it("renders as a paragraph", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardHeader,
+ null,
+ createElement(
+ CardDescription,
+ { "data-testid": "description" },
+ "Description text",
+ ),
+ ),
+ ),
+ );
+ const description = screen.getByTestId("description");
+ expect(description.tagName).toBe("P");
+ });
+
+ it("applies description classes", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardHeader,
+ null,
+ createElement(
+ CardDescription,
+ { "data-testid": "description" },
+ "Description",
+ ),
+ ),
+ ),
+ );
+ const description = screen.getByTestId("description");
+ expect(description.className).toContain("text-sm");
+ expect(description.className).toContain("text-foreground-secondary");
+ });
+ });
+
+ describe("CardContent", () => {
+ it("renders with default medium padding", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(CardContent, { "data-testid": "content" }, "Content"),
+ ),
+ );
+ const content = screen.getByTestId("content");
+ expect(content.className).toContain("p-6");
+ expect(content.className).toContain("pt-0");
+ });
+
+ it("applies small padding", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardContent,
+ { padding: "sm", "data-testid": "content" },
+ "Content",
+ ),
+ ),
+ );
+ const content = screen.getByTestId("content");
+ expect(content.className).toContain("p-4");
+ expect(content.className).toContain("pt-0");
+ });
+
+ it("applies large padding", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardContent,
+ { padding: "lg", "data-testid": "content" },
+ "Content",
+ ),
+ ),
+ );
+ const content = screen.getByTestId("content");
+ expect(content.className).toContain("p-8");
+ expect(content.className).toContain("pt-0");
+ });
+
+ it("applies no padding", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardContent,
+ { padding: "none", "data-testid": "content" },
+ "Content",
+ ),
+ ),
+ );
+ const content = screen.getByTestId("content");
+ expect(content.className).toContain("p-0");
+ });
+ });
+
+ describe("CardFooter", () => {
+ it("renders with default medium padding", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(CardFooter, { "data-testid": "footer" }, "Footer"),
+ ),
+ );
+ const footer = screen.getByTestId("footer");
+ expect(footer.className).toContain("p-6");
+ expect(footer.className).toContain("pt-0");
+ });
+
+ it("applies small padding", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardFooter,
+ { padding: "sm", "data-testid": "footer" },
+ "Footer",
+ ),
+ ),
+ );
+ const footer = screen.getByTestId("footer");
+ expect(footer.className).toContain("p-4");
+ expect(footer.className).toContain("pt-0");
+ });
+
+ it("applies large padding", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardFooter,
+ { padding: "lg", "data-testid": "footer" },
+ "Footer",
+ ),
+ ),
+ );
+ const footer = screen.getByTestId("footer");
+ expect(footer.className).toContain("p-8");
+ expect(footer.className).toContain("pt-0");
+ });
+
+ it("applies flex layout classes", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(CardFooter, { "data-testid": "footer" }, "Footer"),
+ ),
+ );
+ const footer = screen.getByTestId("footer");
+ expect(footer.className).toContain("flex");
+ expect(footer.className).toContain("items-center");
+ });
+ });
+
+ describe("Custom className", () => {
+ it("merges custom className with Card variant classes", () => {
+ render(
+ createElement(
+ Card,
+ {
+ className: "custom-card",
+ "data-testid": "card",
+ },
+ "Custom",
+ ),
+ );
+ const card = screen.getByTestId("card");
+ expect(card.className).toContain("custom-card");
+ expect(card.className).toContain("rounded-lg");
+ });
+
+ it("merges custom className with CardHeader", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardHeader,
+ {
+ className: "custom-header",
+ "data-testid": "header",
+ },
+ "Header",
+ ),
+ ),
+ );
+ const header = screen.getByTestId("header");
+ expect(header.className).toContain("custom-header");
+ expect(header.className).toContain("flex");
+ });
+
+ it("merges custom className with CardTitle", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardHeader,
+ null,
+ createElement(
+ CardTitle,
+ {
+ className: "custom-title",
+ "data-testid": "title",
+ },
+ "Title",
+ ),
+ ),
+ ),
+ );
+ const title = screen.getByTestId("title");
+ expect(title.className).toContain("custom-title");
+ expect(title.className).toContain("font-semibold");
+ });
+ });
+
+ describe("Ref Forwarding", () => {
+ it("forwards ref to Card element", () => {
+ let ref: HTMLDivElement | null = null;
+ render(
+ createElement(
+ Card,
+ {
+ ref: (el: HTMLDivElement | null) => {
+ ref = el;
+ },
+ },
+ "Card",
+ ),
+ );
+ expect(ref).toBeInstanceOf(HTMLDivElement);
+ expect(ref?.tagName).toBe("DIV");
+ });
+
+ it("forwards ref to CardHeader element", () => {
+ let ref: HTMLDivElement | null = null;
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardHeader,
+ {
+ ref: (el: HTMLDivElement | null) => {
+ ref = el;
+ },
+ },
+ "Header",
+ ),
+ ),
+ );
+ expect(ref).toBeInstanceOf(HTMLDivElement);
+ });
+
+ it("forwards ref to CardTitle element", () => {
+ let ref: HTMLHeadingElement | null = null;
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardHeader,
+ null,
+ createElement(
+ CardTitle,
+ {
+ ref: (el: HTMLHeadingElement | null) => {
+ ref = el;
+ },
+ },
+ "Title",
+ ),
+ ),
+ ),
+ );
+ expect(ref).toBeInstanceOf(HTMLHeadingElement);
+ expect(ref?.tagName).toBe("H3");
+ });
+
+ it("forwards ref to CardDescription element", () => {
+ let ref: HTMLParagraphElement | null = null;
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardHeader,
+ null,
+ createElement(
+ CardDescription,
+ {
+ ref: (el: HTMLParagraphElement | null) => {
+ ref = el;
+ },
+ },
+ "Description",
+ ),
+ ),
+ ),
+ );
+ expect(ref).toBeInstanceOf(HTMLParagraphElement);
+ });
+
+ it("forwards ref to CardContent element", () => {
+ let ref: HTMLDivElement | null = null;
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardContent,
+ {
+ ref: (el: HTMLDivElement | null) => {
+ ref = el;
+ },
+ },
+ "Content",
+ ),
+ ),
+ );
+ expect(ref).toBeInstanceOf(HTMLDivElement);
+ });
+
+ it("forwards ref to CardFooter element", () => {
+ let ref: HTMLDivElement | null = null;
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardFooter,
+ {
+ ref: (el: HTMLDivElement | null) => {
+ ref = el;
+ },
+ },
+ "Footer",
+ ),
+ ),
+ );
+ expect(ref).toBeInstanceOf(HTMLDivElement);
+ });
+ });
+
+ describe("HTML Attributes", () => {
+ it("passes through HTML attributes to Card", () => {
+ render(
+ createElement(
+ Card,
+ {
+ id: "test-card",
+ "data-testid": "card",
+ role: "article",
+ },
+ "Card",
+ ),
+ );
+ const card = screen.getByTestId("card");
+ expect(card).toHaveAttribute("id", "test-card");
+ expect(card).toHaveAttribute("role", "article");
+ });
+
+ it("passes through onClick handler to Card", () => {
+ let clicked = false;
+ render(
+ createElement(
+ Card,
+ {
+ onClick: () => {
+ clicked = true;
+ },
+ "data-testid": "card",
+ },
+ "Clickable Card",
+ ),
+ );
+ const card = screen.getByTestId("card");
+ card.click();
+ expect(clicked).toBe(true);
+ });
+ });
+
+ describe("Base Classes", () => {
+ it("Card always includes base classes", () => {
+ render(
+ createElement(
+ Card,
+ {
+ "data-testid": "card",
+ },
+ "Card",
+ ),
+ );
+ const card = screen.getByTestId("card");
+ expect(card.className).toContain("relative");
+ expect(card.className).toContain("rounded-lg");
+ expect(card.className).toContain("transition-base");
+ });
+
+ it("CardHeader always includes base classes", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardHeader,
+ {
+ "data-testid": "header",
+ },
+ "Header",
+ ),
+ ),
+ );
+ const header = screen.getByTestId("header");
+ expect(header.className).toContain("flex");
+ expect(header.className).toContain("flex-col");
+ });
+
+ it("CardTitle always includes base classes", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardHeader,
+ null,
+ createElement(
+ CardTitle,
+ {
+ "data-testid": "title",
+ },
+ "Title",
+ ),
+ ),
+ ),
+ );
+ const title = screen.getByTestId("title");
+ expect(title.className).toContain("text-2xl");
+ expect(title.className).toContain("font-semibold");
+ expect(title.className).toContain("leading-none");
+ expect(title.className).toContain("tracking-tight");
+ });
+
+ it("CardDescription always includes base classes", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardHeader,
+ null,
+ createElement(
+ CardDescription,
+ {
+ "data-testid": "description",
+ },
+ "Description",
+ ),
+ ),
+ ),
+ );
+ const description = screen.getByTestId("description");
+ expect(description.className).toContain("text-sm");
+ expect(description.className).toContain("text-foreground-secondary");
+ expect(description.className).toContain("mt-1.5");
+ });
+
+ it("CardFooter always includes base classes", () => {
+ render(
+ createElement(
+ Card,
+ null,
+ createElement(
+ CardFooter,
+ {
+ "data-testid": "footer",
+ },
+ "Footer",
+ ),
+ ),
+ );
+ const footer = screen.getByTestId("footer");
+ expect(footer.className).toContain("flex");
+ expect(footer.className).toContain("items-center");
+ });
+ });
+
+ describe("Complex Composition", () => {
+ it("renders a complete card with all sections", () => {
+ render(
+ createElement(
+ Card,
+ { variant: "elevated", "data-testid": "card" },
+ createElement(
+ CardHeader,
+ { "data-testid": "header" },
+ createElement(CardTitle, null, "Card Title"),
+ createElement(CardDescription, null, "This is a card description"),
+ ),
+ createElement(
+ CardContent,
+ { "data-testid": "content" },
+ "Card content goes here",
+ ),
+ createElement(
+ CardFooter,
+ { "data-testid": "footer" },
+ "Footer actions",
+ ),
+ ),
+ );
+
+ const card = screen.getByTestId("card");
+ expect(card).toBeInTheDocument();
+ expect(screen.getByText("Card Title")).toBeInTheDocument();
+ expect(
+ screen.getByText("This is a card description"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Card content goes here")).toBeInTheDocument();
+ expect(screen.getByText("Footer actions")).toBeInTheDocument();
+ });
+
+ it("maintains proper structure hierarchy", () => {
+ render(
+ createElement(
+ Card,
+ { "data-testid": "card" },
+ createElement(
+ CardHeader,
+ { "data-testid": "header" },
+ createElement(CardTitle, null, "Title"),
+ ),
+ createElement(CardContent, { "data-testid": "content" }, "Content"),
+ ),
+ );
+
+ const card = screen.getByTestId("card");
+ const header = screen.getByTestId("header");
+ const content = screen.getByTestId("content");
+
+ expect(card.contains(header)).toBe(true);
+ expect(card.contains(content)).toBe(true);
+ });
+ });
+});
diff --git a/packages/ui/src/Card/Card.ts b/packages/ui/src/Card/Card.ts
index adf9305..2231ae8 100644
--- a/packages/ui/src/Card/Card.ts
+++ b/packages/ui/src/Card/Card.ts
@@ -1,53 +1,160 @@
-import { createElement, forwardRef, type HTMLAttributes } from 'react';
-import { cn } from '@tpmjs/utils/cn';
+import { cn } from "@tpmjs/utils/cn";
+import { createElement, forwardRef } from "react";
+import type {
+ CardContentProps,
+ CardDescriptionProps,
+ CardFooterProps,
+ CardHeaderProps,
+ CardProps,
+ CardTitleProps,
+} from "./types";
+import {
+ cardContentVariants,
+ cardDescriptionVariants,
+ cardFooterVariants,
+ cardHeaderVariants,
+ cardTitleVariants,
+ cardVariants,
+} from "./variants";
-export interface CardProps extends HTMLAttributes {}
+/**
+ * Card component
+ *
+ * A flexible container component with multiple variants and sub-components.
+ * Built with .ts-only React using createElement.
+ *
+ * @example
+ * ```typescript
+ * import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@tpmjs/ui/Card/Card';
+ * import { createElement } from 'react';
+ *
+ * function MyComponent() {
+ * return createElement(Card, { variant: 'elevated' },
+ * createElement(CardHeader, null,
+ * createElement(CardTitle, null, 'Card Title'),
+ * createElement(CardDescription, null, 'Card description text')
+ * ),
+ * createElement(CardContent, null, 'Card content goes here'),
+ * createElement(CardFooter, null, 'Footer content')
+ * );
+ * }
+ * ```
+ */
+export const Card = forwardRef(
+ ({ className, variant = "default", padding = "none", ...props }, ref) => {
+ return createElement("div", {
+ ref,
+ className: cn(
+ cardVariants({
+ variant,
+ padding,
+ }),
+ className,
+ ),
+ ...props,
+ });
+ },
+);
-export const Card = forwardRef(({ className, ...props }, ref) => {
- return createElement('div', {
- ref,
- className: cn(
- 'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
- className
- ),
- ...props,
- });
+Card.displayName = "Card";
+
+/**
+ * CardHeader component
+ *
+ * Header section of a card, typically contains title and description.
+ */
+export const CardHeader = forwardRef(
+ ({ className, padding = "md", ...props }, ref) => {
+ return createElement("div", {
+ ref,
+ className: cn(
+ cardHeaderVariants({
+ padding,
+ }),
+ className,
+ ),
+ ...props,
+ });
+ },
+);
+
+CardHeader.displayName = "CardHeader";
+
+/**
+ * CardTitle component
+ *
+ * Title heading for a card.
+ */
+export const CardTitle = forwardRef(
+ ({ className, as = "h3", ...props }, ref) => {
+ return createElement(as, {
+ ref,
+ className: cn(cardTitleVariants(), className),
+ ...props,
+ });
+ },
+);
+
+CardTitle.displayName = "CardTitle";
+
+/**
+ * CardDescription component
+ *
+ * Description text for a card, typically placed below the title.
+ */
+export const CardDescription = forwardRef<
+ HTMLParagraphElement,
+ CardDescriptionProps
+>(({ className, ...props }, ref) => {
+ return createElement("p", {
+ ref,
+ className: cn(cardDescriptionVariants(), className),
+ ...props,
+ });
});
-Card.displayName = 'Card';
+CardDescription.displayName = "CardDescription";
-export const CardHeader = forwardRef(
- ({ className, ...props }, ref) => {
- return createElement('div', {
- ref,
- className: cn('flex flex-col space-y-1.5 p-6', className),
- ...props,
- });
- }
+/**
+ * CardContent component
+ *
+ * Main content area of a card.
+ */
+export const CardContent = forwardRef(
+ ({ className, padding = "md", ...props }, ref) => {
+ return createElement("div", {
+ ref,
+ className: cn(
+ cardContentVariants({
+ padding,
+ }),
+ className,
+ ),
+ ...props,
+ });
+ },
);
-CardHeader.displayName = 'CardHeader';
+CardContent.displayName = "CardContent";
-export const CardTitle = forwardRef>(
- ({ className, ...props }, ref) => {
- return createElement('h3', {
- ref,
- className: cn('text-2xl font-semibold leading-none tracking-tight', className),
- ...props,
- });
- }
+/**
+ * CardFooter component
+ *
+ * Footer section of a card, typically contains actions.
+ */
+export const CardFooter = forwardRef(
+ ({ className, padding = "md", ...props }, ref) => {
+ return createElement("div", {
+ ref,
+ className: cn(
+ cardFooterVariants({
+ padding,
+ }),
+ className,
+ ),
+ ...props,
+ });
+ },
);
-CardTitle.displayName = 'CardTitle';
-
-export const CardContent = forwardRef(
- ({ className, ...props }, ref) => {
- return createElement('div', {
- ref,
- className: cn('p-6 pt-0', className),
- ...props,
- });
- }
-);
-
-CardContent.displayName = 'CardContent';
+CardFooter.displayName = "CardFooter";
diff --git a/packages/ui/src/Card/tokens.ts b/packages/ui/src/Card/tokens.ts
new file mode 100644
index 0000000..979f6b9
--- /dev/null
+++ b/packages/ui/src/Card/tokens.ts
@@ -0,0 +1,45 @@
+import { borderRadius, spacing } from "../tokens";
+
+/**
+ * Card component design tokens
+ * Maps global tokens to card-specific semantics
+ */
+export const cardTokens = {
+ /** Card padding for each size */
+ padding: {
+ none: spacing[0], // 0
+ sm: spacing[4], // 1rem (16px)
+ md: spacing[6], // 1.5rem (24px)
+ lg: spacing[8], // 2rem (32px)
+ },
+
+ /** Header/Content/Footer padding */
+ section: {
+ padding: {
+ none: spacing[0], // 0
+ sm: spacing[4], // 1rem (16px)
+ md: spacing[6], // 1.5rem (24px)
+ lg: spacing[8], // 2rem (32px)
+ },
+ gap: spacing[6], // 1.5rem (24px) - space between sections
+ },
+
+ /** Title spacing */
+ title: {
+ marginBottom: spacing[1.5], // 0.375rem (6px)
+ },
+
+ /** Description spacing */
+ description: {
+ marginTop: spacing[2], // 0.5rem (8px)
+ },
+
+ /** Border radius */
+ borderRadius: borderRadius.lg,
+
+ /** Elevation shadows */
+ elevation: {
+ default: "shadow-sm",
+ elevated: "shadow-md",
+ },
+} as const;
diff --git a/packages/ui/src/Card/types.ts b/packages/ui/src/Card/types.ts
new file mode 100644
index 0000000..d76dce2
--- /dev/null
+++ b/packages/ui/src/Card/types.ts
@@ -0,0 +1,73 @@
+import type { HTMLAttributes } from "react";
+
+/**
+ * Card component props
+ */
+export interface CardProps extends HTMLAttributes {
+ /**
+ * Visual variant of the card
+ * @default 'default'
+ */
+ variant?: "default" | "elevated" | "outline" | "ghost";
+
+ /**
+ * Padding size for the card
+ * @default 'md'
+ */
+ padding?: "none" | "sm" | "md" | "lg";
+}
+
+/**
+ * CardHeader component props
+ */
+export interface CardHeaderProps extends HTMLAttributes {
+ /**
+ * Padding size for the header
+ * @default 'md'
+ */
+ padding?: "none" | "sm" | "md" | "lg";
+}
+
+/**
+ * CardTitle component props
+ */
+export interface CardTitleProps extends HTMLAttributes {
+ /**
+ * Heading level
+ * @default 'h3'
+ */
+ as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
+}
+
+/**
+ * CardDescription component props
+ */
+export interface CardDescriptionProps
+ extends HTMLAttributes {}
+
+/**
+ * CardContent component props
+ */
+export interface CardContentProps extends HTMLAttributes {
+ /**
+ * Padding size for the content
+ * @default 'md'
+ */
+ padding?: "none" | "sm" | "md" | "lg";
+}
+
+/**
+ * CardFooter component props
+ */
+export interface CardFooterProps extends HTMLAttributes {
+ /**
+ * Padding size for the footer
+ * @default 'md'
+ */
+ padding?: "none" | "sm" | "md" | "lg";
+}
+
+/**
+ * Card ref type
+ */
+export type CardRef = HTMLDivElement;
diff --git a/packages/ui/src/Card/variants.ts b/packages/ui/src/Card/variants.ts
new file mode 100644
index 0000000..7207933
--- /dev/null
+++ b/packages/ui/src/Card/variants.ts
@@ -0,0 +1,150 @@
+import { createVariants } from "../system/variants";
+
+/**
+ * Card variant definitions
+ * Uses custom variant system for type-safe class composition
+ */
+export const cardVariants = createVariants({
+ base: [
+ // Layout
+ "relative",
+ // Borders & Radius
+ "rounded-lg",
+ // Transitions
+ "transition-base",
+ ].join(" "),
+
+ variants: {
+ variant: {
+ default: [
+ "border border-border",
+ "bg-card text-card-foreground",
+ "shadow-sm",
+ ].join(" "),
+
+ elevated: [
+ "border border-border",
+ "bg-surface-elevated text-card-foreground",
+ "shadow-md",
+ ].join(" "),
+
+ outline: [
+ "border-2 border-border",
+ "bg-transparent text-foreground",
+ ].join(" "),
+
+ ghost: ["bg-transparent text-foreground"].join(" "),
+ },
+
+ padding: {
+ none: "p-0",
+ sm: "p-4",
+ md: "p-6",
+ lg: "p-8",
+ },
+ },
+
+ compoundVariants: [],
+
+ defaultVariants: {
+ variant: "default",
+ padding: "none", // Card itself has no padding, sections do
+ },
+});
+
+/**
+ * CardHeader variant definitions
+ */
+export const cardHeaderVariants = createVariants({
+ base: ["flex flex-col"].join(" "),
+
+ variants: {
+ padding: {
+ none: "p-0",
+ sm: "p-4",
+ md: "p-6",
+ lg: "p-8",
+ },
+ },
+
+ compoundVariants: [],
+
+ defaultVariants: {
+ padding: "md",
+ },
+});
+
+/**
+ * CardTitle variant definitions
+ */
+export const cardTitleVariants = createVariants({
+ base: [
+ "text-2xl font-semibold leading-none tracking-tight",
+ "text-foreground",
+ ].join(" "),
+
+ variants: {},
+
+ compoundVariants: [],
+
+ defaultVariants: {},
+});
+
+/**
+ * CardDescription variant definitions
+ */
+export const cardDescriptionVariants = createVariants({
+ base: ["text-sm text-foreground-secondary", "mt-1.5"].join(" "),
+
+ variants: {},
+
+ compoundVariants: [],
+
+ defaultVariants: {},
+});
+
+/**
+ * CardContent variant definitions
+ */
+export const cardContentVariants = createVariants({
+ base: [
+ // Base content styles
+ ].join(" "),
+
+ variants: {
+ padding: {
+ none: "p-0",
+ sm: "p-4 pt-0",
+ md: "p-6 pt-0",
+ lg: "p-8 pt-0",
+ },
+ },
+
+ compoundVariants: [],
+
+ defaultVariants: {
+ padding: "md",
+ },
+});
+
+/**
+ * CardFooter variant definitions
+ */
+export const cardFooterVariants = createVariants({
+ base: ["flex items-center"].join(" "),
+
+ variants: {
+ padding: {
+ none: "p-0",
+ sm: "p-4 pt-0",
+ md: "p-6 pt-0",
+ lg: "p-8 pt-0",
+ },
+ },
+
+ compoundVariants: [],
+
+ defaultVariants: {
+ padding: "md",
+ },
+});
diff --git a/packages/ui/src/CodeBlock/CodeBlock.test.ts b/packages/ui/src/CodeBlock/CodeBlock.test.ts
new file mode 100644
index 0000000..0f53d42
--- /dev/null
+++ b/packages/ui/src/CodeBlock/CodeBlock.test.ts
@@ -0,0 +1,424 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { createElement } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { CodeBlock } from "./CodeBlock";
+
+// Mock clipboard API
+const mockClipboard = {
+ writeText: vi.fn(),
+};
+
+Object.defineProperty(navigator, "clipboard", {
+ value: mockClipboard,
+ writable: true,
+ configurable: true,
+});
+
+describe("CodeBlock", () => {
+ beforeEach(() => {
+ mockClipboard.writeText.mockClear();
+ });
+
+ describe("Rendering", () => {
+ it("renders a code block element", () => {
+ render(
+ createElement(CodeBlock, {
+ code: 'console.log("hello")',
+ "data-testid": "codeblock",
+ }),
+ );
+ const codeblock = screen.getByTestId("codeblock");
+ expect(codeblock).toBeInTheDocument();
+ expect(codeblock.tagName).toBe("DIV");
+ });
+
+ it("renders code content", () => {
+ render(createElement(CodeBlock, { code: "npm install package" }));
+ expect(screen.getByText("npm install package")).toBeInTheDocument();
+ });
+
+ it("renders code in a code element", () => {
+ render(
+ createElement(CodeBlock, {
+ code: "test code",
+ "data-testid": "codeblock",
+ }),
+ );
+ const codeblock = screen.getByTestId("codeblock");
+ const code = codeblock.querySelector("code");
+ expect(code).toBeInTheDocument();
+ expect(code?.textContent).toBe("test code");
+ });
+
+ it("preserves whitespace in code", () => {
+ const multilineCode = "line 1\n line 2\n line 3";
+ render(
+ createElement(CodeBlock, {
+ code: multilineCode,
+ "data-testid": "codeblock",
+ }),
+ );
+ const codeblock = screen.getByTestId("codeblock");
+ const code = codeblock.querySelector("code");
+ expect(code?.textContent).toBe(multilineCode);
+ });
+ });
+
+ describe("Language", () => {
+ it("sets data-language attribute", () => {
+ render(
+ createElement(CodeBlock, {
+ code: "const x = 5",
+ language: "javascript",
+ "data-testid": "codeblock",
+ }),
+ );
+ const codeblock = screen.getByTestId("codeblock");
+ const code = codeblock.querySelector("code");
+ expect(code).toHaveAttribute("data-language", "javascript");
+ });
+
+ it("defaults to text language", () => {
+ render(
+ createElement(CodeBlock, {
+ code: "plain text",
+ "data-testid": "codeblock",
+ }),
+ );
+ const codeblock = screen.getByTestId("codeblock");
+ const code = codeblock.querySelector("code");
+ expect(code).toHaveAttribute("data-language", "text");
+ });
+
+ it("supports different languages", () => {
+ const { rerender } = render(
+ createElement(CodeBlock, {
+ code: "code",
+ language: "python",
+ "data-testid": "codeblock",
+ }),
+ );
+ let codeblock = screen.getByTestId("codeblock");
+ let code = codeblock.querySelector("code");
+ expect(code).toHaveAttribute("data-language", "python");
+
+ rerender(
+ createElement(CodeBlock, {
+ code: "code",
+ language: "bash",
+ "data-testid": "codeblock",
+ }),
+ );
+ codeblock = screen.getByTestId("codeblock");
+ code = codeblock.querySelector("code");
+ expect(code).toHaveAttribute("data-language", "bash");
+ });
+ });
+
+ describe("Size Variants", () => {
+ it("applies small size", () => {
+ render(
+ createElement(CodeBlock, {
+ code: "code",
+ size: "sm",
+ "data-testid": "codeblock",
+ }),
+ );
+ const codeblock = screen.getByTestId("codeblock");
+ const code = codeblock.querySelector("code");
+ expect(code?.className).toContain("text-xs");
+ expect(code?.className).toContain("p-3");
+ });
+
+ it("applies medium size", () => {
+ render(
+ createElement(CodeBlock, {
+ code: "code",
+ size: "md",
+ "data-testid": "codeblock",
+ }),
+ );
+ const codeblock = screen.getByTestId("codeblock");
+ const code = codeblock.querySelector("code");
+ expect(code?.className).toContain("text-sm");
+ expect(code?.className).toContain("p-4");
+ });
+
+ it("applies large size", () => {
+ render(
+ createElement(CodeBlock, {
+ code: "code",
+ size: "lg",
+ "data-testid": "codeblock",
+ }),
+ );
+ const codeblock = screen.getByTestId("codeblock");
+ const code = codeblock.querySelector("code");
+ expect(code?.className).toContain("text-base");
+ expect(code?.className).toContain("p-6");
+ });
+
+ it("uses md size by default", () => {
+ render(
+ createElement(CodeBlock, { code: "code", "data-testid": "codeblock" }),
+ );
+ const codeblock = screen.getByTestId("codeblock");
+ const code = codeblock.querySelector("code");
+ expect(code?.className).toContain("text-sm");
+ expect(code?.className).toContain("p-4");
+ });
+ });
+
+ describe("Copy Button", () => {
+ it("shows copy button by default", () => {
+ render(createElement(CodeBlock, { code: "code" }));
+ expect(screen.getByTestId("copy-button")).toBeInTheDocument();
+ });
+
+ it("hides copy button when showCopy is false", () => {
+ render(createElement(CodeBlock, { code: "code", showCopy: false }));
+ expect(screen.queryByTestId("copy-button")).not.toBeInTheDocument();
+ });
+
+ it("copy button has correct aria-label", () => {
+ render(createElement(CodeBlock, { code: "code" }));
+ const button = screen.getByTestId("copy-button");
+ expect(button).toHaveAttribute("aria-label", "Copy code");
+ });
+
+ it("copy button is a button element", () => {
+ render(createElement(CodeBlock, { code: "code" }));
+ const button = screen.getByTestId("copy-button");
+ expect(button.tagName).toBe("BUTTON");
+ expect(button).toHaveAttribute("type", "button");
+ });
+ });
+
+ describe("Copy Functionality", () => {
+ it("copies code to clipboard when button clicked", async () => {
+ render(createElement(CodeBlock, { code: "test code" }));
+ const button = screen.getByTestId("copy-button");
+ button.click();
+
+ await waitFor(() => {
+ expect(mockClipboard.writeText).toHaveBeenCalledWith("test code");
+ });
+ });
+
+ it("shows check icon after successful copy", async () => {
+ render(
+ createElement(CodeBlock, {
+ code: "test code",
+ "data-testid": "codeblock",
+ }),
+ );
+ const button = screen.getByTestId("copy-button");
+ button.click();
+
+ await waitFor(() => {
+ const icon = button.querySelector("svg");
+ // Check icon has a different path than copy icon
+ const path = icon?.querySelector("path");
+ expect(path).toHaveAttribute(
+ "d",
+ "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z",
+ );
+ });
+ });
+
+ it('changes aria-label to "Copied!" after copy', async () => {
+ render(createElement(CodeBlock, { code: "test code" }));
+ const button = screen.getByTestId("copy-button");
+ button.click();
+
+ await waitFor(() => {
+ expect(button).toHaveAttribute("aria-label", "Copied!");
+ });
+ });
+
+ it("reverts to copy icon after 2 seconds", async () => {
+ vi.useFakeTimers();
+
+ try {
+ render(
+ createElement(CodeBlock, {
+ code: "test code",
+ "data-testid": "codeblock",
+ }),
+ );
+ const button = screen.getByTestId("copy-button");
+ button.click();
+
+ // Wait for copy to complete
+ await vi.waitFor(() => {
+ expect(mockClipboard.writeText).toHaveBeenCalled();
+ });
+
+ // Verify check icon appears
+ await vi.waitFor(() => {
+ const icon = button.querySelector("svg");
+ const path = icon?.querySelector("path");
+ expect(path).toHaveAttribute(
+ "d",
+ "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z",
+ );
+ });
+
+ // Fast forward 2 seconds
+ vi.advanceTimersByTime(2000);
+
+ // Verify copy icon is back
+ await vi.waitFor(() => {
+ const icon = button.querySelector("svg");
+ const path = icon?.querySelector("path");
+ expect(path).toHaveAttribute(
+ "d",
+ "M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z",
+ );
+ });
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("handles clipboard API errors gracefully", async () => {
+ const consoleErrorSpy = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+ mockClipboard.writeText.mockRejectedValueOnce(
+ new Error("Clipboard not available"),
+ );
+
+ try {
+ render(createElement(CodeBlock, { code: "test code" }));
+ const button = screen.getByTestId("copy-button");
+ button.click();
+
+ await waitFor(
+ () => {
+ expect(consoleErrorSpy).toHaveBeenCalled();
+ },
+ { timeout: 1000 },
+ );
+ } finally {
+ consoleErrorSpy.mockRestore();
+ }
+ });
+ });
+
+ describe("HTML Attributes", () => {
+ it("passes through id attribute", () => {
+ render(
+ createElement(CodeBlock, {
+ code: "code",
+ id: "codeblock-id",
+ "data-testid": "codeblock",
+ }),
+ );
+ const codeblock = screen.getByTestId("codeblock");
+ expect(codeblock).toHaveAttribute("id", "codeblock-id");
+ });
+
+ it("passes through data attributes", () => {
+ render(
+ createElement(CodeBlock, {
+ code: "code",
+ "data-custom": "test",
+ "data-testid": "codeblock",
+ }),
+ );
+ const codeblock = screen.getByTestId("codeblock");
+ expect(codeblock).toHaveAttribute("data-custom", "test");
+ });
+ });
+
+ describe("Custom className", () => {
+ it("merges custom className with variant classes", () => {
+ render(
+ createElement(CodeBlock, {
+ code: "code",
+ className: "custom-class",
+ "data-testid": "codeblock",
+ }),
+ );
+ const codeblock = screen.getByTestId("codeblock");
+ expect(codeblock.className).toContain("custom-class");
+ expect(codeblock.className).toContain("bg-zinc-900");
+ expect(codeblock.className).toContain("border");
+ });
+ });
+
+ describe("Ref Forwarding", () => {
+ it("forwards ref to container element", () => {
+ let ref: HTMLDivElement | null = null;
+ render(
+ createElement(CodeBlock, {
+ code: "code",
+ ref: (el: HTMLDivElement | null) => {
+ ref = el;
+ },
+ }),
+ );
+ expect(ref).toBeInstanceOf(HTMLDivElement);
+ expect(ref?.querySelector("code")).toBeInTheDocument();
+ });
+ });
+
+ describe("Base Classes", () => {
+ it("container includes base classes", () => {
+ render(
+ createElement(CodeBlock, { code: "code", "data-testid": "codeblock" }),
+ );
+ const codeblock = screen.getByTestId("codeblock");
+ expect(codeblock.className).toContain("relative");
+ expect(codeblock.className).toContain("bg-zinc-900");
+ expect(codeblock.className).toContain("border");
+ expect(codeblock.className).toContain("overflow-hidden");
+ });
+
+ it("code element includes base classes", () => {
+ render(
+ createElement(CodeBlock, { code: "code", "data-testid": "codeblock" }),
+ );
+ const codeblock = screen.getByTestId("codeblock");
+ const code = codeblock.querySelector("code");
+ expect(code?.className).toContain("block");
+ expect(code?.className).toContain("font-mono");
+ expect(code?.className).toContain("text-zinc-300");
+ expect(code?.className).toContain("overflow-x-auto");
+ expect(code?.className).toContain("whitespace-pre");
+ });
+ });
+
+ describe("Compound Scenarios", () => {
+ it("works correctly with large size and custom language", () => {
+ render(
+ createElement(CodeBlock, {
+ code: 'def hello():\n print("world")',
+ language: "python",
+ size: "lg",
+ "data-testid": "codeblock",
+ }),
+ );
+ const codeblock = screen.getByTestId("codeblock");
+ const code = codeblock.querySelector("code");
+ expect(code?.className).toContain("text-base");
+ expect(code?.className).toContain("p-6");
+ expect(code).toHaveAttribute("data-language", "python");
+ });
+
+ it("works correctly with no copy button and custom className", () => {
+ render(
+ createElement(CodeBlock, {
+ code: "test",
+ showCopy: false,
+ className: "my-custom-class",
+ "data-testid": "codeblock",
+ }),
+ );
+ const codeblock = screen.getByTestId("codeblock");
+ expect(codeblock.className).toContain("my-custom-class");
+ expect(screen.queryByTestId("copy-button")).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/ui/src/CodeBlock/CodeBlock.ts b/packages/ui/src/CodeBlock/CodeBlock.ts
new file mode 100644
index 0000000..0943efc
--- /dev/null
+++ b/packages/ui/src/CodeBlock/CodeBlock.ts
@@ -0,0 +1,98 @@
+import { cn } from "@tpmjs/utils/cn";
+import { createElement, forwardRef, useState } from "react";
+import { Icon } from "../Icon/Icon";
+import type { CodeBlockProps } from "./types";
+import {
+ codeBlockCodeVariants,
+ codeBlockContainerVariants,
+ codeBlockCopyButtonVariants,
+} from "./variants";
+
+/**
+ * CodeBlock component
+ *
+ * Displays formatted code with optional copy functionality.
+ * Includes syntax-highlighted display and copy-to-clipboard button.
+ * Built with .ts-only React using createElement.
+ *
+ * @example
+ * ```typescript
+ * import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
+ * import { createElement } from 'react';
+ *
+ * function MyComponent() {
+ * return createElement(CodeBlock, {
+ * code: 'npm install @tpmjs/registry',
+ * language: 'bash',
+ * size: 'md',
+ * showCopy: true,
+ * });
+ * }
+ * ```
+ */
+export const CodeBlock = forwardRef(
+ (
+ {
+ className,
+ code,
+ language = "text",
+ size = "md",
+ showCopy = true,
+ ...props
+ },
+ ref,
+ ) => {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(code);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ // Silently fail if clipboard API is not available
+ console.error("Failed to copy code:", err);
+ }
+ };
+
+ return createElement(
+ "div",
+ {
+ className: cn(codeBlockContainerVariants(), className),
+ ref,
+ ...props,
+ },
+ [
+ createElement(
+ "code",
+ {
+ key: "code",
+ className: codeBlockCodeVariants({
+ size,
+ }),
+ "data-language": language,
+ },
+ code,
+ ),
+ showCopy &&
+ createElement(
+ "button",
+ {
+ key: "copy-button",
+ type: "button",
+ className: codeBlockCopyButtonVariants(),
+ onClick: handleCopy,
+ "aria-label": copied ? "Copied!" : "Copy code",
+ "data-testid": "copy-button",
+ },
+ createElement(Icon, {
+ icon: copied ? "check" : "copy",
+ size: "sm",
+ }),
+ ),
+ ].filter(Boolean),
+ );
+ },
+);
+
+CodeBlock.displayName = "CodeBlock";
diff --git a/packages/ui/src/CodeBlock/tokens.ts b/packages/ui/src/CodeBlock/tokens.ts
new file mode 100644
index 0000000..66fa425
--- /dev/null
+++ b/packages/ui/src/CodeBlock/tokens.ts
@@ -0,0 +1,24 @@
+import { fontSize, spacing } from "../tokens";
+
+/**
+ * CodeBlock component design tokens
+ * Maps global tokens to code block-specific semantics
+ */
+export const codeBlockTokens = {
+ /** Padding for each size */
+ padding: {
+ sm: spacing[3], // 0.75rem (12px)
+ md: spacing[4], // 1rem (16px)
+ lg: spacing[6], // 1.5rem (24px)
+ },
+
+ /** Font size for each size */
+ fontSize: {
+ sm: fontSize.xs, // 0.75rem (12px)
+ md: fontSize.sm, // 0.875rem (14px)
+ lg: fontSize.base, // 1rem (16px)
+ },
+
+ /** Border radius */
+ radius: spacing[2], // 0.5rem (8px)
+} as const;
diff --git a/packages/ui/src/CodeBlock/types.ts b/packages/ui/src/CodeBlock/types.ts
new file mode 100644
index 0000000..10d041f
--- /dev/null
+++ b/packages/ui/src/CodeBlock/types.ts
@@ -0,0 +1,35 @@
+import type { HTMLAttributes } from "react";
+
+/**
+ * CodeBlock component props
+ */
+export interface CodeBlockProps
+ extends Omit, "children"> {
+ /**
+ * Code content to display
+ */
+ code: string;
+
+ /**
+ * Programming language (for display/metadata only)
+ * @default 'text'
+ */
+ language?: string;
+
+ /**
+ * Size variant
+ * @default 'md'
+ */
+ size?: "sm" | "md" | "lg";
+
+ /**
+ * Whether to show copy button
+ * @default true
+ */
+ showCopy?: boolean;
+}
+
+/**
+ * CodeBlock ref type
+ */
+export type CodeBlockRef = HTMLDivElement;
diff --git a/packages/ui/src/CodeBlock/variants.ts b/packages/ui/src/CodeBlock/variants.ts
new file mode 100644
index 0000000..149d967
--- /dev/null
+++ b/packages/ui/src/CodeBlock/variants.ts
@@ -0,0 +1,85 @@
+import { createVariants } from "../system/variants";
+
+/**
+ * CodeBlock container variant definitions
+ */
+export const codeBlockContainerVariants = createVariants({
+ base: [
+ // Layout
+ "relative",
+ // Background
+ "bg-zinc-900",
+ // Border
+ "border border-zinc-800 rounded-lg",
+ // Overflow
+ "overflow-hidden",
+ ].join(" "),
+
+ variants: {},
+
+ compoundVariants: [],
+
+ defaultVariants: {},
+});
+
+/**
+ * CodeBlock code element variant definitions
+ */
+export const codeBlockCodeVariants = createVariants({
+ base: [
+ // Display
+ "block",
+ // Font
+ "font-mono",
+ // Color
+ "text-zinc-300",
+ // Overflow
+ "overflow-x-auto",
+ // Whitespace
+ "whitespace-pre",
+ ].join(" "),
+
+ variants: {
+ size: {
+ sm: "text-xs p-3", // 12px font, 12px padding
+ md: "text-sm p-4", // 14px font, 16px padding
+ lg: "text-base p-6", // 16px font, 24px padding
+ },
+ },
+
+ compoundVariants: [],
+
+ defaultVariants: {
+ size: "md",
+ },
+});
+
+/**
+ * CodeBlock copy button variant definitions
+ */
+export const codeBlockCopyButtonVariants = createVariants({
+ base: [
+ // Position
+ "absolute top-2 right-2",
+ // Display
+ "flex items-center justify-center",
+ // Size
+ "w-8 h-8",
+ // Background
+ "bg-zinc-800 hover:bg-zinc-700",
+ // Border
+ "border border-zinc-700 rounded",
+ // Color
+ "text-zinc-400 hover:text-zinc-100",
+ // Cursor
+ "cursor-pointer",
+ // Transition
+ "transition-colors duration-200",
+ ].join(" "),
+
+ variants: {},
+
+ compoundVariants: [],
+
+ defaultVariants: {},
+});
diff --git a/packages/ui/src/Container/Container.test.ts b/packages/ui/src/Container/Container.test.ts
new file mode 100644
index 0000000..ddafebf
--- /dev/null
+++ b/packages/ui/src/Container/Container.test.ts
@@ -0,0 +1,298 @@
+import { render, screen } from "@testing-library/react";
+import { createElement } from "react";
+import { describe, expect, it } from "vitest";
+import { Container } from "./Container";
+
+describe("Container", () => {
+ describe("Rendering", () => {
+ it("renders a container element", () => {
+ render(
+ createElement(Container, { "data-testid": "container" }, "Content"),
+ );
+ const container = screen.getByTestId("container");
+ expect(container).toBeInTheDocument();
+ expect(container.tagName).toBe("DIV");
+ });
+
+ it("renders children content", () => {
+ render(createElement(Container, null, "Container content"));
+ expect(screen.getByText("Container content")).toBeInTheDocument();
+ });
+ });
+
+ describe("Size Variants", () => {
+ it("applies small size max-width", () => {
+ render(
+ createElement(
+ Container,
+ { size: "sm", "data-testid": "container" },
+ "Small",
+ ),
+ );
+ const container = screen.getByTestId("container");
+ expect(container.className).toContain("max-w-screen-sm");
+ });
+
+ it("applies medium size max-width", () => {
+ render(
+ createElement(
+ Container,
+ { size: "md", "data-testid": "container" },
+ "Medium",
+ ),
+ );
+ const container = screen.getByTestId("container");
+ expect(container.className).toContain("max-w-screen-md");
+ });
+
+ it("applies large size max-width", () => {
+ render(
+ createElement(
+ Container,
+ { size: "lg", "data-testid": "container" },
+ "Large",
+ ),
+ );
+ const container = screen.getByTestId("container");
+ expect(container.className).toContain("max-w-screen-lg");
+ });
+
+ it("applies extra-large size max-width (default)", () => {
+ render(
+ createElement(
+ Container,
+ { size: "xl", "data-testid": "container" },
+ "XLarge",
+ ),
+ );
+ const container = screen.getByTestId("container");
+ expect(container.className).toContain("max-w-screen-xl");
+ });
+
+ it("applies 2xl size max-width", () => {
+ render(
+ createElement(
+ Container,
+ { size: "2xl", "data-testid": "container" },
+ "2XLarge",
+ ),
+ );
+ const container = screen.getByTestId("container");
+ expect(container.className).toContain("max-w-screen-2xl");
+ });
+
+ it("applies full width", () => {
+ render(
+ createElement(
+ Container,
+ { size: "full", "data-testid": "container" },
+ "Full",
+ ),
+ );
+ const container = screen.getByTestId("container");
+ expect(container.className).toContain("max-w-full");
+ });
+
+ it("uses xl size by default", () => {
+ render(
+ createElement(Container, { "data-testid": "container" }, "Default"),
+ );
+ const container = screen.getByTestId("container");
+ expect(container.className).toContain("max-w-screen-xl");
+ });
+ });
+
+ describe("Padding Variants", () => {
+ it("applies no padding", () => {
+ render(
+ createElement(
+ Container,
+ { padding: "none", "data-testid": "container" },
+ "None",
+ ),
+ );
+ const container = screen.getByTestId("container");
+ expect(container.className).toContain("px-0");
+ });
+
+ it("applies small padding", () => {
+ render(
+ createElement(
+ Container,
+ { padding: "sm", "data-testid": "container" },
+ "Small",
+ ),
+ );
+ const container = screen.getByTestId("container");
+ expect(container.className).toContain("px-4");
+ });
+
+ it("applies medium padding (default)", () => {
+ render(
+ createElement(
+ Container,
+ { padding: "md", "data-testid": "container" },
+ "Medium",
+ ),
+ );
+ const container = screen.getByTestId("container");
+ expect(container.className).toContain("px-6");
+ });
+
+ it("applies large padding", () => {
+ render(
+ createElement(
+ Container,
+ { padding: "lg", "data-testid": "container" },
+ "Large",
+ ),
+ );
+ const container = screen.getByTestId("container");
+ expect(container.className).toContain("px-8");
+ });
+
+ it("uses md padding by default", () => {
+ render(
+ createElement(Container, { "data-testid": "container" }, "Default"),
+ );
+ const container = screen.getByTestId("container");
+ expect(container.className).toContain("px-6");
+ });
+ });
+
+ describe("Compound Scenarios", () => {
+ it("works correctly with small size and no padding", () => {
+ render(
+ createElement(
+ Container,
+ {
+ size: "sm",
+ padding: "none",
+ "data-testid": "container",
+ },
+ "Small no padding",
+ ),
+ );
+ const container = screen.getByTestId("container");
+ expect(container.className).toContain("max-w-screen-sm");
+ expect(container.className).toContain("px-0");
+ });
+
+ it("works correctly with full width and large padding", () => {
+ render(
+ createElement(
+ Container,
+ {
+ size: "full",
+ padding: "lg",
+ "data-testid": "container",
+ },
+ "Full large padding",
+ ),
+ );
+ const container = screen.getByTestId("container");
+ expect(container.className).toContain("max-w-full");
+ expect(container.className).toContain("px-8");
+ });
+ });
+
+ describe("HTML Attributes", () => {
+ it("passes through id attribute", () => {
+ render(
+ createElement(
+ Container,
+ {
+ id: "container-id",
+ "data-testid": "container",
+ },
+ "Container",
+ ),
+ );
+ const container = screen.getByTestId("container");
+ expect(container).toHaveAttribute("id", "container-id");
+ });
+
+ it("passes through onClick handler", () => {
+ let clicked = false;
+ render(
+ createElement(
+ Container,
+ {
+ onClick: () => {
+ clicked = true;
+ },
+ "data-testid": "container",
+ },
+ "Clickable",
+ ),
+ );
+ const container = screen.getByTestId("container");
+ container.click();
+ expect(clicked).toBe(true);
+ });
+
+ it("passes through aria attributes", () => {
+ render(
+ createElement(
+ Container,
+ {
+ "aria-label": "Main container",
+ "data-testid": "container",
+ },
+ "Container",
+ ),
+ );
+ const container = screen.getByTestId("container");
+ expect(container).toHaveAttribute("aria-label", "Main container");
+ });
+ });
+
+ describe("Custom className", () => {
+ it("merges custom className with variant classes", () => {
+ render(
+ createElement(
+ Container,
+ {
+ className: "custom-class",
+ "data-testid": "container",
+ },
+ "Custom",
+ ),
+ );
+ const container = screen.getByTestId("container");
+ expect(container.className).toContain("custom-class");
+ expect(container.className).toContain("mx-auto");
+ expect(container.className).toContain("w-full");
+ });
+ });
+
+ describe("Ref Forwarding", () => {
+ it("forwards ref to container element", () => {
+ let ref: HTMLDivElement | null = null;
+ render(
+ createElement(
+ Container,
+ {
+ ref: (el: HTMLDivElement | null) => {
+ ref = el;
+ },
+ },
+ "Container",
+ ),
+ );
+ expect(ref).toBeInstanceOf(HTMLDivElement);
+ expect(ref?.tagName).toBe("DIV");
+ });
+ });
+
+ describe("Base Classes", () => {
+ it("always includes base classes", () => {
+ render(
+ createElement(Container, { "data-testid": "container" }, "Container"),
+ );
+ const container = screen.getByTestId("container");
+ expect(container.className).toContain("mx-auto");
+ expect(container.className).toContain("w-full");
+ });
+ });
+});
diff --git a/packages/ui/src/Container/Container.ts b/packages/ui/src/Container/Container.ts
new file mode 100644
index 0000000..7f5056f
--- /dev/null
+++ b/packages/ui/src/Container/Container.ts
@@ -0,0 +1,47 @@
+import { cn } from "@tpmjs/utils/cn";
+import { createElement, forwardRef } from "react";
+import type { ContainerProps } from "./types";
+import { containerVariants } from "./variants";
+
+/**
+ * Container component
+ *
+ * A layout wrapper component with responsive max-width constraints.
+ * Centers content and provides consistent horizontal padding.
+ * Built with .ts-only React using createElement.
+ *
+ * @example
+ * ```typescript
+ * import { Container } from '@tpmjs/ui/Container/Container';
+ * import { createElement } from 'react';
+ *
+ * function MyComponent() {
+ * return createElement(Container, {
+ * size: 'xl',
+ * padding: 'md',
+ * children: 'Page content goes here',
+ * });
+ * }
+ * ```
+ */
+export const Container = forwardRef(
+ ({ className, size = "xl", padding = "md", children, ...props }, ref) => {
+ return createElement(
+ "div",
+ {
+ className: cn(
+ containerVariants({
+ size,
+ padding,
+ }),
+ className,
+ ),
+ ref,
+ ...props,
+ },
+ children,
+ );
+ },
+);
+
+Container.displayName = "Container";
diff --git a/packages/ui/src/Container/tokens.ts b/packages/ui/src/Container/tokens.ts
new file mode 100644
index 0000000..2a37414
--- /dev/null
+++ b/packages/ui/src/Container/tokens.ts
@@ -0,0 +1,25 @@
+import { spacing } from "../tokens";
+
+/**
+ * Container component design tokens
+ * Maps global tokens to container-specific semantics
+ */
+export const containerTokens = {
+ /** Maximum widths for each size */
+ maxWidth: {
+ sm: "640px", // max-w-screen-sm
+ md: "768px", // max-w-screen-md
+ lg: "1024px", // max-w-screen-lg
+ xl: "1280px", // max-w-screen-xl
+ "2xl": "1536px", // max-w-screen-2xl
+ full: "100%", // max-w-full
+ },
+
+ /** Horizontal padding for each size */
+ padding: {
+ none: spacing[0], // 0
+ sm: spacing[4], // 1rem (16px)
+ md: spacing[6], // 1.5rem (24px)
+ lg: spacing[8], // 2rem (32px)
+ },
+} as const;
diff --git a/packages/ui/src/Container/types.ts b/packages/ui/src/Container/types.ts
new file mode 100644
index 0000000..e3cdb35
--- /dev/null
+++ b/packages/ui/src/Container/types.ts
@@ -0,0 +1,23 @@
+import type { HTMLAttributes } from "react";
+
+/**
+ * Container component props
+ */
+export interface ContainerProps extends HTMLAttributes {
+ /**
+ * Maximum width of the container
+ * @default 'xl'
+ */
+ size?: "sm" | "md" | "lg" | "xl" | "2xl" | "full";
+
+ /**
+ * Horizontal padding
+ * @default 'md'
+ */
+ padding?: "none" | "sm" | "md" | "lg";
+}
+
+/**
+ * Container ref type
+ */
+export type ContainerRef = HTMLDivElement;
diff --git a/packages/ui/src/Container/variants.ts b/packages/ui/src/Container/variants.ts
new file mode 100644
index 0000000..a013904
--- /dev/null
+++ b/packages/ui/src/Container/variants.ts
@@ -0,0 +1,37 @@
+import { createVariants } from "../system/variants";
+
+/**
+ * Container variant definitions
+ * Uses custom variant system for type-safe class composition
+ */
+export const containerVariants = createVariants({
+ base: [
+ // Layout
+ "mx-auto w-full",
+ ].join(" "),
+
+ variants: {
+ size: {
+ sm: "max-w-screen-sm",
+ md: "max-w-screen-md",
+ lg: "max-w-screen-lg",
+ xl: "max-w-screen-xl",
+ "2xl": "max-w-screen-2xl",
+ full: "max-w-full",
+ },
+
+ padding: {
+ none: "px-0",
+ sm: "px-4",
+ md: "px-6",
+ lg: "px-8",
+ },
+ },
+
+ compoundVariants: [],
+
+ defaultVariants: {
+ size: "xl",
+ padding: "md",
+ },
+});
diff --git a/packages/ui/src/Header/Header.test.ts b/packages/ui/src/Header/Header.test.ts
new file mode 100644
index 0000000..8354d9b
--- /dev/null
+++ b/packages/ui/src/Header/Header.test.ts
@@ -0,0 +1,391 @@
+import { render, screen } from "@testing-library/react";
+import { createElement } from "react";
+import { describe, expect, it } from "vitest";
+import { Header } from "./Header";
+
+describe("Header", () => {
+ describe("Rendering", () => {
+ it("renders a header element", () => {
+ render(createElement(Header, { "data-testid": "header" }));
+ const header = screen.getByTestId("header");
+ expect(header).toBeInTheDocument();
+ expect(header.tagName).toBe("HEADER");
+ });
+
+ it("renders without title or actions", () => {
+ render(createElement(Header, { "data-testid": "header" }));
+ const header = screen.getByTestId("header");
+ expect(header).toBeInTheDocument();
+ expect(screen.queryByTestId("header-title")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("header-actions")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Title", () => {
+ it("renders title when provided", () => {
+ render(createElement(Header, { title: "TPMJS Registry" }));
+ expect(screen.getByText("TPMJS Registry")).toBeInTheDocument();
+ });
+
+ it("renders title in title container", () => {
+ render(
+ createElement(Header, { title: "My App", "data-testid": "header" }),
+ );
+ const titleContainer = screen.getByTestId("header-title");
+ expect(titleContainer).toBeInTheDocument();
+ expect(titleContainer.textContent).toBe("My App");
+ });
+
+ it("title container has correct base classes", () => {
+ render(createElement(Header, { title: "App" }));
+ const titleContainer = screen.getByTestId("header-title");
+ expect(titleContainer.className).toContain("flex");
+ expect(titleContainer.className).toContain("items-center");
+ expect(titleContainer.className).toContain("font-semibold");
+ expect(titleContainer.className).toContain("text-zinc-100");
+ });
+
+ it("renders ReactNode as title", () => {
+ render(
+ createElement(Header, {
+ title: createElement(
+ "div",
+ { "data-testid": "custom-title" },
+ "Custom",
+ ),
+ }),
+ );
+ expect(screen.getByTestId("custom-title")).toBeInTheDocument();
+ });
+ });
+
+ describe("Actions", () => {
+ it("renders actions when provided", () => {
+ render(
+ createElement(Header, {
+ actions: createElement(
+ "button",
+ { "data-testid": "action-btn" },
+ "Sign In",
+ ),
+ }),
+ );
+ expect(screen.getByTestId("action-btn")).toBeInTheDocument();
+ });
+
+ it("renders actions in actions container", () => {
+ render(
+ createElement(Header, {
+ actions: "Actions content",
+ "data-testid": "header",
+ }),
+ );
+ const actionsContainer = screen.getByTestId("header-actions");
+ expect(actionsContainer).toBeInTheDocument();
+ expect(actionsContainer.textContent).toBe("Actions content");
+ });
+
+ it("actions container has correct base classes", () => {
+ render(createElement(Header, { actions: "Actions" }));
+ const actionsContainer = screen.getByTestId("header-actions");
+ expect(actionsContainer.className).toContain("flex");
+ expect(actionsContainer.className).toContain("items-center");
+ });
+
+ it("renders ReactNode as actions", () => {
+ render(
+ createElement(Header, {
+ actions: createElement(
+ "div",
+ { "data-testid": "custom-actions" },
+ "Custom Actions",
+ ),
+ }),
+ );
+ expect(screen.getByTestId("custom-actions")).toBeInTheDocument();
+ });
+ });
+
+ describe("Children", () => {
+ it("renders children when provided", () => {
+ render(createElement(Header, { children: "Header content" }));
+ expect(screen.getByText("Header content")).toBeInTheDocument();
+ });
+
+ it("renders children in children container", () => {
+ render(
+ createElement(Header, {
+ children: "Center content",
+ "data-testid": "header",
+ }),
+ );
+ const childrenContainer = screen.getByTestId("header-children");
+ expect(childrenContainer).toBeInTheDocument();
+ expect(childrenContainer.textContent).toBe("Center content");
+ });
+
+ it("children container has centering classes", () => {
+ render(createElement(Header, { children: "Content" }));
+ const childrenContainer = screen.getByTestId("header-children");
+ expect(childrenContainer.className).toContain("flex-1");
+ expect(childrenContainer.className).toContain("flex");
+ expect(childrenContainer.className).toContain("items-center");
+ expect(childrenContainer.className).toContain("justify-center");
+ });
+
+ it("renders ReactNode as children", () => {
+ render(
+ createElement(Header, {
+ children: createElement(
+ "nav",
+ { "data-testid": "custom-nav" },
+ "Navigation",
+ ),
+ }),
+ );
+ expect(screen.getByTestId("custom-nav")).toBeInTheDocument();
+ });
+ });
+
+ describe("Combined Content", () => {
+ it("renders title and actions together", () => {
+ render(
+ createElement(Header, {
+ title: "Title",
+ actions: "Actions",
+ }),
+ );
+ expect(screen.getByText("Title")).toBeInTheDocument();
+ expect(screen.getByText("Actions")).toBeInTheDocument();
+ });
+
+ it("renders title, children, and actions together", () => {
+ render(
+ createElement(Header, {
+ title: "Title",
+ children: "Center",
+ actions: "Actions",
+ }),
+ );
+ expect(screen.getByText("Title")).toBeInTheDocument();
+ expect(screen.getByText("Center")).toBeInTheDocument();
+ expect(screen.getByText("Actions")).toBeInTheDocument();
+ });
+
+ it("maintains correct layout with all content", () => {
+ render(
+ createElement(Header, {
+ title: "Left",
+ children: "Center",
+ actions: "Right",
+ "data-testid": "header",
+ }),
+ );
+ const header = screen.getByTestId("header");
+ expect(header.className).toContain("justify-between");
+ });
+ });
+
+ describe("Size Variants", () => {
+ it("applies small size to header", () => {
+ render(createElement(Header, { size: "sm", "data-testid": "header" }));
+ const header = screen.getByTestId("header");
+ expect(header.className).toContain("h-12");
+ expect(header.className).toContain("px-4");
+ });
+
+ it("applies medium size to header", () => {
+ render(createElement(Header, { size: "md", "data-testid": "header" }));
+ const header = screen.getByTestId("header");
+ expect(header.className).toContain("h-16");
+ expect(header.className).toContain("px-6");
+ });
+
+ it("applies large size to header", () => {
+ render(createElement(Header, { size: "lg", "data-testid": "header" }));
+ const header = screen.getByTestId("header");
+ expect(header.className).toContain("h-20");
+ expect(header.className).toContain("px-8");
+ });
+
+ it("uses md size by default", () => {
+ render(createElement(Header, { "data-testid": "header" }));
+ const header = screen.getByTestId("header");
+ expect(header.className).toContain("h-16");
+ expect(header.className).toContain("px-6");
+ });
+
+ it("applies size to title", () => {
+ render(createElement(Header, { title: "Title", size: "lg" }));
+ const titleContainer = screen.getByTestId("header-title");
+ expect(titleContainer.className).toContain("text-xl");
+ expect(titleContainer.className).toContain("gap-4");
+ });
+
+ it("applies size to actions", () => {
+ render(createElement(Header, { actions: "Actions", size: "sm" }));
+ const actionsContainer = screen.getByTestId("header-actions");
+ expect(actionsContainer.className).toContain("gap-2");
+ });
+ });
+
+ describe("Sticky Positioning", () => {
+ it("applies sticky classes when sticky is true", () => {
+ render(createElement(Header, { sticky: true, "data-testid": "header" }));
+ const header = screen.getByTestId("header");
+ expect(header.className).toContain("sticky");
+ expect(header.className).toContain("top-0");
+ expect(header.className).toContain("z-50");
+ });
+
+ it("does not apply sticky classes when sticky is false", () => {
+ render(createElement(Header, { sticky: false, "data-testid": "header" }));
+ const header = screen.getByTestId("header");
+ expect(header.className).not.toContain("sticky");
+ expect(header.className).not.toContain("top-0");
+ expect(header.className).not.toContain("z-50");
+ });
+
+ it("is not sticky by default", () => {
+ render(createElement(Header, { "data-testid": "header" }));
+ const header = screen.getByTestId("header");
+ expect(header.className).not.toContain("sticky");
+ });
+ });
+
+ describe("HTML Attributes", () => {
+ it("passes through id attribute", () => {
+ render(
+ createElement(Header, {
+ id: "header-id",
+ "data-testid": "header",
+ }),
+ );
+ const header = screen.getByTestId("header");
+ expect(header).toHaveAttribute("id", "header-id");
+ });
+
+ it("passes through data attributes", () => {
+ render(
+ createElement(Header, {
+ "data-custom": "test",
+ "data-testid": "header",
+ }),
+ );
+ const header = screen.getByTestId("header");
+ expect(header).toHaveAttribute("data-custom", "test");
+ });
+
+ it("passes through aria attributes", () => {
+ render(
+ createElement(Header, {
+ "aria-label": "Main header",
+ "data-testid": "header",
+ }),
+ );
+ const header = screen.getByTestId("header");
+ expect(header).toHaveAttribute("aria-label", "Main header");
+ });
+ });
+
+ describe("Custom className", () => {
+ it("merges custom className with variant classes", () => {
+ render(
+ createElement(Header, {
+ className: "custom-class",
+ "data-testid": "header",
+ }),
+ );
+ const header = screen.getByTestId("header");
+ expect(header.className).toContain("custom-class");
+ expect(header.className).toContain("flex");
+ expect(header.className).toContain("bg-black");
+ });
+ });
+
+ describe("Ref Forwarding", () => {
+ it("forwards ref to header element", () => {
+ let ref: HTMLElement | null = null;
+ render(
+ createElement(Header, {
+ ref: (el: HTMLElement | null) => {
+ ref = el;
+ },
+ }),
+ );
+ expect(ref).toBeInstanceOf(HTMLElement);
+ expect(ref?.tagName).toBe("HEADER");
+ });
+ });
+
+ describe("Base Classes", () => {
+ it("always includes base classes", () => {
+ render(createElement(Header, { "data-testid": "header" }));
+ const header = screen.getByTestId("header");
+ expect(header.className).toContain("flex");
+ expect(header.className).toContain("items-center");
+ expect(header.className).toContain("justify-between");
+ expect(header.className).toContain("w-full");
+ expect(header.className).toContain("bg-black");
+ expect(header.className).toContain("border-b");
+ expect(header.className).toContain("border-zinc-800");
+ });
+ });
+
+ describe("Compound Scenarios", () => {
+ it("works correctly with large size and sticky", () => {
+ render(
+ createElement(Header, {
+ title: "App",
+ actions: "Sign In",
+ size: "lg",
+ sticky: true,
+ "data-testid": "header",
+ }),
+ );
+ const header = screen.getByTestId("header");
+ expect(header.className).toContain("h-20");
+ expect(header.className).toContain("px-8");
+ expect(header.className).toContain("sticky");
+ expect(header.className).toContain("top-0");
+ expect(header.className).toContain("z-50");
+ });
+
+ it("works correctly with all content and custom className", () => {
+ render(
+ createElement(Header, {
+ title: "Title",
+ children: "Center",
+ actions: "Actions",
+ className: "my-header",
+ size: "sm",
+ "data-testid": "header",
+ }),
+ );
+ const header = screen.getByTestId("header");
+ expect(header.className).toContain("my-header");
+ expect(header.className).toContain("h-12");
+ expect(screen.getByText("Title")).toBeInTheDocument();
+ expect(screen.getByText("Center")).toBeInTheDocument();
+ expect(screen.getByText("Actions")).toBeInTheDocument();
+ });
+
+ it("works with complex ReactNode content", () => {
+ render(
+ createElement(Header, {
+ title: createElement("div", { className: "logo" }, [
+ createElement("img", { key: "img", src: "logo.png", alt: "Logo" }),
+ createElement("span", { key: "text" }, "TPMJS"),
+ ]),
+ actions: createElement("div", { className: "nav" }, [
+ createElement("button", { key: "btn1" }, "Docs"),
+ createElement("button", { key: "btn2" }, "GitHub"),
+ ]),
+ }),
+ );
+ expect(screen.getByText("TPMJS")).toBeInTheDocument();
+ expect(screen.getByText("Docs")).toBeInTheDocument();
+ expect(screen.getByText("GitHub")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/ui/src/Header/Header.ts b/packages/ui/src/Header/Header.ts
new file mode 100644
index 0000000..795848d
--- /dev/null
+++ b/packages/ui/src/Header/Header.ts
@@ -0,0 +1,99 @@
+import { cn } from "@tpmjs/utils/cn";
+import { createElement, forwardRef } from "react";
+import type { HeaderProps } from "./types";
+import {
+ headerActionsVariants,
+ headerTitleVariants,
+ headerVariants,
+} from "./variants";
+
+/**
+ * Header component
+ *
+ * A flexible header bar with title and action slots.
+ * Supports sticky positioning and responsive sizing.
+ * Built with .ts-only React using createElement.
+ *
+ * @example
+ * ```typescript
+ * import { Header } from '@tpmjs/ui/Header/Header';
+ * import { Button } from '@tpmjs/ui/Button/Button';
+ * import { createElement } from 'react';
+ *
+ * function MyComponent() {
+ * return createElement(Header, {
+ * title: 'TPMJS Registry',
+ * actions: createElement(Button, { variant: 'ghost' }, 'Sign In'),
+ * size: 'md',
+ * sticky: true,
+ * });
+ * }
+ * ```
+ */
+export const Header = forwardRef(
+ (
+ {
+ className,
+ title,
+ actions,
+ size = "md",
+ sticky = false,
+ children,
+ ...props
+ },
+ ref,
+ ) => {
+ return createElement(
+ "header",
+ {
+ className: cn(
+ headerVariants({
+ size,
+ sticky: sticky ? "true" : "false",
+ }),
+ className,
+ ),
+ ref,
+ ...props,
+ },
+ [
+ title &&
+ createElement(
+ "div",
+ {
+ key: "title",
+ className: headerTitleVariants({
+ size,
+ }),
+ "data-testid": "header-title",
+ },
+ title,
+ ),
+ children &&
+ createElement(
+ "div",
+ {
+ key: "children",
+ className: "flex-1 flex items-center justify-center",
+ "data-testid": "header-children",
+ },
+ children,
+ ),
+ actions &&
+ createElement(
+ "div",
+ {
+ key: "actions",
+ className: headerActionsVariants({
+ size,
+ }),
+ "data-testid": "header-actions",
+ },
+ actions,
+ ),
+ ].filter(Boolean),
+ );
+ },
+);
+
+Header.displayName = "Header";
diff --git a/packages/ui/src/Header/tokens.ts b/packages/ui/src/Header/tokens.ts
new file mode 100644
index 0000000..6c2e02c
--- /dev/null
+++ b/packages/ui/src/Header/tokens.ts
@@ -0,0 +1,21 @@
+import { spacing } from "../tokens";
+
+/**
+ * Header component design tokens
+ * Maps global tokens to header-specific semantics
+ */
+export const headerTokens = {
+ /** Height for each size */
+ height: {
+ sm: "3rem", // 48px
+ md: "4rem", // 64px
+ lg: "5rem", // 80px
+ },
+
+ /** Horizontal padding for each size */
+ padding: {
+ sm: spacing[4], // 1rem (16px)
+ md: spacing[6], // 1.5rem (24px)
+ lg: spacing[8], // 2rem (32px)
+ },
+} as const;
diff --git a/packages/ui/src/Header/types.ts b/packages/ui/src/Header/types.ts
new file mode 100644
index 0000000..7816818
--- /dev/null
+++ b/packages/ui/src/Header/types.ts
@@ -0,0 +1,34 @@
+import type { HTMLAttributes, ReactNode } from "react";
+
+/**
+ * Header component props
+ */
+export interface HeaderProps
+ extends Omit, "title"> {
+ /**
+ * Title/logo content for the left side
+ */
+ title?: ReactNode;
+
+ /**
+ * Actions/navigation for the right side
+ */
+ actions?: ReactNode;
+
+ /**
+ * Size variant
+ * @default 'md'
+ */
+ size?: "sm" | "md" | "lg";
+
+ /**
+ * Whether header is sticky
+ * @default false
+ */
+ sticky?: boolean;
+}
+
+/**
+ * Header ref type
+ */
+export type HeaderRef = HTMLElement;
diff --git a/packages/ui/src/Header/variants.ts b/packages/ui/src/Header/variants.ts
new file mode 100644
index 0000000..5f2915f
--- /dev/null
+++ b/packages/ui/src/Header/variants.ts
@@ -0,0 +1,88 @@
+import { createVariants } from "../system/variants";
+
+/**
+ * Header variant definitions
+ */
+export const headerVariants = createVariants({
+ base: [
+ // Display
+ "flex items-center justify-between",
+ // Width
+ "w-full",
+ // Background
+ "bg-black",
+ // Border
+ "border-b border-zinc-800",
+ ].join(" "),
+
+ variants: {
+ size: {
+ sm: "h-12 px-4", // 48px height, 16px padding
+ md: "h-16 px-6", // 64px height, 24px padding
+ lg: "h-20 px-8", // 80px height, 32px padding
+ },
+ sticky: {
+ true: "sticky top-0 z-50",
+ false: "",
+ },
+ },
+
+ compoundVariants: [],
+
+ defaultVariants: {
+ size: "md",
+ sticky: false,
+ },
+});
+
+/**
+ * Header title variant definitions
+ */
+export const headerTitleVariants = createVariants({
+ base: [
+ // Display
+ "flex items-center",
+ // Font
+ "font-semibold",
+ // Color
+ "text-zinc-100",
+ ].join(" "),
+
+ variants: {
+ size: {
+ sm: "text-base gap-2", // 16px
+ md: "text-lg gap-3", // 18px
+ lg: "text-xl gap-4", // 20px
+ },
+ },
+
+ compoundVariants: [],
+
+ defaultVariants: {
+ size: "md",
+ },
+});
+
+/**
+ * Header actions variant definitions
+ */
+export const headerActionsVariants = createVariants({
+ base: [
+ // Display
+ "flex items-center",
+ ].join(" "),
+
+ variants: {
+ size: {
+ sm: "gap-2", // 8px
+ md: "gap-3", // 12px
+ lg: "gap-4", // 16px
+ },
+ },
+
+ compoundVariants: [],
+
+ defaultVariants: {
+ size: "md",
+ },
+});
diff --git a/packages/ui/src/Icon/Icon.test.ts b/packages/ui/src/Icon/Icon.test.ts
new file mode 100644
index 0000000..bd8f7c2
--- /dev/null
+++ b/packages/ui/src/Icon/Icon.test.ts
@@ -0,0 +1,276 @@
+import { render, screen } from "@testing-library/react";
+import { createElement } from "react";
+import { describe, expect, it } from "vitest";
+import { Icon } from "./Icon";
+
+describe("Icon", () => {
+ describe("Rendering", () => {
+ it("renders an SVG element", () => {
+ render(createElement(Icon, { icon: "check", "data-testid": "icon" }));
+ const icon = screen.getByTestId("icon");
+ expect(icon).toBeInTheDocument();
+ expect(icon.tagName).toBe("svg");
+ });
+
+ it("renders with correct viewBox", () => {
+ render(createElement(Icon, { icon: "check", "data-testid": "icon" }));
+ const icon = screen.getByTestId("icon");
+ expect(icon).toHaveAttribute("viewBox", "0 0 24 24");
+ });
+
+ it("renders with currentColor fill", () => {
+ render(createElement(Icon, { icon: "check", "data-testid": "icon" }));
+ const icon = screen.getByTestId("icon");
+ expect(icon).toHaveAttribute("fill", "currentColor");
+ });
+
+ it("includes path element", () => {
+ render(createElement(Icon, { icon: "check", "data-testid": "icon" }));
+ const icon = screen.getByTestId("icon");
+ const path = icon.querySelector("path");
+ expect(path).toBeInTheDocument();
+ });
+
+ it("is aria-hidden by default", () => {
+ render(createElement(Icon, { icon: "check", "data-testid": "icon" }));
+ const icon = screen.getByTestId("icon");
+ expect(icon).toHaveAttribute("aria-hidden", "true");
+ });
+ });
+
+ describe("Size Variants", () => {
+ it("applies small size", () => {
+ render(
+ createElement(Icon, {
+ icon: "check",
+ size: "sm",
+ "data-testid": "icon",
+ }),
+ );
+ const icon = screen.getByTestId("icon");
+ expect(icon.className).toContain("w-4");
+ expect(icon.className).toContain("h-4");
+ });
+
+ it("applies medium size", () => {
+ render(
+ createElement(Icon, {
+ icon: "check",
+ size: "md",
+ "data-testid": "icon",
+ }),
+ );
+ const icon = screen.getByTestId("icon");
+ expect(icon.className).toContain("w-5");
+ expect(icon.className).toContain("h-5");
+ });
+
+ it("applies large size", () => {
+ render(
+ createElement(Icon, {
+ icon: "check",
+ size: "lg",
+ "data-testid": "icon",
+ }),
+ );
+ const icon = screen.getByTestId("icon");
+ expect(icon.className).toContain("w-6");
+ expect(icon.className).toContain("h-6");
+ });
+
+ it("uses md size by default", () => {
+ render(createElement(Icon, { icon: "check", "data-testid": "icon" }));
+ const icon = screen.getByTestId("icon");
+ expect(icon.className).toContain("w-5");
+ expect(icon.className).toContain("h-5");
+ });
+ });
+
+ describe("Icon Variants", () => {
+ it("renders copy icon", () => {
+ render(createElement(Icon, { icon: "copy", "data-testid": "icon" }));
+ const icon = screen.getByTestId("icon");
+ const path = icon.querySelector("path");
+ expect(path).toHaveAttribute(
+ "d",
+ "M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z",
+ );
+ });
+
+ it("renders externalLink icon", () => {
+ render(
+ createElement(Icon, { icon: "externalLink", "data-testid": "icon" }),
+ );
+ const icon = screen.getByTestId("icon");
+ const path = icon.querySelector("path");
+ expect(path).toHaveAttribute(
+ "d",
+ "M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z",
+ );
+ });
+
+ it("renders github icon", () => {
+ render(createElement(Icon, { icon: "github", "data-testid": "icon" }));
+ const icon = screen.getByTestId("icon");
+ const path = icon.querySelector("path");
+ expect(path?.getAttribute("d")).toContain("M12 2C6.477 2 2 6.477 2 12");
+ });
+
+ it("renders check icon", () => {
+ render(createElement(Icon, { icon: "check", "data-testid": "icon" }));
+ const icon = screen.getByTestId("icon");
+ const path = icon.querySelector("path");
+ expect(path).toHaveAttribute(
+ "d",
+ "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z",
+ );
+ });
+
+ it("renders x icon", () => {
+ render(createElement(Icon, { icon: "x", "data-testid": "icon" }));
+ const icon = screen.getByTestId("icon");
+ const path = icon.querySelector("path");
+ expect(path).toHaveAttribute(
+ "d",
+ "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z",
+ );
+ });
+
+ it("renders chevronDown icon", () => {
+ render(
+ createElement(Icon, { icon: "chevronDown", "data-testid": "icon" }),
+ );
+ const icon = screen.getByTestId("icon");
+ const path = icon.querySelector("path");
+ expect(path).toHaveAttribute(
+ "d",
+ "M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z",
+ );
+ });
+ });
+
+ describe("SVG Attributes", () => {
+ it("passes through className", () => {
+ render(
+ createElement(Icon, {
+ icon: "check",
+ className: "custom-class",
+ "data-testid": "icon",
+ }),
+ );
+ const icon = screen.getByTestId("icon");
+ expect(icon.className).toContain("custom-class");
+ });
+
+ it("merges custom className with variant classes", () => {
+ render(
+ createElement(Icon, {
+ icon: "check",
+ className: "text-red-500",
+ "data-testid": "icon",
+ }),
+ );
+ const icon = screen.getByTestId("icon");
+ expect(icon.className).toContain("text-red-500");
+ expect(icon.className).toContain("inline-block");
+ expect(icon.className).toContain("fill-current");
+ });
+
+ it("allows custom aria-hidden", () => {
+ render(
+ createElement(Icon, {
+ icon: "check",
+ "aria-hidden": false,
+ "data-testid": "icon",
+ }),
+ );
+ const icon = screen.getByTestId("icon");
+ expect(icon).toHaveAttribute("aria-hidden", "false");
+ });
+
+ it("passes through aria-label", () => {
+ render(
+ createElement(Icon, {
+ icon: "check",
+ "aria-label": "Success",
+ "aria-hidden": false,
+ "data-testid": "icon",
+ }),
+ );
+ const icon = screen.getByTestId("icon");
+ expect(icon).toHaveAttribute("aria-label", "Success");
+ });
+
+ it("passes through id attribute", () => {
+ render(
+ createElement(Icon, {
+ icon: "check",
+ id: "icon-id",
+ "data-testid": "icon",
+ }),
+ );
+ const icon = screen.getByTestId("icon");
+ expect(icon).toHaveAttribute("id", "icon-id");
+ });
+ });
+
+ describe("Ref Forwarding", () => {
+ it("forwards ref to SVG element", () => {
+ let ref: SVGSVGElement | null = null;
+ render(
+ createElement(Icon, {
+ icon: "check",
+ ref: (el: SVGSVGElement | null) => {
+ ref = el;
+ },
+ }),
+ );
+ expect(ref).toBeInstanceOf(SVGSVGElement);
+ expect(ref?.tagName).toBe("svg");
+ });
+ });
+
+ describe("Base Classes", () => {
+ it("always includes base classes", () => {
+ render(createElement(Icon, { icon: "check", "data-testid": "icon" }));
+ const icon = screen.getByTestId("icon");
+ expect(icon.className).toContain("inline-block");
+ expect(icon.className).toContain("fill-current");
+ });
+ });
+
+ describe("Compound Scenarios", () => {
+ it("works correctly with custom size and className", () => {
+ render(
+ createElement(Icon, {
+ icon: "github",
+ size: "lg",
+ className: "text-zinc-400 hover:text-zinc-100",
+ "data-testid": "icon",
+ }),
+ );
+ const icon = screen.getByTestId("icon");
+ expect(icon.className).toContain("w-6");
+ expect(icon.className).toContain("h-6");
+ expect(icon.className).toContain("text-zinc-400");
+ expect(icon.className).toContain("hover:text-zinc-100");
+ });
+
+ it("works correctly with aria attributes and custom size", () => {
+ render(
+ createElement(Icon, {
+ icon: "externalLink",
+ size: "sm",
+ "aria-label": "Opens in new tab",
+ "aria-hidden": false,
+ "data-testid": "icon",
+ }),
+ );
+ const icon = screen.getByTestId("icon");
+ expect(icon.className).toContain("w-4");
+ expect(icon.className).toContain("h-4");
+ expect(icon).toHaveAttribute("aria-label", "Opens in new tab");
+ expect(icon).toHaveAttribute("aria-hidden", "false");
+ });
+ });
+});
diff --git a/packages/ui/src/Icon/Icon.ts b/packages/ui/src/Icon/Icon.ts
new file mode 100644
index 0000000..22b60e7
--- /dev/null
+++ b/packages/ui/src/Icon/Icon.ts
@@ -0,0 +1,56 @@
+import { cn } from "@tpmjs/utils/cn";
+import { createElement, forwardRef } from "react";
+import { type IconName, icons } from "./icons";
+import type { IconProps } from "./types";
+import { iconVariants } from "./variants";
+
+export type { IconName };
+
+/**
+ * Icon component
+ *
+ * Renders SVG icons with consistent sizing and styling.
+ * Icons inherit text color via currentColor.
+ * Built with .ts-only React using createElement.
+ *
+ * @example
+ * ```typescript
+ * import { Icon } from '@tpmjs/ui/Icon/Icon';
+ * import { createElement } from 'react';
+ *
+ * function MyComponent() {
+ * return createElement(Icon, {
+ * icon: 'github',
+ * size: 'md',
+ * className: 'text-zinc-400',
+ * });
+ * }
+ * ```
+ */
+export const Icon = forwardRef(
+ ({ className, icon, size = "md", ...props }, ref) => {
+ const iconData = icons[icon];
+
+ return createElement(
+ "svg",
+ {
+ className: cn(
+ iconVariants({
+ size,
+ }),
+ className,
+ ),
+ viewBox: iconData.viewBox,
+ fill: "currentColor",
+ "aria-hidden": props["aria-hidden"] ?? true,
+ ref,
+ ...props,
+ },
+ createElement("path", {
+ d: iconData.path,
+ }),
+ );
+ },
+);
+
+Icon.displayName = "Icon";
diff --git a/packages/ui/src/Icon/icons.ts b/packages/ui/src/Icon/icons.ts
new file mode 100644
index 0000000..f4a9c25
--- /dev/null
+++ b/packages/ui/src/Icon/icons.ts
@@ -0,0 +1,41 @@
+/**
+ * Icon definitions
+ * SVG path data for available icons
+ */
+
+export const icons = {
+ copy: {
+ viewBox: "0 0 24 24",
+ path: "M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z",
+ },
+ externalLink: {
+ viewBox: "0 0 24 24",
+ path: "M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z",
+ },
+ github: {
+ viewBox: "0 0 24 24",
+ path: "M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.603-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z",
+ },
+ check: {
+ viewBox: "0 0 24 24",
+ path: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z",
+ },
+ x: {
+ viewBox: "0 0 24 24",
+ path: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z",
+ },
+ chevronDown: {
+ viewBox: "0 0 24 24",
+ path: "M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z",
+ },
+ sun: {
+ viewBox: "0 0 24 24",
+ path: "M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z",
+ },
+ moon: {
+ viewBox: "0 0 24 24",
+ path: "M9.37 5.51c-.18.64-.27 1.31-.27 1.99 0 4.08 3.32 7.4 7.4 7.4.68 0 1.35-.09 1.99-.27C17.45 17.19 14.93 19 12 19c-3.86 0-7-3.14-7-7 0-2.93 1.81-5.45 4.37-6.49zM12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-2.98 0-5.4-2.42-5.4-5.4 0-1.81.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1z",
+ },
+} as const;
+
+export type IconName = keyof typeof icons;
diff --git a/packages/ui/src/Icon/tokens.ts b/packages/ui/src/Icon/tokens.ts
new file mode 100644
index 0000000..90c56b6
--- /dev/null
+++ b/packages/ui/src/Icon/tokens.ts
@@ -0,0 +1,12 @@
+/**
+ * Icon component design tokens
+ * Maps size variants to pixel dimensions
+ */
+export const iconTokens = {
+ /** Icon sizes in pixels */
+ size: {
+ sm: "16px", // 1rem
+ md: "20px", // 1.25rem
+ lg: "24px", // 1.5rem
+ },
+} as const;
diff --git a/packages/ui/src/Icon/types.ts b/packages/ui/src/Icon/types.ts
new file mode 100644
index 0000000..f8007f3
--- /dev/null
+++ b/packages/ui/src/Icon/types.ts
@@ -0,0 +1,24 @@
+import type { SVGAttributes } from "react";
+import type { IconName } from "./icons";
+
+/**
+ * Icon component props
+ */
+export interface IconProps
+ extends Omit, "children"> {
+ /**
+ * Icon to display
+ */
+ icon: IconName;
+
+ /**
+ * Size of the icon
+ * @default 'md'
+ */
+ size?: "sm" | "md" | "lg";
+}
+
+/**
+ * Icon ref type
+ */
+export type IconRef = SVGSVGElement;
diff --git a/packages/ui/src/Icon/variants.ts b/packages/ui/src/Icon/variants.ts
new file mode 100644
index 0000000..7599853
--- /dev/null
+++ b/packages/ui/src/Icon/variants.ts
@@ -0,0 +1,28 @@
+import { createVariants } from "../system/variants";
+
+/**
+ * Icon variant definitions
+ * Uses custom variant system for type-safe class composition
+ */
+export const iconVariants = createVariants({
+ base: [
+ // Display
+ "inline-block",
+ // Fill
+ "fill-current",
+ ].join(" "),
+
+ variants: {
+ size: {
+ sm: "w-4 h-4", // 16px
+ md: "w-5 h-5", // 20px
+ lg: "w-6 h-6", // 24px
+ },
+ },
+
+ compoundVariants: [],
+
+ defaultVariants: {
+ size: "md",
+ },
+});
diff --git a/packages/ui/src/Input/Input.test.ts b/packages/ui/src/Input/Input.test.ts
new file mode 100644
index 0000000..9508c88
--- /dev/null
+++ b/packages/ui/src/Input/Input.test.ts
@@ -0,0 +1,552 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { createElement } from "react";
+import { describe, expect, it, vi } from "vitest";
+import { Input } from "./Input";
+
+describe("Input", () => {
+ describe("Rendering", () => {
+ it("renders an input element", () => {
+ render(createElement(Input, { "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input).toBeInTheDocument();
+ expect(input.tagName).toBe("INPUT");
+ });
+
+ it('renders with default type="text"', () => {
+ render(createElement(Input, { "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("type", "text");
+ });
+ });
+
+ describe("States", () => {
+ it("applies default state classes", () => {
+ render(
+ createElement(Input, { state: "default", "data-testid": "input" }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input.className).toContain("border-border");
+ expect(input.className).toContain("text-foreground");
+ });
+
+ it("applies error state classes", () => {
+ render(createElement(Input, { state: "error", "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input.className).toContain("border-error");
+ });
+
+ it("applies success state classes", () => {
+ render(
+ createElement(Input, { state: "success", "data-testid": "input" }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input.className).toContain("border-success");
+ });
+
+ it("sets aria-invalid on error state", () => {
+ render(createElement(Input, { state: "error", "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("aria-invalid", "true");
+ });
+
+ it("does not set aria-invalid on default state", () => {
+ render(
+ createElement(Input, { state: "default", "data-testid": "input" }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input).not.toHaveAttribute("aria-invalid");
+ });
+
+ it("does not set aria-invalid on success state", () => {
+ render(
+ createElement(Input, { state: "success", "data-testid": "input" }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input).not.toHaveAttribute("aria-invalid");
+ });
+ });
+
+ describe("Sizes", () => {
+ it("applies small size classes", () => {
+ render(createElement(Input, { size: "sm", "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input.className).toContain("h-9");
+ expect(input.className).toContain("px-3");
+ expect(input.className).toContain("text-sm");
+ });
+
+ it("applies medium size classes (default)", () => {
+ render(createElement(Input, { size: "md", "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input.className).toContain("h-10");
+ expect(input.className).toContain("px-3");
+ expect(input.className).toContain("text-base");
+ });
+
+ it("applies large size classes", () => {
+ render(createElement(Input, { size: "lg", "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input.className).toContain("h-11");
+ expect(input.className).toContain("px-4");
+ expect(input.className).toContain("text-lg");
+ });
+ });
+
+ describe("Full Width", () => {
+ it("is full width by default", () => {
+ render(createElement(Input, { "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input.className).toContain("w-full");
+ });
+
+ it("applies full width when explicitly true", () => {
+ render(createElement(Input, { fullWidth: true, "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input.className).toContain("w-full");
+ });
+
+ it("does not apply full width when false", () => {
+ render(
+ createElement(Input, { fullWidth: false, "data-testid": "input" }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input.className).toContain("w-auto");
+ });
+ });
+
+ describe("Input Types", () => {
+ it('renders with type="email"', () => {
+ render(createElement(Input, { type: "email", "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("type", "email");
+ });
+
+ it('renders with type="password"', () => {
+ render(
+ createElement(Input, { type: "password", "data-testid": "input" }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("type", "password");
+ });
+
+ it('renders with type="number"', () => {
+ render(createElement(Input, { type: "number", "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("type", "number");
+ });
+
+ it('renders with type="tel"', () => {
+ render(createElement(Input, { type: "tel", "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("type", "tel");
+ });
+
+ it('renders with type="url"', () => {
+ render(createElement(Input, { type: "url", "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("type", "url");
+ });
+
+ it('renders with type="search"', () => {
+ render(createElement(Input, { type: "search", "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("type", "search");
+ });
+
+ it('renders with type="date"', () => {
+ render(createElement(Input, { type: "date", "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("type", "date");
+ });
+
+ it('renders with type="time"', () => {
+ render(createElement(Input, { type: "time", "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("type", "time");
+ });
+
+ it('renders with type="file"', () => {
+ render(createElement(Input, { type: "file", "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("type", "file");
+ });
+ });
+
+ describe("Disabled State", () => {
+ it("is not disabled by default", () => {
+ render(createElement(Input, { "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input).not.toBeDisabled();
+ });
+
+ it("disables input when disabled prop is true", () => {
+ render(createElement(Input, { disabled: true, "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input).toBeDisabled();
+ });
+
+ it("applies disabled opacity class", () => {
+ render(createElement(Input, { disabled: true, "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input.className).toContain("disabled:opacity-50");
+ expect(input.className).toContain("disabled:cursor-not-allowed");
+ });
+ });
+
+ describe("Placeholder", () => {
+ it("renders with placeholder text", () => {
+ render(
+ createElement(Input, {
+ placeholder: "Enter your email",
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("placeholder", "Enter your email");
+ });
+
+ it("applies placeholder styling classes", () => {
+ render(
+ createElement(Input, {
+ placeholder: "Placeholder text",
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input.className).toContain("placeholder:text-foreground-tertiary");
+ });
+ });
+
+ describe("Value and onChange", () => {
+ it("renders with initial value", () => {
+ render(
+ createElement(Input, {
+ value: "test value",
+ onChange: () => {},
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input") as HTMLInputElement;
+ expect(input.value).toBe("test value");
+ });
+
+ it("calls onChange when input value changes", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Input, {
+ onChange: handleChange,
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ fireEvent.change(input, { target: { value: "new value" } });
+ expect(handleChange).toHaveBeenCalledTimes(1);
+ });
+
+ it("updates value correctly when typing", () => {
+ render(
+ createElement(Input, {
+ defaultValue: "",
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input") as HTMLInputElement;
+ fireEvent.change(input, { target: { value: "typed text" } });
+ expect(input.value).toBe("typed text");
+ });
+ });
+
+ describe("HTML Attributes", () => {
+ it("passes through id attribute", () => {
+ render(
+ createElement(Input, {
+ id: "test-input",
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("id", "test-input");
+ });
+
+ it("passes through name attribute", () => {
+ render(
+ createElement(Input, {
+ name: "email",
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("name", "email");
+ });
+
+ it("passes through required attribute", () => {
+ render(
+ createElement(Input, {
+ required: true,
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("required");
+ });
+
+ it("passes through maxLength attribute", () => {
+ render(
+ createElement(Input, {
+ maxLength: 100,
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("maxLength", "100");
+ });
+
+ it("passes through pattern attribute", () => {
+ render(
+ createElement(Input, {
+ pattern: "[0-9]*",
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("pattern", "[0-9]*");
+ });
+
+ it("passes through min and max for number input", () => {
+ render(
+ createElement(Input, {
+ type: "number",
+ min: 0,
+ max: 100,
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("min", "0");
+ expect(input).toHaveAttribute("max", "100");
+ });
+
+ it("passes through step for number input", () => {
+ render(
+ createElement(Input, {
+ type: "number",
+ step: 0.01,
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("step", "0.01");
+ });
+
+ it("passes through autoComplete attribute", () => {
+ render(
+ createElement(Input, {
+ autoComplete: "email",
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("autoComplete", "email");
+ });
+
+ it("passes through autoFocus attribute", () => {
+ render(
+ createElement(Input, {
+ autoFocus: true,
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input).toHaveFocus();
+ });
+ });
+
+ describe("ARIA Attributes", () => {
+ it("passes through aria-label", () => {
+ render(
+ createElement(Input, {
+ "aria-label": "Email address",
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("aria-label", "Email address");
+ });
+
+ it("passes through aria-describedby", () => {
+ render(
+ createElement(Input, {
+ "aria-describedby": "helper-text",
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("aria-describedby", "helper-text");
+ });
+
+ it("passes through aria-labelledby", () => {
+ render(
+ createElement(Input, {
+ "aria-labelledby": "label-id",
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input).toHaveAttribute("aria-labelledby", "label-id");
+ });
+ });
+
+ describe("Custom className", () => {
+ it("merges custom className with variant classes", () => {
+ render(
+ createElement(Input, {
+ className: "custom-input",
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input.className).toContain("custom-input");
+ expect(input.className).toContain("rounded-md");
+ expect(input.className).toContain("border");
+ });
+ });
+
+ describe("Ref Forwarding", () => {
+ it("forwards ref to input element", () => {
+ let ref: HTMLInputElement | null = null;
+ render(
+ createElement(Input, {
+ ref: (el: HTMLInputElement | null) => {
+ ref = el;
+ },
+ }),
+ );
+ expect(ref).toBeInstanceOf(HTMLInputElement);
+ expect(ref?.tagName).toBe("INPUT");
+ });
+
+ it("can focus input through ref", () => {
+ let ref: HTMLInputElement | null = null;
+ render(
+ createElement(Input, {
+ ref: (el: HTMLInputElement | null) => {
+ ref = el;
+ },
+ "data-testid": "input",
+ }),
+ );
+ ref?.focus();
+ expect(screen.getByTestId("input")).toHaveFocus();
+ });
+ });
+
+ describe("Base Classes", () => {
+ it("always includes base classes", () => {
+ render(createElement(Input, { "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input.className).toContain("flex");
+ expect(input.className).toContain("w-full");
+ expect(input.className).toContain("font-sans");
+ expect(input.className).toContain("rounded-md");
+ expect(input.className).toContain("border");
+ expect(input.className).toContain("bg-background");
+ });
+
+ it("includes transition classes", () => {
+ render(createElement(Input, { "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input.className).toContain("transition-base");
+ });
+
+ it("includes focus ring classes", () => {
+ render(createElement(Input, { "data-testid": "input" }));
+ const input = screen.getByTestId("input");
+ expect(input.className).toContain("focus-ring");
+ });
+ });
+
+ describe("Event Handlers", () => {
+ it("calls onFocus when input is focused", () => {
+ const handleFocus = vi.fn();
+ render(
+ createElement(Input, {
+ onFocus: handleFocus,
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ fireEvent.focus(input);
+ expect(handleFocus).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onBlur when input loses focus", () => {
+ const handleBlur = vi.fn();
+ render(
+ createElement(Input, {
+ onBlur: handleBlur,
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ fireEvent.focus(input);
+ fireEvent.blur(input);
+ expect(handleBlur).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onKeyDown when key is pressed", () => {
+ const handleKeyDown = vi.fn();
+ render(
+ createElement(Input, {
+ onKeyDown: handleKeyDown,
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(handleKeyDown).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("Compound Scenarios", () => {
+ it("works correctly with error state and small size", () => {
+ render(
+ createElement(Input, {
+ state: "error",
+ size: "sm",
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input.className).toContain("border-error");
+ expect(input.className).toContain("h-9");
+ expect(input).toHaveAttribute("aria-invalid", "true");
+ });
+
+ it("works correctly with success state and large size", () => {
+ render(
+ createElement(Input, {
+ state: "success",
+ size: "lg",
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input.className).toContain("border-success");
+ expect(input.className).toContain("h-11");
+ });
+
+ it("works correctly with disabled state and error state", () => {
+ render(
+ createElement(Input, {
+ disabled: true,
+ state: "error",
+ "data-testid": "input",
+ }),
+ );
+ const input = screen.getByTestId("input");
+ expect(input).toBeDisabled();
+ expect(input.className).toContain("border-error");
+ expect(input).toHaveAttribute("aria-invalid", "true");
+ });
+ });
+});
diff --git a/packages/ui/src/Input/Input.ts b/packages/ui/src/Input/Input.ts
new file mode 100644
index 0000000..d72c163
--- /dev/null
+++ b/packages/ui/src/Input/Input.ts
@@ -0,0 +1,58 @@
+import { cn } from "@tpmjs/utils/cn";
+import { createElement, forwardRef } from "react";
+import type { InputProps } from "./types";
+import { inputVariants } from "./variants";
+
+/**
+ * Input component
+ *
+ * A versatile input component with multiple states, sizes, and full HTML input support.
+ * Built with .ts-only React using createElement.
+ *
+ * @example
+ * ```typescript
+ * import { Input } from '@tpmjs/ui/Input/Input';
+ * import { createElement } from 'react';
+ *
+ * function MyComponent() {
+ * return createElement(Input, {
+ * type: 'email',
+ * placeholder: 'Enter your email',
+ * state: 'default',
+ * size: 'md',
+ * });
+ * }
+ * ```
+ */
+export const Input = forwardRef(
+ (
+ {
+ className,
+ type = "text",
+ state = "default",
+ size = "md",
+ fullWidth = true,
+ disabled = false,
+ ...props
+ },
+ ref,
+ ) => {
+ return createElement("input", {
+ type,
+ className: cn(
+ inputVariants({
+ state,
+ size,
+ fullWidth: fullWidth ? "true" : "false",
+ }),
+ className,
+ ),
+ ref,
+ disabled,
+ "aria-invalid": state === "error" ? "true" : undefined,
+ ...props,
+ });
+ },
+);
+
+Input.displayName = "Input";
diff --git a/packages/ui/src/Input/tokens.ts b/packages/ui/src/Input/tokens.ts
new file mode 100644
index 0000000..2b35e75
--- /dev/null
+++ b/packages/ui/src/Input/tokens.ts
@@ -0,0 +1,44 @@
+import { borderRadius, spacing } from "../tokens";
+
+/**
+ * Input component design tokens
+ * Maps global tokens to input-specific semantics
+ */
+export const inputTokens = {
+ /** Input heights for each size */
+ height: {
+ sm: spacing[9], // 2.25rem (36px)
+ md: spacing[10], // 2.5rem (40px)
+ lg: spacing[11], // 2.75rem (44px)
+ },
+
+ /** Horizontal padding for each size */
+ padding: {
+ x: {
+ sm: spacing[3], // 0.75rem (12px)
+ md: spacing[3], // 0.75rem (12px)
+ lg: spacing[4], // 1rem (16px)
+ },
+ y: {
+ sm: spacing[2], // 0.5rem (8px)
+ md: spacing[2.5], // 0.625rem (10px)
+ lg: spacing[3], // 0.75rem (12px)
+ },
+ },
+
+ /** Font sizes for each size */
+ fontSize: {
+ sm: "0.875rem", // 14px
+ md: "1rem", // 16px
+ lg: "1.125rem", // 18px
+ },
+
+ /** Border radius */
+ borderRadius: borderRadius.md,
+
+ /** Border width */
+ borderWidth: "1px",
+
+ /** Transition duration */
+ transitionDuration: "200ms",
+} as const;
diff --git a/packages/ui/src/Input/types.ts b/packages/ui/src/Input/types.ts
new file mode 100644
index 0000000..94b6ac1
--- /dev/null
+++ b/packages/ui/src/Input/types.ts
@@ -0,0 +1,30 @@
+import type { InputHTMLAttributes } from "react";
+
+/**
+ * Input component props
+ */
+export interface InputProps
+ extends Omit, "size"> {
+ /**
+ * Visual state of the input
+ * @default 'default'
+ */
+ state?: "default" | "error" | "success";
+
+ /**
+ * Size of the input
+ * @default 'md'
+ */
+ size?: "sm" | "md" | "lg";
+
+ /**
+ * Full width input
+ * @default false
+ */
+ fullWidth?: boolean;
+}
+
+/**
+ * Input ref type
+ */
+export type InputRef = HTMLInputElement;
diff --git a/packages/ui/src/Input/variants.ts b/packages/ui/src/Input/variants.ts
new file mode 100644
index 0000000..7120473
--- /dev/null
+++ b/packages/ui/src/Input/variants.ts
@@ -0,0 +1,71 @@
+import { createVariants } from "../system/variants";
+
+/**
+ * Input variant definitions
+ * Uses custom variant system for type-safe class composition
+ */
+export const inputVariants = createVariants({
+ base: [
+ // Layout
+ "flex w-full",
+ // Typography
+ "font-sans",
+ // Borders & Radius
+ "rounded-md border",
+ // Background
+ "bg-background",
+ // Transitions
+ "transition-base",
+ // Focus
+ "focus-ring",
+ // Placeholder
+ "placeholder:text-foreground-tertiary",
+ // File input
+ "file:border-0 file:bg-transparent file:text-sm file:font-medium",
+ // Disabled state
+ "disabled:cursor-not-allowed disabled:opacity-50",
+ ].join(" "),
+
+ variants: {
+ state: {
+ default: [
+ "border-border text-foreground",
+ "hover:border-border-strong",
+ "focus:border-primary",
+ ].join(" "),
+
+ error: [
+ "border-error text-foreground",
+ "hover:border-error",
+ "focus:border-error",
+ "focus:ring-error/20",
+ ].join(" "),
+
+ success: [
+ "border-success text-foreground",
+ "hover:border-success",
+ "focus:border-success",
+ "focus:ring-success/20",
+ ].join(" "),
+ },
+
+ size: {
+ sm: "h-9 px-3 py-2 text-sm",
+ md: "h-10 px-3 py-2.5 text-base",
+ lg: "h-11 px-4 py-3 text-lg",
+ },
+
+ fullWidth: {
+ true: "w-full",
+ false: "w-auto",
+ },
+ },
+
+ compoundVariants: [],
+
+ defaultVariants: {
+ state: "default",
+ size: "md",
+ fullWidth: "true",
+ },
+});
diff --git a/packages/ui/src/Label/Label.test.ts b/packages/ui/src/Label/Label.test.ts
new file mode 100644
index 0000000..636a39d
--- /dev/null
+++ b/packages/ui/src/Label/Label.test.ts
@@ -0,0 +1,467 @@
+import { render, screen } from "@testing-library/react";
+import { createElement } from "react";
+import { describe, expect, it } from "vitest";
+import { Label } from "./Label";
+
+describe("Label", () => {
+ describe("Rendering", () => {
+ it("renders a label element", () => {
+ render(createElement(Label, { "data-testid": "label" }, "Label text"));
+ const label = screen.getByTestId("label");
+ expect(label).toBeInTheDocument();
+ expect(label.tagName).toBe("LABEL");
+ });
+
+ it("renders children text", () => {
+ render(createElement(Label, null, "Email Address"));
+ expect(screen.getByText("Email Address")).toBeInTheDocument();
+ });
+ });
+
+ describe("Sizes", () => {
+ it("applies small size classes", () => {
+ render(
+ createElement(
+ Label,
+ {
+ size: "sm",
+ "data-testid": "label",
+ },
+ "Small label",
+ ),
+ );
+ const label = screen.getByTestId("label");
+ expect(label.className).toContain("text-sm");
+ });
+
+ it("applies medium size classes (default)", () => {
+ render(
+ createElement(
+ Label,
+ {
+ size: "md",
+ "data-testid": "label",
+ },
+ "Medium label",
+ ),
+ );
+ const label = screen.getByTestId("label");
+ expect(label.className).toContain("text-base");
+ });
+
+ it("applies large size classes", () => {
+ render(
+ createElement(
+ Label,
+ {
+ size: "lg",
+ "data-testid": "label",
+ },
+ "Large label",
+ ),
+ );
+ const label = screen.getByTestId("label");
+ expect(label.className).toContain("text-lg");
+ });
+
+ it("uses medium size by default", () => {
+ render(
+ createElement(
+ Label,
+ {
+ "data-testid": "label",
+ },
+ "Default label",
+ ),
+ );
+ const label = screen.getByTestId("label");
+ expect(label.className).toContain("text-base");
+ });
+ });
+
+ describe("Required Indicator", () => {
+ it("does not show required indicator by default", () => {
+ render(createElement(Label, null, "Label"));
+ expect(screen.queryByText("*")).not.toBeInTheDocument();
+ });
+
+ it("shows required indicator when required is true", () => {
+ render(
+ createElement(
+ Label,
+ {
+ required: true,
+ },
+ "Required field",
+ ),
+ );
+ expect(screen.getByText("*")).toBeInTheDocument();
+ });
+
+ it("hides required indicator from screen readers", () => {
+ render(
+ createElement(
+ Label,
+ {
+ required: true,
+ "data-testid": "label",
+ },
+ "Required field",
+ ),
+ );
+ const asterisk = screen.getByText("*");
+ expect(asterisk).toHaveAttribute("aria-hidden", "true");
+ });
+
+ it("applies error color to required indicator", () => {
+ render(
+ createElement(
+ Label,
+ {
+ required: true,
+ },
+ "Required field",
+ ),
+ );
+ const asterisk = screen.getByText("*");
+ expect(asterisk.className).toContain("text-error");
+ });
+
+ it("applies proper spacing to required indicator", () => {
+ render(
+ createElement(
+ Label,
+ {
+ required: true,
+ },
+ "Required field",
+ ),
+ );
+ const asterisk = screen.getByText("*");
+ expect(asterisk.className).toContain("ml-1");
+ });
+ });
+
+ describe("Disabled State", () => {
+ it("is not disabled by default", () => {
+ render(
+ createElement(
+ Label,
+ {
+ "data-testid": "label",
+ },
+ "Label",
+ ),
+ );
+ const label = screen.getByTestId("label");
+ // Should not have the direct opacity-50 class (peer-disabled:opacity-50 is okay)
+ expect(label.className).toContain("peer-disabled:opacity-50");
+ expect(label.className.split(" ")).not.toContain("opacity-50");
+ });
+
+ it("applies disabled classes when disabled is true", () => {
+ render(
+ createElement(
+ Label,
+ {
+ disabled: true,
+ "data-testid": "label",
+ },
+ "Disabled label",
+ ),
+ );
+ const label = screen.getByTestId("label");
+ expect(label.className).toContain("opacity-50");
+ expect(label.className).toContain("cursor-not-allowed");
+ });
+ });
+
+ describe("HTML Attributes", () => {
+ it("associates with input using htmlFor", () => {
+ render(
+ createElement(
+ Label,
+ {
+ htmlFor: "email-input",
+ "data-testid": "label",
+ },
+ "Email",
+ ),
+ );
+ const label = screen.getByTestId("label");
+ expect(label).toHaveAttribute("for", "email-input");
+ });
+
+ it("passes through id attribute", () => {
+ render(
+ createElement(
+ Label,
+ {
+ id: "label-id",
+ "data-testid": "label",
+ },
+ "Label",
+ ),
+ );
+ const label = screen.getByTestId("label");
+ expect(label).toHaveAttribute("id", "label-id");
+ });
+
+ it("passes through className", () => {
+ render(
+ createElement(
+ Label,
+ {
+ className: "custom-label",
+ "data-testid": "label",
+ },
+ "Custom",
+ ),
+ );
+ const label = screen.getByTestId("label");
+ expect(label.className).toContain("custom-label");
+ });
+
+ it("passes through onClick handler", () => {
+ let clicked = false;
+ render(
+ createElement(
+ Label,
+ {
+ onClick: () => {
+ clicked = true;
+ },
+ "data-testid": "label",
+ },
+ "Clickable",
+ ),
+ );
+ const label = screen.getByTestId("label");
+ label.click();
+ expect(clicked).toBe(true);
+ });
+ });
+
+ describe("ARIA Attributes", () => {
+ it("passes through aria-label", () => {
+ render(
+ createElement(
+ Label,
+ {
+ "aria-label": "Field label",
+ "data-testid": "label",
+ },
+ "Label",
+ ),
+ );
+ const label = screen.getByTestId("label");
+ expect(label).toHaveAttribute("aria-label", "Field label");
+ });
+
+ it("passes through aria-describedby", () => {
+ render(
+ createElement(
+ Label,
+ {
+ "aria-describedby": "description-id",
+ "data-testid": "label",
+ },
+ "Label",
+ ),
+ );
+ const label = screen.getByTestId("label");
+ expect(label).toHaveAttribute("aria-describedby", "description-id");
+ });
+ });
+
+ describe("Custom className", () => {
+ it("merges custom className with base classes", () => {
+ render(
+ createElement(
+ Label,
+ {
+ className: "custom-class",
+ "data-testid": "label",
+ },
+ "Label",
+ ),
+ );
+ const label = screen.getByTestId("label");
+ expect(label.className).toContain("custom-class");
+ expect(label.className).toContain("font-medium");
+ expect(label.className).toContain("text-foreground");
+ });
+ });
+
+ describe("Ref Forwarding", () => {
+ it("forwards ref to label element", () => {
+ let ref: HTMLLabelElement | null = null;
+ render(
+ createElement(
+ Label,
+ {
+ ref: (el: HTMLLabelElement | null) => {
+ ref = el;
+ },
+ },
+ "Label",
+ ),
+ );
+ expect(ref).toBeInstanceOf(HTMLLabelElement);
+ expect(ref?.tagName).toBe("LABEL");
+ });
+ });
+
+ describe("Base Classes", () => {
+ it("always includes base classes", () => {
+ render(
+ createElement(
+ Label,
+ {
+ "data-testid": "label",
+ },
+ "Label",
+ ),
+ );
+ const label = screen.getByTestId("label");
+ expect(label.className).toContain("font-medium");
+ expect(label.className).toContain("text-foreground");
+ expect(label.className).toContain("cursor-pointer");
+ });
+
+ it("includes peer-disabled classes", () => {
+ render(
+ createElement(
+ Label,
+ {
+ "data-testid": "label",
+ },
+ "Label",
+ ),
+ );
+ const label = screen.getByTestId("label");
+ expect(label.className).toContain("peer-disabled:cursor-not-allowed");
+ expect(label.className).toContain("peer-disabled:opacity-50");
+ });
+ });
+
+ describe("Compound Scenarios", () => {
+ it("works correctly with required and small size", () => {
+ render(
+ createElement(
+ Label,
+ {
+ required: true,
+ size: "sm",
+ "data-testid": "label",
+ },
+ "Small required",
+ ),
+ );
+ const label = screen.getByTestId("label");
+ expect(label.className).toContain("text-sm");
+ expect(screen.getByText("*")).toBeInTheDocument();
+ });
+
+ it("works correctly with disabled and required", () => {
+ render(
+ createElement(
+ Label,
+ {
+ disabled: true,
+ required: true,
+ "data-testid": "label",
+ },
+ "Disabled required",
+ ),
+ );
+ const label = screen.getByTestId("label");
+ expect(label.className).toContain("opacity-50");
+ expect(label.className).toContain("cursor-not-allowed");
+ expect(screen.getByText("*")).toBeInTheDocument();
+ });
+
+ it("works correctly with htmlFor and required", () => {
+ render(
+ createElement(
+ Label,
+ {
+ htmlFor: "field-id",
+ required: true,
+ "data-testid": "label",
+ },
+ "Field label",
+ ),
+ );
+ const label = screen.getByTestId("label");
+ expect(label).toHaveAttribute("for", "field-id");
+ expect(screen.getByText("*")).toBeInTheDocument();
+ });
+
+ it("works correctly with large size and custom className", () => {
+ render(
+ createElement(
+ Label,
+ {
+ size: "lg",
+ className: "font-bold",
+ "data-testid": "label",
+ },
+ "Large bold",
+ ),
+ );
+ const label = screen.getByTestId("label");
+ expect(label.className).toContain("text-lg");
+ expect(label.className).toContain("font-bold");
+ });
+ });
+
+ describe("Integration with Form Controls", () => {
+ it("associates correctly with input element", () => {
+ const { container } = render(
+ createElement(
+ "div",
+ null,
+ createElement(Label, { htmlFor: "test-input" }, "Test Label"),
+ createElement("input", { id: "test-input", type: "text" }),
+ ),
+ );
+
+ const label = container.querySelector("label");
+ const input = container.querySelector("input");
+
+ expect(label).toHaveAttribute("for", "test-input");
+ expect(input).toHaveAttribute("id", "test-input");
+ });
+
+ it("clicking label focuses associated input", () => {
+ const { container } = render(
+ createElement(
+ "div",
+ null,
+ createElement(
+ Label,
+ { htmlFor: "focus-input", "data-testid": "label" },
+ "Click me",
+ ),
+ createElement("input", {
+ id: "focus-input",
+ type: "text",
+ "data-testid": "input",
+ }),
+ ),
+ );
+
+ const label = screen.getByTestId("label");
+ const input = screen.getByTestId("input") as HTMLInputElement;
+
+ // Verify the association exists
+ expect(label).toHaveAttribute("for", "focus-input");
+ expect(input).toHaveAttribute("id", "focus-input");
+
+ // Focus the input directly to verify it can receive focus
+ input.focus();
+ expect(input).toHaveFocus();
+ });
+ });
+});
diff --git a/packages/ui/src/Label/Label.ts b/packages/ui/src/Label/Label.ts
new file mode 100644
index 0000000..e005439
--- /dev/null
+++ b/packages/ui/src/Label/Label.ts
@@ -0,0 +1,65 @@
+import { cn } from "@tpmjs/utils/cn";
+import { createElement, forwardRef } from "react";
+import type { LabelProps } from "./types";
+import { labelVariants } from "./variants";
+
+/**
+ * Label component
+ *
+ * A label component for form inputs with proper accessibility.
+ * Built with .ts-only React using createElement.
+ *
+ * @example
+ * ```typescript
+ * import { Label } from '@tpmjs/ui/Label/Label';
+ * import { createElement } from 'react';
+ *
+ * function MyComponent() {
+ * return createElement(Label, {
+ * htmlFor: 'email',
+ * children: 'Email Address',
+ * });
+ * }
+ * ```
+ */
+export const Label = forwardRef(
+ (
+ {
+ className,
+ size = "md",
+ required = false,
+ disabled = false,
+ children,
+ ...props
+ },
+ ref,
+ ) => {
+ return createElement(
+ "label",
+ {
+ className: cn(
+ labelVariants({
+ size,
+ disabled: disabled ? "true" : "false",
+ }),
+ className,
+ ),
+ ref,
+ ...props,
+ },
+ children,
+ required
+ ? createElement(
+ "span",
+ {
+ className: "ml-1 text-error",
+ "aria-hidden": "true",
+ },
+ "*",
+ )
+ : null,
+ );
+ },
+);
+
+Label.displayName = "Label";
diff --git a/packages/ui/src/Label/tokens.ts b/packages/ui/src/Label/tokens.ts
new file mode 100644
index 0000000..d941e15
--- /dev/null
+++ b/packages/ui/src/Label/tokens.ts
@@ -0,0 +1,27 @@
+import { spacing } from "../tokens";
+
+/**
+ * Label component design tokens
+ * Maps global tokens to label-specific semantics
+ */
+export const labelTokens = {
+ /** Font sizes for each size */
+ fontSize: {
+ sm: "0.875rem", // 14px
+ md: "1rem", // 16px
+ lg: "1.125rem", // 18px
+ },
+
+ /** Line heights for each size */
+ lineHeight: {
+ sm: "1.25rem", // 20px
+ md: "1.5rem", // 24px
+ lg: "1.75rem", // 28px
+ },
+
+ /** Margin bottom (spacing from label to input) */
+ marginBottom: spacing[2], // 0.5rem (8px)
+
+ /** Required indicator spacing */
+ requiredSpacing: spacing[1], // 0.25rem (4px)
+} as const;
diff --git a/packages/ui/src/Label/types.ts b/packages/ui/src/Label/types.ts
new file mode 100644
index 0000000..b121283
--- /dev/null
+++ b/packages/ui/src/Label/types.ts
@@ -0,0 +1,29 @@
+import type { LabelHTMLAttributes } from "react";
+
+/**
+ * Label component props
+ */
+export interface LabelProps extends LabelHTMLAttributes {
+ /**
+ * Size of the label
+ * @default 'md'
+ */
+ size?: "sm" | "md" | "lg";
+
+ /**
+ * Whether the associated field is required
+ * @default false
+ */
+ required?: boolean;
+
+ /**
+ * Whether the label should be visually disabled
+ * @default false
+ */
+ disabled?: boolean;
+}
+
+/**
+ * Label ref type
+ */
+export type LabelRef = HTMLLabelElement;
diff --git a/packages/ui/src/Label/variants.ts b/packages/ui/src/Label/variants.ts
new file mode 100644
index 0000000..6264d69
--- /dev/null
+++ b/packages/ui/src/Label/variants.ts
@@ -0,0 +1,37 @@
+import { createVariants } from "../system/variants";
+
+/**
+ * Label variant definitions
+ * Uses custom variant system for type-safe class composition
+ */
+export const labelVariants = createVariants({
+ base: [
+ // Typography
+ "font-medium text-foreground",
+ "leading-none",
+ // Cursor
+ "cursor-pointer",
+ // Peer styling support
+ "peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
+ ].join(" "),
+
+ variants: {
+ size: {
+ sm: "text-sm",
+ md: "text-base",
+ lg: "text-lg",
+ },
+
+ disabled: {
+ true: "cursor-not-allowed opacity-50",
+ false: "",
+ },
+ },
+
+ compoundVariants: [],
+
+ defaultVariants: {
+ size: "md",
+ disabled: "false",
+ },
+});
diff --git a/packages/ui/src/ProgressBar/ProgressBar.test.ts b/packages/ui/src/ProgressBar/ProgressBar.test.ts
new file mode 100644
index 0000000..b26df92
--- /dev/null
+++ b/packages/ui/src/ProgressBar/ProgressBar.test.ts
@@ -0,0 +1,359 @@
+import { render, screen } from "@testing-library/react";
+import { createElement } from "react";
+import { describe, expect, it } from "vitest";
+import { ProgressBar } from "./ProgressBar";
+
+describe("ProgressBar", () => {
+ describe("Rendering", () => {
+ it("renders a progress bar element", () => {
+ render(
+ createElement(ProgressBar, { value: 50, "data-testid": "progress" }),
+ );
+ const progress = screen.getByTestId("progress");
+ expect(progress).toBeInTheDocument();
+ expect(progress.tagName).toBe("DIV");
+ });
+
+ it("has progressbar role", () => {
+ render(
+ createElement(ProgressBar, { value: 50, "data-testid": "progress" }),
+ );
+ const progress = screen.getByTestId("progress");
+ expect(progress).toHaveAttribute("role", "progressbar");
+ });
+
+ it("sets correct aria attributes", () => {
+ render(
+ createElement(ProgressBar, { value: 75, "data-testid": "progress" }),
+ );
+ const progress = screen.getByTestId("progress");
+ expect(progress).toHaveAttribute("aria-valuenow", "75");
+ expect(progress).toHaveAttribute("aria-valuemin", "0");
+ expect(progress).toHaveAttribute("aria-valuemax", "100");
+ });
+
+ it("renders fill element with correct width", () => {
+ render(
+ createElement(ProgressBar, { value: 60, "data-testid": "progress" }),
+ );
+ const progress = screen.getByTestId("progress");
+ const fill = progress.querySelector("div");
+ expect(fill).toHaveStyle({ width: "60%" });
+ });
+ });
+
+ describe("Value Handling", () => {
+ it("clamps value above 100 to 100", () => {
+ render(
+ createElement(ProgressBar, { value: 150, "data-testid": "progress" }),
+ );
+ const progress = screen.getByTestId("progress");
+ expect(progress).toHaveAttribute("aria-valuenow", "100");
+ const fill = progress.querySelector("div");
+ expect(fill).toHaveStyle({ width: "100%" });
+ });
+
+ it("clamps value below 0 to 0", () => {
+ render(
+ createElement(ProgressBar, { value: -10, "data-testid": "progress" }),
+ );
+ const progress = screen.getByTestId("progress");
+ expect(progress).toHaveAttribute("aria-valuenow", "0");
+ const fill = progress.querySelector("div");
+ expect(fill).toHaveStyle({ width: "0%" });
+ });
+
+ it("handles 0 value", () => {
+ render(
+ createElement(ProgressBar, { value: 0, "data-testid": "progress" }),
+ );
+ const progress = screen.getByTestId("progress");
+ expect(progress).toHaveAttribute("aria-valuenow", "0");
+ const fill = progress.querySelector("div");
+ expect(fill).toHaveStyle({ width: "0%" });
+ });
+
+ it("handles 100 value", () => {
+ render(
+ createElement(ProgressBar, { value: 100, "data-testid": "progress" }),
+ );
+ const progress = screen.getByTestId("progress");
+ expect(progress).toHaveAttribute("aria-valuenow", "100");
+ const fill = progress.querySelector("div");
+ expect(fill).toHaveStyle({ width: "100%" });
+ });
+
+ it("handles decimal values", () => {
+ render(
+ createElement(ProgressBar, { value: 33.7, "data-testid": "progress" }),
+ );
+ const progress = screen.getByTestId("progress");
+ expect(progress).toHaveAttribute("aria-valuenow", "33.7");
+ const fill = progress.querySelector("div");
+ expect(fill).toHaveStyle({ width: "33.7%" });
+ });
+ });
+
+ describe("Size Variants", () => {
+ it("applies small size", () => {
+ render(
+ createElement(ProgressBar, {
+ value: 50,
+ size: "sm",
+ "data-testid": "progress",
+ }),
+ );
+ const progress = screen.getByTestId("progress");
+ expect(progress.className).toContain("h-1");
+ });
+
+ it("applies medium size", () => {
+ render(
+ createElement(ProgressBar, {
+ value: 50,
+ size: "md",
+ "data-testid": "progress",
+ }),
+ );
+ const progress = screen.getByTestId("progress");
+ expect(progress.className).toContain("h-2");
+ });
+
+ it("applies large size", () => {
+ render(
+ createElement(ProgressBar, {
+ value: 50,
+ size: "lg",
+ "data-testid": "progress",
+ }),
+ );
+ const progress = screen.getByTestId("progress");
+ expect(progress.className).toContain("h-3");
+ });
+
+ it("uses md size by default", () => {
+ render(
+ createElement(ProgressBar, { value: 50, "data-testid": "progress" }),
+ );
+ const progress = screen.getByTestId("progress");
+ expect(progress.className).toContain("h-2");
+ });
+ });
+
+ describe("Color Variants", () => {
+ it("applies primary variant", () => {
+ render(
+ createElement(ProgressBar, {
+ value: 50,
+ variant: "primary",
+ "data-testid": "progress",
+ }),
+ );
+ const progress = screen.getByTestId("progress");
+ const fill = progress.querySelector("div");
+ expect(fill?.className).toContain("bg-blue-500");
+ });
+
+ it("applies success variant", () => {
+ render(
+ createElement(ProgressBar, {
+ value: 50,
+ variant: "success",
+ "data-testid": "progress",
+ }),
+ );
+ const progress = screen.getByTestId("progress");
+ const fill = progress.querySelector("div");
+ expect(fill?.className).toContain("bg-green-500");
+ });
+
+ it("applies warning variant", () => {
+ render(
+ createElement(ProgressBar, {
+ value: 50,
+ variant: "warning",
+ "data-testid": "progress",
+ }),
+ );
+ const progress = screen.getByTestId("progress");
+ const fill = progress.querySelector("div");
+ expect(fill?.className).toContain("bg-yellow-500");
+ });
+
+ it("applies danger variant", () => {
+ render(
+ createElement(ProgressBar, {
+ value: 50,
+ variant: "danger",
+ "data-testid": "progress",
+ }),
+ );
+ const progress = screen.getByTestId("progress");
+ const fill = progress.querySelector("div");
+ expect(fill?.className).toContain("bg-red-500");
+ });
+
+ it("uses primary variant by default", () => {
+ render(
+ createElement(ProgressBar, { value: 50, "data-testid": "progress" }),
+ );
+ const progress = screen.getByTestId("progress");
+ const fill = progress.querySelector("div");
+ expect(fill?.className).toContain("bg-blue-500");
+ });
+ });
+
+ describe("Label Display", () => {
+ it("does not show label by default", () => {
+ const { container } = render(createElement(ProgressBar, { value: 50 }));
+ expect(container.textContent).toBe("");
+ });
+
+ it("shows label when showLabel is true", () => {
+ render(createElement(ProgressBar, { value: 50, showLabel: true }));
+ expect(screen.getByText("50%")).toBeInTheDocument();
+ });
+
+ it("rounds label to nearest integer", () => {
+ render(createElement(ProgressBar, { value: 33.7, showLabel: true }));
+ expect(screen.getByText("34%")).toBeInTheDocument();
+ });
+
+ it("shows 0% label for 0 value", () => {
+ render(createElement(ProgressBar, { value: 0, showLabel: true }));
+ expect(screen.getByText("0%")).toBeInTheDocument();
+ });
+
+ it("shows 100% label for 100 value", () => {
+ render(createElement(ProgressBar, { value: 100, showLabel: true }));
+ expect(screen.getByText("100%")).toBeInTheDocument();
+ });
+
+ it("label has correct styling classes", () => {
+ render(createElement(ProgressBar, { value: 50, showLabel: true }));
+ const label = screen.getByText("50%");
+ expect(label.className).toContain("text-sm");
+ expect(label.className).toContain("text-zinc-400");
+ expect(label.className).toContain("tabular-nums");
+ });
+ });
+
+ describe("HTML Attributes", () => {
+ it("passes through id attribute", () => {
+ render(
+ createElement(ProgressBar, {
+ value: 50,
+ id: "progress-id",
+ "data-testid": "progress",
+ }),
+ );
+ const progress = screen.getByTestId("progress");
+ expect(progress).toHaveAttribute("id", "progress-id");
+ });
+
+ it("passes through data attributes", () => {
+ render(
+ createElement(ProgressBar, {
+ value: 50,
+ "data-custom": "test",
+ "data-testid": "progress",
+ }),
+ );
+ const progress = screen.getByTestId("progress");
+ expect(progress).toHaveAttribute("data-custom", "test");
+ });
+ });
+
+ describe("Custom className", () => {
+ it("merges custom className with variant classes", () => {
+ render(
+ createElement(ProgressBar, {
+ value: 50,
+ className: "custom-class",
+ "data-testid": "progress",
+ }),
+ );
+ const progress = screen.getByTestId("progress");
+ expect(progress.className).toContain("custom-class");
+ expect(progress.className).toContain("bg-zinc-800");
+ expect(progress.className).toContain("rounded");
+ });
+ });
+
+ describe("Ref Forwarding", () => {
+ it("forwards ref to progress bar element", () => {
+ let ref: HTMLDivElement | null = null;
+ render(
+ createElement(ProgressBar, {
+ value: 50,
+ ref: (el: HTMLDivElement | null) => {
+ ref = el;
+ },
+ }),
+ );
+ expect(ref).toBeInstanceOf(HTMLDivElement);
+ expect(ref).toHaveAttribute("role", "progressbar");
+ });
+ });
+
+ describe("Base Classes", () => {
+ it("always includes track base classes", () => {
+ render(
+ createElement(ProgressBar, { value: 50, "data-testid": "progress" }),
+ );
+ const progress = screen.getByTestId("progress");
+ expect(progress.className).toContain("relative");
+ expect(progress.className).toContain("overflow-hidden");
+ expect(progress.className).toContain("bg-zinc-800");
+ expect(progress.className).toContain("rounded");
+ });
+
+ it("fill includes base classes", () => {
+ render(
+ createElement(ProgressBar, { value: 50, "data-testid": "progress" }),
+ );
+ const progress = screen.getByTestId("progress");
+ const fill = progress.querySelector("div");
+ expect(fill?.className).toContain("h-full");
+ expect(fill?.className).toContain("transition-all");
+ expect(fill?.className).toContain("rounded");
+ });
+ });
+
+ describe("Compound Scenarios", () => {
+ it("works correctly with large size and success variant", () => {
+ render(
+ createElement(ProgressBar, {
+ value: 80,
+ size: "lg",
+ variant: "success",
+ "data-testid": "progress",
+ }),
+ );
+ const progress = screen.getByTestId("progress");
+ expect(progress.className).toContain("h-3");
+ const fill = progress.querySelector("div");
+ expect(fill?.className).toContain("bg-green-500");
+ expect(fill).toHaveStyle({ width: "80%" });
+ });
+
+ it("works correctly with label, custom className, and warning variant", () => {
+ render(
+ createElement(ProgressBar, {
+ value: 45,
+ variant: "warning",
+ showLabel: true,
+ className: "my-custom-class",
+ "data-testid": "progress",
+ }),
+ );
+ const wrapper = screen.getByTestId("progress");
+ // Custom className is applied to the track, which is the first child
+ const track = wrapper.querySelector('[role="progressbar"]');
+ expect(track?.className).toContain("my-custom-class");
+ const fill = track?.querySelector("div");
+ expect(fill?.className).toContain("bg-yellow-500");
+ expect(screen.getByText("45%")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/ui/src/ProgressBar/ProgressBar.ts b/packages/ui/src/ProgressBar/ProgressBar.ts
new file mode 100644
index 0000000..859dd28
--- /dev/null
+++ b/packages/ui/src/ProgressBar/ProgressBar.ts
@@ -0,0 +1,116 @@
+import { cn } from "@tpmjs/utils/cn";
+import { createElement, forwardRef } from "react";
+import type { ProgressBarProps } from "./types";
+import { progressBarFillVariants, progressBarTrackVariants } from "./variants";
+
+/**
+ * ProgressBar component
+ *
+ * Displays progress as a horizontal bar with optional label.
+ * Supports multiple size and color variants.
+ * Built with .ts-only React using createElement.
+ *
+ * @example
+ * ```typescript
+ * import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar';
+ * import { createElement } from 'react';
+ *
+ * function MyComponent() {
+ * return createElement(ProgressBar, {
+ * value: 75,
+ * variant: 'success',
+ * size: 'md',
+ * showLabel: true,
+ * });
+ * }
+ * ```
+ */
+export const ProgressBar = forwardRef(
+ (
+ {
+ className,
+ value,
+ size = "md",
+ variant = "primary",
+ showLabel = false,
+ ...props
+ },
+ ref,
+ ) => {
+ // Clamp value between 0 and 100
+ const clampedValue = Math.min(Math.max(value, 0), 100);
+
+ if (!showLabel) {
+ return createElement(
+ "div",
+ {
+ className: cn(
+ progressBarTrackVariants({
+ size,
+ }),
+ className,
+ ),
+ role: "progressbar",
+ "aria-valuenow": clampedValue,
+ "aria-valuemin": 0,
+ "aria-valuemax": 100,
+ ref,
+ ...props,
+ },
+ createElement("div", {
+ className: progressBarFillVariants({
+ variant,
+ }),
+ style: {
+ width: `${clampedValue}%`,
+ },
+ }),
+ );
+ }
+
+ return createElement(
+ "div",
+ {
+ className: "flex items-center gap-3",
+ ref,
+ ...props,
+ },
+ [
+ createElement(
+ "div",
+ {
+ key: "track",
+ className: cn(
+ progressBarTrackVariants({
+ size,
+ }),
+ className,
+ ),
+ role: "progressbar",
+ "aria-valuenow": clampedValue,
+ "aria-valuemin": 0,
+ "aria-valuemax": 100,
+ },
+ createElement("div", {
+ className: progressBarFillVariants({
+ variant,
+ }),
+ style: {
+ width: `${clampedValue}%`,
+ },
+ }),
+ ),
+ createElement(
+ "span",
+ {
+ key: "label",
+ className: "text-sm text-zinc-400 tabular-nums min-w-[3ch]",
+ },
+ `${Math.round(clampedValue)}%`,
+ ),
+ ],
+ );
+ },
+);
+
+ProgressBar.displayName = "ProgressBar";
diff --git a/packages/ui/src/ProgressBar/tokens.ts b/packages/ui/src/ProgressBar/tokens.ts
new file mode 100644
index 0000000..f14fa48
--- /dev/null
+++ b/packages/ui/src/ProgressBar/tokens.ts
@@ -0,0 +1,17 @@
+import { spacing } from "../tokens";
+
+/**
+ * ProgressBar component design tokens
+ * Maps global tokens to progress bar-specific semantics
+ */
+export const progressBarTokens = {
+ /** Height for each size */
+ height: {
+ sm: spacing[1], // 0.25rem (4px)
+ md: spacing[2], // 0.5rem (8px)
+ lg: spacing[3], // 0.75rem (12px)
+ },
+
+ /** Border radius */
+ radius: spacing[1], // 0.25rem (4px)
+} as const;
diff --git a/packages/ui/src/ProgressBar/types.ts b/packages/ui/src/ProgressBar/types.ts
new file mode 100644
index 0000000..70873ec
--- /dev/null
+++ b/packages/ui/src/ProgressBar/types.ts
@@ -0,0 +1,35 @@
+import type { HTMLAttributes } from "react";
+
+/**
+ * ProgressBar component props
+ */
+export interface ProgressBarProps
+ extends Omit, "children"> {
+ /**
+ * Progress value (0-100)
+ */
+ value: number;
+
+ /**
+ * Size variant
+ * @default 'md'
+ */
+ size?: "sm" | "md" | "lg";
+
+ /**
+ * Color variant
+ * @default 'primary'
+ */
+ variant?: "primary" | "success" | "warning" | "danger";
+
+ /**
+ * Whether to show percentage label
+ * @default false
+ */
+ showLabel?: boolean;
+}
+
+/**
+ * ProgressBar ref type
+ */
+export type ProgressBarRef = HTMLDivElement;
diff --git a/packages/ui/src/ProgressBar/variants.ts b/packages/ui/src/ProgressBar/variants.ts
new file mode 100644
index 0000000..55eda3b
--- /dev/null
+++ b/packages/ui/src/ProgressBar/variants.ts
@@ -0,0 +1,58 @@
+import { createVariants } from "../system/variants";
+
+/**
+ * ProgressBar track variant definitions
+ */
+export const progressBarTrackVariants = createVariants({
+ base: [
+ // Layout
+ "relative overflow-hidden",
+ // Background
+ "bg-zinc-800",
+ // Border
+ "rounded",
+ ].join(" "),
+
+ variants: {
+ size: {
+ sm: "h-1", // 4px
+ md: "h-2", // 8px
+ lg: "h-3", // 12px
+ },
+ },
+
+ compoundVariants: [],
+
+ defaultVariants: {
+ size: "md",
+ },
+});
+
+/**
+ * ProgressBar fill variant definitions
+ */
+export const progressBarFillVariants = createVariants({
+ base: [
+ // Layout
+ "h-full",
+ // Transition
+ "transition-all duration-300 ease-out",
+ // Border
+ "rounded",
+ ].join(" "),
+
+ variants: {
+ variant: {
+ primary: "bg-blue-500",
+ success: "bg-green-500",
+ warning: "bg-yellow-500",
+ danger: "bg-red-500",
+ },
+ },
+
+ compoundVariants: [],
+
+ defaultVariants: {
+ variant: "primary",
+ },
+});
diff --git a/packages/ui/src/Tabs/Tabs.test.ts b/packages/ui/src/Tabs/Tabs.test.ts
new file mode 100644
index 0000000..eb7950f
--- /dev/null
+++ b/packages/ui/src/Tabs/Tabs.test.ts
@@ -0,0 +1,537 @@
+import { render, screen } from "@testing-library/react";
+import { createElement } from "react";
+import { describe, expect, it, vi } from "vitest";
+import { Tabs } from "./Tabs";
+import type { Tab } from "./types";
+
+const mockTabs: Tab[] = [
+ { id: "all", label: "All Tools", count: 1234 },
+ { id: "featured", label: "Featured", count: 42 },
+ { id: "recent", label: "Recent" },
+];
+
+describe("Tabs", () => {
+ describe("Rendering", () => {
+ it("renders tabs container", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ "data-testid": "tabs",
+ }),
+ );
+ const tabs = screen.getByTestId("tabs");
+ expect(tabs).toBeInTheDocument();
+ expect(tabs.tagName).toBe("DIV");
+ });
+
+ it("has tablist role", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ "data-testid": "tabs",
+ }),
+ );
+ const tabs = screen.getByTestId("tabs");
+ expect(tabs).toHaveAttribute("role", "tablist");
+ });
+
+ it("renders all tabs", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ }),
+ );
+ expect(screen.getByTestId("tab-all")).toBeInTheDocument();
+ expect(screen.getByTestId("tab-featured")).toBeInTheDocument();
+ expect(screen.getByTestId("tab-recent")).toBeInTheDocument();
+ });
+
+ it("renders tab labels", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ }),
+ );
+ expect(screen.getByText("All Tools")).toBeInTheDocument();
+ expect(screen.getByText("Featured")).toBeInTheDocument();
+ expect(screen.getByText("Recent")).toBeInTheDocument();
+ });
+
+ it("renders tabs as buttons", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ }),
+ );
+ const tab = screen.getByTestId("tab-all");
+ expect(tab.tagName).toBe("BUTTON");
+ expect(tab).toHaveAttribute("type", "button");
+ });
+ });
+
+ describe("Active State", () => {
+ it("marks active tab with aria-selected", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "featured",
+ onTabChange: handleChange,
+ }),
+ );
+ expect(screen.getByTestId("tab-featured")).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ expect(screen.getByTestId("tab-all")).toHaveAttribute(
+ "aria-selected",
+ "false",
+ );
+ expect(screen.getByTestId("tab-recent")).toHaveAttribute(
+ "aria-selected",
+ "false",
+ );
+ });
+
+ it("applies active styling to active tab", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ }),
+ );
+ const activeTab = screen.getByTestId("tab-all");
+ expect(activeTab.className).toContain("text-zinc-100");
+ expect(activeTab.className).toContain("border-blue-500");
+ });
+
+ it("applies inactive styling to inactive tabs", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ }),
+ );
+ const inactiveTab = screen.getByTestId("tab-featured");
+ expect(inactiveTab.className).toContain("text-zinc-400");
+ expect(inactiveTab.className).toContain("border-transparent");
+ });
+ });
+
+ describe("Click Handling", () => {
+ it("calls onTabChange when tab is clicked", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ }),
+ );
+ screen.getByTestId("tab-featured").click();
+ expect(handleChange).toHaveBeenCalledWith("featured");
+ });
+
+ it("calls onTabChange with correct tab ID", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ }),
+ );
+ screen.getByTestId("tab-recent").click();
+ expect(handleChange).toHaveBeenCalledWith("recent");
+ expect(handleChange).toHaveBeenCalledTimes(1);
+ });
+
+ it("allows clicking already active tab", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ }),
+ );
+ screen.getByTestId("tab-all").click();
+ expect(handleChange).toHaveBeenCalledWith("all");
+ });
+ });
+
+ describe("Count Badges", () => {
+ it("renders count badge when count is provided", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ }),
+ );
+ expect(screen.getByText("1234")).toBeInTheDocument();
+ expect(screen.getByText("42")).toBeInTheDocument();
+ });
+
+ it("does not render count badge when count is undefined", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ }),
+ );
+ const recentTab = screen.getByTestId("tab-recent");
+ const badge = recentTab.querySelector("span[aria-label]");
+ expect(badge).not.toBeInTheDocument();
+ });
+
+ it("count badge has aria-label", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ }),
+ );
+ const badge = screen.getByText("1234");
+ expect(badge).toHaveAttribute("aria-label", "1234 items");
+ });
+
+ it("applies active styling to count badge on active tab", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ }),
+ );
+ const activeBadge = screen.getByText("1234");
+ expect(activeBadge.className).toContain("text-zinc-100");
+ });
+
+ it("applies inactive styling to count badge on inactive tab", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ }),
+ );
+ const inactiveBadge = screen.getByText("42");
+ expect(inactiveBadge.className).toContain("text-zinc-500");
+ });
+
+ it("handles zero count", () => {
+ const handleChange = vi.fn();
+ const tabsWithZero: Tab[] = [{ id: "empty", label: "Empty", count: 0 }];
+ render(
+ createElement(Tabs, {
+ tabs: tabsWithZero,
+ activeTab: "empty",
+ onTabChange: handleChange,
+ }),
+ );
+ expect(screen.getByText("0")).toBeInTheDocument();
+ });
+ });
+
+ describe("Size Variants", () => {
+ it("applies small size", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ size: "sm",
+ }),
+ );
+ const tab = screen.getByTestId("tab-all");
+ expect(tab.className).toContain("text-sm");
+ expect(tab.className).toContain("px-3");
+ expect(tab.className).toContain("py-2");
+ });
+
+ it("applies medium size", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ size: "md",
+ }),
+ );
+ const tab = screen.getByTestId("tab-all");
+ expect(tab.className).toContain("text-base");
+ expect(tab.className).toContain("px-4");
+ expect(tab.className).toContain("py-3");
+ });
+
+ it("applies large size", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ size: "lg",
+ }),
+ );
+ const tab = screen.getByTestId("tab-all");
+ expect(tab.className).toContain("text-lg");
+ expect(tab.className).toContain("px-6");
+ expect(tab.className).toContain("py-4");
+ });
+
+ it("uses md size by default", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ }),
+ );
+ const tab = screen.getByTestId("tab-all");
+ expect(tab.className).toContain("text-base");
+ expect(tab.className).toContain("px-4");
+ expect(tab.className).toContain("py-3");
+ });
+ });
+
+ describe("Accessibility", () => {
+ it('tabs have role="tab"', () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ }),
+ );
+ const tab = screen.getByTestId("tab-all");
+ expect(tab).toHaveAttribute("role", "tab");
+ });
+
+ it("tabs have unique IDs", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ }),
+ );
+ expect(screen.getByTestId("tab-all")).toHaveAttribute("id", "tab-all");
+ expect(screen.getByTestId("tab-featured")).toHaveAttribute(
+ "id",
+ "tab-featured",
+ );
+ expect(screen.getByTestId("tab-recent")).toHaveAttribute(
+ "id",
+ "tab-recent",
+ );
+ });
+
+ it("tabs have aria-controls attribute", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ }),
+ );
+ expect(screen.getByTestId("tab-all")).toHaveAttribute(
+ "aria-controls",
+ "tabpanel-all",
+ );
+ expect(screen.getByTestId("tab-featured")).toHaveAttribute(
+ "aria-controls",
+ "tabpanel-featured",
+ );
+ });
+ });
+
+ describe("HTML Attributes", () => {
+ it("passes through id attribute", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ id: "tabs-id",
+ "data-testid": "tabs",
+ }),
+ );
+ const tabs = screen.getByTestId("tabs");
+ expect(tabs).toHaveAttribute("id", "tabs-id");
+ });
+
+ it("passes through data attributes", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ "data-custom": "test",
+ "data-testid": "tabs",
+ }),
+ );
+ const tabs = screen.getByTestId("tabs");
+ expect(tabs).toHaveAttribute("data-custom", "test");
+ });
+ });
+
+ describe("Custom className", () => {
+ it("merges custom className with variant classes", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ className: "custom-class",
+ "data-testid": "tabs",
+ }),
+ );
+ const tabs = screen.getByTestId("tabs");
+ expect(tabs.className).toContain("custom-class");
+ expect(tabs.className).toContain("flex");
+ expect(tabs.className).toContain("border-b");
+ });
+ });
+
+ describe("Ref Forwarding", () => {
+ it("forwards ref to container element", () => {
+ const handleChange = vi.fn();
+ let ref: HTMLDivElement | null = null;
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ ref: (el: HTMLDivElement | null) => {
+ ref = el;
+ },
+ }),
+ );
+ expect(ref).toBeInstanceOf(HTMLDivElement);
+ expect(ref).toHaveAttribute("role", "tablist");
+ });
+ });
+
+ describe("Base Classes", () => {
+ it("container includes base classes", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ "data-testid": "tabs",
+ }),
+ );
+ const tabs = screen.getByTestId("tabs");
+ expect(tabs.className).toContain("flex");
+ expect(tabs.className).toContain("items-center");
+ expect(tabs.className).toContain("overflow-x-auto");
+ expect(tabs.className).toContain("border-b");
+ });
+
+ it("tab buttons include base classes", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "all",
+ onTabChange: handleChange,
+ }),
+ );
+ const tab = screen.getByTestId("tab-all");
+ expect(tab.className).toContain("inline-flex");
+ expect(tab.className).toContain("items-center");
+ expect(tab.className).toContain("font-medium");
+ expect(tab.className).toContain("whitespace-nowrap");
+ expect(tab.className).toContain("border-b-2");
+ expect(tab.className).toContain("transition-colors");
+ });
+ });
+
+ describe("Compound Scenarios", () => {
+ it("works correctly with large size and active tab with count", () => {
+ const handleChange = vi.fn();
+ render(
+ createElement(Tabs, {
+ tabs: mockTabs,
+ activeTab: "featured",
+ onTabChange: handleChange,
+ size: "lg",
+ }),
+ );
+ const tab = screen.getByTestId("tab-featured");
+ expect(tab.className).toContain("text-lg");
+ expect(tab.className).toContain("px-6");
+ expect(tab.className).toContain("py-4");
+ expect(tab.className).toContain("text-zinc-100");
+ expect(tab.className).toContain("border-blue-500");
+ const badge = screen.getByText("42");
+ expect(badge.className).toContain("text-zinc-100");
+ });
+
+ it("handles single tab", () => {
+ const handleChange = vi.fn();
+ const singleTab: Tab[] = [{ id: "only", label: "Only Tab" }];
+ render(
+ createElement(Tabs, {
+ tabs: singleTab,
+ activeTab: "only",
+ onTabChange: handleChange,
+ }),
+ );
+ expect(screen.getByTestId("tab-only")).toBeInTheDocument();
+ expect(screen.getByText("Only Tab")).toBeInTheDocument();
+ });
+
+ it("handles empty tabs array", () => {
+ const handleChange = vi.fn();
+ const { container } = render(
+ createElement(Tabs, {
+ tabs: [],
+ activeTab: "",
+ onTabChange: handleChange,
+ }),
+ );
+ const buttons = container.querySelectorAll("button");
+ expect(buttons.length).toBe(0);
+ });
+ });
+});
diff --git a/packages/ui/src/Tabs/Tabs.ts b/packages/ui/src/Tabs/Tabs.ts
new file mode 100644
index 0000000..8bfd339
--- /dev/null
+++ b/packages/ui/src/Tabs/Tabs.ts
@@ -0,0 +1,98 @@
+import { cn } from "@tpmjs/utils/cn";
+import { createElement, forwardRef } from "react";
+import type { TabsProps } from "./types";
+import {
+ tabButtonVariants,
+ tabCountVariants,
+ tabsContainerVariants,
+} from "./variants";
+
+/**
+ * Tabs component
+ *
+ * Displays a horizontal list of tabs with optional count badges.
+ * Supports keyboard navigation and accessibility.
+ * Built with .ts-only React using createElement.
+ *
+ * @example
+ * ```typescript
+ * import { Tabs } from '@tpmjs/ui/Tabs/Tabs';
+ * import { createElement, useState } from 'react';
+ *
+ * function MyComponent() {
+ * const [activeTab, setActiveTab] = useState('all');
+ *
+ * return createElement(Tabs, {
+ * tabs: [
+ * { id: 'all', label: 'All Tools', count: 1234 },
+ * { id: 'featured', label: 'Featured', count: 42 },
+ * ],
+ * activeTab,
+ * onTabChange: setActiveTab,
+ * size: 'md',
+ * });
+ * }
+ * ```
+ */
+export const Tabs = forwardRef(
+ ({ className, tabs, activeTab, onTabChange, size = "md", ...props }, ref) => {
+ return createElement(
+ "div",
+ {
+ className: cn(
+ tabsContainerVariants({
+ size,
+ }),
+ className,
+ ),
+ role: "tablist",
+ ref,
+ ...props,
+ },
+ tabs.map((tab) => {
+ const isActive = tab.id === activeTab;
+
+ return createElement(
+ "button",
+ {
+ key: tab.id,
+ type: "button",
+ role: "tab",
+ "aria-selected": isActive,
+ "aria-controls": `tabpanel-${tab.id}`,
+ id: `tab-${tab.id}`,
+ className: tabButtonVariants({
+ size,
+ active: isActive ? "true" : "false",
+ }),
+ onClick: () => onTabChange(tab.id),
+ "data-testid": `tab-${tab.id}`,
+ },
+ [
+ createElement(
+ "span",
+ {
+ key: "label",
+ },
+ tab.label,
+ ),
+ tab.count !== undefined &&
+ createElement(
+ "span",
+ {
+ key: "count",
+ className: tabCountVariants({
+ active: isActive ? "true" : "false",
+ }),
+ "aria-label": `${tab.count} items`,
+ },
+ tab.count.toString(),
+ ),
+ ].filter(Boolean),
+ );
+ }),
+ );
+ },
+);
+
+Tabs.displayName = "Tabs";
diff --git a/packages/ui/src/Tabs/tokens.ts b/packages/ui/src/Tabs/tokens.ts
new file mode 100644
index 0000000..6403ff1
--- /dev/null
+++ b/packages/ui/src/Tabs/tokens.ts
@@ -0,0 +1,33 @@
+import { fontSize, spacing } from "../tokens";
+
+/**
+ * Tabs component design tokens
+ * Maps global tokens to tabs-specific semantics
+ */
+export const tabsTokens = {
+ /** Padding for each size */
+ padding: {
+ sm: {
+ x: spacing[3], // 0.75rem (12px)
+ y: spacing[2], // 0.5rem (8px)
+ },
+ md: {
+ x: spacing[4], // 1rem (16px)
+ y: spacing[3], // 0.75rem (12px)
+ },
+ lg: {
+ x: spacing[6], // 1.5rem (24px)
+ y: spacing[4], // 1rem (16px)
+ },
+ },
+
+ /** Font size for each size */
+ fontSize: {
+ sm: fontSize.sm, // 0.875rem (14px)
+ md: fontSize.base, // 1rem (16px)
+ lg: fontSize.lg, // 1.125rem (18px)
+ },
+
+ /** Gap between tabs */
+ gap: spacing[1], // 0.25rem (4px)
+} as const;
diff --git a/packages/ui/src/Tabs/types.ts b/packages/ui/src/Tabs/types.ts
new file mode 100644
index 0000000..cdb60d5
--- /dev/null
+++ b/packages/ui/src/Tabs/types.ts
@@ -0,0 +1,53 @@
+import type { HTMLAttributes } from "react";
+
+/**
+ * Tab definition
+ */
+export interface Tab {
+ /**
+ * Unique identifier for the tab
+ */
+ id: string;
+
+ /**
+ * Display label for the tab
+ */
+ label: string;
+
+ /**
+ * Optional badge count to display
+ */
+ count?: number;
+}
+
+/**
+ * Tabs component props
+ */
+export interface TabsProps
+ extends Omit, "onChange"> {
+ /**
+ * Array of tabs to display
+ */
+ tabs: Tab[];
+
+ /**
+ * Currently active tab ID
+ */
+ activeTab: string;
+
+ /**
+ * Callback when tab is changed
+ */
+ onTabChange: (tabId: string) => void;
+
+ /**
+ * Size variant
+ * @default 'md'
+ */
+ size?: "sm" | "md" | "lg";
+}
+
+/**
+ * Tabs ref type
+ */
+export type TabsRef = HTMLDivElement;
diff --git a/packages/ui/src/Tabs/variants.ts b/packages/ui/src/Tabs/variants.ts
new file mode 100644
index 0000000..316df60
--- /dev/null
+++ b/packages/ui/src/Tabs/variants.ts
@@ -0,0 +1,100 @@
+import { createVariants } from "../system/variants";
+
+/**
+ * Tabs container variant definitions
+ */
+export const tabsContainerVariants = createVariants({
+ base: [
+ // Layout
+ "flex items-center",
+ // Overflow
+ "overflow-x-auto",
+ // Border
+ "border-b border-zinc-800",
+ ].join(" "),
+
+ variants: {
+ size: {
+ sm: "gap-1",
+ md: "gap-1",
+ lg: "gap-1",
+ },
+ },
+
+ compoundVariants: [],
+
+ defaultVariants: {
+ size: "md",
+ },
+});
+
+/**
+ * Tab button variant definitions
+ */
+export const tabButtonVariants = createVariants({
+ base: [
+ // Display
+ "inline-flex items-center gap-2",
+ // Font
+ "font-medium whitespace-nowrap",
+ // Border
+ "border-b-2",
+ // Transition
+ "transition-colors duration-200",
+ // Cursor
+ "cursor-pointer",
+ ].join(" "),
+
+ variants: {
+ size: {
+ sm: "text-sm px-3 py-2",
+ md: "text-base px-4 py-3",
+ lg: "text-lg px-6 py-4",
+ },
+ active: {
+ true: "text-zinc-100 border-blue-500",
+ false:
+ "text-zinc-400 border-transparent hover:text-zinc-300 hover:border-zinc-700",
+ },
+ },
+
+ compoundVariants: [],
+
+ defaultVariants: {
+ size: "md",
+ active: "false",
+ },
+});
+
+/**
+ * Tab count badge variant definitions
+ */
+export const tabCountVariants = createVariants({
+ base: [
+ // Display
+ "inline-flex items-center justify-center",
+ // Size
+ "min-w-[1.25rem] h-5",
+ // Padding
+ "px-1.5",
+ // Font
+ "text-xs font-medium tabular-nums",
+ // Background
+ "bg-zinc-800",
+ // Border
+ "rounded-full",
+ ].join(" "),
+
+ variants: {
+ active: {
+ true: "text-zinc-100",
+ false: "text-zinc-500",
+ },
+ },
+
+ compoundVariants: [],
+
+ defaultVariants: {
+ active: "false",
+ },
+});
diff --git a/packages/ui/src/system/variants.ts b/packages/ui/src/system/variants.ts
new file mode 100644
index 0000000..9d95d6b
--- /dev/null
+++ b/packages/ui/src/system/variants.ts
@@ -0,0 +1,111 @@
+/**
+ * Custom variant system for TPMJS UI components
+ * Lightweight, type-safe alternative to class-variance-authority (CVA)
+ *
+ * @example
+ * ```typescript
+ * const buttonVariants = createVariants({
+ * base: 'inline-flex items-center',
+ * variants: {
+ * variant: {
+ * default: 'bg-primary text-primary-foreground',
+ * outline: 'border border-border',
+ * },
+ * size: {
+ * sm: 'h-9 px-3',
+ * md: 'h-10 px-4',
+ * },
+ * },
+ * compoundVariants: [
+ * {
+ * conditions: { variant: 'outline', size: 'sm' },
+ * className: 'border-2',
+ * },
+ * ],
+ * defaultVariants: {
+ * variant: 'default',
+ * size: 'md',
+ * },
+ * });
+ *
+ * // Usage
+ * buttonVariants({ variant: 'outline', size: 'sm' });
+ * // Returns: "inline-flex items-center border border-border h-9 px-3 border-2"
+ * ```
+ */
+
+/**
+ * Configuration for a variant system
+ */
+export type VariantConfig>> = {
+ /** Base classes applied to all variants */
+ base: string;
+
+ /** Variant definitions - each key is a variant axis with possible values */
+ variants: T;
+
+ /** Compound variants - apply classes when multiple conditions are met */
+ compoundVariants?: Array<{
+ conditions: Partial<{ [K in keyof T]: keyof T[K] }>;
+ className: string;
+ }>;
+
+ /** Default variant values */
+ defaultVariants?: Partial<{ [K in keyof T]: keyof T[K] }>;
+};
+
+/**
+ * Props type for consuming variant functions
+ */
+export type VariantProps>> =
+ Partial<{
+ [K in keyof T]: keyof T[K];
+ }>;
+
+/**
+ * Creates a type-safe variant composition function
+ *
+ * @param config - Variant configuration with base, variants, compound variants, and defaults
+ * @returns Function that accepts variant props and returns composed className string
+ */
+export function createVariants<
+ T extends Record>,
+>(config: VariantConfig) {
+ return function getVariantClasses(props?: VariantProps): string {
+ const classes: string[] = [config.base];
+
+ // Merge props with defaults
+ const mergedProps = {
+ ...config.defaultVariants,
+ ...props,
+ } as VariantProps;
+
+ // Apply variant classes
+ for (const [key, value] of Object.entries(mergedProps)) {
+ const valueStr = value as string;
+ if (
+ value !== undefined &&
+ value !== null &&
+ config.variants[key]?.[valueStr]
+ ) {
+ classes.push(config.variants[key][valueStr]);
+ }
+ }
+
+ // Apply compound variants
+ if (config.compoundVariants) {
+ for (const { conditions, className } of config.compoundVariants) {
+ const matches = Object.entries(conditions).every(
+ ([key, value]) =>
+ mergedProps[key as keyof typeof mergedProps] === value,
+ );
+ if (matches) {
+ classes.push(className);
+ }
+ }
+ }
+
+ // Filter out any empty strings and join
+ return classes.filter(Boolean).join(" ");
+ };
+}
diff --git a/packages/ui/src/tokens/animations.ts b/packages/ui/src/tokens/animations.ts
new file mode 100644
index 0000000..0444958
--- /dev/null
+++ b/packages/ui/src/tokens/animations.ts
@@ -0,0 +1,129 @@
+/**
+ * Animation token system for TPMJS UI
+ * Smooth, subtle transitions for polished micro-interactions
+ */
+
+/**
+ * Animation durations
+ */
+export const duration = {
+ fast: "150ms",
+ base: "200ms",
+ slow: "300ms",
+ slower: "500ms",
+ slowest: "700ms",
+} as const;
+
+/**
+ * Easing functions
+ * Cubic bezier curves for natural motion
+ */
+export const easing = {
+ linear: "linear",
+ ease: "ease",
+ easeIn: "cubic-bezier(0.4, 0, 1, 1)",
+ easeOut: "cubic-bezier(0, 0, 0.2, 1)",
+ easeInOut: "cubic-bezier(0.4, 0, 0.2, 1)",
+ // Custom easings for specific interactions
+ smooth: "cubic-bezier(0.25, 0.1, 0.25, 1)",
+ bounce: "cubic-bezier(0.68, -0.55, 0.265, 1.55)",
+ spring: "cubic-bezier(0.175, 0.885, 0.32, 1.275)",
+} as const;
+
+/**
+ * Keyframe animations
+ * Predefined animation sequences
+ */
+export const keyframes = {
+ fadeIn: {
+ from: { opacity: "0" },
+ to: { opacity: "1" },
+ },
+ fadeOut: {
+ from: { opacity: "1" },
+ to: { opacity: "0" },
+ },
+ slideUp: {
+ from: { transform: "translateY(10px)", opacity: "0" },
+ to: { transform: "translateY(0)", opacity: "1" },
+ },
+ slideDown: {
+ from: { transform: "translateY(-10px)", opacity: "0" },
+ to: { transform: "translateY(0)", opacity: "1" },
+ },
+ slideLeft: {
+ from: { transform: "translateX(10px)", opacity: "0" },
+ to: { transform: "translateX(0)", opacity: "1" },
+ },
+ slideRight: {
+ from: { transform: "translateX(-10px)", opacity: "0" },
+ to: { transform: "translateX(0)", opacity: "1" },
+ },
+ scaleIn: {
+ from: { transform: "scale(0.95)", opacity: "0" },
+ to: { transform: "scale(1)", opacity: "1" },
+ },
+ scaleOut: {
+ from: { transform: "scale(1)", opacity: "1" },
+ to: { transform: "scale(0.95)", opacity: "0" },
+ },
+ shimmer: {
+ "0%": { backgroundPosition: "-1000px 0" },
+ "100%": { backgroundPosition: "1000px 0" },
+ },
+ pulse: {
+ "0%, 100%": { opacity: "1" },
+ "50%": { opacity: "0.5" },
+ },
+ spin: {
+ from: { transform: "rotate(0deg)" },
+ to: { transform: "rotate(360deg)" },
+ },
+ ping: {
+ "75%, 100%": { transform: "scale(2)", opacity: "0" },
+ },
+ bounce: {
+ "0%, 100%": {
+ transform: "translateY(-25%)",
+ animationTimingFunction: "cubic-bezier(0.8, 0, 1, 1)",
+ },
+ "50%": {
+ transform: "translateY(0)",
+ animationTimingFunction: "cubic-bezier(0, 0, 0.2, 1)",
+ },
+ },
+} as const;
+
+/**
+ * Transition presets
+ * Commonly used transition combinations
+ */
+export const transitions = {
+ /** Base transition for colors and backgrounds */
+ base: `background-color ${duration.base} ${easing.easeInOut}, border-color ${duration.base} ${easing.easeInOut}, color ${duration.base} ${easing.easeInOut}`,
+
+ /** Transform transition */
+ transform: `transform ${duration.base} ${easing.easeInOut}`,
+
+ /** Opacity transition */
+ opacity: `opacity ${duration.base} ${easing.easeInOut}`,
+
+ /** All properties transition */
+ all: `all ${duration.slow} ${easing.easeInOut}`,
+
+ /** Fast transition for immediate feedback */
+ fast: `all ${duration.fast} ${easing.easeOut}`,
+
+ /** Smooth transition for hover effects */
+ smooth: `all ${duration.base} ${easing.smooth}`,
+} as const;
+
+/**
+ * Complete animations export
+ */
+export const animations = {
+ duration,
+ easing,
+ keyframes,
+ transitions,
+} as const;
diff --git a/packages/ui/src/tokens/borders.ts b/packages/ui/src/tokens/borders.ts
new file mode 100644
index 0000000..7b3c87a
--- /dev/null
+++ b/packages/ui/src/tokens/borders.ts
@@ -0,0 +1,72 @@
+/**
+ * Border token system for TPMJS UI
+ * 1px borders with dotted/dashed variants for technical aesthetic
+ */
+
+/**
+ * Border widths
+ */
+export const borderWidth = {
+ DEFAULT: "1px",
+ 0: "0",
+ 2: "2px",
+ 4: "4px",
+ 8: "8px",
+} as const;
+
+/**
+ * Border styles
+ */
+export const borderStyle = {
+ solid: "solid",
+ dashed: "dashed",
+ dotted: "dotted",
+ none: "none",
+} as const;
+
+/**
+ * Border radius values
+ * References CSS variables for theme consistency
+ */
+export const borderRadius = {
+ none: "0",
+ sm: "var(--radius-sm)",
+ md: "var(--radius-md)",
+ lg: "var(--radius-lg)",
+ xl: "var(--radius-xl)",
+ "2xl": "var(--radius-2xl, 1rem)",
+ "3xl": "var(--radius-3xl, 1.5rem)",
+ full: "9999px",
+} as const;
+
+/**
+ * CSS variable border radius tokens
+ * Used in globals.css
+ */
+export const borderRadiusVars = {
+ sm: "4px",
+ md: "6px",
+ lg: "8px",
+ xl: "12px",
+} as const;
+
+/**
+ * Border color references (use CSS variables)
+ */
+export const borderColor = {
+ DEFAULT: "hsl(var(--border))",
+ strong: "hsl(var(--border-strong))",
+ subtle: "hsl(var(--border-subtle))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+} as const;
+
+/**
+ * Complete border export
+ */
+export const borders = {
+ width: borderWidth,
+ style: borderStyle,
+ radius: borderRadius,
+ color: borderColor,
+} as const;
diff --git a/packages/ui/src/tokens/colors.ts b/packages/ui/src/tokens/colors.ts
new file mode 100644
index 0000000..d0d5745
--- /dev/null
+++ b/packages/ui/src/tokens/colors.ts
@@ -0,0 +1,160 @@
+/**
+ * Color token system for TPMJS UI
+ * Dark mode first approach with Vercel/Cursor/Perplexity aesthetic
+ *
+ * Colors use HSL format for easy dark mode implementation
+ */
+
+/**
+ * Neutral grayscale palette (16 stops for granular control)
+ * Optimized for dark mode with subtle, desaturated tones
+ */
+export const neutral = {
+ 0: "#000000",
+ 50: "#0a0a0a",
+ 100: "#171717",
+ 200: "#262626",
+ 300: "#404040",
+ 400: "#525252",
+ 500: "#737373",
+ 600: "#a3a3a3",
+ 700: "#d4d4d4",
+ 800: "#e5e5e5",
+ 900: "#f5f5f5",
+ 950: "#fafafa",
+ 1000: "#ffffff",
+} as const;
+
+/**
+ * Semantic color tokens
+ * Reference CSS variables for theme-aware colors
+ */
+export const semantic = {
+ background: {
+ DEFAULT: "hsl(var(--background))",
+ surface: "hsl(var(--surface))",
+ elevated: "hsl(var(--surface-elevated))",
+ overlay: "hsl(var(--surface-overlay))",
+ },
+ foreground: {
+ DEFAULT: "hsl(var(--foreground))",
+ secondary: "hsl(var(--foreground-secondary))",
+ tertiary: "hsl(var(--foreground-tertiary))",
+ muted: "hsl(var(--foreground-muted))",
+ },
+ border: {
+ DEFAULT: "hsl(var(--border))",
+ strong: "hsl(var(--border-strong))",
+ subtle: "hsl(var(--border-subtle))",
+ },
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+} as const;
+
+/**
+ * Status colors (subtle, desaturated for dark mode)
+ */
+export const status = {
+ success: {
+ DEFAULT: "hsl(var(--success))",
+ foreground: "hsl(var(--success-foreground))",
+ },
+ error: {
+ DEFAULT: "hsl(var(--error))",
+ foreground: "hsl(var(--error-foreground))",
+ },
+ warning: {
+ DEFAULT: "hsl(var(--warning))",
+ foreground: "hsl(var(--warning-foreground))",
+ },
+ info: {
+ DEFAULT: "hsl(var(--info))",
+ foreground: "hsl(var(--info-foreground))",
+ },
+} as const;
+
+/**
+ * Micro-accent colors for dark mode highlights
+ * Used sparingly for visual interest
+ */
+export const accents = {
+ blue: "#60a5fa",
+ purple: "#a78bfa",
+ green: "#34d399",
+ pink: "#f472b6",
+ orange: "#fb923c",
+ yellow: "#fbbf24",
+} as const;
+
+/**
+ * Form element colors
+ */
+export const form = {
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ ringOffset: "hsl(var(--ring-offset))",
+} as const;
+
+/**
+ * Grid/blueprint pattern colors
+ */
+export const grid = {
+ color: "hsl(var(--grid-color))",
+ size: "var(--grid-size)",
+} as const;
+
+/**
+ * Card colors
+ */
+export const card = {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+} as const;
+
+/**
+ * Legacy destructive color (for backward compatibility)
+ */
+export const destructive = {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+} as const;
+
+/**
+ * Complete color export
+ */
+export const colors = {
+ neutral,
+ semantic,
+ status,
+ accents,
+ form,
+ grid,
+ card,
+ destructive,
+ // Flat exports for easy access
+ background: semantic.background.DEFAULT,
+ foreground: semantic.foreground.DEFAULT,
+ border: semantic.border.DEFAULT,
+ primary: semantic.primary.DEFAULT,
+ secondary: semantic.secondary.DEFAULT,
+ accent: semantic.accent.DEFAULT,
+ muted: semantic.muted.DEFAULT,
+ success: status.success.DEFAULT,
+ error: status.error.DEFAULT,
+ warning: status.warning.DEFAULT,
+ info: status.info.DEFAULT,
+} as const;
diff --git a/packages/ui/src/tokens/index.ts b/packages/ui/src/tokens/index.ts
new file mode 100644
index 0000000..a6558c1
--- /dev/null
+++ b/packages/ui/src/tokens/index.ts
@@ -0,0 +1,11 @@
+/**
+ * Global design tokens for TPMJS UI
+ * Export all token categories
+ */
+
+export * from "./colors";
+export * from "./typography";
+export * from "./spacing";
+export * from "./borders";
+export * from "./shadows";
+export * from "./animations";
diff --git a/packages/ui/src/tokens/shadows.ts b/packages/ui/src/tokens/shadows.ts
new file mode 100644
index 0000000..dbef7d5
--- /dev/null
+++ b/packages/ui/src/tokens/shadows.ts
@@ -0,0 +1,55 @@
+/**
+ * Shadow token system for TPMJS UI
+ * Subtle shadows for dark mode with minimal elevation
+ */
+
+/**
+ * Box shadow definitions
+ * Subtle and understated for technical aesthetic
+ */
+export const boxShadow = {
+ none: "none",
+ sm: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
+ base: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
+ md: "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
+ lg: "0 10px 15px -3px rgb(0 0 0 / 0.2), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
+ xl: "0 20px 25px -5px rgb(0 0 0 / 0.2), 0 8px 10px -6px rgb(0 0 0 / 0.1)",
+ "2xl": "0 25px 50px -12px rgb(0 0 0 / 0.3)",
+ inner: "inset 0 2px 4px 0 rgb(0 0 0 / 0.05)",
+} as const;
+
+/**
+ * Drop shadow definitions (for SVG and images)
+ */
+export const dropShadow = {
+ none: "none",
+ sm: "drop-shadow(0 1px 1px rgb(0 0 0 / 0.05))",
+ base: "drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow(0 1px 1px rgb(0 0 0 / 0.06))",
+ md: "drop-shadow(0 4px 3px rgb(0 0 0 / 0.07)) drop-shadow(0 2px 2px rgb(0 0 0 / 0.06))",
+ lg: "drop-shadow(0 10px 8px rgb(0 0 0 / 0.04)) drop-shadow(0 4px 3px rgb(0 0 0 / 0.1))",
+ xl: "drop-shadow(0 20px 13px rgb(0 0 0 / 0.03)) drop-shadow(0 8px 5px rgb(0 0 0 / 0.08))",
+ "2xl": "drop-shadow(0 25px 25px rgb(0 0 0 / 0.15))",
+} as const;
+
+/**
+ * Elevation system
+ * Semantic elevation levels for layered interfaces
+ */
+export const elevation = {
+ 0: boxShadow.none,
+ 1: boxShadow.sm,
+ 2: boxShadow.base,
+ 3: boxShadow.md,
+ 4: boxShadow.lg,
+ 5: boxShadow.xl,
+ 6: boxShadow["2xl"],
+} as const;
+
+/**
+ * Complete shadows export
+ */
+export const shadows = {
+ box: boxShadow,
+ drop: dropShadow,
+ elevation,
+} as const;
diff --git a/packages/ui/src/tokens/spacing.ts b/packages/ui/src/tokens/spacing.ts
new file mode 100644
index 0000000..ec25df7
--- /dev/null
+++ b/packages/ui/src/tokens/spacing.ts
@@ -0,0 +1,92 @@
+/**
+ * Spacing token system for TPMJS UI
+ * Based on 4px base unit for consistent rhythm
+ */
+
+/**
+ * Spacing scale
+ * Uses rem units for accessibility (scales with user font size)
+ */
+export const spacing = {
+ px: "1px",
+ 0: "0",
+ 0.5: "0.125rem", // 2px
+ 1: "0.25rem", // 4px
+ 1.5: "0.375rem", // 6px
+ 2: "0.5rem", // 8px
+ 2.5: "0.625rem", // 10px
+ 3: "0.75rem", // 12px
+ 3.5: "0.875rem", // 14px
+ 4: "1rem", // 16px
+ 5: "1.25rem", // 20px
+ 6: "1.5rem", // 24px
+ 7: "1.75rem", // 28px
+ 8: "2rem", // 32px
+ 9: "2.25rem", // 36px
+ 10: "2.5rem", // 40px
+ 11: "2.75rem", // 44px
+ 12: "3rem", // 48px
+ 14: "3.5rem", // 56px
+ 16: "4rem", // 64px
+ 20: "5rem", // 80px
+ 24: "6rem", // 96px
+ 28: "7rem", // 112px
+ 32: "8rem", // 128px
+ 36: "9rem", // 144px
+ 40: "10rem", // 160px
+ 44: "11rem", // 176px
+ 48: "12rem", // 192px
+ 52: "13rem", // 208px
+ 56: "14rem", // 224px
+ 60: "15rem", // 240px
+ 64: "16rem", // 256px
+ 72: "18rem", // 288px
+ 80: "20rem", // 320px
+ 96: "24rem", // 384px
+} as const;
+
+/**
+ * Container widths
+ */
+export const containerWidth = {
+ xs: "20rem", // 320px
+ sm: "24rem", // 384px
+ md: "28rem", // 448px
+ lg: "32rem", // 512px
+ xl: "36rem", // 576px
+ "2xl": "42rem", // 672px
+ "3xl": "48rem", // 768px
+ "4xl": "56rem", // 896px
+ "5xl": "64rem", // 1024px
+ "6xl": "72rem", // 1152px
+ "7xl": "80rem", // 1280px
+ full: "100%",
+ screen: "100vw",
+} as const;
+
+/**
+ * Component-specific spacing presets
+ */
+export const component = {
+ /** Padding for buttons, inputs, etc. */
+ padding: {
+ sm: spacing[3], // 12px
+ md: spacing[4], // 16px
+ lg: spacing[5], // 20px
+ },
+
+ /** Gap between elements */
+ gap: {
+ sm: spacing[2], // 8px
+ md: spacing[3], // 12px
+ lg: spacing[4], // 16px
+ },
+
+ /** Margin between sections */
+ margin: {
+ sm: spacing[4], // 16px
+ md: spacing[6], // 24px
+ lg: spacing[8], // 32px
+ xl: spacing[12], // 48px
+ },
+} as const;
diff --git a/packages/ui/src/tokens/typography.ts b/packages/ui/src/tokens/typography.ts
new file mode 100644
index 0000000..16e031d
--- /dev/null
+++ b/packages/ui/src/tokens/typography.ts
@@ -0,0 +1,98 @@
+/**
+ * Typography token system for TPMJS UI
+ * Geist-inspired with generous spacing and technical feel
+ */
+
+/**
+ * Font families
+ * Geist font stack with system fallbacks
+ */
+export const fontFamily = {
+ sans: [
+ "Geist",
+ "-apple-system",
+ "BlinkMacSystemFont",
+ '"Segoe UI"',
+ "Roboto",
+ '"Helvetica Neue"',
+ "Arial",
+ "sans-serif",
+ ],
+ mono: [
+ "Geist Mono",
+ '"SF Mono"',
+ "Monaco",
+ "Consolas",
+ '"Liberation Mono"',
+ '"Courier New"',
+ "monospace",
+ ],
+} as const;
+
+/**
+ * Font sizes with line heights and letter spacing
+ * Generous line heights for readability
+ * Tighter letter spacing for larger sizes (optical sizing)
+ */
+export const fontSize = {
+ xs: ["0.75rem", { lineHeight: "1rem", letterSpacing: "0" }],
+ sm: ["0.875rem", { lineHeight: "1.25rem", letterSpacing: "0" }],
+ base: ["1rem", { lineHeight: "1.5rem", letterSpacing: "0" }],
+ lg: ["1.125rem", { lineHeight: "1.75rem", letterSpacing: "-0.01em" }],
+ xl: ["1.25rem", { lineHeight: "1.75rem", letterSpacing: "-0.01em" }],
+ "2xl": ["1.5rem", { lineHeight: "2rem", letterSpacing: "-0.02em" }],
+ "3xl": ["1.875rem", { lineHeight: "2.25rem", letterSpacing: "-0.02em" }],
+ "4xl": ["2.25rem", { lineHeight: "2.5rem", letterSpacing: "-0.03em" }],
+ "5xl": ["3rem", { lineHeight: "1", letterSpacing: "-0.03em" }],
+ "6xl": ["3.75rem", { lineHeight: "1", letterSpacing: "-0.04em" }],
+ "7xl": ["4.5rem", { lineHeight: "1", letterSpacing: "-0.04em" }],
+ "8xl": ["6rem", { lineHeight: "1", letterSpacing: "-0.05em" }],
+ "9xl": ["8rem", { lineHeight: "1", letterSpacing: "-0.05em" }],
+} as const;
+
+/**
+ * Font weights
+ */
+export const fontWeight = {
+ light: "300",
+ normal: "400",
+ medium: "500",
+ semibold: "600",
+ bold: "700",
+ extrabold: "800",
+} as const;
+
+/**
+ * Letter spacing values
+ */
+export const letterSpacing = {
+ tighter: "-0.05em",
+ tight: "-0.025em",
+ normal: "0",
+ wide: "0.025em",
+ wider: "0.05em",
+ widest: "0.1em",
+} as const;
+
+/**
+ * Line heights
+ */
+export const lineHeight = {
+ none: "1",
+ tight: "1.25",
+ snug: "1.375",
+ normal: "1.5",
+ relaxed: "1.625",
+ loose: "2",
+} as const;
+
+/**
+ * Complete typography export
+ */
+export const typography = {
+ fontFamily,
+ fontSize,
+ fontWeight,
+ letterSpacing,
+ lineHeight,
+} as const;
diff --git a/packages/ui/tsup.config.ts b/packages/ui/tsup.config.ts
index 52933a1..c44c86e 100644
--- a/packages/ui/tsup.config.ts
+++ b/packages/ui/tsup.config.ts
@@ -1,14 +1,43 @@
-import { defineConfig } from 'tsup';
+import path from "node:path";
+import { glob } from "glob";
+import { defineConfig } from "tsup";
+
+/**
+ * Auto-discover all component entry points
+ * Looks for src/ComponentName/ComponentName.ts files where the folder name matches the file name
+ */
+const allFiles = glob.sync("src/**/[A-Z]*.ts", {
+ ignore: [
+ "**/*.test.ts",
+ "**/*.stories.ts",
+ "**/types.ts",
+ "**/tokens.ts",
+ "**/variants.ts",
+ "**/index.ts",
+ "src/test-setup.ts",
+ "src/tokens/**",
+ "src/system/**",
+ ],
+});
+
+// Filter to only include files where the folder name matches the file name
+// e.g., src/Button/Button.ts is included, but src/Button/helpers.ts is not
+const entries = allFiles.filter((file) => {
+ const dir = path.dirname(file);
+ const folderName = path.basename(dir);
+ const fileName = path.basename(file, ".ts");
+ return folderName === fileName;
+});
export default defineConfig({
- entry: ['src/Button/Button.ts', 'src/Card/Card.ts'],
- format: ['esm'],
- dts: true,
- clean: true,
- treeshake: true,
- splitting: false,
- external: ['react', 'react-dom'],
- esbuildOptions(options) {
- options.jsx = 'automatic';
- },
+ entry: entries,
+ format: ["esm"],
+ dts: true,
+ clean: true,
+ treeshake: true,
+ splitting: false,
+ external: ["react", "react-dom"],
+ esbuildOptions(options) {
+ options.jsx = "automatic";
+ },
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1e73330..2d3d546 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -44,6 +44,9 @@ importers:
next:
specifier: ^16.0.4
version: 16.0.4(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ next-themes:
+ specifier: ^0.4.6
+ version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react:
specifier: ^19.0.0
version: 19.2.0
@@ -268,6 +271,9 @@ importers:
'@types/react-dom':
specifier: ^19.0.2
version: 19.2.3(@types/react@19.2.7)
+ glob:
+ specifier: ^13.0.0
+ version: 13.0.0
happy-dom:
specifier: ^15.11.7
version: 15.11.7
@@ -1194,6 +1200,14 @@ packages:
'@types/node':
optional: true
+ '@isaacs/balanced-match@4.0.1':
+ resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
+ engines: {node: 20 || >=22}
+
+ '@isaacs/brace-expansion@5.0.0':
+ resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==}
+ engines: {node: 20 || >=22}
+
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@@ -2635,6 +2649,10 @@ packages:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
hasBin: true
+ glob@13.0.0:
+ resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==}
+ engines: {node: 20 || >=22}
+
globals@14.0.0:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
@@ -3047,6 +3065,10 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
+ lru-cache@11.2.2:
+ resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==}
+ engines: {node: 20 || >=22}
+
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -3083,6 +3105,10 @@ packages:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
+ minimatch@10.1.1:
+ resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==}
+ engines: {node: 20 || >=22}
+
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@@ -3137,6 +3163,12 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+ next-themes@0.4.6:
+ resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
+ peerDependencies:
+ react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
+
next@16.0.4:
resolution: {integrity: sha512-vICcxKusY8qW7QFOzTvnRL1ejz2ClTqDKtm1AcUjm2mPv/lVAdgpGNsftsPRIDJOXOjRQO68i1dM8Lp8GZnqoA==}
engines: {node: '>=20.9.0'}
@@ -3276,6 +3308,10 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
+ path-scurry@2.0.1:
+ resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==}
+ engines: {node: 20 || >=22}
+
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
@@ -4845,6 +4881,12 @@ snapshots:
optionalDependencies:
'@types/node': 22.19.1
+ '@isaacs/balanced-match@4.0.1': {}
+
+ '@isaacs/brace-expansion@5.0.0':
+ dependencies:
+ '@isaacs/balanced-match': 4.0.1
+
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@@ -6585,6 +6627,12 @@ snapshots:
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
+ glob@13.0.0:
+ dependencies:
+ minimatch: 10.1.1
+ minipass: 7.1.2
+ path-scurry: 2.0.1
+
globals@14.0.0: {}
globals@16.4.0: {}
@@ -6958,6 +7006,8 @@ snapshots:
lru-cache@10.4.3: {}
+ lru-cache@11.2.2: {}
+
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@@ -6989,6 +7039,10 @@ snapshots:
min-indent@1.0.1: {}
+ minimatch@10.1.1:
+ dependencies:
+ '@isaacs/brace-expansion': 5.0.0
+
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.12
@@ -7051,6 +7105,11 @@ snapshots:
natural-compare@1.4.0: {}
+ next-themes@0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
+ dependencies:
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+
next@16.0.4(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@next/env': 16.0.4
@@ -7194,6 +7253,11 @@ snapshots:
lru-cache: 10.4.3
minipass: 7.1.2
+ path-scurry@2.0.1:
+ dependencies:
+ lru-cache: 11.2.2
+ minipass: 7.1.2
+
path-to-regexp@6.3.0: {}
path-type@4.0.0: {}