diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index b4e92d6..ca52535 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -3,161 +3,393 @@
@tailwind utilities;
@layer base {
- /* Light mode (default) */
+ /* Light mode (default) - Warm earthy palette with copper accent */
:root {
- /* Backgrounds & Surfaces - DRAMATIC CONTRAST */
- --background: 220 15% 96%; /* Light blue-gray background */
- --surface: 0 0% 100%; /* Pure white - cards really pop! */
- --surface-secondary: 220 15% 94%; /* Subtle gray for secondary surfaces */
- --surface-elevated: 0 0% 100%; /* White (elevated) */
- --surface-overlay: 0 0% 100%; /* White overlays */
+ /* ===========================================
+ COMPREHENSIVE COLOR PALETTE
+ Warm, earthy tones with copper accent
+ =========================================== */
- /* Foreground (Text) */
- --foreground: 222 47% 11%; /* Almost black */
- --foreground-secondary: 215 25% 35%; /* Much darker for readability */
- --foreground-tertiary: 215 20% 50%; /* Medium gray */
- --foreground-muted: 215 16% 65%; /* Light gray */
+ /* Backgrounds - Layered surface system */
+ --background: 30 15% 95%; /* #F5F3F0 - Warm off-white base */
+ --surface: 0 0% 100%; /* #FFFFFF - Cards, panels */
+ --surface-2: 30 20% 97%; /* #FAF8F6 - Elevated surfaces */
+ --surface-3: 25 12% 93%; /* #F0EDEA - Highest elevation */
+ --surface-secondary: 30 10% 95%; /* Legacy alias */
+ --surface-elevated: 30 20% 97%; /* Legacy alias */
+ --surface-overlay: 0 0% 100%; /* Overlays, modals */
- /* Borders - MUCH MORE VISIBLE */
- --border: 214 25% 80%; /* Strong medium gray */
- --border-strong: 214 30% 60%; /* Dark gray for emphasis */
- --border-subtle: 214 20% 88%; /* Subtle but visible */
+ /* Text - Clear hierarchy */
+ --foreground: 25 15% 10%; /* #1A1715 - Primary text */
+ --foreground-secondary: 25 8% 35%; /* #5C564F - Secondary text */
+ --foreground-tertiary: 25 6% 50%; /* #817B74 - Tertiary text */
+ --foreground-muted: 25 5% 60%; /* #9A958F - Muted/hints */
- /* Interactive States - MODERN & REFINED */
- --primary: 221 83% 53%; /* Sophisticated blue */
- --primary-foreground: 0 0% 100%; /* White text */
- --secondary: 220 15% 90%; /* Subtle gray-blue bg */
- --secondary-foreground: 222 47% 11%; /* Dark text */
- --accent: 221 75% 95%; /* Soft blue tint */
- --accent-foreground: 221 70% 35%; /* Rich blue text */
- --muted: 220 15% 92%; /* Subtle muted bg */
- --muted-foreground: 215 25% 40%; /* Darker muted text */
+ /* Borders - Warm grays */
+ --border: 30 8% 80%; /* #CEC9C3 - Default borders */
+ --border-strong: 25 8% 50%; /* #857F77 - Emphasized borders */
+ --border-subtle: 30 6% 88%; /* #E3E0DC - Subtle borders */
- /* Status Colors - Modern Editorial Palette */
- --success: 152 57% 45%; /* Refined emerald green */
- --success-foreground: 0 0% 100%; /* White text on success */
- --error: 0 65% 51%; /* Sophisticated red, less harsh */
- --error-foreground: 0 0% 100%; /* White text on error */
- --warning: 36 100% 50%; /* Warm sophisticated amber */
- --warning-foreground: 0 0% 100%; /* White text on warning */
- --info: 210 100% 56%; /* Cool modern blue */
- --info-foreground: 0 0% 100%; /* White text on info */
+ /* Primary - Copper accent #A6592D */
+ --primary: 22 57% 41%; /* #A6592D - Brand copper */
+ --primary-hover: 22 57% 35%; /* #8B4A26 - Hover state */
+ --primary-active: 22 57% 30%; /* #753F20 - Active/pressed */
+ --primary-foreground: 0 0% 100%; /* White text on primary */
- /* Destructive (legacy) */
- --destructive: 0 65% 51%; /* Match error color */
+ /* Accent - Copper variations */
+ --accent: 22 57% 41%; /* #A6592D - Same as primary */
+ --accent-strong: 22 60% 35%; /* #8F4722 - Darker/hover */
+ --accent-muted: 22 30% 90%; /* #EDE5DF - Soft copper tint */
+ --accent-foreground: 0 0% 100%; /* White text */
+
+ /* Secondary - Warm neutrals */
+ --secondary: 30 10% 92%; /* #EDEBE8 - Soft warm bg */
+ --secondary-hover: 30 10% 88%; /* #E3E0DC - Hover */
+ --secondary-foreground: 25 15% 10%; /* Dark text */
+
+ /* Muted - Subtle backgrounds */
+ --muted: 30 8% 94%; /* #F2F0ED - Muted background */
+ --muted-foreground: 25 8% 40%; /* #6B655E - Muted text */
+
+ /* Status Colors - Earthy, warm variants */
+ --success: 145 45% 36%; /* #327D52 - Forest green */
+ --success-light: 145 35% 92%; /* #E5F3EC - Light bg */
+ --success-foreground: 0 0% 100%;
+
+ --warning: 38 75% 50%; /* #D9A020 - Warm amber */
+ --warning-light: 38 60% 92%; /* #F8F2E0 - Light bg */
+ --warning-foreground: 25 15% 10%;
+
+ --error: 0 55% 50%; /* #C44545 - Warm red */
+ --error-light: 0 50% 95%; /* #F9EDED - Light bg */
+ --error-foreground: 0 0% 100%;
+
+ --info: 210 60% 50%; /* #3380CC - Cool blue */
+ --info-light: 210 50% 95%; /* #EDF4FB - Light bg */
+ --info-foreground: 0 0% 100%;
+
+ /* Destructive (alias for error) */
+ --destructive: 0 55% 50%;
--destructive-foreground: 0 0% 100%;
/* Form Elements */
- --input: 214 32% 91%;
- --ring: 222 84% 5%;
+ --input: 30 8% 80%; /* Input borders */
+ --input-focus: 22 57% 41%; /* Copper focus */
+ --ring: 22 57% 41%; /* Focus ring - copper */
--ring-offset: 0 0% 100%;
/* Grid/Blueprint Pattern */
- --grid-color: 214 32% 90%;
- --grid-size: 24px; /* Grid cell size */
+ --grid-color: 25 8% 88%;
+ --grid-size: 24px;
/* Card */
- --card: 0 0% 100%; /* Pure white - stands out dramatically on blue-gray background */
- --card-foreground: 222 47% 11%;
+ --card: 0 0% 100%;
+ --card-foreground: 25 15% 10%;
- /* Border Radii */
- --radius-sm: 4px;
- --radius-md: 6px;
- --radius-lg: 8px;
- --radius-xl: 12px;
- --radius: var(--radius-md);
+ /* Border Radii - SHARP CORNERS (0) */
+ --radius-sm: 0;
+ --radius-md: 0;
+ --radius-lg: 0;
+ --radius-xl: 0;
+ --radius: 0;
- /* Spacing - Editorial scale for airy layouts */
- --spacing-section: 6rem; /* 96px - Between major sections */
- --spacing-component: 3rem; /* 48px - Between components */
- --spacing-element: 1.5rem; /* 24px - Between related elements */
- --spacing-tight: 0.75rem; /* 12px - Tight groupings */
- --spacing-comfortable: 2rem; /* 32px - Comfortable breathing room */
+ /* Spacing - Generous whitespace */
+ --spacing-section: 8rem;
+ --spacing-component: 4rem;
+ --spacing-element: 2rem;
+ --spacing-tight: 1rem;
+ --spacing-comfortable: 3rem;
/* Typography */
- --font-sans: var(--font-sans), -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
- --font-mono: var(--font-mono), "SF Mono", Consolas, monospace;
+ --font-sans: var(--font-inter), -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
+ --font-mono: var(--font-jetbrains), "JetBrains Mono", "SF Mono", Consolas, monospace;
- /* Editorial Line Heights - Airy, readable */
- --leading-tight: 1.25;
- --leading-snug: 1.375;
- --leading-normal: 1.5;
- --leading-relaxed: 1.625;
- --leading-loose: 1.75;
- --leading-editorial: 1.8; /* Extra generous for body copy */
+ /* Line Heights */
+ --leading-tight: 1.2;
+ --leading-snug: 1.4;
+ --leading-normal: 1.6;
+ --leading-relaxed: 1.7;
+ --leading-loose: 1.8;
+ --leading-editorial: 1.9;
- /* Letter Spacing - Technical precision */
- --tracking-tighter: -0.05em;
- --tracking-tight: -0.025em;
+ /* Letter Spacing */
+ --tracking-tighter: -0.03em;
+ --tracking-tight: -0.015em;
--tracking-normal: 0;
--tracking-wide: 0.025em;
--tracking-wider: 0.05em;
--tracking-widest: 0.1em;
- /* Brutalist Accent Colors - Modern Editorial */
- --brutalist-accent: 221 83% 53%; /* Sophisticated blue */
- --brutalist-accent-hover: 221 83% 45%; /* Slightly deeper on hover */
+ /* Brutalist Accents */
+ --brutalist-accent: 22 57% 41%;
+ --brutalist-accent-hover: 22 57% 35%;
+
+ /* Gradients */
+ --gradient-start: 24 70% 50%;
+ --gradient-end: 15 60% 20%;
+
+ /* ===========================================
+ MOTION & ANIMATION TOKENS
+ Motion communicates state, not decoration
+ Fast by default (150-200ms)
+ =========================================== */
+
+ /* Duration tokens */
+ --motion-instant: 0ms;
+ --motion-fast: 150ms;
+ --motion-base: 200ms;
+ --motion-slow: 300ms;
+ --motion-slower: 500ms;
+
+ /* Easing tokens */
+ --easing-standard: cubic-bezier(0.4, 0, 0.2, 1);
+ --easing-decelerate: cubic-bezier(0, 0, 0.2, 1);
+ --easing-accelerate: cubic-bezier(0.4, 0, 1, 1);
+ --easing-linear: linear;
+ --easing-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275);
+
+ /* ===========================================
+ TYPOGRAPHY SCALE
+ Modular scale with 1.25 ratio
+ Base: 16px
+ =========================================== */
+
+ /* Type scale */
+ --text-xs: 0.75rem; /* 12px */
+ --text-sm: 0.875rem; /* 14px */
+ --text-base: 1rem; /* 16px */
+ --text-lg: 1.125rem; /* 18px */
+ --text-xl: 1.25rem; /* 20px */
+ --text-2xl: 1.5rem; /* 24px */
+ --text-3xl: 2rem; /* 32px */
+ --text-4xl: 2.5rem; /* 40px */
+ --text-5xl: 3rem; /* 48px */
+ --text-6xl: 4rem; /* 64px */
+
+ /* Max line widths for readability */
+ --prose-width: 65ch;
+ --prose-width-narrow: 45ch;
+ --prose-width-wide: 80ch;
+
+ /* ===========================================
+ BREAKPOINTS
+ Mobile-first responsive design
+ =========================================== */
+
+ /* Breakpoint values (for reference, Tailwind handles classes) */
+ --breakpoint-sm: 640px;
+ --breakpoint-md: 768px;
+ --breakpoint-lg: 1024px;
+ --breakpoint-xl: 1280px;
+ --breakpoint-2xl: 1536px;
+
+ /* ===========================================
+ DENSITY MODES
+ Compact / Comfortable / Spacious
+ =========================================== */
+
+ /* Default is comfortable */
+ --density-row-height: 48px;
+ --density-cell-padding-x: 16px;
+ --density-cell-padding-y: 12px;
+ --density-icon-size: 20px;
+ --density-font-size: var(--text-sm);
+
+ /* ===========================================
+ Z-INDEX SCALE
+ Consistent layering
+ =========================================== */
+
+ --z-base: 0;
+ --z-dropdown: 100;
+ --z-sticky: 200;
+ --z-fixed: 300;
+ --z-modal-backdrop: 400;
+ --z-modal: 500;
+ --z-popover: 600;
+ --z-tooltip: 700;
+ --z-toast: 800;
+
+ /* ===========================================
+ FOCUS & ACCESSIBILITY
+ Never remove focus indicators
+ =========================================== */
+
+ --focus-ring-width: 2px;
+ --focus-ring-offset: 2px;
+ --focus-ring-color: var(--ring);
+
+ /* ===========================================
+ ELEVATION & SHADOWS
+ Subtle depth, prefer borders for emphasis
+ =========================================== */
+
+ --shadow-none: none;
+ --shadow-xs: 0 1px 2px 0 hsl(var(--foreground) / 0.05);
+ --shadow-sm: 0 1px 3px 0 hsl(var(--foreground) / 0.08), 0 1px 2px -1px hsl(var(--foreground) / 0.08);
+ --shadow-md: 0 4px 6px -1px hsl(var(--foreground) / 0.08), 0 2px 4px -2px hsl(var(--foreground) / 0.08);
+ --shadow-lg: 0 10px 15px -3px hsl(var(--foreground) / 0.08), 0 4px 6px -4px hsl(var(--foreground) / 0.08);
+ --shadow-xl: 0 20px 25px -5px hsl(var(--foreground) / 0.08), 0 8px 10px -6px hsl(var(--foreground) / 0.08);
+ --shadow-2xl: 0 25px 50px -12px hsl(var(--foreground) / 0.15);
+ --shadow-inner: inset 0 2px 4px 0 hsl(var(--foreground) / 0.05);
+
+ /* ===========================================
+ RADIUS SCALE
+ Sharp corners by default (brutalist)
+ Explicit tokens for rare roundness
+ =========================================== */
+
+ --radius-none: 0;
+ --radius-xs: 2px;
+ --radius-sm: 4px;
+ --radius-md: 6px;
+ --radius-lg: 8px;
+ --radius-xl: 12px;
+ --radius-2xl: 16px;
+ --radius-full: 9999px;
+
+ /* ===========================================
+ BORDER WIDTH SCALE
+ =========================================== */
+
+ --border-hairline: 0.5px;
+ --border-thin: 1px;
+ --border-medium: 2px;
+ --border-thick: 4px;
+ --border-heavy: 6px;
+ --border-brutalist: 8px;
+
+ /* ===========================================
+ OPACITY SCALE
+ For disabled, subtle, overlay states
+ =========================================== */
+
+ --opacity-0: 0;
+ --opacity-disabled: 0.5;
+ --opacity-subtle: 0.6;
+ --opacity-muted: 0.4;
+ --opacity-overlay: 0.8;
+ --opacity-overlay-heavy: 0.9;
+ --opacity-hover: 0.9;
+ --opacity-full: 1;
+
+ /* ===========================================
+ ADDITIONAL Z-INDEX LAYERS
+ Extended from base scale
+ =========================================== */
+
+ --z-drawer: 350;
+ --z-command-palette: 900;
+ --z-notification: 850;
}
- /* Dark mode (opt-in) - Vercel/Cursor/Perplexity aesthetic */
+ /* Dark mode (opt-in) - Warm dark with copper accent */
.dark {
- /* Backgrounds & Surfaces */
- --background: 210 10% 5%; /* #0d0d0f - Almost black */
- --surface: 210 10% 8%; /* #14141a - Slightly lighter */
- --surface-secondary: 210 10% 10%; /* Secondary surfaces */
- --surface-elevated: 210 10% 12%; /* #1c1c24 - Cards, modals */
- --surface-overlay: 210 10% 16%; /* #25252f - Overlays, popovers */
+ /* ===========================================
+ DARK MODE - Warm charcoal with copper
+ =========================================== */
- /* 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 */
+ /* Backgrounds - Layered dark surfaces */
+ --background: 25 10% 6%; /* #0F0E0D - Warm dark base */
+ --surface: 25 10% 9%; /* #171514 - Cards, panels */
+ --surface-2: 25 10% 12%; /* #1F1D1B - Elevated */
+ --surface-3: 25 10% 16%; /* #2A2725 - Highest elevation */
+ --surface-secondary: 25 10% 10%;
+ --surface-elevated: 25 10% 12%;
+ --surface-overlay: 25 10% 16%;
- /* 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 */
+ /* Text - Clear hierarchy in dark */
+ --foreground: 30 10% 90%; /* #E8E5E2 - Primary text */
+ --foreground-secondary: 25 6% 60%; /* #9A9592 - Secondary */
+ --foreground-tertiary: 25 5% 45%; /* #757270 - Tertiary */
+ --foreground-muted: 25 4% 35%; /* #5A5856 - Muted */
- /* 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 */
+ /* Borders - Subtle in dark */
+ --border: 25 8% 20%; /* #363230 - Default */
+ --border-strong: 25 8% 35%; /* #5C5854 - Emphasized */
+ --border-subtle: 25 6% 14%; /* #252321 - Subtle */
- /* Status Colors - Modern Editorial (Dark Mode) */
- --success: 152 57% 50%; /* Refined emerald - slightly brighter for dark */
- --success-foreground: 0 0% 100%; /* White text */
- --error: 0 65% 58%; /* Sophisticated red - brighter for dark */
- --error-foreground: 0 0% 100%; /* White text */
- --warning: 36 100% 55%; /* Warm amber - brighter for dark */
- --warning-foreground: 0 0% 100%; /* White text */
- --info: 210 100% 60%; /* Cool blue - brighter for dark */
- --info-foreground: 0 0% 100%; /* White text */
+ /* Primary - Copper, brighter for dark mode */
+ --primary: 22 57% 50%; /* #C96A38 - Brighter copper */
+ --primary-hover: 22 57% 55%; /* #D47942 - Hover */
+ --primary-active: 22 57% 45%; /* #B55E2E - Active */
+ --primary-foreground: 0 0% 100%;
- /* Destructive (legacy support) */
- --destructive: 0 65% 58%;
+ /* Accent - Copper variations */
+ --accent: 22 57% 50%;
+ --accent-strong: 22 60% 55%;
+ --accent-muted: 22 20% 15%;
+ --accent-foreground: 0 0% 100%;
+
+ /* Secondary - Warm dark */
+ --secondary: 25 10% 15%;
+ --secondary-hover: 25 10% 20%;
+ --secondary-foreground: 30 10% 90%;
+
+ /* Muted - Subtle dark */
+ --muted: 25 8% 12%;
+ --muted-foreground: 25 6% 55%;
+
+ /* Status Colors - Brighter for dark mode */
+ --success: 145 50% 45%; /* Brighter green */
+ --success-light: 145 30% 15%;
+ --success-foreground: 0 0% 100%;
+
+ --warning: 38 80% 55%; /* Brighter amber */
+ --warning-light: 38 40% 15%;
+ --warning-foreground: 25 15% 10%;
+
+ --error: 0 60% 55%; /* Brighter red */
+ --error-light: 0 40% 15%;
+ --error-foreground: 0 0% 100%;
+
+ --info: 210 70% 55%; /* Brighter blue */
+ --info-light: 210 40% 15%;
+ --info-foreground: 0 0% 100%;
+
+ /* Destructive */
+ --destructive: 0 60% 55%;
--destructive-foreground: 0 0% 100%;
/* Form Elements */
- --input: 210 10% 20%; /* Input border */
- --ring: 210 10% 90%; /* Focus ring */
- --ring-offset: 210 10% 5%; /* Focus ring offset */
+ --input: 25 8% 20%;
+ --input-focus: 22 57% 50%;
+ --ring: 22 57% 50%;
+ --ring-offset: 25 10% 6%;
- /* Grid/Blueprint Pattern */
- --grid-color: 210 10% 15%; /* Grid line color */
+ /* Grid */
+ --grid-color: 25 8% 15%;
/* Card */
- --card: 210 10% 8%;
- --card-foreground: 210 10% 90%;
+ --card: 25 10% 9%;
+ --card-foreground: 30 10% 90%;
- /* Brutalist Accent Colors - Modern Editorial */
- --brutalist-accent: 210 100% 60%; /* Cool sophisticated blue for dark mode */
- --brutalist-accent-hover: 210 100% 65%; /* Slightly brighter on hover */
+ /* Brutalist Accents */
+ --brutalist-accent: 22 57% 50%;
+ --brutalist-accent-hover: 22 57% 55%;
+
+ /* Border Radii - SHARP CORNERS (0) */
+ --radius-sm: 0;
+ --radius-md: 0;
+ --radius-lg: 0;
+ --radius-xl: 0;
+ --radius: 0;
+
+ /* ===========================================
+ DARK MODE SHADOWS
+ Darker, more prominent for visibility
+ =========================================== */
+
+ --shadow-none: none;
+ --shadow-xs: 0 1px 2px 0 hsl(0 0% 0% / 0.2);
+ --shadow-sm: 0 1px 3px 0 hsl(0 0% 0% / 0.25), 0 1px 2px -1px hsl(0 0% 0% / 0.25);
+ --shadow-md: 0 4px 6px -1px hsl(0 0% 0% / 0.25), 0 2px 4px -2px hsl(0 0% 0% / 0.25);
+ --shadow-lg: 0 10px 15px -3px hsl(0 0% 0% / 0.25), 0 4px 6px -4px hsl(0 0% 0% / 0.25);
+ --shadow-xl: 0 20px 25px -5px hsl(0 0% 0% / 0.3), 0 8px 10px -6px hsl(0 0% 0% / 0.3);
+ --shadow-2xl: 0 25px 50px -12px hsl(0 0% 0% / 0.4);
+ --shadow-inner: inset 0 2px 4px 0 hsl(0 0% 0% / 0.2);
+
+ /* Radius, border, opacity tokens inherit from light mode */
}
/* Base element styles */
@@ -518,4 +750,96 @@
transition-duration: 0.01ms !important;
}
}
+
+ /* ===========================================
+ DENSITY MODES
+ Use data-density attribute on container
+ =========================================== */
+
+ /* Compact density */
+ [data-density="compact"] {
+ --density-row-height: 36px;
+ --density-cell-padding-x: 8px;
+ --density-cell-padding-y: 6px;
+ --density-icon-size: 16px;
+ --density-font-size: var(--text-xs);
+ }
+
+ /* Comfortable density (default) */
+ [data-density="comfortable"] {
+ --density-row-height: 48px;
+ --density-cell-padding-x: 16px;
+ --density-cell-padding-y: 12px;
+ --density-icon-size: 20px;
+ --density-font-size: var(--text-sm);
+ }
+
+ /* Spacious density */
+ [data-density="spacious"] {
+ --density-row-height: 64px;
+ --density-cell-padding-x: 24px;
+ --density-cell-padding-y: 20px;
+ --density-icon-size: 24px;
+ --density-font-size: var(--text-base);
+ }
+
+ /* Density utility classes */
+ .density-row {
+ height: var(--density-row-height);
+ font-size: var(--density-font-size);
+ }
+
+ .density-cell {
+ padding: var(--density-cell-padding-y) var(--density-cell-padding-x);
+ font-size: var(--density-font-size);
+ }
+
+ .density-icon {
+ width: var(--density-icon-size);
+ height: var(--density-icon-size);
+ }
+
+ /* ===========================================
+ PROSE WIDTHS FOR READABILITY
+ =========================================== */
+
+ .prose-width {
+ max-width: var(--prose-width);
+ }
+
+ .prose-width-narrow {
+ max-width: var(--prose-width-narrow);
+ }
+
+ .prose-width-wide {
+ max-width: var(--prose-width-wide);
+ }
+
+ /* ===========================================
+ MOTION UTILITIES
+ Using CSS custom properties
+ =========================================== */
+
+ .motion-fast {
+ transition-duration: var(--motion-fast);
+ transition-timing-function: var(--easing-standard);
+ }
+
+ .motion-base {
+ transition-duration: var(--motion-base);
+ transition-timing-function: var(--easing-standard);
+ }
+
+ .motion-slow {
+ transition-duration: var(--motion-slow);
+ transition-timing-function: var(--easing-standard);
+ }
+
+ .motion-linear {
+ transition-timing-function: var(--easing-linear);
+ }
+
+ .motion-spring {
+ transition-timing-function: var(--easing-spring);
+ }
}
diff --git a/apps/web/src/app/style-guide/page.tsx b/apps/web/src/app/style-guide/page.tsx
new file mode 100644
index 0000000..5d374d0
--- /dev/null
+++ b/apps/web/src/app/style-guide/page.tsx
@@ -0,0 +1,153 @@
+'use client';
+
+import { Badge } from '@tpmjs/ui/Badge/Badge';
+import { Container } from '@tpmjs/ui/Container/Container';
+import { useState } from 'react';
+import { AppHeader } from '~/components/AppHeader';
+import {
+ FieldsetSection,
+ NavItem,
+ SectionA11yChecklists,
+ SectionAccessibility,
+ SectionColors,
+ SectionComponentAPIs,
+ SectionComponents,
+ SectionContent,
+ SectionContentGuidelines,
+ SectionDataViz,
+ SectionIconSystem,
+ SectionIcons,
+ SectionLayout,
+ SectionMotion,
+ SectionPatternFeedback,
+ SectionPatternForms,
+ SectionPatternNavigation,
+ SectionPatternSearch,
+ SectionPatternTables,
+ SectionPrinciples,
+ SectionSpacing,
+ SectionTheming,
+ SectionTypography,
+} from '~/components/style-guide';
+
+export default function StyleGuidePage(): React.ReactElement {
+ const [activeTab, setActiveTab] = useState('all');
+ const [radioValue, setRadioValue] = useState('option1');
+ const [counterValue, setCounterValue] = useState(1234);
+ const [density, setDensity] = useState<'compact' | 'comfortable' | 'spacious'>('comfortable');
+
+ return (
+
+
+
+
+
+ {/* Hero Section */}
+
+
+ tpmjs design system
+
+
+ The comprehensive design system for TPMJS. This guide covers design principles,
+ foundations, components, and governance rules that scale across teams, AI agents,
+ and external contributors.
+
+
+ v2.0
+ wcag aa
+ dark mode
+
+
+
+ {/* Table of Contents */}
+
+
+
+
foundations
+
1. design principles
+
2. color system
+
3. typography
+
4. spacing
+
5. motion
+
+
+
systems
+
6. accessibility
+
7. layout & responsiveness
+
8. content guidelines
+
9. data visualization
+
10. iconography
+
+
+
implementation
+
11. theming
+
12. components
+
13. component apis
+
+
+
patterns
+
14. navigation
+
15. forms
+
16. feedback
+
17. tables
+
18. search & filtering
+
+
+
governance
+
19. a11y checklists
+
20. content & writing
+
21. icon system
+
+
+
+
+ {/* Foundation Sections */}
+
+
+
+
+
+
+ {/* Systems Sections */}
+
+
+
+
+
+
+ {/* Implementation Sections */}
+
+
+
+
+ {/* Pattern Library Sections */}
+
+
+
+
+
+
+ {/* Governance Sections */}
+
+
+
+
+ {/* Footer */}
+
+
+ tpmjs design system v2.0
+
+
+ built with @tpmjs/ui • inspired by turbopuffer.com
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionA11yChecklists.tsx b/apps/web/src/components/style-guide/SectionA11yChecklists.tsx
new file mode 100644
index 0000000..dae9222
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionA11yChecklists.tsx
@@ -0,0 +1,465 @@
+'use client';
+
+import { Badge } from '@tpmjs/ui/Badge/Badge';
+import { Icon } from '@tpmjs/ui/Icon/Icon';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@tpmjs/ui/Table/Table';
+import { FieldsetSection, SubSection } from './shared';
+
+type A11yRequirement = {
+ component: string;
+ ariaPattern: string;
+ focusTrap: boolean;
+ keyboard: string[];
+ reducedMotion: string;
+};
+
+const overlayComponents: A11yRequirement[] = [
+ {
+ component: 'Modal',
+ ariaPattern: 'dialog',
+ focusTrap: true,
+ keyboard: ['Esc (close)', 'Tab (cycle focus)'],
+ reducedMotion: 'Instant open/close',
+ },
+ {
+ component: 'Drawer',
+ ariaPattern: 'dialog',
+ focusTrap: true,
+ keyboard: ['Esc (close)', 'Tab (cycle focus)'],
+ reducedMotion: 'No slide animation',
+ },
+ {
+ component: 'Popover',
+ ariaPattern: 'dialog',
+ focusTrap: false,
+ keyboard: ['Esc (close)', 'Tab (move to next)'],
+ reducedMotion: 'Instant show/hide',
+ },
+ {
+ component: 'Tooltip',
+ ariaPattern: 'tooltip',
+ focusTrap: false,
+ keyboard: ['Focus trigger (show)', 'Blur (hide)'],
+ reducedMotion: 'Instant show/hide',
+ },
+ {
+ component: 'DropdownMenu',
+ ariaPattern: 'menu',
+ focusTrap: false,
+ keyboard: ['Enter/Space (select)', 'Arrow keys (navigate)', 'Esc (close)'],
+ reducedMotion: 'Instant show/hide',
+ },
+ {
+ component: 'Toast',
+ ariaPattern: 'alert / status',
+ focusTrap: false,
+ keyboard: ['Focus action button', 'Enter (action)'],
+ reducedMotion: 'No slide animation',
+ },
+];
+
+const formComponents: A11yRequirement[] = [
+ {
+ component: 'Input',
+ ariaPattern: 'textbox',
+ focusTrap: false,
+ keyboard: ['Tab (focus)', 'Type (input)'],
+ reducedMotion: 'N/A',
+ },
+ {
+ component: 'Select',
+ ariaPattern: 'listbox',
+ focusTrap: false,
+ keyboard: ['Enter/Space (open)', 'Arrow keys (navigate)', 'Esc (close)'],
+ reducedMotion: 'Instant show/hide',
+ },
+ {
+ component: 'Checkbox',
+ ariaPattern: 'checkbox',
+ focusTrap: false,
+ keyboard: ['Space (toggle)', 'Tab (focus)'],
+ reducedMotion: 'N/A',
+ },
+ {
+ component: 'Radio',
+ ariaPattern: 'radiogroup',
+ focusTrap: false,
+ keyboard: ['Arrow keys (select)', 'Tab (focus)'],
+ reducedMotion: 'N/A',
+ },
+ {
+ component: 'Switch',
+ ariaPattern: 'switch',
+ focusTrap: false,
+ keyboard: ['Space (toggle)', 'Tab (focus)'],
+ reducedMotion: 'Instant toggle',
+ },
+ {
+ component: 'Slider',
+ ariaPattern: 'slider',
+ focusTrap: false,
+ keyboard: ['Arrow keys (adjust)', 'Home/End (min/max)'],
+ reducedMotion: 'N/A',
+ },
+];
+
+const navigationComponents: A11yRequirement[] = [
+ {
+ component: 'Tabs',
+ ariaPattern: 'tablist',
+ focusTrap: false,
+ keyboard: ['Arrow keys (navigate)', 'Enter/Space (select)', 'Home/End (first/last)'],
+ reducedMotion: 'N/A',
+ },
+ {
+ component: 'Breadcrumbs',
+ ariaPattern: 'navigation',
+ focusTrap: false,
+ keyboard: ['Tab (focus links)', 'Enter (activate)'],
+ reducedMotion: 'N/A',
+ },
+ {
+ component: 'Pagination',
+ ariaPattern: 'navigation',
+ focusTrap: false,
+ keyboard: ['Tab (focus)', 'Enter (activate)'],
+ reducedMotion: 'N/A',
+ },
+ {
+ component: 'Accordion',
+ ariaPattern: 'region',
+ focusTrap: false,
+ keyboard: ['Enter/Space (toggle)', 'Tab (focus)'],
+ reducedMotion: 'Instant expand/collapse',
+ },
+];
+
+function A11yTable({ components, title }: { components: A11yRequirement[]; title: string }) {
+ return (
+
+
+ {title}
+
+
+
+
+
+ component
+ aria pattern
+ focus trap
+ keyboard
+ reduced motion
+
+
+
+ {components.map((comp) => (
+
+ {comp.component}
+
+ {comp.ariaPattern}
+
+
+ {comp.focusTrap ? (
+ yes
+ ) : (
+ no
+ )}
+
+
+
+ {comp.keyboard.map((k, i) => (
+
{k}
+ ))}
+
+
+
+ {comp.reducedMotion}
+
+
+ ))}
+
+
+
+
+ );
+}
+
+export function SectionA11yChecklists(): React.ReactElement {
+ return (
+
+
+ Component-level accessibility requirements and implementation guides.
+ All components follow WCAG 2.1 AA standards.
+
+
+
+
+ Each component has specific ARIA patterns and keyboard interactions that must be implemented.
+
+
+
+
+
+
+
+
+
+ Proper focus management is critical for keyboard and screen reader users.
+
+
+
+
+
+ focus trap required
+
+
+ Modal dialogs - trap focus inside
+ Drawer sheets - trap until closed
+ Full-screen overlays
+
+
+
+
+
+ focus restoration
+
+
+ Return focus to trigger on close
+ Save and restore focus position
+ Skip links for long content
+
+
+
+
+
implementation pattern
+
+{`// Focus trap implementation
+const dialogRef = useRef(null);
+const triggerRef = useRef(null);
+
+// Save trigger reference before opening
+const handleOpen = () => {
+ triggerRef.current = document.activeElement as HTMLButtonElement;
+ setOpen(true);
+};
+
+// Restore focus on close
+const handleClose = () => {
+ setOpen(false);
+ triggerRef.current?.focus();
+};`}
+
+
+
+
+
+
+ Use live regions to announce dynamic content changes.
+
+
+
+
aria-live regions
+
+
+
aria-live="polite"
+
+ Toast notifications, status updates
+
+
+
+
aria-live="assertive"
+
+ Error messages, critical alerts
+
+
+
+
role="status"
+
+ Loading states, progress updates
+
+
+
+
+
+
+
component announcements
+
+
+
+ event
+ announcement
+
+
+
+
+ Toast shown
+ "[message content]"
+
+
+ Modal opened
+ "[dialog title], dialog"
+
+
+ Form error
+ "Error: [field name], [error message]"
+
+
+ Loading complete
+ "Loading complete, [N] results"
+
+
+
+
+
+
+
+
+
+ All text must meet WCAG AA contrast requirements (4.5:1 for normal text, 3:1 for large text).
+
+
+
+
+ foreground on background
+ 9.2:1
+
+
+ secondary on surface
+ 5.8:1
+
+
+ tertiary on surface
+ 4.5:1
+
+
+
+
contrast verification
+
+ • Test all color combinations in both themes
+ • Verify focus indicators (3:1 minimum)
+ • Ensure error states meet requirements
+ • Check interactive state contrast changes
+
+
+
+
+
+
+
+ Respect the prefers-reduced-motion media query
+ for users who experience motion sickness or vestibular disorders.
+
+
+
implementation
+
+{`// CSS approach
+@media (prefers-reduced-motion: reduce) {
+ * {
+ animation-duration: 0.01ms !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+
+// React hook approach
+import { useReducedMotion } from '@tpmjs/ui/system/hooks/useReducedMotion';
+
+function AnimatedComponent() {
+ const prefersReducedMotion = useReducedMotion();
+
+ return (
+
+ );
+}`}
+
+
+
+
+
always animate
+
Progress bars, loading spinners
+
+
+
respect preference
+
Page transitions, modal animations
+
+
+
never animate
+
Auto-playing video, parallax
+
+
+
+
+
+
+ Before shipping any component, verify accessibility with these tests.
+
+
+
+
+
+ keyboard testing
+
+
+
+
+ Tab through all interactive elements
+
+
+
+ Verify focus is visible at all times
+
+
+
+ Test Enter/Space activation
+
+
+
+ Verify Escape closes overlays
+
+
+
+ Test arrow key navigation
+
+
+
+
+
+
+ screen reader testing
+
+
+
+
+ Test with VoiceOver (macOS/iOS)
+
+
+
+ Test with NVDA (Windows)
+
+
+
+ Verify all content is announced
+
+
+
+ Check landmark navigation
+
+
+
+ Test live region updates
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionAccessibility.tsx b/apps/web/src/components/style-guide/SectionAccessibility.tsx
new file mode 100644
index 0000000..ecd20cb
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionAccessibility.tsx
@@ -0,0 +1,99 @@
+'use client';
+
+import { Button } from '@tpmjs/ui/Button/Button';
+import { Icon } from '@tpmjs/ui/Icon/Icon';
+import { Input } from '@tpmjs/ui/Input/Input';
+import { DoDontCard, FieldsetSection, SubSection } from './shared';
+
+export function SectionAccessibility(): React.ReactElement {
+ return (
+
+
+ TPMJS targets WCAG 2.1 AA compliance. Accessibility is not optional—it's
+ a core requirement for every component.
+
+
+
+
+
+
+
4.5:1
+
min contrast (text)
+
+
+
3:1
+
min contrast (UI)
+
+
+
+
+
+
+
+
focus ring design
+
+ Focus rings use the copper accent color with 2px width and 2px offset.
+ They are never removed from interactive elements.
+
+
+
+ tab to me
+
+
+
+
+
+
+
keyboard navigation
+
+ • Tab moves focus forward through interactive elements
+ • Shift+Tab moves focus backward
+ • Enter / Space activates buttons and links
+ • Esc closes modals and dropdowns
+ • Arrow keys navigate within components (tabs, radios, menus)
+
+
+
+
+
+
+
+
+
+
+
+ Published
+
+
+
+ Failed
+
+
+
+
+
+ Published
+ Failed
+
+
+
+
+
+
+
+
+ • All interactive elements have accessible names (aria-label or visible text)
+ • Images include alt text describing content
+ • Form inputs are associated with labels
+ • Error messages are announced via aria-live regions
+ • Loading states communicate progress to screen readers
+ • Decorative elements are hidden from assistive technology
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionColors.tsx b/apps/web/src/components/style-guide/SectionColors.tsx
new file mode 100644
index 0000000..85842f9
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionColors.tsx
@@ -0,0 +1,178 @@
+'use client';
+
+import { Badge } from '@tpmjs/ui/Badge/Badge';
+import { Button } from '@tpmjs/ui/Button/Button';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@tpmjs/ui/Table/Table';
+import { ColorCard, DoDontCard, FieldsetSection, SubSection } from './shared';
+
+export function SectionColors(): React.ReactElement {
+ return (
+
+
+ A warm earthy palette with copper accent. Colors are designed for clarity,
+ hierarchy, and accessibility.
+
+
+ {/* Color Palette Display */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Color Usage Rules */}
+
+
+
+
when to use copper vs neutral
+
+ • Copper: Primary actions, active states, links, key metrics
+ • Neutral: Secondary actions, borders, backgrounds, body text
+ • Rule: Copper should be max ~10% of visible screen area
+
+
+
+
semantic color priority
+
+ error
+ {'>'}
+ warning
+ {'>'}
+ success
+ {'>'}
+ info
+
+
+ When multiple states apply, show the highest priority color.
+
+
+
+
+ {/* Do/Don't Examples */}
+
+
+
+ publish tool
+ cancel
+
+
+
+
+ publish
+ cancel
+ back
+
+
+
+
+ published
+ pending
+ failed
+
+
+
+
+ tools
+ agents
+ users
+
+
+
+
+
+
+
+
+
+
+ combination
+ ratio
+ normal text
+ large text
+
+
+
+
+ foreground / background
+ 12.5:1
+ pass
+ pass
+
+
+ accent / background
+ 5.2:1
+ pass
+ pass
+
+
+ foreground-secondary / background
+ 5.8:1
+ pass
+ pass
+
+
+ foreground-tertiary / background
+ 3.9:1
+ large only
+ pass
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionComponentAPIs.tsx b/apps/web/src/components/style-guide/SectionComponentAPIs.tsx
new file mode 100644
index 0000000..d0f0074
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionComponentAPIs.tsx
@@ -0,0 +1,178 @@
+'use client';
+
+import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@tpmjs/ui/Table/Table';
+import { FieldsetSection, SubSection } from './shared';
+
+export function SectionComponentAPIs(): React.ReactElement {
+ return (
+
+
+ Complete API documentation for each component with props, types,
+ and usage examples.
+
+
+
+
+
+
+
+ prop
+ type
+ default
+ description
+
+
+
+
+ variant
+ 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'
+ 'default'
+ visual style
+
+
+ size
+ 'sm' | 'md' | 'lg' | 'icon'
+ 'md'
+ button size
+
+
+ loading
+ boolean
+ false
+ show spinner
+
+
+ disabled
+ boolean
+ false
+ disable interaction
+
+
+
+
+
click me
+
+// With variant and size
+
+ delete tool
+
+
+// Loading state
+publishing...
+
+// With icon
+
+
+ add tool
+ `}
+ language="tsx"
+ />
+
+
+
+
+
+
+
+
+
+
+
don't do this
+
+ delete
+
+
+// ❌ Nesting buttons
+
+ nested
+
+
+// ❌ Missing accessible name
+
+
+ `}
+ language="tsx"
+ size="sm"
+ />
+
+
+
do this instead
+
+ delete
+
+
+// ✅ Single button
+action
+
+// ✅ With aria-label
+
+
+ `}
+ language="tsx"
+ size="sm"
+ />
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionComponents.tsx b/apps/web/src/components/style-guide/SectionComponents.tsx
new file mode 100644
index 0000000..ae12cd8
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionComponents.tsx
@@ -0,0 +1,286 @@
+'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 { Checkbox } from '@tpmjs/ui/Checkbox/Checkbox';
+import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
+import { FormField } from '@tpmjs/ui/FormField/FormField';
+import { Icon } from '@tpmjs/ui/Icon/Icon';
+import { Input } from '@tpmjs/ui/Input/Input';
+import { Radio } from '@tpmjs/ui/Radio/Radio';
+import { RadioGroup } from '@tpmjs/ui/Radio/RadioGroup';
+import { Select } from '@tpmjs/ui/Select/Select';
+import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
+import { Switch } from '@tpmjs/ui/Switch/Switch';
+import { Tabs } from '@tpmjs/ui/Tabs/Tabs';
+import {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@tpmjs/ui/Table/Table';
+import { Textarea } from '@tpmjs/ui/Textarea/Textarea';
+import { FieldsetSection, SubSection } from './shared';
+
+interface SectionComponentsProps {
+ activeTab: string;
+ onTabChange: (tab: string) => void;
+ radioValue: string;
+ onRadioChange: (value: string) => void;
+}
+
+export function SectionComponents({
+ activeTab,
+ onTabChange,
+ radioValue,
+ onRadioChange
+}: SectionComponentsProps): React.ReactElement {
+ return (
+
+
+ Complete component catalog with all variants and states.
+
+
+ {/* Button */}
+
+
+
+
variants
+
+ default
+ secondary
+ destructive
+ outline
+ outline dotted
+ blueprint
+ ghost
+ link
+
+
+
+
sizes
+
+ small
+ medium
+ large
+
+
+
+
+
states
+
+ normal
+ disabled
+ loading
+
+
+
+
+
+ {/* Badge */}
+
+
+
+
variants
+
+ default
+ secondary
+ outline
+ success
+ error
+ warning
+ info
+
+
+
+
sizes
+
+ small
+ medium
+ large
+
+
+
+
+
+ {/* Card */}
+
+
+ {(['default', 'elevated', 'outline', 'blueprint', 'featured', 'brutalist'] as const).map((variant) => (
+
+
+ {variant} card
+ card variant example
+
+
+ card content goes here.
+
+
+ action
+
+
+ ))}
+
+
+
+ {/* Form Elements */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
checkbox
+
+
+ {}} />
+
+
+
+
+
switch
+
+
+ {}} />
+
+
+
+
+
radio group
+
+
+
+
+
+
+
+
+
+
+ {/* Tabs */}
+
+
+
+
+
+
+ {/* Table */}
+
+
+ example data table
+
+
+ name
+ category
+ downloads
+ score
+
+
+
+
+ @tpmjs/parser
+ utility
+ 125,432
+ 0.92
+
+
+ @tpmjs/validator
+ validation
+ 89,231
+ 0.87
+
+
+ @tpmjs/transform
+ data
+ 45,678
+ 0.81
+
+
+
+
+
+ {/* Spinner */}
+
+
+
+
+ {/* Code Block */}
+
+
+ click me
+
+ );
+}`}
+ language="typescript"
+ />
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionContent.tsx b/apps/web/src/components/style-guide/SectionContent.tsx
new file mode 100644
index 0000000..d7e67a4
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionContent.tsx
@@ -0,0 +1,144 @@
+'use client';
+
+import { Button } from '@tpmjs/ui/Button/Button';
+import {
+ Card,
+ CardContent,
+} from '@tpmjs/ui/Card/Card';
+import { Icon } from '@tpmjs/ui/Icon/Icon';
+import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
+import { DoDontCard, FieldsetSection, SubSection } from './shared';
+
+export function SectionContent(): React.ReactElement {
+ return (
+
+
+ Voice and tone guidelines for consistent, helpful communication
+ across the platform.
+
+
+
+
+
+
we are
+
+ • Technical: Precise, accurate, developer-friendly
+ • Direct: Say what needs to be said, no fluff
+ • Helpful: Guide users to success
+ • Neutral: Professional, not corporate
+
+
+
+
we are not
+
+ • Marketing-speak: No "revolutionary" or "game-changing"
+ • Cute: No jokes, puns, or playful language
+ • Vague: No "something went wrong"
+ • Condescending: No "simply" or "just"
+
+
+
+
+
+
+
+
+
+
+ publish
+ save draft
+ delete
+
+
+
+
+ submit
+ ok
+ yes
+
+
+
+
+
+
error message format
+
+ Error messages should explain what happened and
+ what to do next .
+
+
+
+ Good: "API key is invalid. Generate a new key in your dashboard settings."
+
+
+ Bad: "Error: Invalid credentials"
+
+
+
+
+
+
+
+
+
+
+
+ no tools yet
+
+ Publish your first tool to get started.
+
+ publish tool
+
+
+
+
+
+
+ no results found
+
+ Try adjusting your search or filters.
+
+ clear filters
+
+
+
+
+
+
+ failed to load
+
+ Check your connection and try again.
+
+ retry
+
+
+
+
+
+
+
+
+
skeleton loading
+
+ Use for content that will load quickly (<2s).
+
+
+
+
+
spinner loading
+
+ Use for actions or longer operations.
+
+
+
+ publishing tool...
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionContentGuidelines.tsx b/apps/web/src/components/style-guide/SectionContentGuidelines.tsx
new file mode 100644
index 0000000..2f26706
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionContentGuidelines.tsx
@@ -0,0 +1,419 @@
+'use client';
+
+import { Badge } from '@tpmjs/ui/Badge/Badge';
+import { Icon } from '@tpmjs/ui/Icon/Icon';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@tpmjs/ui/Table/Table';
+import { FieldsetSection, SubSection, DoDontCard } from './shared';
+
+const glossaryTerms = [
+ { term: 'Tool', definition: 'A reusable MCP server or utility that can be installed via npm', avoid: 'Package, Module, Plugin' },
+ { term: 'Agent', definition: 'An AI-powered assistant that uses tools to complete tasks', avoid: 'Bot, Assistant, AI' },
+ { term: 'Collection', definition: 'A curated group of related tools', avoid: 'Bundle, Set, Kit' },
+ { term: 'Publish', definition: 'Release a new version to the registry', avoid: 'Deploy, Ship, Push' },
+ { term: 'Install', definition: 'Add a tool to your project dependencies', avoid: 'Download, Get, Add' },
+ { term: 'Execute', definition: 'Run a tool with specific parameters', avoid: 'Invoke, Call, Trigger' },
+];
+
+const verbConsistency = [
+ { action: 'Remove permanently', use: 'Delete', avoid: 'Remove, Erase, Destroy' },
+ { action: 'Remove from list', use: 'Remove', avoid: 'Delete, Drop, Clear' },
+ { action: 'Cancel access', use: 'Revoke', avoid: 'Remove, Delete, Cancel' },
+ { action: 'Make inactive', use: 'Disable', avoid: 'Turn off, Deactivate' },
+ { action: 'Make active', use: 'Enable', avoid: 'Turn on, Activate' },
+ { action: 'Start new', use: 'Create', avoid: 'Add, New, Make' },
+ { action: 'Change existing', use: 'Edit', avoid: 'Modify, Update, Change' },
+ { action: 'Look at details', use: 'View', avoid: 'See, Show, Open' },
+];
+
+const iconUsageRules = [
+ { icon: 'check', usage: 'Success, completion, enabled', size: 'sm-md' },
+ { icon: 'x', usage: 'Close, cancel, remove, disabled', size: 'sm-md' },
+ { icon: 'alertCircle', usage: 'Error, critical issue', size: 'sm-md' },
+ { icon: 'alertTriangle', usage: 'Warning, caution needed', size: 'sm-md' },
+ { icon: 'info', usage: 'Information, help', size: 'sm-md' },
+ { icon: 'loader', usage: 'Loading, processing', size: 'sm-md' },
+ { icon: 'search', usage: 'Search input, find action', size: 'sm' },
+ { icon: 'edit', usage: 'Edit, configuration', size: 'sm-md' },
+ { icon: 'chevronRight', usage: 'Navigate forward, expand', size: 'xs-sm' },
+ { icon: 'chevronDown', usage: 'Expand, dropdown', size: 'xs-sm' },
+ { icon: 'plus', usage: 'Add, create new', size: 'sm-md' },
+ { icon: 'moreHorizontal', usage: 'More options menu', size: 'sm' },
+] as const;
+
+export function SectionContentGuidelines(): React.ReactElement {
+ return (
+
+
+ Consistent language and terminology across the platform.
+ These guidelines ensure clarity for users and maintainability for developers.
+
+
+
+
+ Use these terms consistently throughout the platform.
+
+
+
+
+
+ term
+ definition
+ avoid
+
+
+
+ {glossaryTerms.map((item) => (
+
+ {item.term}
+ {item.definition}
+
+ {item.avoid}
+
+
+ ))}
+
+
+
+
+
+
+
+ Use these verbs for common actions to maintain consistency.
+
+
+
+
+
+ action
+ use
+ avoid
+
+
+
+ {verbConsistency.map((item) => (
+
+ {item.action}
+
+ {item.use}
+
+
+ {item.avoid}
+
+
+ ))}
+
+
+
+
+
+
+
+ Follow these capitalization rules for UI text.
+
+
+
+
+
create tool
+
view details
+
my collections
+
+
+
+
+
Create Tool
+
View Details
+
My Collections
+
+
+
+
+
exceptions
+
+ • Proper nouns: "GitHub", "Anthropic", "Claude"
+ • Product names: "TPMJS", "MCP"
+ • Start of sentences in paragraphs
+
+
+
+
+
+
+ Format numbers and data consistently.
+
+
+
+
numbers
+
+
+ Small:
+ 1, 42, 100
+
+
+ Large:
+ 1,234
+
+
+ Abbreviated:
+ 12.5k, 1.2M
+
+
+
+
+
dates
+
+
+ Full:
+ Jan 15, 2025
+
+
+ Relative:
+ 2 hours ago
+
+
+ ISO:
+ 2025-01-15
+
+
+
+
+
units
+
+
+ Bytes:
+ 2.4 MB
+
+
+ Duration:
+ 3m 24s
+
+
+ Version:
+ v1.2.3
+
+
+
+
+
+
+
+
+ Write error messages that are helpful and actionable.
+
+
+
+
+
+
Invalid email format
+
Please enter a valid email address like user@example.com
+
+
+
Tool name already exists
+
Choose a different name or update the existing tool
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function SectionIconSystem(): React.ReactElement {
+ return (
+
+
+ Icons from Lucide React, used consistently across components.
+ All icons use 2px stroke weight and are available in multiple sizes.
+
+
+
+
+ Source: Lucide React .
+ Icons should be imported from @tpmjs/ui/Icon/Icon.
+
+
+
common icons
+
+ {iconUsageRules.map((item) => (
+
+
+ {item.icon}
+
+ ))}
+
+
+
+
+
+
+ Choose icon size based on context and surrounding content.
+
+
+
+
+
xs (12px)
+
Inline, tight spaces
+
+
+
+
sm (16px)
+
Buttons, inputs
+
+
+
+
md (20px)
+
Default, nav items
+
+
+
+
lg (24px)
+
Empty states, hero
+
+
+
+
+
+
+ When and how to use specific icons.
+
+
+
+
+
+ icon
+ usage
+ recommended size
+
+
+
+ {iconUsageRules.map((item) => (
+
+
+
+
+ {item.icon}
+
+
+ {item.usage}
+
+ {item.size}
+
+
+ ))}
+
+
+
+
+
+
+
+ Consistent styling rules for icons in different contexts.
+
+
+
+
colors
+
+
+
+ Success states
+
+
+
+ Error states
+
+
+
+ Warning states
+
+
+
+ Information
+
+
+
+ Neutral/default
+
+
+
+
+
with text
+
+
+
+ create tool
+
+
+ settings
+
+
+
+
+ 2 hours ago
+
+
+
+
+
+
+
+
+ Process for adding new icons to the system.
+
+
+
+
+ 1.
+ Check if a suitable icon exists in Lucide
+
+
+ 2.
+ Add the icon import to packages/ui/src/Icon/icons.ts
+
+
+ 3.
+ Add the icon name to the IconName type
+
+
+ 4.
+ Document the usage in this style guide
+
+
+
+
example: adding a new icon
+
+{`// icons.ts
+import { Heart } from 'lucide-react';
+
+export const icons = {
+ // ...existing icons
+ heart: Heart,
+};
+
+export type IconName = keyof typeof icons;`}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionDataViz.tsx b/apps/web/src/components/style-guide/SectionDataViz.tsx
new file mode 100644
index 0000000..a5dba11
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionDataViz.tsx
@@ -0,0 +1,142 @@
+'use client';
+
+import { AnimatedCounter } from '@tpmjs/ui/AnimatedCounter/AnimatedCounter';
+import { Button } from '@tpmjs/ui/Button/Button';
+import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar';
+import { StatCard } from '@tpmjs/ui/StatCard/StatCard';
+import { FieldsetSection, SubSection } from './shared';
+
+interface SectionDataVizProps {
+ counterValue: number;
+ onCounterChange: (value: number) => void;
+}
+
+export function SectionDataViz({ counterValue, onCounterChange }: SectionDataVizProps): React.ReactElement {
+ return (
+
+
+ Guidelines for charts, metrics, and data displays. Semantic colors only—
+ no rainbow charts.
+
+
+
+
+
+
+
color rules for data
+
+ • Use copper for primary/highlighted data series
+ • Use grayscale for comparison and secondary series
+ • Reserve semantic colors (success/warning/error) for threshold indicators
+ • Create emphasis through opacity variation, not hue changes
+ • Maximum 4 colors in any single visualization
+
+
+
+
+
+
+ Tool quality scores use a consistent visual language across the platform.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
success (completion)
+
+
+
+
warning (approaching limit)
+
+
+
+
+
+
+
+
+
+ onCounterChange(counterValue + 1000)}>+1000
+ onCounterChange(Math.max(0, counterValue - 1000))}>-1000
+ onCounterChange(Math.floor(Math.random() * 100000))}>random
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionIcons.tsx b/apps/web/src/components/style-guide/SectionIcons.tsx
new file mode 100644
index 0000000..5452868
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionIcons.tsx
@@ -0,0 +1,102 @@
+'use client';
+
+import { Button } from '@tpmjs/ui/Button/Button';
+import { Icon } from '@tpmjs/ui/Icon/Icon';
+import { FieldsetSection, SubSection } from './shared';
+
+export function SectionIcons(): React.ReactElement {
+ return (
+
+
+ Consistent icon usage for clear visual communication.
+
+
+
+
+
+
+
+
+
+
icon-only buttons
+
+ Only allowed when the action is universally understood
+ (close, search, menu) AND space is limited.
+
+
+
+
+
+
+
+
+
icon + label (preferred)
+
+ Always include text labels when space permits for clarity.
+
+
+ add tool
+ upload
+
+
+
+
+
+
+
+
+
+ success
+
+
+
+ warning
+
+
+
+ error
+
+
+
+ info
+
+
+
+
+
+
+ {[
+ 'copy', 'github', 'check', 'x', 'chevronDown', 'chevronRight',
+ 'clock', 'link', 'sun', 'moon', 'discord', 'menu',
+ 'folder', 'plus', 'trash', 'edit', 'search', 'loader',
+ 'upload', 'alertCircle', 'globe', 'terminal', 'puzzle', 'message',
+ 'key', 'info', 'send', 'home', 'user', 'heart',
+ 'star', 'externalLink', 'arrowLeft', 'box', 'alertTriangle',
+ ].map((iconName) => (
+
+ [0]['icon']} size="md" className="text-foreground" />
+ {iconName}
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionLayout.tsx b/apps/web/src/components/style-guide/SectionLayout.tsx
new file mode 100644
index 0000000..7933116
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionLayout.tsx
@@ -0,0 +1,151 @@
+'use client';
+
+import { Badge } from '@tpmjs/ui/Badge/Badge';
+import { Button } from '@tpmjs/ui/Button/Button';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@tpmjs/ui/Table/Table';
+import { FieldsetSection, SubSection, TokenRow } from './shared';
+
+interface SectionLayoutProps {
+ density: 'compact' | 'comfortable' | 'spacious';
+ onDensityChange: (density: 'compact' | 'comfortable' | 'spacious') => void;
+}
+
+export function SectionLayout({ density, onDensityChange }: SectionLayoutProps): React.ReactElement {
+ return (
+
+
+ Mobile-first responsive design with clear breakpoints and density modes
+ for different use cases.
+
+
+
+
+ mobile landscape} />
+ tablet} />
+ desktop} />
+ wide} />
+ ultrawide} />
+
+
+
+
responsive behaviors
+
+ • Sidebar: Collapsible on mobile, visible on lg+
+ • Tables: Horizontal scroll on mobile, full width on lg+
+ • Cards: Single column on mobile, grid on md+
+ • Navigation: Hamburger menu on mobile, horizontal on lg+
+
+
+
+
+
+
+ Density modes adjust spacing, font size, and row heights for different contexts.
+ Essential for data-dense developer tools.
+
+
+
+ onDensityChange('compact')}
+ >
+ compact
+
+ onDensityChange('comfortable')}
+ >
+ comfortable
+
+ onDensityChange('spacious')}
+ >
+ spacious
+
+
+
+
+
+
+
+ name
+ downloads
+ status
+
+
+
+
+ @tpmjs/parser
+ 125,432
+ active
+
+
+ @tpmjs/validator
+ 89,231
+ active
+
+
+ @tpmjs/transform
+ 45,678
+ beta
+
+
+
+
+
+
+
+
+
comfortable
+
48px rows
+
+
+
+
+
+
+
+
+
12-column grid
+
+ {[...Array(12)].map((_, i) => (
+
+ {i + 1}
+
+ ))}
+
+
+
+
+
sidebar + content (3 + 9)
+
+
+ sidebar
+
+
+ main content
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionMotion.tsx b/apps/web/src/components/style-guide/SectionMotion.tsx
new file mode 100644
index 0000000..0455f74
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionMotion.tsx
@@ -0,0 +1,82 @@
+'use client';
+
+import { Button } from '@tpmjs/ui/Button/Button';
+import { FieldsetSection, SubSection, TokenRow } from './shared';
+
+export function SectionMotion(): React.ReactElement {
+ return (
+
+
+ Motion communicates state changes, not decoration. Animations are fast
+ by default and respect user preferences.
+
+
+
+
+
+
fast by default
+
+ Most transitions complete in 150-200ms. Users should never wait
+ for animations to finish before interacting.
+
+
+
+
state communication
+
+ Motion indicates something changed: a panel opened, an item was
+ selected, data updated. Never animate just for visual interest.
+
+
+
+
linear for data
+
+ Data updates (counters, progress bars, charts) use linear easing.
+ This feels more precise and mechanical.
+
+
+
+
respect preferences
+
+ Honor prefers-reduced-motion. All animations disable when the
+ user has requested reduced motion.
+
+
+
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ fast (150ms)
+
+
+ base (200ms)
+
+
+ slow (300ms)
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionPatternFeedback.tsx b/apps/web/src/components/style-guide/SectionPatternFeedback.tsx
new file mode 100644
index 0000000..ff4c580
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionPatternFeedback.tsx
@@ -0,0 +1,253 @@
+'use client';
+
+import { Badge } from '@tpmjs/ui/Badge/Badge';
+import { Button } from '@tpmjs/ui/Button/Button';
+import { Icon } from '@tpmjs/ui/Icon/Icon';
+import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar';
+import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
+import { FieldsetSection, SubSection } from './shared';
+
+export function SectionPatternFeedback(): React.ReactElement {
+ return (
+
+
+ Feedback patterns communicate status, progress, and system responses.
+ Choose the right pattern based on context and urgency.
+
+
+
+
+
+
+
Q1
+
+
Is this a page-level system message?
+
+ Yes → Use Banner (maintenance, outage, announcement)
+
+
+
+
+
Q2
+
+
Is it a response to a user action?
+
+ Yes → Use Toast (save, submit, delete confirmation)
+
+
+
+
+
Q3
+
+
Is it contextual to a specific element?
+
+ Yes → Use Inline Alert (form errors, field hints)
+
+
+
+
+
+
+
+
+
+ Ephemeral messages that appear briefly and auto-dismiss.
+
+
+ {/* Success toast */}
+
+
+
+
tool published
+
+ @tpmjs/parser v1.0.0 is now live
+
+
+
+
+ {/* Error toast */}
+
+
+
+
publish failed
+
+ Package validation errors. Check your config.
+
+
+
+
+ {/* Warning toast */}
+
+
+
+
api rate limit
+
+ You've used 90% of your monthly quota
+
+
+
+
+ {/* Info toast */}
+
+
+
+
new version available
+
+ Refresh to get the latest features
+
+
+
+
+
+
+
toast guidelines
+
+ • Auto-dismiss after 5 seconds (except errors)
+ • Errors should require manual dismissal
+ • Stack from bottom-right, newest at bottom
+ • Maximum 3 visible toasts at once
+
+
+
+
+
+
+ Persistent messages within content flow.
+
+
+
+
+
+
verification complete
+
+ Your email has been verified. You can now publish tools.
+
+
+
+
+
+
+
+
deprecated package
+
+ This package is deprecated. Consider migrating to @tpmjs/v2.
+
+
+
+
+
+
+
+
critical security issue
+
+ This version has known vulnerabilities. Update immediately.
+
+
update now
+
+
+
+
+
+
+
+ System-wide announcements that span the full width.
+
+
+ {/* Maintenance banner */}
+
+
+
+
+ Scheduled maintenance: Jan 15, 2-4am UTC. Some services may be unavailable.
+
+
+
+
+
+
+
+ {/* Outage banner */}
+
+
+
+
+ Service degradation detected. Some API calls may fail. We are investigating.
+
+
+
status page
+
+
+ {/* Announcement banner */}
+
+
+
+
+ New: AI-powered code review is now available for all tools!
+
+
+
learn more
+
+
+
+
+
+
+ For long-running operations, show determinate progress when possible.
+
+
+ {/* Determinate progress */}
+
+
determinate progress
+
+ Use when you can calculate progress (file upload, batch processing).
+
+
+
+ uploading package.tgz
+ 67%
+
+
+
+
+
+ {/* Indeterminate progress */}
+
+
indeterminate progress
+
+ Use when duration is unknown (API calls, processing).
+
+
+
+
+ validating package...
+
+
+
+
+
+
+
+
+ When operations fail, provide clear retry options.
+
+
+
+
+
+
+
+
failed to load tools
+
+ We couldn't connect to the server. This could be a network issue or the service may be temporarily unavailable.
+
+
+ retry
+ view details
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionPatternForms.tsx b/apps/web/src/components/style-guide/SectionPatternForms.tsx
new file mode 100644
index 0000000..9809d8c
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionPatternForms.tsx
@@ -0,0 +1,231 @@
+'use client';
+
+import { Button } from '@tpmjs/ui/Button/Button';
+import { FormField } from '@tpmjs/ui/FormField/FormField';
+import { Icon } from '@tpmjs/ui/Icon/Icon';
+import { Input } from '@tpmjs/ui/Input/Input';
+import { Label } from '@tpmjs/ui/Label/Label';
+import { Select } from '@tpmjs/ui/Select/Select';
+import { Textarea } from '@tpmjs/ui/Textarea/Textarea';
+import { DoDontCard, FieldsetSection, SubSection } from './shared';
+
+export function SectionPatternForms(): React.ReactElement {
+ return (
+
+
+ Consistent form patterns for data entry, validation, and submission.
+ Forms should guide users through tasks with clear feedback.
+
+
+
+
+ Choose validation timing based on the field type and user expectations.
+
+
+
+
validate on blur
+
+ Best for: format validation, required fields
+
+
+
+
+
+
+
validate on submit
+
+ Best for: async validation, complex rules
+
+
+
+
+
+
+
+
+
+
+ For forms with multiple errors, show a summary at the top.
+
+
+ {/* Error summary */}
+
+
+
+ please fix 3 errors
+
+
+
+
+ {/* Form fields */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
api key
+
+ You can find this in your dashboard settings
+
+
+
+
+
+
+
+
+
+
+
+ Rule: Mark the minority. If most fields are required, mark optional fields.
+ If most are optional, mark required fields.
+
+
+
+
+
+
+ Group related fields with visual hierarchy.
+
+
+
+ {/* Group 1 */}
+
+
+ {/* Group 2 */}
+
+
+ {/* Group 3 */}
+
+
+ repository
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Destructive actions require explicit confirmation.
+
+
+
+
+
danger zone
+
+
+
+
delete this tool
+
+ Once deleted, this tool cannot be recovered.
+
+
+
delete tool
+
+
+
+
transfer ownership
+
+ Transfer this tool to another user or organization.
+
+
+
transfer
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionPatternNavigation.tsx b/apps/web/src/components/style-guide/SectionPatternNavigation.tsx
new file mode 100644
index 0000000..6827ff8
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionPatternNavigation.tsx
@@ -0,0 +1,222 @@
+'use client';
+
+import { Badge } from '@tpmjs/ui/Badge/Badge';
+import { Breadcrumbs, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage } from '@tpmjs/ui/Breadcrumbs/Breadcrumbs';
+import { Button } from '@tpmjs/ui/Button/Button';
+import { Icon } from '@tpmjs/ui/Icon/Icon';
+import { Tabs } from '@tpmjs/ui/Tabs/Tabs';
+import { useState } from 'react';
+import { FieldsetSection, SubSection } from './shared';
+
+export function SectionPatternNavigation(): React.ReactElement {
+ const [activeTab, setActiveTab] = useState('overview');
+
+ return (
+
+
+ Navigation patterns for consistent wayfinding across the platform. Each pattern
+ has specific use cases and accessibility requirements.
+
+
+
+
+ The primary header navigation appears on every page and provides access to main sections.
+
+
+ {/* Mock header */}
+
+
+
+ sign in
+ get started
+
+
+
+
+
guidelines
+
+ • Maximum 5-6 top-level items to avoid cognitive overload
+ • Use visible active states (underline or background)
+ • Logo always links to home
+ • Auth actions stay in top-right corner
+
+
+
+
+
+
+ Vertical sidebar for section-level navigation within the app.
+
+
+ {/* Expanded sidebar */}
+
+
+ {/* Collapsed sidebar */}
+
+
collapsed state
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
keyboard shortcuts
+
+
[ collapse/expand sidebar
+
g then h go to home
+
g then t go to tools
+
g then s go to settings
+
+
+
+
+
+
+ Show hierarchical location and enable navigation back to parent pages.
+
+
+ {/* Simple breadcrumb */}
+
+
default
+
+
+ home
+
+
+
+ tools
+
+
+
+ @tpmjs/parser
+
+
+
+
+ {/* With icons */}
+
+
with icons
+
+
+
+
+ home
+
+
+
+
+
+
+ projects
+
+
+
+
+ my-tool
+
+
+
+
+
+
+
+
+ For switching between views within a page context.
+
+
+
+
+
+
+
+
keyboard behavior
+
+ • ← / → navigate between tabs
+ • Home focus first tab
+ • End focus last tab
+ • Enter / Space activate focused tab
+
+
+
+
+
+
+
+ Hamburger menu pattern for narrow viewports.
+
+
+
+ {/* Mock mobile header */}
+
+ tpmjs
+
+
+
+
+ {/* Expanded menu preview */}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionPatternSearch.tsx b/apps/web/src/components/style-guide/SectionPatternSearch.tsx
new file mode 100644
index 0000000..29bab71
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionPatternSearch.tsx
@@ -0,0 +1,288 @@
+'use client';
+
+import { Badge } from '@tpmjs/ui/Badge/Badge';
+import { Button } from '@tpmjs/ui/Button/Button';
+import { Icon } from '@tpmjs/ui/Icon/Icon';
+import { Input } from '@tpmjs/ui/Input/Input';
+import { Select } from '@tpmjs/ui/Select/Select';
+import { FieldsetSection, SubSection } from './shared';
+
+export function SectionPatternSearch(): React.ReactElement {
+ return (
+
+
+ Search and filter patterns help users find content quickly.
+ Design for progressive disclosure and instant feedback.
+
+
+
+
+ Search boxes should show clear states and provide helpful feedback.
+
+
+ {/* Default state */}
+
+
+ {/* Active state */}
+
+
active / has value
+
+
+
+
+
+
+
+
+
+ {/* Loading state */}
+
+
+ {/* No results state */}
+
+
no results
+
+
+
+
+
+
+
+
No results found
+
+
+
+
+
+
+ Use chips to show active filters with easy removal.
+
+
+
+
+ category: utility
+
+
+
+
+
+ status: active
+
+
+
+
+
+ downloads: >10k
+
+
+
+
+ clear all
+
+
+ showing 42 of 128 tools
+
+
+
+
+
+
+ For complex filtering, use a dedicated panel with grouped options.
+
+
+ {/* Filter panel */}
+
+
filters
+
+
+ category
+
+
+
+ status
+
+
+
+ min downloads
+
+
+
+ apply filters
+
+
+
+
+ {/* Results preview */}
+
+
results area
+
+ {['@tpmjs/parser', '@tpmjs/validator', '@tpmjs/transform'].map((name) => (
+
+ {name}
+ active
+
+ ))}
+
+
+
+
+
+
+
+ Allow users to save and recall filter combinations.
+
+
+
+
+ saved views
+ 3
+
+
+
+ save current
+
+
+
+
+ my active tools
+
+ 42 results
+
+
+
+
+ deprecated packages
+ 8 results
+
+
+ high-download tools
+ 15 results
+
+
+
+
+
+
+
+ For power users, display the underlying query syntax.
+
+
+
+ query
+
+
+ copy
+
+
+
+ category:
+ utility
+ AND
+ status:
+ active
+ AND
+ downloads:
+ >10000
+
+
+ Tip: Use this syntax directly in the search box for quick filtering.
+
+
+
+
+
+
+ Quick access to search and navigation via keyboard shortcut.
+
+
+
+ {/* Search input */}
+
+
+ {/* Results groups */}
+
+ {/* Recent */}
+
+
recent
+
+
+ @tpmjs/parser
+
+
+
+ {/* Actions */}
+
+
actions
+
+
+
+ create new tool
+
+ ⌘N
+
+
+
+
+ go to dashboard
+
+ ⌘D
+
+
+
+
+ go to settings
+
+ ⌘,
+
+
+
+
+ {/* Footer */}
+
+
+ ↑↓ navigate
+ ↵ select
+ esc close
+
+
+
+
+
+ ⌘K
+ to open command palette
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionPatternTables.tsx b/apps/web/src/components/style-guide/SectionPatternTables.tsx
new file mode 100644
index 0000000..5274813
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionPatternTables.tsx
@@ -0,0 +1,253 @@
+'use client';
+
+import { Badge } from '@tpmjs/ui/Badge/Badge';
+import { Button } from '@tpmjs/ui/Button/Button';
+import { Checkbox } from '@tpmjs/ui/Checkbox/Checkbox';
+import { Icon } from '@tpmjs/ui/Icon/Icon';
+import { Pagination } from '@tpmjs/ui/Pagination/Pagination';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@tpmjs/ui/Table/Table';
+import { useState } from 'react';
+import { FieldsetSection, SubSection } from './shared';
+
+export function SectionPatternTables(): React.ReactElement {
+ const [page, setPage] = useState(1);
+ const [selectedRows, setSelectedRows] = useState([]);
+
+ const mockData = [
+ { id: '1', name: '@tpmjs/parser', category: 'utility', downloads: 125432, status: 'active', score: 0.92 },
+ { id: '2', name: '@tpmjs/validator', category: 'validation', downloads: 89231, status: 'active', score: 0.87 },
+ { id: '3', name: '@tpmjs/transform', category: 'data', downloads: 45678, status: 'beta', score: 0.81 },
+ { id: '4', name: '@tpmjs/executor', category: 'runtime', downloads: 34521, status: 'active', score: 0.78 },
+ { id: '5', name: '@tpmjs/config', category: 'utility', downloads: 23456, status: 'deprecated', score: 0.65 },
+ ];
+
+ const toggleRow = (id: string) => {
+ setSelectedRows(prev =>
+ prev.includes(id) ? prev.filter(r => r !== id) : [...prev, id]
+ );
+ };
+
+ const toggleAll = () => {
+ if (selectedRows.length === mockData.length) {
+ setSelectedRows([]);
+ } else {
+ setSelectedRows(mockData.map(d => d.id));
+ }
+ };
+
+ return (
+
+
+ Data table patterns for displaying, sorting, filtering, and acting on tabular data.
+
+
+
+
+ Click column headers to sort. Show sort direction with icons.
+
+
+
+
+
+
+
+ name
+
+
+
+ category
+
+
+ downloads
+
+
+
+ score
+
+
+
+ {mockData.slice(0, 3).map((row) => (
+
+ {row.name}
+ {row.category}
+ {row.downloads.toLocaleString()}
+ {row.score}
+
+ ))}
+
+
+
+
+
+
+
+ Select rows to enable bulk actions. Show action bar when rows are selected.
+
+
+ {/* Bulk action bar */}
+ {selectedRows.length > 0 && (
+
+
+ {selectedRows.length} item{selectedRows.length !== 1 ? 's' : ''} selected
+
+
+ export
+ delete
+
+
+ )}
+
+
+
+
+
+
+ name
+ category
+ status
+ downloads
+
+
+
+ {mockData.map((row) => (
+
+
+ toggleRow(row.id)}
+ aria-label={`Select ${row.name}`}
+ />
+
+ {row.name}
+ {row.category}
+
+
+ {row.status}
+
+
+ {row.downloads.toLocaleString()}
+
+ ))}
+
+
+
+
+
+
+
+ Use pagination for large datasets. Show page info and navigation.
+
+
+ {/* Full pagination */}
+
+
+ {/* Simple pagination */}
+
+
+ {/* Minimal pagination */}
+
+
+
+
+
+
+ {/* Empty state */}
+
+
+
+
no tools found
+
+ Try adjusting your filters or create your first tool.
+
+
create tool
+
+
+
+ {/* Loading state */}
+
+
+
+
+ name
+ category
+ downloads
+
+
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ Tables respect the page density mode for compact views.
+
+
+
row heights by density
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionPrinciples.tsx b/apps/web/src/components/style-guide/SectionPrinciples.tsx
new file mode 100644
index 0000000..c10ceb0
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionPrinciples.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import { FieldsetSection, PrincipleCard, SubSection } from './shared';
+
+export function SectionPrinciples(): React.ReactElement {
+ return (
+
+
+ These principles guide every design decision in TPMJS. They ensure consistency
+ and prevent drift as the system grows.
+
+
+
+
+
+
+
+ TPMJS is a developer platform for AI tools. The design reflects
+ this through industrial aesthetics: sharp edges, technical typography, and
+ a muted palette with copper as the signal color.
+
+
+ Unlike consumer products that aim for delight, TPMJS aims for
+ efficiency and trust . Users should feel confident that the
+ interface will behave predictably and help them accomplish tasks quickly.
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionSpacing.tsx b/apps/web/src/components/style-guide/SectionSpacing.tsx
new file mode 100644
index 0000000..3e9dce3
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionSpacing.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { FieldsetSection, SubSection, TokenRow } from './shared';
+
+export function SectionSpacing(): React.ReactElement {
+ return (
+
+
+ Consistent spacing creates visual rhythm. Use the 4px base unit
+ and generous whitespace for clarity.
+
+
+
+
+ {[1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24, 32, 40, 48, 64].map((space) => (
+
+
{space}
+
+
{space * 4}px
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionTheming.tsx b/apps/web/src/components/style-guide/SectionTheming.tsx
new file mode 100644
index 0000000..f5f76d0
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionTheming.tsx
@@ -0,0 +1,95 @@
+'use client';
+
+import { Badge } from '@tpmjs/ui/Badge/Badge';
+import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
+import { FieldsetSection, SubSection } from './shared';
+
+export function SectionTheming(): React.ReactElement {
+ return (
+
+
+ Theme architecture and customization guidelines for consistent extension.
+
+
+
+
+
+
+ core
+
+ Raw values: HSL colors, pixel sizes, timing functions
+
+
+
+ semantic
+
+ Named tokens: --primary, --background, --motion-fast
+
+
+
+ component
+
+ Component-specific: button-bg, input-border, card-shadow
+
+
+
+
+
+
+
+
+
+
+ Dark mode is opt-in via the .dark class
+ on the root element. All semantic tokens have dark mode variants.
+
+
+
+
+
+
+
for sdk users embedding tpmjs ui
+
+ • Import the CSS variables from @tpmjs/ui/styles
+ • Override semantic tokens in your own CSS to match your brand
+ • Do not override core tokens (raw values)
+ • Test both light and dark modes if supporting theme switching
+ • Use data-density attribute for density control
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/SectionTypography.tsx b/apps/web/src/components/style-guide/SectionTypography.tsx
new file mode 100644
index 0000000..90d88c0
--- /dev/null
+++ b/apps/web/src/components/style-guide/SectionTypography.tsx
@@ -0,0 +1,104 @@
+'use client';
+
+import { FieldsetSection, SubSection, TokenRow } from './shared';
+
+export function SectionTypography(): React.ReactElement {
+ return (
+
+
+ Two font families create clear hierarchy: monospace for headings and technical
+ content, sans-serif for body text and descriptions.
+
+
+
+
+
+
JetBrains Mono
+
--font-mono
+
Used for: headings, code, data, labels, technical content
+
+
+
Inter
+
--font-sans
+
Used for: body text, descriptions, long-form content
+
+
+
+
+
+
+ Aa} />
+ Aa} />
+ Aa} />
+ Aa} />
+ Aa} />
+ Aa} />
+ Aa} />
+ Aa} />
+ Aa} />
+
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
+
+
+
+
+
+
+ This paragraph is constrained to 65 characters per line, the optimal width
+ for reading comprehension. Lines that are too long cause eye fatigue, while
+ lines that are too short disrupt reading rhythm.
+
+
+
+
+
+
+
+
monospace (JetBrains Mono)
+
+ • Page titles and section headings
+ • Code snippets and technical content
+ • Data values, metrics, timestamps
+ • Form labels and button text
+ • Table headers and numeric columns
+
+
+
+
sans-serif (Inter)
+
+ • Body paragraphs and descriptions
+ • Help text and instructions
+ • Error messages and notifications
+ • Marketing and explanatory content
+ • Long-form documentation
+
+
+
+
+
+
+
+
heading 1 (48px)
+ heading 2 (40px)
+ heading 3 (32px)
+ heading 4 (24px)
+ heading 5 (20px)
+ heading 6 (18px)
+
+
+
+ );
+}
diff --git a/apps/web/src/components/style-guide/index.ts b/apps/web/src/components/style-guide/index.ts
new file mode 100644
index 0000000..4d2bc45
--- /dev/null
+++ b/apps/web/src/components/style-guide/index.ts
@@ -0,0 +1,40 @@
+// Shared helpers
+export {
+ FieldsetSection,
+ SubSection,
+ ColorCard,
+ DoDontCard,
+ PrincipleCard,
+ TokenRow,
+ NavItem,
+} from './shared';
+
+// Foundation sections
+export { SectionPrinciples } from './SectionPrinciples';
+export { SectionColors } from './SectionColors';
+export { SectionTypography } from './SectionTypography';
+export { SectionSpacing } from './SectionSpacing';
+export { SectionMotion } from './SectionMotion';
+
+// Systems sections
+export { SectionAccessibility } from './SectionAccessibility';
+export { SectionLayout } from './SectionLayout';
+export { SectionContent } from './SectionContent';
+export { SectionDataViz } from './SectionDataViz';
+export { SectionIcons } from './SectionIcons';
+
+// Implementation sections
+export { SectionTheming } from './SectionTheming';
+export { SectionComponents } from './SectionComponents';
+export { SectionComponentAPIs } from './SectionComponentAPIs';
+
+// Pattern library sections
+export { SectionPatternNavigation } from './SectionPatternNavigation';
+export { SectionPatternForms } from './SectionPatternForms';
+export { SectionPatternFeedback } from './SectionPatternFeedback';
+export { SectionPatternTables } from './SectionPatternTables';
+export { SectionPatternSearch } from './SectionPatternSearch';
+
+// Additional sections
+export { SectionA11yChecklists } from './SectionA11yChecklists';
+export { SectionContentGuidelines, SectionIconSystem } from './SectionContentGuidelines';
diff --git a/apps/web/src/components/style-guide/shared.tsx b/apps/web/src/components/style-guide/shared.tsx
new file mode 100644
index 0000000..3645b73
--- /dev/null
+++ b/apps/web/src/components/style-guide/shared.tsx
@@ -0,0 +1,169 @@
+'use client';
+
+import { Icon } from '@tpmjs/ui/Icon/Icon';
+
+/**
+ * Fieldset-style container with legend label
+ */
+export function FieldsetSection({
+ title,
+ children,
+ id,
+}: {
+ title: string;
+ children: React.ReactNode;
+ id?: string;
+}): React.ReactElement {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+/**
+ * Sub-section with title
+ */
+export function SubSection({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}): React.ReactElement {
+ return (
+
+
{title}
+ {children}
+
+ );
+}
+
+/**
+ * Color swatch card
+ */
+export function ColorCard({
+ name,
+ color,
+ hex,
+ desc,
+ textLight = false,
+}: {
+ name: string;
+ color: string;
+ hex: string;
+ desc: string;
+ textLight?: boolean;
+}): React.ReactElement {
+ return (
+
+
+ {name}
+
+
+ {hex}
+
+
+ {desc}
+
+
+ );
+}
+
+/**
+ * Do/Don't example card
+ */
+export function DoDontCard({
+ type,
+ title,
+ children,
+}: {
+ type: 'do' | 'dont';
+ title: string;
+ children: React.ReactNode;
+}): React.ReactElement {
+ const isDo = type === 'do';
+ return (
+
+
+
+ {isDo ? 'do' : "don't"}
+
+
{title}
+
+ {children}
+
+
+ );
+}
+
+/**
+ * Principle card with icon
+ */
+export function PrincipleCard({
+ icon,
+ title,
+ description,
+}: {
+ icon: string;
+ title: string;
+ description: string;
+}): React.ReactElement {
+ return (
+
+
+
+ [0]['icon']} size="md" className="text-accent" />
+
+
{title}
+
+
{description}
+
+ );
+}
+
+/**
+ * Token display row
+ */
+export function TokenRow({
+ name,
+ value,
+ preview,
+}: {
+ name: string;
+ value: string;
+ preview?: React.ReactNode;
+}): React.ReactElement {
+ return (
+
+
{name}
+
{value}
+ {preview &&
{preview}
}
+
+ );
+}
+
+/**
+ * Navigation item for table of contents
+ */
+export function NavItem({
+ href,
+ children,
+ indent = false,
+}: {
+ href: string;
+ children: React.ReactNode;
+ indent?: boolean;
+}): React.ReactElement {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts
index 9e9217f..e5310e6 100644
--- a/apps/web/tailwind.config.ts
+++ b/apps/web/tailwind.config.ts
@@ -8,5 +8,22 @@ export default {
'./src/components/**/*.{ts,tsx}',
'../../packages/ui/src/**/*.ts',
],
+ safelist: [
+ ...(baseConfig.safelist || []),
+ // Extended color utilities for style guide
+ 'bg-accent-strong',
+ 'bg-accent-muted',
+ 'bg-surface-2',
+ 'bg-surface-3',
+ 'bg-success-light',
+ 'bg-warning-light',
+ 'bg-error-light',
+ 'bg-info-light',
+ 'text-foreground-secondary',
+ 'text-foreground-tertiary',
+ 'text-foreground-muted',
+ 'border-border-strong',
+ 'border-border-subtle',
+ ],
plugins: [...(baseConfig.plugins || []), require('@tailwindcss/typography')],
} satisfies Config;
diff --git a/packages/config/tailwind/base.ts b/packages/config/tailwind/base.ts
index 05e2913..26c41c4 100644
--- a/packages/config/tailwind/base.ts
+++ b/packages/config/tailwind/base.ts
@@ -18,18 +18,40 @@ export default {
'dotted-grid-background',
'blueprint-background',
'grid-background',
+ // Extended color utilities for style guide
+ 'bg-accent-strong',
+ 'bg-accent-muted',
+ 'bg-surface-2',
+ 'bg-surface-3',
+ 'bg-success-light',
+ 'bg-warning-light',
+ 'bg-error-light',
+ 'bg-info-light',
+ 'text-foreground-secondary',
+ 'text-foreground-tertiary',
+ 'text-foreground-muted',
+ 'border-border-strong',
+ 'border-border-subtle',
],
theme: {
extend: {
colors: {
- // Backgrounds & Surfaces
+ // Backgrounds & Surfaces - Layered system
background: 'hsl(var(--background))',
- surface: 'hsl(var(--surface))',
+ surface: {
+ DEFAULT: 'hsl(var(--surface))',
+ 2: 'hsl(var(--surface-2))',
+ 3: 'hsl(var(--surface-3))',
+ secondary: 'hsl(var(--surface-secondary))',
+ elevated: 'hsl(var(--surface-elevated))',
+ overlay: 'hsl(var(--surface-overlay))',
+ },
+ // Legacy flat aliases
'surface-secondary': 'hsl(var(--surface-secondary))',
'surface-elevated': 'hsl(var(--surface-elevated))',
'surface-overlay': 'hsl(var(--surface-overlay))',
- // Foreground (Text)
+ // Foreground (Text) - Clear hierarchy
foreground: {
DEFAULT: 'hsl(var(--foreground))',
secondary: 'hsl(var(--foreground-secondary))',
@@ -37,57 +59,75 @@ export default {
muted: 'hsl(var(--foreground-muted))',
},
- // Borders
+ // Borders - Multiple weights
border: {
DEFAULT: 'hsl(var(--border))',
strong: 'hsl(var(--border-strong))',
subtle: 'hsl(var(--border-subtle))',
},
- // Interactive States
+ // Primary - With hover/active states
primary: {
DEFAULT: 'hsl(var(--primary))',
+ hover: 'hsl(var(--primary-hover))',
+ active: 'hsl(var(--primary-active))',
foreground: 'hsl(var(--primary-foreground))',
},
+
+ // Secondary - With hover state
secondary: {
DEFAULT: 'hsl(var(--secondary))',
+ hover: 'hsl(var(--secondary-hover))',
foreground: 'hsl(var(--secondary-foreground))',
},
+
+ // Accent - Copper variations
accent: {
DEFAULT: 'hsl(var(--accent))',
+ strong: 'hsl(var(--accent-strong))',
+ muted: 'hsl(var(--accent-muted))',
foreground: 'hsl(var(--accent-foreground))',
},
+
+ // Muted
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
- // Status Colors
+ // Status Colors - With light variants
success: {
DEFAULT: 'hsl(var(--success))',
+ light: 'hsl(var(--success-light))',
foreground: 'hsl(var(--success-foreground))',
},
error: {
DEFAULT: 'hsl(var(--error))',
+ light: 'hsl(var(--error-light))',
foreground: 'hsl(var(--error-foreground))',
},
warning: {
DEFAULT: 'hsl(var(--warning))',
+ light: 'hsl(var(--warning-light))',
foreground: 'hsl(var(--warning-foreground))',
},
info: {
DEFAULT: 'hsl(var(--info))',
+ light: 'hsl(var(--info-light))',
foreground: 'hsl(var(--info-foreground))',
},
- // Destructive (legacy support)
+ // Destructive (alias for error)
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
// Form Elements
- input: 'hsl(var(--input))',
+ input: {
+ DEFAULT: 'hsl(var(--input))',
+ focus: 'hsl(var(--input-focus))',
+ },
ring: 'hsl(var(--ring))',
// Card
diff --git a/packages/ui/package.json b/packages/ui/package.json
index a8b22c7..51fcf29 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -166,6 +166,58 @@
"./Table/Table": {
"types": "./dist/Table/Table.d.ts",
"default": "./dist/Table/Table.js"
+ },
+ "./Modal/Modal": {
+ "types": "./dist/Modal/Modal.d.ts",
+ "default": "./dist/Modal/Modal.js"
+ },
+ "./Toast/Toast": {
+ "types": "./dist/Toast/Toast.d.ts",
+ "default": "./dist/Toast/Toast.js"
+ },
+ "./Drawer/Drawer": {
+ "types": "./dist/Drawer/Drawer.d.ts",
+ "default": "./dist/Drawer/Drawer.js"
+ },
+ "./Popover/Popover": {
+ "types": "./dist/Popover/Popover.d.ts",
+ "default": "./dist/Popover/Popover.js"
+ },
+ "./Tooltip/Tooltip": {
+ "types": "./dist/Tooltip/Tooltip.d.ts",
+ "default": "./dist/Tooltip/Tooltip.js"
+ },
+ "./DropdownMenu/DropdownMenu": {
+ "types": "./dist/DropdownMenu/DropdownMenu.d.ts",
+ "default": "./dist/DropdownMenu/DropdownMenu.js"
+ },
+ "./Breadcrumbs/Breadcrumbs": {
+ "types": "./dist/Breadcrumbs/Breadcrumbs.d.ts",
+ "default": "./dist/Breadcrumbs/Breadcrumbs.js"
+ },
+ "./Pagination/Pagination": {
+ "types": "./dist/Pagination/Pagination.d.ts",
+ "default": "./dist/Pagination/Pagination.js"
+ },
+ "./Accordion/Accordion": {
+ "types": "./dist/Accordion/Accordion.d.ts",
+ "default": "./dist/Accordion/Accordion.js"
+ },
+ "./Skeleton/Skeleton": {
+ "types": "./dist/Skeleton/Skeleton.d.ts",
+ "default": "./dist/Skeleton/Skeleton.js"
+ },
+ "./InstallSnippet/InstallSnippet": {
+ "types": "./dist/InstallSnippet/InstallSnippet.d.ts",
+ "default": "./dist/InstallSnippet/InstallSnippet.js"
+ },
+ "./QualityScore/QualityScore": {
+ "types": "./dist/QualityScore/QualityScore.d.ts",
+ "default": "./dist/QualityScore/QualityScore.js"
+ },
+ "./ToolCard/ToolCard": {
+ "types": "./dist/ToolCard/ToolCard.d.ts",
+ "default": "./dist/ToolCard/ToolCard.js"
}
},
"files": [
diff --git a/packages/ui/src/Accordion/Accordion.tsx b/packages/ui/src/Accordion/Accordion.tsx
new file mode 100644
index 0000000..5ad5f22
--- /dev/null
+++ b/packages/ui/src/Accordion/Accordion.tsx
@@ -0,0 +1,280 @@
+'use client';
+
+import { cn } from '@tpmjs/utils/cn';
+import {
+ createContext,
+ forwardRef,
+ useCallback,
+ useContext,
+ useId,
+ useMemo,
+ useState,
+} from 'react';
+import { Icon } from '../Icon/Icon';
+import type {
+ AccordionContentProps,
+ AccordionContextValue,
+ AccordionItemContextValue,
+ AccordionItemProps,
+ AccordionProps,
+ AccordionTriggerProps,
+} from './types';
+import {
+ accordionContentInnerVariants,
+ accordionContentVariants,
+ accordionItemVariants,
+ accordionTriggerIconVariants,
+ accordionTriggerVariants,
+ accordionVariants,
+} from './variants';
+
+// Accordion context
+const AccordionContext = createContext(null);
+
+// Accordion item context
+const AccordionItemContext = createContext(null);
+
+/**
+ * Accordion component
+ *
+ * A vertically stacked set of interactive headings that reveal content.
+ *
+ * @example
+ * ```tsx
+ * import {
+ * Accordion,
+ * AccordionItem,
+ * AccordionTrigger,
+ * AccordionContent,
+ * } from '@tpmjs/ui/Accordion/Accordion';
+ *
+ * function MyComponent() {
+ * return (
+ *
+ *
+ * Section 1
+ * Content for section 1
+ *
+ *
+ * Section 2
+ * Content for section 2
+ *
+ *
+ * );
+ * }
+ * ```
+ */
+export const Accordion = forwardRef(
+ (
+ {
+ type = 'single',
+ value: controlledValue,
+ defaultValue,
+ onValueChange,
+ collapsible = true,
+ variant = 'default',
+ children,
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ // Normalize value to array format internally
+ const normalizeValue = (val: string | string[] | undefined): string[] => {
+ if (val === undefined) return [];
+ return Array.isArray(val) ? val : [val];
+ };
+
+ const isControlled = controlledValue !== undefined;
+ const [internalValue, setInternalValue] = useState(
+ normalizeValue(defaultValue)
+ );
+
+ const value = isControlled ? normalizeValue(controlledValue) : internalValue;
+
+ const toggleItem = useCallback(
+ (itemValue: string) => {
+ let newValue: string[];
+
+ if (type === 'single') {
+ if (value.includes(itemValue)) {
+ // If collapsible, allow closing; otherwise keep it open
+ newValue = collapsible ? [] : value;
+ } else {
+ newValue = [itemValue];
+ }
+ } else {
+ // Multiple: toggle the item
+ if (value.includes(itemValue)) {
+ newValue = value.filter((v) => v !== itemValue);
+ } else {
+ newValue = [...value, itemValue];
+ }
+ }
+
+ if (!isControlled) {
+ setInternalValue(newValue);
+ }
+
+ // Emit in the format expected by the type
+ if (type === 'single') {
+ onValueChange?.(newValue[0] || '');
+ } else {
+ onValueChange?.(newValue);
+ }
+ },
+ [type, value, collapsible, isControlled, onValueChange]
+ );
+
+ const contextValue = useMemo(
+ () => ({
+ type,
+ value,
+ toggleItem,
+ variant,
+ }),
+ [type, value, toggleItem, variant]
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+);
+
+Accordion.displayName = 'Accordion';
+
+/**
+ * AccordionItem component
+ */
+export const AccordionItem = forwardRef(
+ ({ value, disabled = false, children, className, ...props }, ref) => {
+ const context = useContext(AccordionContext);
+ if (!context) {
+ throw new Error('AccordionItem must be used within an Accordion');
+ }
+
+ const isExpanded = context.value.includes(value);
+
+ const itemContextValue = useMemo(
+ () => ({
+ value,
+ disabled,
+ isExpanded,
+ }),
+ [value, disabled, isExpanded]
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+);
+
+AccordionItem.displayName = 'AccordionItem';
+
+/**
+ * AccordionTrigger component
+ */
+export const AccordionTrigger = forwardRef(
+ ({ icon, children, className, ...props }, ref) => {
+ const accordionContext = useContext(AccordionContext);
+ const itemContext = useContext(AccordionItemContext);
+
+ if (!accordionContext || !itemContext) {
+ throw new Error('AccordionTrigger must be used within an AccordionItem');
+ }
+
+ const { toggleItem } = accordionContext;
+ const { value, disabled, isExpanded } = itemContext;
+
+ const triggerId = useId();
+ const contentId = useId();
+
+ const handleClick = useCallback(() => {
+ if (!disabled) {
+ toggleItem(value);
+ }
+ }, [disabled, toggleItem, value]);
+
+ const handleKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ handleClick();
+ }
+ },
+ [handleClick]
+ );
+
+ return (
+
+ {children}
+
+ {icon ?? }
+
+
+ );
+ }
+);
+
+AccordionTrigger.displayName = 'AccordionTrigger';
+
+/**
+ * AccordionContent component
+ */
+export const AccordionContent = forwardRef(
+ ({ children, className, ...props }, ref) => {
+ const itemContext = useContext(AccordionItemContext);
+
+ if (!itemContext) {
+ throw new Error('AccordionContent must be used within an AccordionItem');
+ }
+
+ const { isExpanded } = itemContext;
+
+ return (
+
+ );
+ }
+);
+
+AccordionContent.displayName = 'AccordionContent';
diff --git a/packages/ui/src/Accordion/types.ts b/packages/ui/src/Accordion/types.ts
new file mode 100644
index 0000000..644ae6d
--- /dev/null
+++ b/packages/ui/src/Accordion/types.ts
@@ -0,0 +1,126 @@
+import type { HTMLAttributes, ReactNode } from 'react';
+
+/**
+ * Accordion type - single or multiple items can be expanded
+ */
+export type AccordionType = 'single' | 'multiple';
+
+/**
+ * Accordion variant types
+ */
+export type AccordionVariant = 'default' | 'bordered' | 'separated';
+
+/**
+ * Accordion component props
+ */
+export interface AccordionProps extends HTMLAttributes {
+ /**
+ * Whether single or multiple items can be expanded
+ * @default 'single'
+ */
+ type?: AccordionType;
+
+ /**
+ * Value of expanded item(s) - controlled mode
+ * For single: string | undefined
+ * For multiple: string[]
+ */
+ value?: string | string[];
+
+ /**
+ * Default expanded value(s) - uncontrolled mode
+ */
+ defaultValue?: string | string[];
+
+ /**
+ * Callback when expanded state changes
+ */
+ onValueChange?: (value: string | string[]) => void;
+
+ /**
+ * Whether to collapse others when opening an item (only for type="single")
+ * @default true
+ */
+ collapsible?: boolean;
+
+ /**
+ * Visual variant
+ * @default 'default'
+ */
+ variant?: AccordionVariant;
+
+ /**
+ * Accordion items
+ */
+ children: ReactNode;
+}
+
+/**
+ * AccordionItem component props
+ */
+export interface AccordionItemProps extends HTMLAttributes {
+ /**
+ * Unique value for this item
+ */
+ value: string;
+
+ /**
+ * Whether this item is disabled
+ * @default false
+ */
+ disabled?: boolean;
+
+ /**
+ * Item content (trigger + panel)
+ */
+ children: ReactNode;
+}
+
+/**
+ * AccordionTrigger component props
+ */
+export interface AccordionTriggerProps extends HTMLAttributes {
+ /**
+ * Icon to display (defaults to chevron)
+ */
+ icon?: ReactNode;
+
+ /**
+ * Trigger content
+ */
+ children: ReactNode;
+}
+
+/**
+ * AccordionContent component props
+ */
+export interface AccordionContentProps extends HTMLAttributes {
+ /**
+ * Content to display when expanded
+ */
+ children: ReactNode;
+}
+
+/**
+ * Accordion ref type
+ */
+export type AccordionRef = HTMLDivElement;
+
+/**
+ * Internal accordion context
+ */
+export interface AccordionContextValue {
+ type: AccordionType;
+ value: string[];
+ toggleItem: (itemValue: string) => void;
+ variant: AccordionVariant;
+}
+
+/**
+ * Internal accordion item context
+ */
+export interface AccordionItemContextValue {
+ value: string;
+ disabled: boolean;
+ isExpanded: boolean;
+}
diff --git a/packages/ui/src/Accordion/variants.ts b/packages/ui/src/Accordion/variants.ts
new file mode 100644
index 0000000..c5d43c0
--- /dev/null
+++ b/packages/ui/src/Accordion/variants.ts
@@ -0,0 +1,136 @@
+import { createVariants } from '../system/variants';
+
+/**
+ * Accordion container variant definitions
+ */
+export const accordionVariants = createVariants({
+ base: [
+ 'w-full',
+ ].join(' '),
+
+ variants: {
+ variant: {
+ default: '',
+ bordered: 'border border-border rounded-none',
+ separated: 'space-y-2',
+ },
+ },
+
+ defaultVariants: {
+ variant: 'default',
+ },
+});
+
+/**
+ * Accordion item variant definitions
+ */
+export const accordionItemVariants = createVariants({
+ base: [
+ 'w-full',
+ ].join(' '),
+
+ variants: {
+ variant: {
+ default: 'border-b border-border last:border-b-0',
+ bordered: 'border-b border-border last:border-b-0',
+ separated: 'border border-border rounded-none',
+ },
+ },
+
+ defaultVariants: {
+ variant: 'default',
+ },
+});
+
+/**
+ * Accordion trigger variant definitions
+ */
+export const accordionTriggerVariants = createVariants({
+ base: [
+ // Layout
+ 'w-full',
+ 'flex items-center justify-between gap-4',
+ 'py-4 px-4',
+ // Typography
+ 'font-mono text-sm font-medium',
+ 'text-foreground',
+ 'text-left',
+ // Interaction
+ 'cursor-pointer',
+ 'transition-colors duration-150',
+ // Focus
+ 'outline-none',
+ 'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',
+ // States
+ 'hover:bg-accent/5',
+ ].join(' '),
+
+ variants: {
+ disabled: {
+ 'true': 'opacity-50 cursor-not-allowed hover:bg-transparent',
+ 'false': '',
+ },
+ },
+
+ defaultVariants: {
+ disabled: 'false',
+ },
+});
+
+/**
+ * Accordion trigger icon variant definitions
+ */
+export const accordionTriggerIconVariants = createVariants({
+ base: [
+ 'flex-shrink-0',
+ 'text-foreground-muted',
+ 'transition-transform duration-200',
+ ].join(' '),
+
+ variants: {
+ expanded: {
+ 'true': 'rotate-180',
+ 'false': 'rotate-0',
+ },
+ },
+
+ defaultVariants: {
+ expanded: 'false',
+ },
+});
+
+/**
+ * Accordion content variant definitions
+ */
+export const accordionContentVariants = createVariants({
+ base: [
+ 'overflow-hidden',
+ 'transition-all duration-200 ease-in-out',
+ ].join(' '),
+
+ variants: {
+ expanded: {
+ 'true': 'max-h-[1000px] opacity-100',
+ 'false': 'max-h-0 opacity-0',
+ },
+ },
+
+ defaultVariants: {
+ expanded: 'false',
+ },
+});
+
+/**
+ * Accordion content inner variant definitions
+ */
+export const accordionContentInnerVariants = createVariants({
+ base: [
+ 'px-4 pb-4',
+ 'font-mono text-sm',
+ 'text-foreground-muted',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
diff --git a/packages/ui/src/Badge/variants.ts b/packages/ui/src/Badge/variants.ts
index cebbdb7..0ac81c3 100644
--- a/packages/ui/src/Badge/variants.ts
+++ b/packages/ui/src/Badge/variants.ts
@@ -8,13 +8,13 @@ export const badgeVariants = createVariants({
base: [
// Layout
'inline-flex items-center',
- // Typography
- 'font-semibold',
+ // Typography - Monospace, lowercase
+ 'font-mono font-medium lowercase',
'whitespace-nowrap',
- // Borders & Radius
- 'rounded-full',
+ // Borders & Radius - SHARP CORNERS
+ 'rounded-none',
// Transitions
- 'transition-base',
+ 'transition-colors duration-150',
].join(' '),
variants: {
diff --git a/packages/ui/src/Breadcrumbs/Breadcrumbs.tsx b/packages/ui/src/Breadcrumbs/Breadcrumbs.tsx
new file mode 100644
index 0000000..bdc648b
--- /dev/null
+++ b/packages/ui/src/Breadcrumbs/Breadcrumbs.tsx
@@ -0,0 +1,277 @@
+'use client';
+
+import { cn } from '@tpmjs/utils/cn';
+import {
+ Children,
+ forwardRef,
+ isValidElement,
+ useMemo,
+ useState,
+} from 'react';
+import { Icon, type IconName } from '../Icon/Icon';
+import type {
+ BreadcrumbEllipsisProps,
+ BreadcrumbItemProps,
+ BreadcrumbLinkProps,
+ BreadcrumbSeparator,
+ BreadcrumbSeparatorProps,
+ BreadcrumbsProps,
+} from './types';
+import {
+ breadcrumbEllipsisVariants,
+ breadcrumbIconVariants,
+ breadcrumbItemVariants,
+ breadcrumbLinkVariants,
+ breadcrumbSeparatorVariants,
+ breadcrumbsListVariants,
+ breadcrumbsVariants,
+} from './variants';
+
+/**
+ * Get the separator icon based on type
+ */
+function getSeparatorIcon(separator: BreadcrumbSeparator): IconName {
+ switch (separator) {
+ case 'chevron':
+ return 'chevronRight';
+ case 'arrow':
+ return 'arrowRight';
+ case 'dot':
+ return 'circle';
+ case 'slash':
+ default:
+ return 'slash';
+ }
+}
+
+/**
+ * Breadcrumbs component
+ *
+ * A navigation component that shows the user's location in a hierarchy.
+ *
+ * @example
+ * ```tsx
+ * import { Breadcrumbs, BreadcrumbItem } from '@tpmjs/ui/Breadcrumbs/Breadcrumbs';
+ *
+ * function MyComponent() {
+ * return (
+ *
+ * Home
+ * Tools
+ * Current Tool
+ *
+ * );
+ * }
+ * ```
+ */
+export const Breadcrumbs = forwardRef(
+ (
+ {
+ separator = 'slash',
+ maxItems,
+ itemsBeforeCollapse = 1,
+ itemsAfterCollapse = 1,
+ children,
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ const [expanded, setExpanded] = useState(false);
+
+ const items = useMemo(() => {
+ return Children.toArray(children).filter(isValidElement);
+ }, [children]);
+
+ const shouldCollapse = maxItems && items.length > maxItems && !expanded;
+
+ const visibleItems = useMemo(() => {
+ if (!shouldCollapse) return items;
+
+ const start = items.slice(0, itemsBeforeCollapse);
+ const end = items.slice(-itemsAfterCollapse);
+
+ return [...start, 'ellipsis', ...end];
+ }, [items, shouldCollapse, itemsBeforeCollapse, itemsAfterCollapse]);
+
+ const renderSeparator = (key: string) => {
+ if (typeof separator === 'string') {
+ const iconName = getSeparatorIcon(separator as BreadcrumbSeparator);
+ return (
+
+
+
+ );
+ }
+ return (
+
+ {separator}
+
+ );
+ };
+
+ return (
+
+
+ {visibleItems.map((item, index) => {
+ if (item === 'ellipsis') {
+ return (
+
+ {index > 0 && renderSeparator(`sep-before-ellipsis`)}
+ setExpanded(true)} />
+
+ );
+ }
+
+ return (
+
+ {index > 0 && renderSeparator(`sep-${index}`)}
+ {item}
+
+ );
+ })}
+
+
+ );
+ }
+);
+
+Breadcrumbs.displayName = 'Breadcrumbs';
+
+/**
+ * BreadcrumbItem component
+ */
+export const BreadcrumbItem = forwardRef(
+ ({ current = false, href, icon, children, className, ...props }, ref) => {
+ const content = (
+ <>
+ {icon && {icon} }
+ {children}
+ >
+ );
+
+ if (current) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ if (href) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+ }
+);
+
+BreadcrumbItem.displayName = 'BreadcrumbItem';
+
+/**
+ * BreadcrumbLink component
+ */
+export const BreadcrumbLink = forwardRef(
+ ({ href, children, className, ...props }, ref) => (
+
+ {children}
+
+ )
+);
+
+BreadcrumbLink.displayName = 'BreadcrumbLink';
+
+/**
+ * BreadcrumbSeparator component (internal)
+ */
+const BreadcrumbSeparatorComponent = forwardRef(
+ ({ children, className, ...props }, ref) => (
+
+ {children ?? '/'}
+
+ )
+);
+
+BreadcrumbSeparatorComponent.displayName = 'BreadcrumbSeparator';
+
+// Export as BreadcrumbSeparator
+export { BreadcrumbSeparatorComponent as BreadcrumbSeparator };
+
+/**
+ * BreadcrumbEllipsis component
+ */
+export const BreadcrumbEllipsis = forwardRef(
+ ({ className, ...props }, ref) => (
+
+
+
+ )
+);
+
+BreadcrumbEllipsis.displayName = 'BreadcrumbEllipsis';
+
+/**
+ * BreadcrumbPage component
+ *
+ * Represents the current page in breadcrumbs (non-link, just text)
+ */
+export const BreadcrumbPage = forwardRef>(
+ ({ children, className, ...props }, ref) => (
+
+ {children}
+
+ )
+);
+
+BreadcrumbPage.displayName = 'BreadcrumbPage';
diff --git a/packages/ui/src/Breadcrumbs/types.ts b/packages/ui/src/Breadcrumbs/types.ts
new file mode 100644
index 0000000..9726197
--- /dev/null
+++ b/packages/ui/src/Breadcrumbs/types.ts
@@ -0,0 +1,101 @@
+import type { HTMLAttributes, ReactNode } from 'react';
+
+/**
+ * Breadcrumb separator types
+ */
+export type BreadcrumbSeparator = 'slash' | 'chevron' | 'arrow' | 'dot';
+
+/**
+ * Breadcrumbs component props
+ */
+export interface BreadcrumbsProps extends HTMLAttributes {
+ /**
+ * Separator between items
+ * @default 'slash'
+ */
+ separator?: BreadcrumbSeparator | ReactNode;
+
+ /**
+ * Maximum number of items to show before collapsing
+ * @default undefined (no collapse)
+ */
+ maxItems?: number;
+
+ /**
+ * Number of items to show at start when collapsed
+ * @default 1
+ */
+ itemsBeforeCollapse?: number;
+
+ /**
+ * Number of items to show at end when collapsed
+ * @default 1
+ */
+ itemsAfterCollapse?: number;
+
+ /**
+ * Breadcrumb items
+ */
+ children: ReactNode;
+}
+
+/**
+ * BreadcrumbItem component props
+ */
+export interface BreadcrumbItemProps extends HTMLAttributes {
+ /**
+ * Whether this is the current/active page
+ * @default false
+ */
+ current?: boolean;
+
+ /**
+ * Link href (if not current)
+ */
+ href?: string;
+
+ /**
+ * Icon to display before text
+ */
+ icon?: ReactNode;
+
+ /**
+ * Item content
+ */
+ children: ReactNode;
+}
+
+/**
+ * BreadcrumbLink component props
+ */
+export interface BreadcrumbLinkProps extends HTMLAttributes {
+ /**
+ * Link href
+ */
+ href: string;
+
+ /**
+ * Link content
+ */
+ children: ReactNode;
+}
+
+/**
+ * BreadcrumbSeparator component props
+ */
+export interface BreadcrumbSeparatorProps extends HTMLAttributes {
+ /**
+ * Custom separator content
+ */
+ children?: ReactNode;
+}
+
+/**
+ * BreadcrumbEllipsis component props
+ */
+export interface BreadcrumbEllipsisProps extends HTMLAttributes {}
+
+/**
+ * Breadcrumbs ref type
+ */
+export type BreadcrumbsRef = HTMLElement;
diff --git a/packages/ui/src/Breadcrumbs/variants.ts b/packages/ui/src/Breadcrumbs/variants.ts
new file mode 100644
index 0000000..6784628
--- /dev/null
+++ b/packages/ui/src/Breadcrumbs/variants.ts
@@ -0,0 +1,125 @@
+import { createVariants } from '../system/variants';
+
+/**
+ * Breadcrumbs container variant definitions
+ */
+export const breadcrumbsVariants = createVariants({
+ base: [
+ 'flex items-center',
+ 'font-mono text-sm',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Breadcrumbs list variant definitions
+ */
+export const breadcrumbsListVariants = createVariants({
+ base: [
+ 'flex items-center gap-1',
+ 'flex-wrap',
+ 'list-none',
+ 'm-0 p-0',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Breadcrumb item variant definitions
+ */
+export const breadcrumbItemVariants = createVariants({
+ base: [
+ 'flex items-center gap-1',
+ ].join(' '),
+
+ variants: {
+ current: {
+ 'true': '',
+ 'false': '',
+ },
+ },
+
+ defaultVariants: {
+ current: 'false',
+ },
+});
+
+/**
+ * Breadcrumb link variant definitions
+ */
+export const breadcrumbLinkVariants = createVariants({
+ base: [
+ 'text-foreground-muted',
+ 'hover:text-foreground',
+ 'transition-colors duration-150',
+ 'outline-none',
+ 'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
+ 'lowercase',
+ ].join(' '),
+
+ variants: {
+ current: {
+ 'true': 'text-foreground font-medium pointer-events-none',
+ 'false': 'cursor-pointer',
+ },
+ },
+
+ defaultVariants: {
+ current: 'false',
+ },
+});
+
+/**
+ * Breadcrumb separator variant definitions
+ */
+export const breadcrumbSeparatorVariants = createVariants({
+ base: [
+ 'mx-1',
+ 'text-foreground-muted',
+ 'select-none',
+ 'opacity-60',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Breadcrumb ellipsis variant definitions
+ */
+export const breadcrumbEllipsisVariants = createVariants({
+ base: [
+ 'flex items-center justify-center',
+ 'w-6 h-6',
+ 'text-foreground-muted',
+ 'cursor-pointer',
+ 'hover:text-foreground',
+ 'transition-colors duration-150',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Breadcrumb icon variant definitions
+ */
+export const breadcrumbIconVariants = createVariants({
+ base: [
+ 'flex-shrink-0',
+ 'w-4 h-4',
+ 'mr-1',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
diff --git a/packages/ui/src/Button/variants.ts b/packages/ui/src/Button/variants.ts
index 45d66d9..dbca368 100644
--- a/packages/ui/src/Button/variants.ts
+++ b/packages/ui/src/Button/variants.ts
@@ -8,15 +8,15 @@ 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',
+ // Typography - Monospace for buttons
+ 'font-mono font-medium text-sm',
+ 'whitespace-nowrap lowercase',
+ // Borders & Radius - SHARP CORNERS
+ 'rounded-none',
+ // Transitions - Fast, subtle
+ 'transition-colors duration-150',
+ // Focus - Copper accent
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
// Disabled state
'disabled:pointer-events-none disabled:opacity-50',
].join(' '),
diff --git a/packages/ui/src/Card/types.ts b/packages/ui/src/Card/types.ts
index 5bab59b..27d37d9 100644
--- a/packages/ui/src/Card/types.ts
+++ b/packages/ui/src/Card/types.ts
@@ -8,7 +8,7 @@ export interface CardProps extends HTMLAttributes {
* Visual variant of the card
* @default 'default'
*/
- variant?: 'default' | 'elevated' | 'outline' | 'blueprint' | 'ghost' | 'brutalist';
+ variant?: 'default' | 'elevated' | 'outline' | 'blueprint' | 'ghost' | 'featured' | 'brutalist';
/**
* Padding size for the card
diff --git a/packages/ui/src/Card/variants.ts b/packages/ui/src/Card/variants.ts
index 54a3bae..be5f4db 100644
--- a/packages/ui/src/Card/variants.ts
+++ b/packages/ui/src/Card/variants.ts
@@ -8,37 +8,38 @@ export const cardVariants = createVariants({
base: [
// Layout
'relative',
- // Borders & Radius
- 'rounded-lg',
+ // Borders & Radius - SHARP CORNERS
+ 'rounded-none',
// Transitions
- 'transition-base',
+ 'transition-colors duration-150',
].join(' '),
variants: {
variant: {
default: [
- 'border border-dotted border-border',
+ 'border border-dashed border-border',
'bg-card text-card-foreground',
- 'shadow-sm',
].join(' '),
elevated: [
- 'border border-dotted border-border',
+ 'border border-dashed border-border',
'bg-surface-elevated text-card-foreground',
- 'shadow-md',
].join(' '),
- outline: ['border-2 border-dotted border-border', 'bg-transparent text-foreground'].join(' '),
+ outline: ['border-2 border-dashed border-border', 'bg-transparent text-foreground'].join(' '),
blueprint: [
- 'border border-dotted border-border',
+ 'border border-dashed border-border',
'bg-card text-card-foreground',
- 'shadow-blueprint',
- 'hover:shadow-blueprint-hover',
].join(' '),
ghost: ['bg-transparent text-foreground'].join(' '),
+ featured: [
+ 'border-2 border-solid border-foreground',
+ 'bg-card text-card-foreground',
+ ].join(' '),
+
brutalist: [
'border-[6px] border-foreground',
'bg-background text-foreground',
diff --git a/packages/ui/src/Checkbox/variants.ts b/packages/ui/src/Checkbox/variants.ts
index 64bdfca..0efa7e5 100644
--- a/packages/ui/src/Checkbox/variants.ts
+++ b/packages/ui/src/Checkbox/variants.ts
@@ -41,15 +41,15 @@ export const checkboxUIVariants = createVariants({
base: [
// Layout
'relative inline-flex items-center justify-center flex-shrink-0',
- // Border & Background
- 'rounded-sm border-2 border-border',
+ // Border & Background - SHARP CORNERS
+ 'rounded-none border-2 border-border',
'bg-background',
- // Transitions
- 'transition-all duration-200',
+ // Transitions - Fast, subtle
+ 'transition-colors duration-150',
// Hover state
'peer-hover:border-border-strong',
- // Focus state (via peer)
- 'peer-focus-visible:ring-2 peer-focus-visible:ring-primary/20 peer-focus-visible:ring-offset-2',
+ // Focus state (via peer) - Copper accent
+ 'peer-focus-visible:ring-2 peer-focus-visible:ring-primary peer-focus-visible:ring-offset-2',
// Checked state
'peer-checked:bg-primary peer-checked:border-primary',
// Indeterminate state (custom data attribute)
diff --git a/packages/ui/src/Drawer/Drawer.tsx b/packages/ui/src/Drawer/Drawer.tsx
new file mode 100644
index 0000000..0d24663
--- /dev/null
+++ b/packages/ui/src/Drawer/Drawer.tsx
@@ -0,0 +1,303 @@
+'use client';
+
+import { cn } from '@tpmjs/utils/cn';
+import { forwardRef, useCallback, useEffect, useId, useRef } from 'react';
+import { createPortal } from 'react-dom';
+import { Icon } from '../Icon/Icon';
+import type {
+ DrawerBodyProps,
+ DrawerFooterProps,
+ DrawerHeaderProps,
+ DrawerProps,
+} from './types';
+import {
+ drawerBackdropVariants,
+ drawerBodyVariants,
+ drawerCloseButtonVariants,
+ drawerContainerVariants,
+ drawerFooterVariants,
+ drawerHeaderVariants,
+ drawerPanelVariants,
+ drawerTitleVariants,
+} from './variants';
+
+/**
+ * Drawer component
+ *
+ * A slide-out panel overlay with focus trap, keyboard support, and accessibility features.
+ *
+ * @example
+ * ```tsx
+ * import { Drawer } from '@tpmjs/ui/Drawer/Drawer';
+ * import { Button } from '@tpmjs/ui/Button/Button';
+ *
+ * function MyComponent() {
+ * const [open, setOpen] = useState(false);
+ *
+ * return (
+ * <>
+ * setOpen(true)}>Open Drawer
+ * setOpen(false)}
+ * title="Settings"
+ * side="right"
+ * footer={
+ * <>
+ * setOpen(false)}>
+ * Cancel
+ *
+ * setOpen(false)}>
+ * Save
+ *
+ * >
+ * }
+ * >
+ * Drawer content goes here.
+ *
+ * >
+ * );
+ * }
+ * ```
+ */
+export const Drawer = forwardRef(
+ (
+ {
+ open,
+ onClose,
+ title,
+ description,
+ side = 'right',
+ size = 'md',
+ closeOnBackdropClick = true,
+ closeOnEscape = true,
+ showCloseButton = true,
+ footer,
+ children,
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ const titleId = useId();
+ const descriptionId = useId();
+ const panelRef = useRef(null);
+ const previousActiveElement = useRef(null);
+
+ // Handle escape key
+ useEffect(() => {
+ if (!open || !closeOnEscape) return;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ onClose();
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [open, closeOnEscape, onClose]);
+
+ // Handle focus trap
+ useEffect(() => {
+ if (!open) return;
+
+ // Store the previously focused element
+ previousActiveElement.current = document.activeElement as HTMLElement;
+
+ // Focus the panel
+ const timer = setTimeout(() => {
+ panelRef.current?.focus();
+ }, 0);
+
+ // Prevent body scroll
+ const originalOverflow = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+
+ return () => {
+ clearTimeout(timer);
+ document.body.style.overflow = originalOverflow;
+
+ // Restore focus to the previously focused element
+ previousActiveElement.current?.focus();
+ };
+ }, [open]);
+
+ // Handle backdrop click
+ const handleBackdropClick = useCallback(
+ (event: React.MouseEvent) => {
+ if (closeOnBackdropClick && event.target === event.currentTarget) {
+ onClose();
+ }
+ },
+ [closeOnBackdropClick, onClose]
+ );
+
+ // Handle focus trap within drawer
+ const handleKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key !== 'Tab') return;
+
+ const panel = panelRef.current;
+ if (!panel) return;
+
+ const focusableElements = panel.querySelectorAll(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
+ );
+
+ const firstElement = focusableElements[0];
+ const lastElement = focusableElements[focusableElements.length - 1];
+
+ if (event.shiftKey && document.activeElement === firstElement) {
+ event.preventDefault();
+ lastElement?.focus();
+ } else if (!event.shiftKey && document.activeElement === lastElement) {
+ event.preventDefault();
+ firstElement?.focus();
+ }
+ },
+ []
+ );
+
+ if (!open) return null;
+
+ // Only render in browser (for SSR compatibility)
+ if (typeof window === 'undefined') return null;
+
+ const drawerContent = (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Container */}
+
+ {/* Panel */}
+
{
+ // Handle both refs
+ (panelRef as React.MutableRefObject
).current = node;
+ if (typeof ref === 'function') {
+ ref(node);
+ } else if (ref) {
+ ref.current = node;
+ }
+ }}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby={title ? titleId : undefined}
+ aria-describedby={description ? descriptionId : undefined}
+ tabIndex={-1}
+ className={cn(
+ drawerPanelVariants({ side, size, state: 'entered' }),
+ className
+ )}
+ {...props}
+ >
+ {/* Header */}
+ {(title || showCloseButton) && (
+
+ {title && (
+
+ {title}
+
+ )}
+ {showCloseButton && (
+
+
+
+ )}
+
+ )}
+
+ {/* Hidden description for screen readers */}
+ {description && (
+
+ {description}
+
+ )}
+
+ {/* Body */}
+
+ {children}
+
+
+ {/* Footer */}
+ {footer && (
+
+ {footer}
+
+ )}
+
+
+ >
+ );
+
+ return createPortal(drawerContent, document.body);
+ }
+);
+
+Drawer.displayName = 'Drawer';
+
+/**
+ * DrawerHeader component for custom headers
+ */
+export const DrawerHeader = forwardRef(
+ ({ className, children, ...props }, ref) => (
+
+ {children}
+
+ )
+);
+
+DrawerHeader.displayName = 'DrawerHeader';
+
+/**
+ * DrawerBody component for custom body content
+ */
+export const DrawerBody = forwardRef(
+ ({ className, children, ...props }, ref) => (
+
+ {children}
+
+ )
+);
+
+DrawerBody.displayName = 'DrawerBody';
+
+/**
+ * DrawerFooter component for custom footers
+ */
+export const DrawerFooter = forwardRef(
+ ({ className, children, ...props }, ref) => (
+
+ {children}
+
+ )
+);
+
+DrawerFooter.displayName = 'DrawerFooter';
diff --git a/packages/ui/src/Drawer/types.ts b/packages/ui/src/Drawer/types.ts
new file mode 100644
index 0000000..998ff14
--- /dev/null
+++ b/packages/ui/src/Drawer/types.ts
@@ -0,0 +1,102 @@
+import type { HTMLAttributes, ReactNode } from 'react';
+
+/**
+ * Drawer side types
+ */
+export type DrawerSide = 'left' | 'right' | 'top' | 'bottom';
+
+/**
+ * Drawer size types
+ */
+export type DrawerSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
+
+/**
+ * Drawer component props
+ */
+export interface DrawerProps extends Omit, 'title'> {
+ /**
+ * Whether the drawer is open
+ */
+ open: boolean;
+
+ /**
+ * Callback when the drawer should close
+ */
+ onClose: () => void;
+
+ /**
+ * Which side the drawer slides in from
+ * @default 'right'
+ */
+ side?: DrawerSide;
+
+ /**
+ * Size of the drawer
+ * @default 'md'
+ */
+ size?: DrawerSize;
+
+ /**
+ * Drawer title (displayed in header)
+ */
+ title?: ReactNode;
+
+ /**
+ * Drawer description (for accessibility)
+ */
+ description?: string;
+
+ /**
+ * Whether to close on backdrop click
+ * @default true
+ */
+ closeOnBackdropClick?: boolean;
+
+ /**
+ * Whether to close on Escape key
+ * @default true
+ */
+ closeOnEscape?: boolean;
+
+ /**
+ * Whether to show the close button
+ * @default true
+ */
+ showCloseButton?: boolean;
+
+ /**
+ * Footer content (buttons, actions)
+ */
+ footer?: ReactNode;
+
+ /**
+ * Drawer content
+ */
+ children: ReactNode;
+}
+
+/**
+ * DrawerHeader component props
+ */
+export interface DrawerHeaderProps extends HTMLAttributes {
+ children: ReactNode;
+}
+
+/**
+ * DrawerBody component props
+ */
+export interface DrawerBodyProps extends HTMLAttributes {
+ children: ReactNode;
+}
+
+/**
+ * DrawerFooter component props
+ */
+export interface DrawerFooterProps extends HTMLAttributes {
+ children: ReactNode;
+}
+
+/**
+ * Drawer ref type
+ */
+export type DrawerRef = HTMLDivElement;
diff --git a/packages/ui/src/Drawer/variants.ts b/packages/ui/src/Drawer/variants.ts
new file mode 100644
index 0000000..ffa20b4
--- /dev/null
+++ b/packages/ui/src/Drawer/variants.ts
@@ -0,0 +1,203 @@
+import { createVariants } from '../system/variants';
+
+/**
+ * Drawer backdrop variant definitions
+ */
+export const drawerBackdropVariants = createVariants({
+ base: [
+ 'fixed inset-0',
+ 'bg-foreground/80',
+ 'backdrop-blur-sm',
+ 'z-[var(--z-drawer)]',
+ 'transition-opacity duration-200',
+ ].join(' '),
+
+ variants: {
+ state: {
+ entering: 'opacity-0',
+ entered: 'opacity-100',
+ exiting: 'opacity-0',
+ },
+ },
+
+ defaultVariants: {
+ state: 'entered',
+ },
+});
+
+/**
+ * Drawer container variant definitions
+ */
+export const drawerContainerVariants = createVariants({
+ base: [
+ 'fixed inset-0',
+ 'z-[var(--z-drawer)]',
+ 'overflow-hidden',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Drawer panel variant definitions
+ */
+export const drawerPanelVariants = createVariants({
+ base: [
+ // Layout
+ 'fixed',
+ 'flex flex-col',
+ // Styling - Sharp corners, blueprint aesthetic
+ 'bg-surface border-border',
+ // Shadow
+ 'shadow-xl',
+ // Animation
+ 'transition-transform duration-200 ease-out',
+ ].join(' '),
+
+ variants: {
+ side: {
+ left: 'inset-y-0 left-0 border-r',
+ right: 'inset-y-0 right-0 border-l',
+ top: 'inset-x-0 top-0 border-b',
+ bottom: 'inset-x-0 bottom-0 border-t',
+ },
+ size: {
+ sm: '',
+ md: '',
+ lg: '',
+ xl: '',
+ full: '',
+ },
+ state: {
+ entering: '',
+ entered: 'translate-x-0 translate-y-0',
+ exiting: '',
+ },
+ },
+
+ defaultVariants: {
+ side: 'right',
+ size: 'md',
+ state: 'entered',
+ },
+
+ compoundVariants: [
+ // Size + Side combinations for horizontal drawers (left/right)
+ { conditions: { side: 'left', size: 'sm' }, className: 'w-64 max-w-[80vw]' },
+ { conditions: { side: 'left', size: 'md' }, className: 'w-80 max-w-[80vw]' },
+ { conditions: { side: 'left', size: 'lg' }, className: 'w-96 max-w-[80vw]' },
+ { conditions: { side: 'left', size: 'xl' }, className: 'w-[32rem] max-w-[80vw]' },
+ { conditions: { side: 'left', size: 'full' }, className: 'w-screen' },
+ { conditions: { side: 'right', size: 'sm' }, className: 'w-64 max-w-[80vw]' },
+ { conditions: { side: 'right', size: 'md' }, className: 'w-80 max-w-[80vw]' },
+ { conditions: { side: 'right', size: 'lg' }, className: 'w-96 max-w-[80vw]' },
+ { conditions: { side: 'right', size: 'xl' }, className: 'w-[32rem] max-w-[80vw]' },
+ { conditions: { side: 'right', size: 'full' }, className: 'w-screen' },
+
+ // Size + Side combinations for vertical drawers (top/bottom)
+ { conditions: { side: 'top', size: 'sm' }, className: 'h-48 max-h-[50vh]' },
+ { conditions: { side: 'top', size: 'md' }, className: 'h-64 max-h-[50vh]' },
+ { conditions: { side: 'top', size: 'lg' }, className: 'h-80 max-h-[50vh]' },
+ { conditions: { side: 'top', size: 'xl' }, className: 'h-96 max-h-[50vh]' },
+ { conditions: { side: 'top', size: 'full' }, className: 'h-screen' },
+ { conditions: { side: 'bottom', size: 'sm' }, className: 'h-48 max-h-[50vh]' },
+ { conditions: { side: 'bottom', size: 'md' }, className: 'h-64 max-h-[50vh]' },
+ { conditions: { side: 'bottom', size: 'lg' }, className: 'h-80 max-h-[50vh]' },
+ { conditions: { side: 'bottom', size: 'xl' }, className: 'h-96 max-h-[50vh]' },
+ { conditions: { side: 'bottom', size: 'full' }, className: 'h-screen' },
+
+ // State + Side combinations for enter/exit transforms
+ { conditions: { side: 'left', state: 'entering' }, className: '-translate-x-full' },
+ { conditions: { side: 'left', state: 'exiting' }, className: '-translate-x-full' },
+ { conditions: { side: 'right', state: 'entering' }, className: 'translate-x-full' },
+ { conditions: { side: 'right', state: 'exiting' }, className: 'translate-x-full' },
+ { conditions: { side: 'top', state: 'entering' }, className: '-translate-y-full' },
+ { conditions: { side: 'top', state: 'exiting' }, className: '-translate-y-full' },
+ { conditions: { side: 'bottom', state: 'entering' }, className: 'translate-y-full' },
+ { conditions: { side: 'bottom', state: 'exiting' }, className: 'translate-y-full' },
+ ],
+});
+
+/**
+ * Drawer header variant definitions
+ */
+export const drawerHeaderVariants = createVariants({
+ base: [
+ 'flex items-center justify-between',
+ 'px-6 py-4',
+ 'border-b border-border',
+ 'bg-surface',
+ 'flex-shrink-0',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Drawer body variant definitions
+ */
+export const drawerBodyVariants = createVariants({
+ base: [
+ 'flex-1',
+ 'px-6 py-4',
+ 'overflow-y-auto',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Drawer footer variant definitions
+ */
+export const drawerFooterVariants = createVariants({
+ base: [
+ 'flex items-center justify-end gap-3',
+ 'px-6 py-4',
+ 'border-t border-border',
+ 'bg-surface',
+ 'flex-shrink-0',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Drawer title variant definitions
+ */
+export const drawerTitleVariants = createVariants({
+ base: [
+ 'font-mono font-semibold text-lg',
+ 'text-foreground',
+ 'lowercase',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Drawer close button variant definitions
+ */
+export const drawerCloseButtonVariants = createVariants({
+ base: [
+ 'p-2 -m-2',
+ 'text-foreground-muted',
+ 'hover:text-foreground hover:bg-accent/10',
+ 'transition-colors duration-150',
+ 'rounded-none',
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
diff --git a/packages/ui/src/DropdownMenu/DropdownMenu.tsx b/packages/ui/src/DropdownMenu/DropdownMenu.tsx
new file mode 100644
index 0000000..81c1623
--- /dev/null
+++ b/packages/ui/src/DropdownMenu/DropdownMenu.tsx
@@ -0,0 +1,456 @@
+'use client';
+
+import { cn } from '@tpmjs/utils/cn';
+import {
+ cloneElement,
+ createContext,
+ forwardRef,
+ isValidElement,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { createPortal } from 'react-dom';
+import type {
+ DropdownMenuGroupProps,
+ DropdownMenuItemProps,
+ DropdownMenuLabelProps,
+ DropdownMenuPlacement,
+ DropdownMenuProps,
+ DropdownMenuSeparatorProps,
+} from './types';
+import {
+ dropdownMenuContentVariants,
+ dropdownMenuItemIconVariants,
+ dropdownMenuItemShortcutVariants,
+ dropdownMenuItemVariants,
+ dropdownMenuLabelVariants,
+ dropdownMenuSeparatorVariants,
+} from './variants';
+
+// Context for menu state
+interface DropdownMenuContextValue {
+ closeMenu: () => void;
+ closeOnSelect: boolean;
+ activeIndex: number;
+ setActiveIndex: (index: number) => void;
+ registerItem: () => number;
+}
+
+const DropdownMenuContext = createContext(null);
+
+/**
+ * Calculate position based on trigger and placement
+ */
+function calculatePosition(
+ triggerRect: DOMRect,
+ contentRect: DOMRect,
+ placement: DropdownMenuPlacement,
+ offset: number
+): { top: number; left: number } {
+ let top = 0;
+ let left = 0;
+
+ const scrollX = window.scrollX;
+ const scrollY = window.scrollY;
+
+ switch (placement) {
+ case 'bottom':
+ top = triggerRect.bottom + scrollY + offset;
+ left = triggerRect.left + scrollX + (triggerRect.width - contentRect.width) / 2;
+ break;
+ case 'bottom-start':
+ top = triggerRect.bottom + scrollY + offset;
+ left = triggerRect.left + scrollX;
+ break;
+ case 'bottom-end':
+ top = triggerRect.bottom + scrollY + offset;
+ left = triggerRect.right + scrollX - contentRect.width;
+ break;
+ case 'top':
+ top = triggerRect.top + scrollY - contentRect.height - offset;
+ left = triggerRect.left + scrollX + (triggerRect.width - contentRect.width) / 2;
+ break;
+ case 'top-start':
+ top = triggerRect.top + scrollY - contentRect.height - offset;
+ left = triggerRect.left + scrollX;
+ break;
+ case 'top-end':
+ top = triggerRect.top + scrollY - contentRect.height - offset;
+ left = triggerRect.right + scrollX - contentRect.width;
+ break;
+ }
+
+ return { top, left };
+}
+
+/**
+ * DropdownMenu component
+ *
+ * A menu that appears when clicking a trigger element.
+ *
+ * @example
+ * ```tsx
+ * import {
+ * DropdownMenu,
+ * DropdownMenuItem,
+ * DropdownMenuSeparator,
+ * } from '@tpmjs/ui/DropdownMenu/DropdownMenu';
+ * import { Button } from '@tpmjs/ui/Button/Button';
+ *
+ * function MyComponent() {
+ * return (
+ * Open Menu}>
+ * console.log('Edit')}>
+ * Edit
+ *
+ * console.log('Duplicate')}>
+ * Duplicate
+ *
+ *
+ * console.log('Delete')}>
+ * Delete
+ *
+ *
+ * );
+ * }
+ * ```
+ */
+export const DropdownMenu = forwardRef(
+ (
+ {
+ open: controlledOpen,
+ onOpenChange,
+ defaultOpen = false,
+ trigger,
+ placement = 'bottom-start',
+ offset = 4,
+ closeOnClickOutside = true,
+ closeOnEscape = true,
+ closeOnSelect = true,
+ children,
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ const isControlled = controlledOpen !== undefined;
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
+ const isOpen = isControlled ? controlledOpen : internalOpen;
+
+ const triggerRef = useRef(null);
+ const contentRef = useRef(null);
+ const [position, setPosition] = useState({ top: 0, left: 0 });
+ const [activeIndex, setActiveIndex] = useState(-1);
+ const itemCountRef = useRef(0);
+
+ const setOpen = useCallback(
+ (value: boolean) => {
+ if (!isControlled) {
+ setInternalOpen(value);
+ }
+ onOpenChange?.(value);
+ if (!value) {
+ setActiveIndex(-1);
+ }
+ },
+ [isControlled, onOpenChange]
+ );
+
+ const handleToggle = useCallback(() => {
+ setOpen(!isOpen);
+ }, [isOpen, setOpen]);
+
+ const closeMenu = useCallback(() => {
+ setOpen(false);
+ }, [setOpen]);
+
+ const registerItem = useCallback(() => {
+ const index = itemCountRef.current;
+ itemCountRef.current += 1;
+ return index;
+ }, []);
+
+ // Reset item count when menu closes
+ useEffect(() => {
+ if (!isOpen) {
+ itemCountRef.current = 0;
+ }
+ }, [isOpen]);
+
+ // Update position when open
+ useEffect(() => {
+ if (!isOpen || !triggerRef.current || !contentRef.current) return;
+
+ const updatePosition = () => {
+ const triggerRect = triggerRef.current!.getBoundingClientRect();
+ const contentRect = contentRef.current!.getBoundingClientRect();
+ const newPosition = calculatePosition(triggerRect, contentRect, placement, offset);
+ setPosition(newPosition);
+ };
+
+ updatePosition();
+
+ window.addEventListener('scroll', updatePosition, true);
+ window.addEventListener('resize', updatePosition);
+
+ return () => {
+ window.removeEventListener('scroll', updatePosition, true);
+ window.removeEventListener('resize', updatePosition);
+ };
+ }, [isOpen, placement, offset]);
+
+ // Handle click outside
+ useEffect(() => {
+ if (!isOpen || !closeOnClickOutside) return;
+
+ const handleClickOutside = (event: MouseEvent) => {
+ const target = event.target as Node;
+ if (
+ triggerRef.current?.contains(target) ||
+ contentRef.current?.contains(target)
+ ) {
+ return;
+ }
+ closeMenu();
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [isOpen, closeOnClickOutside, closeMenu]);
+
+ // Handle escape key and keyboard navigation
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ switch (event.key) {
+ case 'Escape':
+ if (closeOnEscape) {
+ event.preventDefault();
+ closeMenu();
+ }
+ break;
+ case 'ArrowDown':
+ event.preventDefault();
+ setActiveIndex((prev) =>
+ prev < itemCountRef.current - 1 ? prev + 1 : 0
+ );
+ break;
+ case 'ArrowUp':
+ event.preventDefault();
+ setActiveIndex((prev) =>
+ prev > 0 ? prev - 1 : itemCountRef.current - 1
+ );
+ break;
+ case 'Home':
+ event.preventDefault();
+ setActiveIndex(0);
+ break;
+ case 'End':
+ event.preventDefault();
+ setActiveIndex(itemCountRef.current - 1);
+ break;
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [isOpen, closeOnEscape, closeMenu]);
+
+ // Clone trigger element with click handler
+ const triggerElement = isValidElement(trigger)
+ ? cloneElement(trigger as React.ReactElement, {
+ ref: triggerRef,
+ onClick: (e: React.MouseEvent) => {
+ (trigger as React.ReactElement).props.onClick?.(e);
+ handleToggle();
+ },
+ 'aria-haspopup': 'menu',
+ 'aria-expanded': isOpen,
+ })
+ : trigger;
+
+ const contextValue = useMemo(
+ () => ({
+ closeMenu,
+ closeOnSelect,
+ activeIndex,
+ setActiveIndex,
+ registerItem,
+ }),
+ [closeMenu, closeOnSelect, activeIndex, registerItem]
+ );
+
+ // Only render portal in browser
+ const canRenderPortal = typeof window !== 'undefined';
+
+ return (
+
+ {triggerElement}
+ {canRenderPortal &&
+ isOpen &&
+ createPortal(
+ {
+ (contentRef as React.MutableRefObject).current = node;
+ if (typeof ref === 'function') {
+ ref(node);
+ } else if (ref) {
+ ref.current = node;
+ }
+ }}
+ role="menu"
+ aria-orientation="vertical"
+ className={cn(dropdownMenuContentVariants({ state: 'entered' }), className)}
+ style={{
+ top: position.top,
+ left: position.left,
+ }}
+ {...props}
+ >
+ {children}
+
,
+ document.body
+ )}
+
+ );
+ }
+);
+
+DropdownMenu.displayName = 'DropdownMenu';
+
+/**
+ * DropdownMenuItem component
+ */
+export const DropdownMenuItem = forwardRef(
+ (
+ {
+ disabled = false,
+ destructive = false,
+ icon,
+ shortcut,
+ onSelect,
+ children,
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ const context = useContext(DropdownMenuContext);
+ const indexRef = useRef(-1);
+
+ // Register this item and get its index
+ useEffect(() => {
+ if (context && indexRef.current === -1) {
+ indexRef.current = context.registerItem();
+ }
+ }, [context]);
+
+ const isActive = context?.activeIndex === indexRef.current;
+
+ const handleClick = useCallback(() => {
+ if (disabled) return;
+ onSelect?.();
+ if (context?.closeOnSelect) {
+ context.closeMenu();
+ }
+ }, [disabled, onSelect, context]);
+
+ const handleKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ handleClick();
+ }
+ },
+ [handleClick]
+ );
+
+ return (
+ context?.setActiveIndex(indexRef.current)}
+ {...props}
+ >
+ {icon && (
+
+ {icon}
+
+ )}
+ {children}
+ {shortcut && (
+
+ {shortcut}
+
+ )}
+
+ );
+ }
+);
+
+DropdownMenuItem.displayName = 'DropdownMenuItem';
+
+/**
+ * DropdownMenuSeparator component
+ */
+export const DropdownMenuSeparator = forwardRef(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+
+DropdownMenuSeparator.displayName = 'DropdownMenuSeparator';
+
+/**
+ * DropdownMenuLabel component
+ */
+export const DropdownMenuLabel = forwardRef(
+ ({ children, className, ...props }, ref) => (
+
+ {children}
+
+ )
+);
+
+DropdownMenuLabel.displayName = 'DropdownMenuLabel';
+
+/**
+ * DropdownMenuGroup component
+ */
+export const DropdownMenuGroup = forwardRef(
+ ({ label, children, className, ...props }, ref) => (
+
+ {label && {label} }
+ {children}
+
+ )
+);
+
+DropdownMenuGroup.displayName = 'DropdownMenuGroup';
diff --git a/packages/ui/src/DropdownMenu/types.ts b/packages/ui/src/DropdownMenu/types.ts
new file mode 100644
index 0000000..d34f749
--- /dev/null
+++ b/packages/ui/src/DropdownMenu/types.ts
@@ -0,0 +1,142 @@
+import type { HTMLAttributes, ReactNode } from 'react';
+
+/**
+ * Dropdown menu placement types
+ */
+export type DropdownMenuPlacement =
+ | 'bottom'
+ | 'bottom-start'
+ | 'bottom-end'
+ | 'top'
+ | 'top-start'
+ | 'top-end';
+
+/**
+ * DropdownMenu component props
+ */
+export interface DropdownMenuProps extends HTMLAttributes {
+ /**
+ * Whether the menu is open (controlled mode)
+ */
+ open?: boolean;
+
+ /**
+ * Callback when open state changes
+ */
+ onOpenChange?: (open: boolean) => void;
+
+ /**
+ * Default open state (uncontrolled mode)
+ * @default false
+ */
+ defaultOpen?: boolean;
+
+ /**
+ * The trigger element
+ */
+ trigger: ReactNode;
+
+ /**
+ * Placement of the menu relative to trigger
+ * @default 'bottom-start'
+ */
+ placement?: DropdownMenuPlacement;
+
+ /**
+ * Offset from the trigger element in pixels
+ * @default 4
+ */
+ offset?: number;
+
+ /**
+ * Whether to close when clicking outside
+ * @default true
+ */
+ closeOnClickOutside?: boolean;
+
+ /**
+ * Whether to close on Escape key
+ * @default true
+ */
+ closeOnEscape?: boolean;
+
+ /**
+ * Whether to close when an item is selected
+ * @default true
+ */
+ closeOnSelect?: boolean;
+
+ /**
+ * Menu items
+ */
+ children: ReactNode;
+}
+
+/**
+ * DropdownMenuItem component props
+ */
+export interface DropdownMenuItemProps extends HTMLAttributes {
+ /**
+ * Whether the item is disabled
+ * @default false
+ */
+ disabled?: boolean;
+
+ /**
+ * Whether the item is destructive (red styling)
+ * @default false
+ */
+ destructive?: boolean;
+
+ /**
+ * Icon to display before the label
+ */
+ icon?: ReactNode;
+
+ /**
+ * Keyboard shortcut to display
+ */
+ shortcut?: string;
+
+ /**
+ * Callback when item is selected
+ */
+ onSelect?: () => void;
+
+ /**
+ * Item content
+ */
+ children: ReactNode;
+}
+
+/**
+ * DropdownMenuSeparator component props
+ */
+export interface DropdownMenuSeparatorProps extends HTMLAttributes {}
+
+/**
+ * DropdownMenuLabel component props
+ */
+export interface DropdownMenuLabelProps extends HTMLAttributes {
+ children: ReactNode;
+}
+
+/**
+ * DropdownMenuGroup component props
+ */
+export interface DropdownMenuGroupProps extends HTMLAttributes {
+ /**
+ * Group label
+ */
+ label?: string;
+
+ /**
+ * Group items
+ */
+ children: ReactNode;
+}
+
+/**
+ * DropdownMenu ref type
+ */
+export type DropdownMenuRef = HTMLDivElement;
diff --git a/packages/ui/src/DropdownMenu/variants.ts b/packages/ui/src/DropdownMenu/variants.ts
new file mode 100644
index 0000000..043c50b
--- /dev/null
+++ b/packages/ui/src/DropdownMenu/variants.ts
@@ -0,0 +1,145 @@
+import { createVariants } from '../system/variants';
+
+/**
+ * Dropdown menu content variant definitions
+ */
+export const dropdownMenuContentVariants = createVariants({
+ base: [
+ // Layout
+ 'absolute',
+ 'z-[var(--z-dropdown)]',
+ 'min-w-[10rem]',
+ 'py-1',
+ // Styling - Sharp corners, blueprint aesthetic
+ 'bg-surface border border-border',
+ 'rounded-none',
+ // Shadow
+ 'shadow-lg',
+ // Animation
+ 'transition-all duration-150',
+ ].join(' '),
+
+ variants: {
+ state: {
+ entering: 'opacity-0 scale-95',
+ entered: 'opacity-100 scale-100',
+ exiting: 'opacity-0 scale-95',
+ },
+ },
+
+ defaultVariants: {
+ state: 'entered',
+ },
+});
+
+/**
+ * Dropdown menu item variant definitions
+ */
+export const dropdownMenuItemVariants = createVariants({
+ base: [
+ // Layout
+ 'relative w-full',
+ 'flex items-center gap-2',
+ 'px-3 py-2',
+ // Typography
+ 'font-mono text-sm text-left',
+ 'text-foreground',
+ // Interaction
+ 'cursor-pointer',
+ 'transition-colors duration-150',
+ // Focus
+ 'outline-none',
+ 'hover:bg-accent/10',
+ 'focus-visible:bg-accent/10',
+ ].join(' '),
+
+ variants: {
+ disabled: {
+ 'true': 'opacity-50 cursor-not-allowed hover:bg-transparent',
+ 'false': '',
+ },
+ destructive: {
+ 'true': 'text-error hover:bg-error/10 focus-visible:bg-error/10',
+ 'false': '',
+ },
+ active: {
+ 'true': 'bg-accent/10',
+ 'false': '',
+ },
+ },
+
+ defaultVariants: {
+ disabled: 'false',
+ destructive: 'false',
+ active: 'false',
+ },
+});
+
+/**
+ * Dropdown menu item icon variant definitions
+ */
+export const dropdownMenuItemIconVariants = createVariants({
+ base: [
+ 'flex-shrink-0',
+ 'w-4 h-4',
+ 'text-foreground-muted',
+ ].join(' '),
+
+ variants: {
+ destructive: {
+ 'true': 'text-error',
+ 'false': '',
+ },
+ },
+
+ defaultVariants: {
+ destructive: 'false',
+ },
+});
+
+/**
+ * Dropdown menu item shortcut variant definitions
+ */
+export const dropdownMenuItemShortcutVariants = createVariants({
+ base: [
+ 'ml-auto',
+ 'font-mono text-xs',
+ 'text-foreground-muted',
+ 'opacity-60',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Dropdown menu separator variant definitions
+ */
+export const dropdownMenuSeparatorVariants = createVariants({
+ base: [
+ 'my-1',
+ 'h-px',
+ 'bg-border',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Dropdown menu label variant definitions
+ */
+export const dropdownMenuLabelVariants = createVariants({
+ base: [
+ 'px-3 py-2',
+ 'font-mono text-xs font-semibold',
+ 'text-foreground-muted',
+ 'uppercase tracking-wider',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
diff --git a/packages/ui/src/Icon/icons.ts b/packages/ui/src/Icon/icons.ts
index f2a14a1..ff187c7 100644
--- a/packages/ui/src/Icon/icons.ts
+++ b/packages/ui/src/Icon/icons.ts
@@ -156,6 +156,54 @@ export const icons = {
viewBox: '0 0 24 24',
path: 'M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z',
},
+ checkCircle: {
+ viewBox: '0 0 24 24',
+ path: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z',
+ },
+ xCircle: {
+ viewBox: '0 0 24 24',
+ path: 'M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z',
+ },
+ chevronsLeft: {
+ viewBox: '0 0 24 24',
+ path: 'M18.41 16.59L13.82 12l4.59-4.59L17 6l-6 6 6 6 1.41-1.41zM6 6h2v12H6V6z',
+ },
+ chevronsRight: {
+ viewBox: '0 0 24 24',
+ path: 'M5.59 7.41L10.18 12l-4.59 4.59L7 18l6-6-6-6-1.41 1.41zM16 6h2v12h-2V6z',
+ },
+ moreHorizontal: {
+ viewBox: '0 0 24 24',
+ path: 'M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z',
+ },
+ bell: {
+ viewBox: '0 0 24 24',
+ path: 'M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z',
+ },
+ slash: {
+ viewBox: '0 0 24 24',
+ path: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z',
+ },
+ arrowRight: {
+ viewBox: '0 0 24 24',
+ path: 'M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z',
+ },
+ circle: {
+ viewBox: '0 0 24 24',
+ path: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z',
+ },
+ badgeCheck: {
+ viewBox: '0 0 24 24',
+ path: 'M23 12l-2.44-2.79.34-3.69-3.61-.82-1.89-3.2L12 2.96 8.6 1.5 6.71 4.69 3.1 5.5l.34 3.7L1 12l2.44 2.79-.34 3.7 3.61.82L8.6 22.5l3.4-1.47 3.4 1.46 1.89-3.19 3.61-.82-.34-3.69L23 12zm-12.91 4.72l-3.8-3.81 1.48-1.48 2.32 2.33 5.85-5.87 1.48 1.48-7.33 7.35z',
+ },
+ download: {
+ viewBox: '0 0 24 24',
+ path: 'M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z',
+ },
+ chevronLeft: {
+ viewBox: '0 0 24 24',
+ path: 'M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z',
+ },
} as const;
export type IconName = keyof typeof icons;
diff --git a/packages/ui/src/Input/variants.ts b/packages/ui/src/Input/variants.ts
index 096d84e..a3d4f1b 100644
--- a/packages/ui/src/Input/variants.ts
+++ b/packages/ui/src/Input/variants.ts
@@ -8,16 +8,16 @@ export const inputVariants = createVariants({
base: [
// Layout
'flex w-full',
- // Typography
- 'font-sans',
- // Borders & Radius
- 'rounded-md border',
+ // Typography - Monospace for inputs
+ 'font-mono',
+ // Borders & Radius - SHARP CORNERS
+ 'rounded-none border',
// Background - Pure white to stand out
'bg-surface',
// Transitions
- 'transition-base',
- // Focus
- 'focus-ring',
+ 'transition-colors duration-150',
+ // Focus - Copper accent
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
// Placeholder
'placeholder:text-foreground-tertiary',
// File input
diff --git a/packages/ui/src/InstallSnippet/InstallSnippet.tsx b/packages/ui/src/InstallSnippet/InstallSnippet.tsx
new file mode 100644
index 0000000..377d647
--- /dev/null
+++ b/packages/ui/src/InstallSnippet/InstallSnippet.tsx
@@ -0,0 +1,174 @@
+'use client';
+
+import { cn } from '@tpmjs/utils/cn';
+import { forwardRef, useCallback, useState } from 'react';
+import { Icon } from '../Icon/Icon';
+import type { InstallSnippetProps, PackageManager } from './types';
+import {
+ installSnippetCodeVariants,
+ installSnippetCopyButtonVariants,
+ installSnippetTabVariants,
+ installSnippetTabsVariants,
+ installSnippetVariants,
+} from './variants';
+
+/**
+ * Package manager configurations
+ */
+const PACKAGE_MANAGERS: Record<
+ PackageManager,
+ {
+ label: string;
+ install: string;
+ devInstall: string;
+ globalInstall: string;
+ }
+> = {
+ npm: {
+ label: 'npm',
+ install: 'npm install',
+ devInstall: 'npm install -D',
+ globalInstall: 'npm install -g',
+ },
+ pnpm: {
+ label: 'pnpm',
+ install: 'pnpm add',
+ devInstall: 'pnpm add -D',
+ globalInstall: 'pnpm add -g',
+ },
+ yarn: {
+ label: 'yarn',
+ install: 'yarn add',
+ devInstall: 'yarn add -D',
+ globalInstall: 'yarn global add',
+ },
+ bun: {
+ label: 'bun',
+ install: 'bun add',
+ devInstall: 'bun add -d',
+ globalInstall: 'bun add -g',
+ },
+};
+
+const MANAGER_ORDER: PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun'];
+
+/**
+ * InstallSnippet component
+ *
+ * A component for displaying package installation commands with package manager tabs.
+ *
+ * @example
+ * ```tsx
+ * import { InstallSnippet } from '@tpmjs/ui/InstallSnippet/InstallSnippet';
+ *
+ * function MyComponent() {
+ * return (
+ *
+ * );
+ * }
+ * ```
+ */
+export const InstallSnippet = forwardRef(
+ (
+ {
+ packageName,
+ version,
+ defaultManager = 'npm',
+ showTabs = true,
+ installType = 'dependencies',
+ copyable = true,
+ variant = 'default',
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ const [activeManager, setActiveManager] = useState(defaultManager);
+ const [copied, setCopied] = useState(false);
+
+ // Generate install command
+ const getInstallCommand = useCallback(
+ (manager: PackageManager): string => {
+ const config = PACKAGE_MANAGERS[manager];
+ let command: string;
+
+ switch (installType) {
+ case 'devDependencies':
+ command = config.devInstall;
+ break;
+ case 'global':
+ command = config.globalInstall;
+ break;
+ default:
+ command = config.install;
+ }
+
+ const pkg = version ? `${packageName}@${version}` : packageName;
+ return `${command} ${pkg}`;
+ },
+ [packageName, version, installType]
+ );
+
+ const currentCommand = getInstallCommand(activeManager);
+
+ // Copy to clipboard
+ const handleCopy = useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(currentCommand);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ console.error('Failed to copy:', err);
+ }
+ }, [currentCommand]);
+
+ return (
+
+ {/* Tabs */}
+ {showTabs && (
+
+ {MANAGER_ORDER.map((manager) => (
+ setActiveManager(manager)}
+ className={installSnippetTabVariants({
+ variant,
+ active: activeManager === manager ? 'true' : 'false',
+ })}
+ >
+ {PACKAGE_MANAGERS[manager].label}
+
+ ))}
+
+ )}
+
+ {/* Code */}
+
+ {currentCommand}
+
+ {copyable && (
+
+
+
+ )}
+
+
+ );
+ }
+);
+
+InstallSnippet.displayName = 'InstallSnippet';
diff --git a/packages/ui/src/InstallSnippet/types.ts b/packages/ui/src/InstallSnippet/types.ts
new file mode 100644
index 0000000..7e78519
--- /dev/null
+++ b/packages/ui/src/InstallSnippet/types.ts
@@ -0,0 +1,56 @@
+import type { HTMLAttributes } from 'react';
+
+/**
+ * Package manager types
+ */
+export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
+
+/**
+ * InstallSnippet component props
+ */
+export interface InstallSnippetProps extends HTMLAttributes {
+ /**
+ * Package name to install
+ */
+ packageName: string;
+
+ /**
+ * Package version (optional)
+ */
+ version?: string;
+
+ /**
+ * Default package manager
+ * @default 'npm'
+ */
+ defaultManager?: PackageManager;
+
+ /**
+ * Whether to show package manager tabs
+ * @default true
+ */
+ showTabs?: boolean;
+
+ /**
+ * Install type
+ * @default 'dependencies'
+ */
+ installType?: 'dependencies' | 'devDependencies' | 'global';
+
+ /**
+ * Whether the snippet is copyable
+ * @default true
+ */
+ copyable?: boolean;
+
+ /**
+ * Variant style
+ * @default 'default'
+ */
+ variant?: 'default' | 'minimal' | 'dark';
+}
+
+/**
+ * InstallSnippet ref type
+ */
+export type InstallSnippetRef = HTMLDivElement;
diff --git a/packages/ui/src/InstallSnippet/variants.ts b/packages/ui/src/InstallSnippet/variants.ts
new file mode 100644
index 0000000..f18d2e5
--- /dev/null
+++ b/packages/ui/src/InstallSnippet/variants.ts
@@ -0,0 +1,148 @@
+import { createVariants } from '../system/variants';
+
+/**
+ * InstallSnippet container variant definitions
+ */
+export const installSnippetVariants = createVariants({
+ base: [
+ 'w-full',
+ 'border border-border',
+ 'rounded-none',
+ 'overflow-hidden',
+ ].join(' '),
+
+ variants: {
+ variant: {
+ default: 'bg-surface',
+ minimal: 'border-0 bg-transparent',
+ dark: 'bg-foreground border-foreground',
+ },
+ },
+
+ defaultVariants: {
+ variant: 'default',
+ },
+});
+
+/**
+ * InstallSnippet tabs variant definitions
+ */
+export const installSnippetTabsVariants = createVariants({
+ base: [
+ 'flex items-center',
+ 'border-b border-border',
+ 'px-1',
+ 'gap-0',
+ ].join(' '),
+
+ variants: {
+ variant: {
+ default: 'bg-surface',
+ minimal: 'bg-transparent border-0',
+ dark: 'bg-foreground border-foreground/20',
+ },
+ },
+
+ defaultVariants: {
+ variant: 'default',
+ },
+});
+
+/**
+ * InstallSnippet tab variant definitions
+ */
+export const installSnippetTabVariants = createVariants({
+ base: [
+ 'px-3 py-2',
+ 'font-mono text-xs',
+ 'border-b-2 border-transparent',
+ '-mb-px',
+ 'cursor-pointer',
+ 'transition-colors duration-150',
+ ].join(' '),
+
+ variants: {
+ variant: {
+ default: 'text-foreground-muted hover:text-foreground',
+ minimal: 'text-foreground-muted hover:text-foreground',
+ dark: 'text-background/60 hover:text-background',
+ },
+ active: {
+ 'true': '',
+ 'false': '',
+ },
+ },
+
+ defaultVariants: {
+ variant: 'default',
+ active: 'false',
+ },
+
+ compoundVariants: [
+ { conditions: { variant: 'default', active: 'true' }, className: 'text-foreground border-primary' },
+ { conditions: { variant: 'minimal', active: 'true' }, className: 'text-foreground border-primary' },
+ { conditions: { variant: 'dark', active: 'true' }, className: 'text-background border-background' },
+ ],
+});
+
+/**
+ * InstallSnippet code area variant definitions
+ */
+export const installSnippetCodeVariants = createVariants({
+ base: [
+ 'flex items-center justify-between',
+ 'px-4 py-3',
+ 'font-mono text-sm',
+ 'overflow-x-auto',
+ ].join(' '),
+
+ variants: {
+ variant: {
+ default: 'text-foreground',
+ minimal: 'text-foreground',
+ dark: 'text-background',
+ },
+ },
+
+ defaultVariants: {
+ variant: 'default',
+ },
+});
+
+/**
+ * InstallSnippet copy button variant definitions
+ */
+export const installSnippetCopyButtonVariants = createVariants({
+ base: [
+ 'flex-shrink-0',
+ 'p-1.5',
+ '-m-1',
+ 'ml-3',
+ 'rounded-none',
+ 'transition-colors duration-150',
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
+ ].join(' '),
+
+ variants: {
+ variant: {
+ default: 'text-foreground-muted hover:text-foreground hover:bg-accent/10',
+ minimal: 'text-foreground-muted hover:text-foreground hover:bg-accent/10',
+ dark: 'text-background/60 hover:text-background hover:bg-background/10',
+ },
+ copied: {
+ 'true': '',
+ 'false': '',
+ },
+ },
+
+ defaultVariants: {
+ variant: 'default',
+ copied: 'false',
+ },
+
+ compoundVariants: [
+ { conditions: { variant: 'default', copied: 'true' }, className: 'text-success' },
+ { conditions: { variant: 'minimal', copied: 'true' }, className: 'text-success' },
+ { conditions: { variant: 'dark', copied: 'true' }, className: 'text-success' },
+ ],
+});
diff --git a/packages/ui/src/Modal/Modal.tsx b/packages/ui/src/Modal/Modal.tsx
new file mode 100644
index 0000000..dfb49f0
--- /dev/null
+++ b/packages/ui/src/Modal/Modal.tsx
@@ -0,0 +1,301 @@
+'use client';
+
+import { cn } from '@tpmjs/utils/cn';
+import { forwardRef, useCallback, useEffect, useId, useRef } from 'react';
+import { createPortal } from 'react-dom';
+import { Icon } from '../Icon/Icon';
+import type {
+ ModalBodyProps,
+ ModalFooterProps,
+ ModalHeaderProps,
+ ModalProps,
+} from './types';
+import {
+ modalBackdropVariants,
+ modalBodyVariants,
+ modalCloseButtonVariants,
+ modalContainerVariants,
+ modalFooterVariants,
+ modalHeaderVariants,
+ modalPanelVariants,
+ modalTitleVariants,
+} from './variants';
+
+/**
+ * Modal component
+ *
+ * A dialog overlay with focus trap, keyboard support, and accessibility features.
+ *
+ * @example
+ * ```tsx
+ * import { Modal } from '@tpmjs/ui/Modal/Modal';
+ * import { Button } from '@tpmjs/ui/Button/Button';
+ *
+ * function MyComponent() {
+ * const [open, setOpen] = useState(false);
+ *
+ * return (
+ * <>
+ * setOpen(true)}>Open Modal
+ * setOpen(false)}
+ * title="Confirm Action"
+ * footer={
+ * <>
+ * setOpen(false)}>
+ * Cancel
+ *
+ * setOpen(false)}>
+ * Confirm
+ *
+ * >
+ * }
+ * >
+ * Are you sure you want to proceed?
+ *
+ * >
+ * );
+ * }
+ * ```
+ */
+export const Modal = forwardRef(
+ (
+ {
+ open,
+ onClose,
+ title,
+ description,
+ size = 'md',
+ closeOnBackdropClick = true,
+ closeOnEscape = true,
+ showCloseButton = true,
+ footer,
+ children,
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ const titleId = useId();
+ const descriptionId = useId();
+ const panelRef = useRef(null);
+ const previousActiveElement = useRef(null);
+
+ // Handle escape key
+ useEffect(() => {
+ if (!open || !closeOnEscape) return;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ onClose();
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [open, closeOnEscape, onClose]);
+
+ // Handle focus trap
+ useEffect(() => {
+ if (!open) return;
+
+ // Store the previously focused element
+ previousActiveElement.current = document.activeElement as HTMLElement;
+
+ // Focus the panel
+ const timer = setTimeout(() => {
+ panelRef.current?.focus();
+ }, 0);
+
+ // Prevent body scroll
+ const originalOverflow = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+
+ return () => {
+ clearTimeout(timer);
+ document.body.style.overflow = originalOverflow;
+
+ // Restore focus to the previously focused element
+ previousActiveElement.current?.focus();
+ };
+ }, [open]);
+
+ // Handle backdrop click
+ const handleBackdropClick = useCallback(
+ (event: React.MouseEvent) => {
+ if (closeOnBackdropClick && event.target === event.currentTarget) {
+ onClose();
+ }
+ },
+ [closeOnBackdropClick, onClose]
+ );
+
+ // Handle focus trap within modal
+ const handleKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key !== 'Tab') return;
+
+ const panel = panelRef.current;
+ if (!panel) return;
+
+ const focusableElements = panel.querySelectorAll(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
+ );
+
+ const firstElement = focusableElements[0];
+ const lastElement = focusableElements[focusableElements.length - 1];
+
+ if (event.shiftKey && document.activeElement === firstElement) {
+ event.preventDefault();
+ lastElement?.focus();
+ } else if (!event.shiftKey && document.activeElement === lastElement) {
+ event.preventDefault();
+ firstElement?.focus();
+ }
+ },
+ []
+ );
+
+ if (!open) return null;
+
+ // Only render in browser (for SSR compatibility)
+ if (typeof window === 'undefined') return null;
+
+ const modalContent = (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Container */}
+
+ {/* Panel */}
+
{
+ // Handle both refs
+ (panelRef as React.MutableRefObject
).current = node;
+ if (typeof ref === 'function') {
+ ref(node);
+ } else if (ref) {
+ ref.current = node;
+ }
+ }}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby={title ? titleId : undefined}
+ aria-describedby={description ? descriptionId : undefined}
+ tabIndex={-1}
+ className={cn(
+ modalPanelVariants({ size, state: 'entered' }),
+ className
+ )}
+ {...props}
+ >
+ {/* Header */}
+ {(title || showCloseButton) && (
+
+ {title && (
+
+ {title}
+
+ )}
+ {showCloseButton && (
+
+
+
+ )}
+
+ )}
+
+ {/* Hidden description for screen readers */}
+ {description && (
+
+ {description}
+
+ )}
+
+ {/* Body */}
+
+ {children}
+
+
+ {/* Footer */}
+ {footer && (
+
+ {footer}
+
+ )}
+
+
+ >
+ );
+
+ return createPortal(modalContent, document.body);
+ }
+);
+
+Modal.displayName = 'Modal';
+
+/**
+ * ModalHeader component for custom headers
+ */
+export const ModalHeader = forwardRef(
+ ({ className, children, ...props }, ref) => (
+
+ {children}
+
+ )
+);
+
+ModalHeader.displayName = 'ModalHeader';
+
+/**
+ * ModalBody component for custom body content
+ */
+export const ModalBody = forwardRef(
+ ({ className, children, ...props }, ref) => (
+
+ {children}
+
+ )
+);
+
+ModalBody.displayName = 'ModalBody';
+
+/**
+ * ModalFooter component for custom footers
+ */
+export const ModalFooter = forwardRef(
+ ({ className, children, ...props }, ref) => (
+
+ {children}
+
+ )
+);
+
+ModalFooter.displayName = 'ModalFooter';
diff --git a/packages/ui/src/Modal/types.ts b/packages/ui/src/Modal/types.ts
new file mode 100644
index 0000000..83b7b5e
--- /dev/null
+++ b/packages/ui/src/Modal/types.ts
@@ -0,0 +1,86 @@
+import type { HTMLAttributes, ReactNode } from 'react';
+
+/**
+ * Modal component props
+ */
+export interface ModalProps extends Omit, 'title'> {
+ /**
+ * Whether the modal is open
+ */
+ open: boolean;
+
+ /**
+ * Callback when the modal should close
+ */
+ onClose: () => void;
+
+ /**
+ * Modal title (displayed in header)
+ */
+ title?: ReactNode;
+
+ /**
+ * Modal description (for accessibility)
+ */
+ description?: string;
+
+ /**
+ * Size of the modal
+ * @default 'md'
+ */
+ size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
+
+ /**
+ * Whether to close on backdrop click
+ * @default true
+ */
+ closeOnBackdropClick?: boolean;
+
+ /**
+ * Whether to close on Escape key
+ * @default true
+ */
+ closeOnEscape?: boolean;
+
+ /**
+ * Whether to show the close button
+ * @default true
+ */
+ showCloseButton?: boolean;
+
+ /**
+ * Footer content (buttons, actions)
+ */
+ footer?: ReactNode;
+
+ /**
+ * Modal content
+ */
+ children: ReactNode;
+}
+
+/**
+ * ModalHeader component props
+ */
+export interface ModalHeaderProps extends HTMLAttributes {
+ children: ReactNode;
+}
+
+/**
+ * ModalBody component props
+ */
+export interface ModalBodyProps extends HTMLAttributes {
+ children: ReactNode;
+}
+
+/**
+ * ModalFooter component props
+ */
+export interface ModalFooterProps extends HTMLAttributes {
+ children: ReactNode;
+}
+
+/**
+ * Modal ref type
+ */
+export type ModalRef = HTMLDivElement;
diff --git a/packages/ui/src/Modal/variants.ts b/packages/ui/src/Modal/variants.ts
new file mode 100644
index 0000000..6a4de22
--- /dev/null
+++ b/packages/ui/src/Modal/variants.ts
@@ -0,0 +1,162 @@
+import { createVariants } from '../system/variants';
+
+/**
+ * Modal backdrop variant definitions
+ */
+export const modalBackdropVariants = createVariants({
+ base: [
+ 'fixed inset-0',
+ 'bg-foreground/80',
+ 'backdrop-blur-sm',
+ 'z-[var(--z-modal-backdrop)]',
+ 'transition-opacity duration-200',
+ ].join(' '),
+
+ variants: {
+ state: {
+ entering: 'opacity-0',
+ entered: 'opacity-100',
+ exiting: 'opacity-0',
+ },
+ },
+
+ defaultVariants: {
+ state: 'entered',
+ },
+});
+
+/**
+ * Modal container variant definitions
+ */
+export const modalContainerVariants = createVariants({
+ base: [
+ 'fixed inset-0',
+ 'z-[var(--z-modal)]',
+ 'flex items-center justify-center',
+ 'p-4',
+ 'overflow-y-auto',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Modal panel variant definitions
+ */
+export const modalPanelVariants = createVariants({
+ base: [
+ // Layout
+ 'relative w-full',
+ 'flex flex-col',
+ 'max-h-[calc(100vh-2rem)]',
+ // Styling - Sharp corners, blueprint aesthetic
+ 'bg-surface border border-border',
+ 'rounded-none',
+ // Shadow
+ 'shadow-lg',
+ // Animation
+ 'transition-all duration-200',
+ ].join(' '),
+
+ variants: {
+ size: {
+ sm: 'max-w-sm',
+ md: 'max-w-md',
+ lg: 'max-w-lg',
+ xl: 'max-w-xl',
+ full: 'max-w-[calc(100vw-2rem)] max-h-[calc(100vh-2rem)]',
+ },
+ state: {
+ entering: 'opacity-0 scale-95 translate-y-4',
+ entered: 'opacity-100 scale-100 translate-y-0',
+ exiting: 'opacity-0 scale-95 translate-y-4',
+ },
+ },
+
+ defaultVariants: {
+ size: 'md',
+ state: 'entered',
+ },
+});
+
+/**
+ * Modal header variant definitions
+ */
+export const modalHeaderVariants = createVariants({
+ base: [
+ 'flex items-center justify-between',
+ 'px-6 py-4',
+ 'border-b border-border',
+ 'bg-surface',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Modal body variant definitions
+ */
+export const modalBodyVariants = createVariants({
+ base: [
+ 'flex-1',
+ 'px-6 py-4',
+ 'overflow-y-auto',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Modal footer variant definitions
+ */
+export const modalFooterVariants = createVariants({
+ base: [
+ 'flex items-center justify-end gap-3',
+ 'px-6 py-4',
+ 'border-t border-border',
+ 'bg-surface',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Modal title variant definitions
+ */
+export const modalTitleVariants = createVariants({
+ base: [
+ 'font-mono font-semibold text-lg',
+ 'text-foreground',
+ 'lowercase',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Modal close button variant definitions
+ */
+export const modalCloseButtonVariants = createVariants({
+ base: [
+ 'p-2 -m-2',
+ 'text-foreground-muted',
+ 'hover:text-foreground hover:bg-accent/10',
+ 'transition-colors duration-150',
+ 'rounded-none',
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
diff --git a/packages/ui/src/Pagination/Pagination.tsx b/packages/ui/src/Pagination/Pagination.tsx
new file mode 100644
index 0000000..f9e6573
--- /dev/null
+++ b/packages/ui/src/Pagination/Pagination.tsx
@@ -0,0 +1,339 @@
+'use client';
+
+import { cn } from '@tpmjs/utils/cn';
+import { forwardRef, useCallback, useMemo } from 'react';
+import { Icon } from '../Icon/Icon';
+import type {
+ PaginationEllipsisProps,
+ PaginationItemProps,
+ PaginationProps,
+} from './types';
+import {
+ paginationEllipsisVariants,
+ paginationInfoVariants,
+ paginationItemVariants,
+ paginationNavButtonVariants,
+ paginationVariants,
+} from './variants';
+
+/**
+ * Generate pagination range with ellipsis
+ */
+function generatePaginationRange(
+ page: number,
+ totalPages: number,
+ siblings: number,
+ boundaries: number
+): (number | 'ellipsis')[] {
+ const range: (number | 'ellipsis')[] = [];
+
+ // Always show first `boundaries` pages
+ for (let i = 1; i <= Math.min(boundaries, totalPages); i++) {
+ range.push(i);
+ }
+
+ // Calculate sibling range
+ const siblingStart = Math.max(
+ boundaries + 1,
+ page - siblings
+ );
+ const siblingEnd = Math.min(
+ totalPages - boundaries,
+ page + siblings
+ );
+
+ // Add ellipsis if there's a gap after boundaries
+ if (siblingStart > boundaries + 1) {
+ range.push('ellipsis');
+ }
+
+ // Add sibling pages
+ for (let i = siblingStart; i <= siblingEnd; i++) {
+ if (!range.includes(i)) {
+ range.push(i);
+ }
+ }
+
+ // Add ellipsis if there's a gap before end boundaries
+ if (siblingEnd < totalPages - boundaries) {
+ range.push('ellipsis');
+ }
+
+ // Always show last `boundaries` pages
+ for (let i = Math.max(totalPages - boundaries + 1, 1); i <= totalPages; i++) {
+ if (!range.includes(i)) {
+ range.push(i);
+ }
+ }
+
+ return range;
+}
+
+/**
+ * Pagination component
+ *
+ * A component for navigating between pages of content.
+ *
+ * @example
+ * ```tsx
+ * import { Pagination } from '@tpmjs/ui/Pagination/Pagination';
+ *
+ * function MyComponent() {
+ * const [page, setPage] = useState(1);
+ *
+ * return (
+ *
+ * );
+ * }
+ * ```
+ */
+export const Pagination = forwardRef(
+ (
+ {
+ page,
+ totalPages,
+ onPageChange,
+ siblings = 1,
+ boundaries = 1,
+ size = 'md',
+ variant = 'default',
+ showFirstLast = false,
+ showPrevNext = true,
+ previousLabel = 'Previous',
+ nextLabel = 'Next',
+ disabled = false,
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ const paginationRange = useMemo(
+ () => generatePaginationRange(page, totalPages, siblings, boundaries),
+ [page, totalPages, siblings, boundaries]
+ );
+
+ const handlePageChange = useCallback(
+ (newPage: number) => {
+ if (newPage >= 1 && newPage <= totalPages && newPage !== page) {
+ onPageChange(newPage);
+ }
+ },
+ [page, totalPages, onPageChange]
+ );
+
+ const isFirstPage = page === 1;
+ const isLastPage = page === totalPages;
+
+ // Simple variant: just prev/next with page info
+ if (variant === 'simple') {
+ return (
+
+ handlePageChange(page - 1)}
+ className={paginationNavButtonVariants({ size, disabled: (disabled || isFirstPage) ? 'true' : 'false' })}
+ aria-label="Go to previous page"
+ >
+
+ {previousLabel}
+
+
+
+ {page} / {totalPages}
+
+
+ handlePageChange(page + 1)}
+ className={paginationNavButtonVariants({ size, disabled: (disabled || isLastPage) ? 'true' : 'false' })}
+ aria-label="Go to next page"
+ >
+ {nextLabel}
+
+
+
+ );
+ }
+
+ // Minimal variant: just prev/next icons
+ if (variant === 'minimal') {
+ return (
+
+ handlePageChange(page - 1)}
+ className={paginationItemVariants({ size, disabled: (disabled || isFirstPage) ? 'true' : 'false' })}
+ aria-label="Go to previous page"
+ >
+
+
+
+
+ {page} / {totalPages}
+
+
+ handlePageChange(page + 1)}
+ className={paginationItemVariants({ size, disabled: (disabled || isLastPage) ? 'true' : 'false' })}
+ aria-label="Go to next page"
+ >
+
+
+
+ );
+ }
+
+ // Default variant: full pagination with page numbers
+ return (
+
+ {/* First page button */}
+ {showFirstLast && (
+ handlePageChange(1)}
+ className={paginationItemVariants({ size, disabled: (disabled || isFirstPage) ? 'true' : 'false' })}
+ aria-label="Go to first page"
+ >
+
+
+ )}
+
+ {/* Previous button */}
+ {showPrevNext && (
+ handlePageChange(page - 1)}
+ className={paginationItemVariants({ size, disabled: (disabled || isFirstPage) ? 'true' : 'false' })}
+ aria-label="Go to previous page"
+ >
+
+
+ )}
+
+ {/* Page numbers */}
+ {paginationRange.map((item, index) => {
+ if (item === 'ellipsis') {
+ return (
+
+ );
+ }
+
+ return (
+ handlePageChange(item)}
+ className={paginationItemVariants({
+ size,
+ active: item === page ? 'true' : 'false',
+ disabled: disabled ? 'true' : 'false',
+ })}
+ aria-label={`Go to page ${item}`}
+ aria-current={item === page ? 'page' : undefined}
+ >
+ {item}
+
+ );
+ })}
+
+ {/* Next button */}
+ {showPrevNext && (
+ handlePageChange(page + 1)}
+ className={paginationItemVariants({ size, disabled: (disabled || isLastPage) ? 'true' : 'false' })}
+ aria-label="Go to next page"
+ >
+
+
+ )}
+
+ {/* Last page button */}
+ {showFirstLast && (
+ handlePageChange(totalPages)}
+ className={paginationItemVariants({ size, disabled: (disabled || isLastPage) ? 'true' : 'false' })}
+ aria-label="Go to last page"
+ >
+
+
+ )}
+
+ );
+ }
+);
+
+Pagination.displayName = 'Pagination';
+
+/**
+ * PaginationItem component (for custom usage)
+ */
+export const PaginationItem = forwardRef(
+ ({ active = false, disabled = false, size = 'md', children, className, ...props }, ref) => (
+
+ {children}
+
+ )
+);
+
+PaginationItem.displayName = 'PaginationItem';
+
+/**
+ * PaginationEllipsis component
+ */
+export const PaginationEllipsis = forwardRef(
+ ({ size = 'md', className, ...props }, ref) => (
+
+
+
+ )
+);
+
+PaginationEllipsis.displayName = 'PaginationEllipsis';
diff --git a/packages/ui/src/Pagination/types.ts b/packages/ui/src/Pagination/types.ts
new file mode 100644
index 0000000..a31e191
--- /dev/null
+++ b/packages/ui/src/Pagination/types.ts
@@ -0,0 +1,125 @@
+import type { HTMLAttributes, ReactNode } from 'react';
+
+/**
+ * Pagination size types
+ */
+export type PaginationSize = 'sm' | 'md' | 'lg';
+
+/**
+ * Pagination variant types
+ */
+export type PaginationVariant = 'default' | 'simple' | 'minimal';
+
+/**
+ * Pagination component props
+ */
+export interface PaginationProps extends HTMLAttributes {
+ /**
+ * Current page (1-indexed)
+ */
+ page: number;
+
+ /**
+ * Total number of pages
+ */
+ totalPages: number;
+
+ /**
+ * Callback when page changes
+ */
+ onPageChange: (page: number) => void;
+
+ /**
+ * Number of sibling pages to show on each side
+ * @default 1
+ */
+ siblings?: number;
+
+ /**
+ * Number of boundary pages to show at start/end
+ * @default 1
+ */
+ boundaries?: number;
+
+ /**
+ * Size variant
+ * @default 'md'
+ */
+ size?: PaginationSize;
+
+ /**
+ * Visual variant
+ * @default 'default'
+ */
+ variant?: PaginationVariant;
+
+ /**
+ * Whether to show first/last page buttons
+ * @default false
+ */
+ showFirstLast?: boolean;
+
+ /**
+ * Whether to show previous/next buttons
+ * @default true
+ */
+ showPrevNext?: boolean;
+
+ /**
+ * Label for previous button
+ * @default 'Previous'
+ */
+ previousLabel?: ReactNode;
+
+ /**
+ * Label for next button
+ * @default 'Next'
+ */
+ nextLabel?: ReactNode;
+
+ /**
+ * Whether the pagination is disabled
+ * @default false
+ */
+ disabled?: boolean;
+}
+
+/**
+ * PaginationItem component props
+ */
+export interface PaginationItemProps extends HTMLAttributes {
+ /**
+ * Whether this is the current page
+ */
+ active?: boolean;
+
+ /**
+ * Whether the item is disabled
+ */
+ disabled?: boolean;
+
+ /**
+ * Size variant
+ */
+ size?: PaginationSize;
+
+ /**
+ * Item content
+ */
+ children: ReactNode;
+}
+
+/**
+ * PaginationEllipsis component props
+ */
+export interface PaginationEllipsisProps extends HTMLAttributes {
+ /**
+ * Size variant
+ */
+ size?: PaginationSize;
+}
+
+/**
+ * Pagination ref type
+ */
+export type PaginationRef = HTMLElement;
diff --git a/packages/ui/src/Pagination/variants.ts b/packages/ui/src/Pagination/variants.ts
new file mode 100644
index 0000000..b4ed8c1
--- /dev/null
+++ b/packages/ui/src/Pagination/variants.ts
@@ -0,0 +1,151 @@
+import { createVariants } from '../system/variants';
+
+/**
+ * Pagination container variant definitions
+ */
+export const paginationVariants = createVariants({
+ base: [
+ 'flex items-center gap-1',
+ 'font-mono',
+ ].join(' '),
+
+ variants: {
+ size: {
+ sm: 'text-xs',
+ md: 'text-sm',
+ lg: 'text-base',
+ },
+ },
+
+ defaultVariants: {
+ size: 'md',
+ },
+});
+
+/**
+ * Pagination item variant definitions
+ */
+export const paginationItemVariants = createVariants({
+ base: [
+ // Layout
+ 'inline-flex items-center justify-center',
+ 'font-mono',
+ // Styling - Sharp corners
+ 'border border-transparent',
+ 'rounded-none',
+ // Interaction
+ 'cursor-pointer',
+ 'transition-all duration-150',
+ // Focus
+ 'outline-none',
+ 'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
+ ].join(' '),
+
+ variants: {
+ size: {
+ sm: 'h-7 min-w-7 px-2 text-xs',
+ md: 'h-9 min-w-9 px-3 text-sm',
+ lg: 'h-11 min-w-11 px-4 text-base',
+ },
+ active: {
+ 'true': 'bg-primary text-primary-foreground border-primary',
+ 'false': 'text-foreground-muted hover:text-foreground hover:bg-accent/10 hover:border-border',
+ },
+ disabled: {
+ 'true': 'opacity-50 cursor-not-allowed pointer-events-none',
+ 'false': '',
+ },
+ },
+
+ defaultVariants: {
+ size: 'md',
+ active: 'false',
+ disabled: 'false',
+ },
+});
+
+/**
+ * Pagination nav button variant definitions
+ */
+export const paginationNavButtonVariants = createVariants({
+ base: [
+ // Layout
+ 'inline-flex items-center justify-center gap-1',
+ 'font-mono',
+ // Styling
+ 'border border-border',
+ 'rounded-none',
+ // Interaction
+ 'cursor-pointer',
+ 'transition-all duration-150',
+ // Focus
+ 'outline-none',
+ 'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
+ // States
+ 'text-foreground-muted',
+ 'hover:text-foreground hover:bg-accent/10',
+ ].join(' '),
+
+ variants: {
+ size: {
+ sm: 'h-7 px-2 text-xs',
+ md: 'h-9 px-3 text-sm',
+ lg: 'h-11 px-4 text-base',
+ },
+ disabled: {
+ 'true': 'opacity-50 cursor-not-allowed pointer-events-none',
+ 'false': '',
+ },
+ },
+
+ defaultVariants: {
+ size: 'md',
+ disabled: 'false',
+ },
+});
+
+/**
+ * Pagination ellipsis variant definitions
+ */
+export const paginationEllipsisVariants = createVariants({
+ base: [
+ 'inline-flex items-center justify-center',
+ 'text-foreground-muted',
+ 'select-none',
+ ].join(' '),
+
+ variants: {
+ size: {
+ sm: 'h-7 w-7 text-xs',
+ md: 'h-9 w-9 text-sm',
+ lg: 'h-11 w-11 text-base',
+ },
+ },
+
+ defaultVariants: {
+ size: 'md',
+ },
+});
+
+/**
+ * Pagination info variant definitions (for showing "Page X of Y")
+ */
+export const paginationInfoVariants = createVariants({
+ base: [
+ 'text-foreground-muted',
+ 'font-mono',
+ 'mx-2',
+ ].join(' '),
+
+ variants: {
+ size: {
+ sm: 'text-xs',
+ md: 'text-sm',
+ lg: 'text-base',
+ },
+ },
+
+ defaultVariants: {
+ size: 'md',
+ },
+});
diff --git a/packages/ui/src/Popover/Popover.tsx b/packages/ui/src/Popover/Popover.tsx
new file mode 100644
index 0000000..b630d0c
--- /dev/null
+++ b/packages/ui/src/Popover/Popover.tsx
@@ -0,0 +1,334 @@
+'use client';
+
+import { cn } from '@tpmjs/utils/cn';
+import {
+ cloneElement,
+ forwardRef,
+ isValidElement,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import { createPortal } from 'react-dom';
+import type { PopoverPlacement, PopoverProps } from './types';
+import {
+ popoverArrowVariants,
+ popoverBodyVariants,
+ popoverContentVariants,
+} from './variants';
+
+/**
+ * Calculate position based on trigger and placement
+ */
+function calculatePosition(
+ triggerRect: DOMRect,
+ contentRect: DOMRect,
+ placement: PopoverPlacement,
+ offset: number
+): { top: number; left: number } {
+ let top = 0;
+ let left = 0;
+
+ const scrollX = window.scrollX;
+ const scrollY = window.scrollY;
+
+ switch (placement) {
+ case 'top':
+ top = triggerRect.top + scrollY - contentRect.height - offset;
+ left = triggerRect.left + scrollX + (triggerRect.width - contentRect.width) / 2;
+ break;
+ case 'top-start':
+ top = triggerRect.top + scrollY - contentRect.height - offset;
+ left = triggerRect.left + scrollX;
+ break;
+ case 'top-end':
+ top = triggerRect.top + scrollY - contentRect.height - offset;
+ left = triggerRect.right + scrollX - contentRect.width;
+ break;
+ case 'bottom':
+ top = triggerRect.bottom + scrollY + offset;
+ left = triggerRect.left + scrollX + (triggerRect.width - contentRect.width) / 2;
+ break;
+ case 'bottom-start':
+ top = triggerRect.bottom + scrollY + offset;
+ left = triggerRect.left + scrollX;
+ break;
+ case 'bottom-end':
+ top = triggerRect.bottom + scrollY + offset;
+ left = triggerRect.right + scrollX - contentRect.width;
+ break;
+ case 'left':
+ top = triggerRect.top + scrollY + (triggerRect.height - contentRect.height) / 2;
+ left = triggerRect.left + scrollX - contentRect.width - offset;
+ break;
+ case 'left-start':
+ top = triggerRect.top + scrollY;
+ left = triggerRect.left + scrollX - contentRect.width - offset;
+ break;
+ case 'left-end':
+ top = triggerRect.bottom + scrollY - contentRect.height;
+ left = triggerRect.left + scrollX - contentRect.width - offset;
+ break;
+ case 'right':
+ top = triggerRect.top + scrollY + (triggerRect.height - contentRect.height) / 2;
+ left = triggerRect.right + scrollX + offset;
+ break;
+ case 'right-start':
+ top = triggerRect.top + scrollY;
+ left = triggerRect.right + scrollX + offset;
+ break;
+ case 'right-end':
+ top = triggerRect.bottom + scrollY - contentRect.height;
+ left = triggerRect.right + scrollX + offset;
+ break;
+ }
+
+ return { top, left };
+}
+
+/**
+ * Popover component
+ *
+ * A floating content panel that appears next to a trigger element.
+ *
+ * @example
+ * ```tsx
+ * import { Popover } from '@tpmjs/ui/Popover/Popover';
+ * import { Button } from '@tpmjs/ui/Button/Button';
+ *
+ * function MyComponent() {
+ * return (
+ * This is popover content}
+ * placement="bottom"
+ * >
+ * Click me
+ *
+ * );
+ * }
+ * ```
+ */
+export const Popover = forwardRef(
+ (
+ {
+ open: controlledOpen,
+ onOpenChange,
+ defaultOpen = false,
+ children,
+ content,
+ placement = 'bottom',
+ trigger = 'click',
+ offset = 8,
+ closeOnClickOutside = true,
+ closeOnEscape = true,
+ showDelay = 0,
+ hideDelay = 0,
+ hasArrow = false,
+ disabled = false,
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ const isControlled = controlledOpen !== undefined;
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
+ const isOpen = isControlled ? controlledOpen : internalOpen;
+
+ const triggerRef = useRef(null);
+ const contentRef = useRef(null);
+ const showTimeoutRef = useRef | undefined>(undefined);
+ const hideTimeoutRef = useRef | undefined>(undefined);
+
+ const [position, setPosition] = useState({ top: 0, left: 0 });
+
+ const setOpen = useCallback(
+ (value: boolean) => {
+ if (!isControlled) {
+ setInternalOpen(value);
+ }
+ onOpenChange?.(value);
+ },
+ [isControlled, onOpenChange]
+ );
+
+ const handleOpen = useCallback(() => {
+ if (disabled) return;
+
+ if (hideTimeoutRef.current) {
+ clearTimeout(hideTimeoutRef.current);
+ }
+
+ if (showDelay > 0) {
+ showTimeoutRef.current = setTimeout(() => {
+ setOpen(true);
+ }, showDelay);
+ } else {
+ setOpen(true);
+ }
+ }, [disabled, showDelay, setOpen]);
+
+ const handleClose = useCallback(() => {
+ if (showTimeoutRef.current) {
+ clearTimeout(showTimeoutRef.current);
+ }
+
+ if (hideDelay > 0) {
+ hideTimeoutRef.current = setTimeout(() => {
+ setOpen(false);
+ }, hideDelay);
+ } else {
+ setOpen(false);
+ }
+ }, [hideDelay, setOpen]);
+
+ const handleToggle = useCallback(() => {
+ if (isOpen) {
+ handleClose();
+ } else {
+ handleOpen();
+ }
+ }, [isOpen, handleOpen, handleClose]);
+
+ // Update position when open
+ useEffect(() => {
+ if (!isOpen || !triggerRef.current || !contentRef.current) return;
+
+ const updatePosition = () => {
+ const triggerRect = triggerRef.current!.getBoundingClientRect();
+ const contentRect = contentRef.current!.getBoundingClientRect();
+ const newPosition = calculatePosition(triggerRect, contentRect, placement, offset);
+ setPosition(newPosition);
+ };
+
+ updatePosition();
+
+ // Update on scroll/resize
+ window.addEventListener('scroll', updatePosition, true);
+ window.addEventListener('resize', updatePosition);
+
+ return () => {
+ window.removeEventListener('scroll', updatePosition, true);
+ window.removeEventListener('resize', updatePosition);
+ };
+ }, [isOpen, placement, offset]);
+
+ // Handle click outside
+ useEffect(() => {
+ if (!isOpen || !closeOnClickOutside) return;
+
+ const handleClickOutside = (event: MouseEvent) => {
+ const target = event.target as Node;
+ if (
+ triggerRef.current?.contains(target) ||
+ contentRef.current?.contains(target)
+ ) {
+ return;
+ }
+ handleClose();
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [isOpen, closeOnClickOutside, handleClose]);
+
+ // Handle escape key
+ useEffect(() => {
+ if (!isOpen || !closeOnEscape) return;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ handleClose();
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [isOpen, closeOnEscape, handleClose]);
+
+ // Cleanup timeouts on unmount
+ useEffect(() => {
+ return () => {
+ if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current);
+ if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
+ };
+ }, []);
+
+ // Clone trigger element with event handlers
+ const triggerElement = isValidElement(children)
+ ? cloneElement(children as React.ReactElement, {
+ ref: triggerRef,
+ ...(trigger === 'click' && {
+ onClick: (e: React.MouseEvent) => {
+ (children as React.ReactElement).props.onClick?.(e);
+ handleToggle();
+ },
+ }),
+ ...(trigger === 'hover' && {
+ onMouseEnter: (e: React.MouseEvent) => {
+ (children as React.ReactElement).props.onMouseEnter?.(e);
+ handleOpen();
+ },
+ onMouseLeave: (e: React.MouseEvent) => {
+ (children as React.ReactElement).props.onMouseLeave?.(e);
+ handleClose();
+ },
+ }),
+ ...(trigger === 'focus' && {
+ onFocus: (e: React.FocusEvent) => {
+ (children as React.ReactElement).props.onFocus?.(e);
+ handleOpen();
+ },
+ onBlur: (e: React.FocusEvent) => {
+ (children as React.ReactElement).props.onBlur?.(e);
+ handleClose();
+ },
+ }),
+ })
+ : children;
+
+ // Only render portal in browser
+ const canRenderPortal = typeof window !== 'undefined';
+
+ return (
+ <>
+ {triggerElement}
+ {canRenderPortal &&
+ isOpen &&
+ createPortal(
+ {
+ (contentRef as React.MutableRefObject
).current = node;
+ if (typeof ref === 'function') {
+ ref(node);
+ } else if (ref) {
+ ref.current = node;
+ }
+ }}
+ role="dialog"
+ aria-modal="false"
+ className={cn(popoverContentVariants({ state: 'entered' }), className)}
+ style={{
+ top: position.top,
+ left: position.left,
+ }}
+ onMouseEnter={trigger === 'hover' ? handleOpen : undefined}
+ onMouseLeave={trigger === 'hover' ? handleClose : undefined}
+ {...props}
+ >
+ {hasArrow && (
+
+ )}
+
+ {content}
+
+ ,
+ document.body
+ )}
+ >
+ );
+ }
+);
+
+Popover.displayName = 'Popover';
diff --git a/packages/ui/src/Popover/types.ts b/packages/ui/src/Popover/types.ts
new file mode 100644
index 0000000..cf392e7
--- /dev/null
+++ b/packages/ui/src/Popover/types.ts
@@ -0,0 +1,137 @@
+import type { HTMLAttributes, ReactNode, RefObject } from 'react';
+
+/**
+ * Popover placement types
+ */
+export type PopoverPlacement =
+ | 'top'
+ | 'top-start'
+ | 'top-end'
+ | 'bottom'
+ | 'bottom-start'
+ | 'bottom-end'
+ | 'left'
+ | 'left-start'
+ | 'left-end'
+ | 'right'
+ | 'right-start'
+ | 'right-end';
+
+/**
+ * Popover trigger types
+ */
+export type PopoverTrigger = 'click' | 'hover' | 'focus' | 'manual';
+
+/**
+ * Popover component props
+ */
+export interface PopoverProps extends Omit, 'content'> {
+ /**
+ * Whether the popover is open (controlled mode)
+ */
+ open?: boolean;
+
+ /**
+ * Callback when open state changes
+ */
+ onOpenChange?: (open: boolean) => void;
+
+ /**
+ * Default open state (uncontrolled mode)
+ * @default false
+ */
+ defaultOpen?: boolean;
+
+ /**
+ * The trigger element (must accept ref)
+ */
+ children: ReactNode;
+
+ /**
+ * The popover content
+ */
+ content: ReactNode;
+
+ /**
+ * Placement of the popover relative to trigger
+ * @default 'bottom'
+ */
+ placement?: PopoverPlacement;
+
+ /**
+ * How the popover is triggered
+ * @default 'click'
+ */
+ trigger?: PopoverTrigger;
+
+ /**
+ * Offset from the trigger element in pixels
+ * @default 8
+ */
+ offset?: number;
+
+ /**
+ * Whether to close when clicking outside
+ * @default true
+ */
+ closeOnClickOutside?: boolean;
+
+ /**
+ * Whether to close on Escape key
+ * @default true
+ */
+ closeOnEscape?: boolean;
+
+ /**
+ * Delay before showing (for hover trigger) in milliseconds
+ * @default 0
+ */
+ showDelay?: number;
+
+ /**
+ * Delay before hiding (for hover trigger) in milliseconds
+ * @default 0
+ */
+ hideDelay?: number;
+
+ /**
+ * Whether the popover has an arrow
+ * @default false
+ */
+ hasArrow?: boolean;
+
+ /**
+ * Whether the popover is disabled
+ * @default false
+ */
+ disabled?: boolean;
+}
+
+/**
+ * PopoverContent component props
+ */
+export interface PopoverContentProps extends HTMLAttributes {
+ children: ReactNode;
+}
+
+/**
+ * PopoverTrigger component props
+ */
+export interface PopoverTriggerProps extends HTMLAttributes {
+ children: ReactNode;
+ asChild?: boolean;
+}
+
+/**
+ * Popover ref type
+ */
+export type PopoverRef = HTMLDivElement;
+
+/**
+ * Internal popover state
+ */
+export interface PopoverState {
+ isOpen: boolean;
+ triggerRef: RefObject;
+ contentRef: RefObject;
+}
diff --git a/packages/ui/src/Popover/variants.ts b/packages/ui/src/Popover/variants.ts
new file mode 100644
index 0000000..55f1603
--- /dev/null
+++ b/packages/ui/src/Popover/variants.ts
@@ -0,0 +1,81 @@
+import { createVariants } from '../system/variants';
+
+/**
+ * Popover content variant definitions
+ */
+export const popoverContentVariants = createVariants({
+ base: [
+ // Layout
+ 'absolute',
+ 'z-[var(--z-popover)]',
+ 'min-w-[8rem]',
+ 'max-w-[20rem]',
+ // Styling - Sharp corners, blueprint aesthetic
+ 'bg-surface border border-border',
+ 'rounded-none',
+ // Shadow
+ 'shadow-lg',
+ // Animation
+ 'transition-all duration-150',
+ ].join(' '),
+
+ variants: {
+ state: {
+ entering: 'opacity-0 scale-95',
+ entered: 'opacity-100 scale-100',
+ exiting: 'opacity-0 scale-95',
+ },
+ },
+
+ defaultVariants: {
+ state: 'entered',
+ },
+});
+
+/**
+ * Popover arrow variant definitions
+ */
+export const popoverArrowVariants = createVariants({
+ base: [
+ 'absolute',
+ 'w-2 h-2',
+ 'bg-surface border-border',
+ 'rotate-45',
+ ].join(' '),
+
+ variants: {
+ placement: {
+ top: 'bottom-[-5px] border-r border-b',
+ 'top-start': 'bottom-[-5px] border-r border-b left-4',
+ 'top-end': 'bottom-[-5px] border-r border-b right-4',
+ bottom: 'top-[-5px] border-l border-t',
+ 'bottom-start': 'top-[-5px] border-l border-t left-4',
+ 'bottom-end': 'top-[-5px] border-l border-t right-4',
+ left: 'right-[-5px] border-r border-t',
+ 'left-start': 'right-[-5px] border-r border-t top-4',
+ 'left-end': 'right-[-5px] border-r border-t bottom-4',
+ right: 'left-[-5px] border-l border-b',
+ 'right-start': 'left-[-5px] border-l border-b top-4',
+ 'right-end': 'left-[-5px] border-l border-b bottom-4',
+ },
+ },
+
+ defaultVariants: {
+ placement: 'bottom',
+ },
+});
+
+/**
+ * Popover body variant definitions
+ */
+export const popoverBodyVariants = createVariants({
+ base: [
+ 'p-3',
+ 'font-mono text-sm',
+ 'text-foreground',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
diff --git a/packages/ui/src/ProgressBar/variants.ts b/packages/ui/src/ProgressBar/variants.ts
index 68b957a..0b1439d 100644
--- a/packages/ui/src/ProgressBar/variants.ts
+++ b/packages/ui/src/ProgressBar/variants.ts
@@ -9,8 +9,8 @@ export const progressBarTrackVariants = createVariants({
'relative overflow-hidden',
// Background
'bg-surface',
- // Border
- 'rounded',
+ // Border - SHARP CORNERS
+ 'rounded-none',
].join(' '),
variants: {
@@ -35,10 +35,10 @@ export const progressBarFillVariants = createVariants({
base: [
// Layout
'h-full',
- // Transition
- 'transition-all duration-300 ease-out',
- // Border
- 'rounded',
+ // Transition - Fast
+ 'transition-all duration-150 ease-out',
+ // Border - SHARP CORNERS
+ 'rounded-none',
].join(' '),
variants: {
diff --git a/packages/ui/src/QualityScore/QualityScore.tsx b/packages/ui/src/QualityScore/QualityScore.tsx
new file mode 100644
index 0000000..2e597a1
--- /dev/null
+++ b/packages/ui/src/QualityScore/QualityScore.tsx
@@ -0,0 +1,236 @@
+'use client';
+
+import { cn } from '@tpmjs/utils/cn';
+import { forwardRef, useMemo } from 'react';
+import type { QualityScoreBreakdown, QualityScoreProps, QualityTier } from './types';
+import {
+ qualityScoreBarFillVariants,
+ qualityScoreBarVariants,
+ qualityScoreBreakdownRowVariants,
+ qualityScoreBreakdownVariants,
+ qualityScoreCircleVariants,
+ qualityScoreTierVariants,
+ qualityScoreVariants,
+} from './variants';
+
+/**
+ * Get tier from score percentage
+ */
+function getTierFromScore(score: number): QualityTier {
+ if (score >= 80) return 'excellent';
+ if (score >= 60) return 'good';
+ if (score >= 40) return 'fair';
+ return 'poor';
+}
+
+/**
+ * Get tier label
+ */
+function getTierLabel(tier: QualityTier): string {
+ switch (tier) {
+ case 'excellent':
+ return 'Excellent';
+ case 'good':
+ return 'Good';
+ case 'fair':
+ return 'Fair';
+ case 'poor':
+ return 'Poor';
+ }
+}
+
+/**
+ * Breakdown category labels
+ */
+const BREAKDOWN_LABELS: Record = {
+ documentation: 'Docs',
+ maintenance: 'Maintenance',
+ popularity: 'Popularity',
+ security: 'Security',
+ tests: 'Tests',
+};
+
+/**
+ * QualityScore component
+ *
+ * A component for displaying quality scores with visual indicators.
+ *
+ * @example
+ * ```tsx
+ * import { QualityScore } from '@tpmjs/ui/QualityScore/QualityScore';
+ *
+ * function MyComponent() {
+ * return (
+ *
+ * );
+ * }
+ * ```
+ */
+export const QualityScore = forwardRef(
+ (
+ {
+ score,
+ isDecimal = false,
+ size = 'md',
+ variant = 'default',
+ showTier = true,
+ showScore = true,
+ tierLabel: customTierLabel,
+ breakdown,
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ // Normalize score to percentage
+ const normalizedScore = useMemo(() => {
+ if (isDecimal) {
+ return Math.round(score * 100);
+ }
+ return Math.round(score);
+ }, [score, isDecimal]);
+
+ const tier = getTierFromScore(normalizedScore);
+ const tierLabel = customTierLabel || getTierLabel(tier);
+
+ // Badge variant
+ if (variant === 'badge') {
+ return (
+
+
+ {showScore && normalizedScore}
+
+ {showTier && (
+
+ {tierLabel}
+
+ )}
+
+ );
+ }
+
+ // Inline variant
+ if (variant === 'inline') {
+ return (
+
+ {showScore && (
+
+ {normalizedScore}
+
+ )}
+ {showTier && (
+
+ ({tierLabel})
+
+ )}
+
+ );
+ }
+
+ // Detailed variant
+ if (variant === 'detailed') {
+ return (
+
+ {/* Main score display */}
+
+
+ {showScore && normalizedScore}
+
+
+ {showTier && (
+
+ {tierLabel}
+
+ )}
+ {showScore && (
+
+ out of 100
+
+ )}
+
+
+
+ {/* Breakdown */}
+ {breakdown && (
+
+ {(Object.keys(breakdown) as Array
).map(
+ (key) => {
+ const value = breakdown[key];
+ if (value === undefined) return null;
+
+ const percentage = Math.round(value * 100);
+ const breakdownTier = getTierFromScore(percentage);
+
+ return (
+
+
+ {BREAKDOWN_LABELS[key]}
+
+
+
{percentage}
+
+ );
+ }
+ )}
+
+ )}
+
+ );
+ }
+
+ // Default variant
+ return (
+
+
+ {showScore && normalizedScore}
+
+ {showTier && (
+
+ )}
+
+ );
+ }
+);
+
+QualityScore.displayName = 'QualityScore';
diff --git a/packages/ui/src/QualityScore/types.ts b/packages/ui/src/QualityScore/types.ts
new file mode 100644
index 0000000..18bf99b
--- /dev/null
+++ b/packages/ui/src/QualityScore/types.ts
@@ -0,0 +1,101 @@
+import type { HTMLAttributes } from 'react';
+
+/**
+ * Quality tier types
+ */
+export type QualityTier = 'excellent' | 'good' | 'fair' | 'poor';
+
+/**
+ * QualityScore size types
+ */
+export type QualityScoreSize = 'sm' | 'md' | 'lg';
+
+/**
+ * QualityScore variant types
+ */
+export type QualityScoreVariant = 'default' | 'badge' | 'inline' | 'detailed';
+
+/**
+ * QualityScore component props
+ */
+export interface QualityScoreProps extends HTMLAttributes {
+ /**
+ * Score value (0-100 or 0-1)
+ */
+ score: number;
+
+ /**
+ * Whether score is in decimal format (0-1) vs percentage (0-100)
+ * @default false
+ */
+ isDecimal?: boolean;
+
+ /**
+ * Size variant
+ * @default 'md'
+ */
+ size?: QualityScoreSize;
+
+ /**
+ * Visual variant
+ * @default 'default'
+ */
+ variant?: QualityScoreVariant;
+
+ /**
+ * Whether to show the tier label
+ * @default true
+ */
+ showTier?: boolean;
+
+ /**
+ * Whether to show the numeric score
+ * @default true
+ */
+ showScore?: boolean;
+
+ /**
+ * Custom tier label
+ */
+ tierLabel?: string;
+
+ /**
+ * Breakdown of score components (for detailed variant)
+ */
+ breakdown?: QualityScoreBreakdown;
+}
+
+/**
+ * Quality score breakdown
+ */
+export interface QualityScoreBreakdown {
+ /**
+ * Documentation score (0-1)
+ */
+ documentation?: number;
+
+ /**
+ * Maintenance score (0-1)
+ */
+ maintenance?: number;
+
+ /**
+ * Popularity score (0-1)
+ */
+ popularity?: number;
+
+ /**
+ * Security score (0-1)
+ */
+ security?: number;
+
+ /**
+ * Test coverage score (0-1)
+ */
+ tests?: number;
+}
+
+/**
+ * QualityScore ref type
+ */
+export type QualityScoreRef = HTMLDivElement;
diff --git a/packages/ui/src/QualityScore/variants.ts b/packages/ui/src/QualityScore/variants.ts
new file mode 100644
index 0000000..8237c20
--- /dev/null
+++ b/packages/ui/src/QualityScore/variants.ts
@@ -0,0 +1,175 @@
+import { createVariants } from '../system/variants';
+
+/**
+ * QualityScore container variant definitions
+ */
+export const qualityScoreVariants = createVariants({
+ base: [
+ 'inline-flex items-center',
+ 'font-mono',
+ ].join(' '),
+
+ variants: {
+ variant: {
+ default: 'gap-2',
+ badge: 'gap-1.5 px-2 py-1 border border-border rounded-none',
+ inline: 'gap-1',
+ detailed: 'flex-col items-start gap-2',
+ },
+ size: {
+ sm: 'text-xs',
+ md: 'text-sm',
+ lg: 'text-base',
+ },
+ },
+
+ defaultVariants: {
+ variant: 'default',
+ size: 'md',
+ },
+});
+
+/**
+ * QualityScore circle variant definitions
+ */
+export const qualityScoreCircleVariants = createVariants({
+ base: [
+ 'relative',
+ 'flex items-center justify-center',
+ 'rounded-full',
+ 'border-2',
+ 'font-mono font-semibold',
+ ].join(' '),
+
+ variants: {
+ size: {
+ sm: 'w-8 h-8 text-xs',
+ md: 'w-10 h-10 text-sm',
+ lg: 'w-14 h-14 text-base',
+ },
+ tier: {
+ excellent: 'border-success text-success bg-success/10',
+ good: 'border-primary text-primary bg-primary/10',
+ fair: 'border-warning text-warning bg-warning/10',
+ poor: 'border-error text-error bg-error/10',
+ },
+ },
+
+ defaultVariants: {
+ size: 'md',
+ tier: 'fair',
+ },
+});
+
+/**
+ * QualityScore tier label variant definitions
+ */
+export const qualityScoreTierVariants = createVariants({
+ base: [
+ 'font-mono font-medium',
+ 'uppercase tracking-wider',
+ ].join(' '),
+
+ variants: {
+ size: {
+ sm: 'text-[10px]',
+ md: 'text-xs',
+ lg: 'text-sm',
+ },
+ tier: {
+ excellent: 'text-success',
+ good: 'text-primary',
+ fair: 'text-warning',
+ poor: 'text-error',
+ },
+ },
+
+ defaultVariants: {
+ size: 'md',
+ tier: 'fair',
+ },
+});
+
+/**
+ * QualityScore bar variant definitions
+ */
+export const qualityScoreBarVariants = createVariants({
+ base: [
+ 'h-1.5',
+ 'bg-accent/20',
+ 'rounded-none',
+ 'overflow-hidden',
+ ].join(' '),
+
+ variants: {
+ size: {
+ sm: 'w-16',
+ md: 'w-20',
+ lg: 'w-24',
+ },
+ },
+
+ defaultVariants: {
+ size: 'md',
+ },
+});
+
+/**
+ * QualityScore bar fill variant definitions
+ */
+export const qualityScoreBarFillVariants = createVariants({
+ base: [
+ 'h-full',
+ 'transition-all duration-300',
+ ].join(' '),
+
+ variants: {
+ tier: {
+ excellent: 'bg-success',
+ good: 'bg-primary',
+ fair: 'bg-warning',
+ poor: 'bg-error',
+ },
+ },
+
+ defaultVariants: {
+ tier: 'fair',
+ },
+});
+
+/**
+ * QualityScore breakdown container variant definitions
+ */
+export const qualityScoreBreakdownVariants = createVariants({
+ base: [
+ 'w-full',
+ 'space-y-1.5',
+ 'pt-2',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * QualityScore breakdown row variant definitions
+ */
+export const qualityScoreBreakdownRowVariants = createVariants({
+ base: [
+ 'flex items-center gap-2',
+ 'text-foreground-muted',
+ ].join(' '),
+
+ variants: {
+ size: {
+ sm: 'text-[10px]',
+ md: 'text-xs',
+ lg: 'text-sm',
+ },
+ },
+
+ defaultVariants: {
+ size: 'md',
+ },
+});
diff --git a/packages/ui/src/Skeleton/Skeleton.tsx b/packages/ui/src/Skeleton/Skeleton.tsx
new file mode 100644
index 0000000..1917a05
--- /dev/null
+++ b/packages/ui/src/Skeleton/Skeleton.tsx
@@ -0,0 +1,319 @@
+'use client';
+
+import { cn } from '@tpmjs/utils/cn';
+import { forwardRef } from 'react';
+import type {
+ SkeletonAvatarProps,
+ SkeletonCardProps,
+ SkeletonProps,
+ SkeletonTextProps,
+} from './types';
+import {
+ skeletonAvatarVariants,
+ skeletonCardImageVariants,
+ skeletonCardVariants,
+ skeletonTextContainerVariants,
+ skeletonVariants,
+} from './variants';
+
+/**
+ * Normalize dimension to CSS value
+ */
+function normalizeDimension(value: string | number | undefined): string | undefined {
+ if (value === undefined) return undefined;
+ return typeof value === 'number' ? `${value}px` : value;
+}
+
+/**
+ * Skeleton component
+ *
+ * A placeholder component for loading states.
+ *
+ * @example
+ * ```tsx
+ * import { Skeleton } from '@tpmjs/ui/Skeleton/Skeleton';
+ *
+ * function MyComponent() {
+ * return (
+ *
+ *
+ *
+ *
+ *
+ *
+ * );
+ * }
+ * ```
+ */
+export const Skeleton = forwardRef(
+ (
+ {
+ variant = 'text',
+ animation = 'pulse',
+ width,
+ height,
+ lines = 1,
+ gap = '0.5rem',
+ lastLineShort = false,
+ className,
+ style,
+ ...props
+ },
+ ref
+ ) => {
+ // Handle multiple lines for text variant
+ if (variant === 'text' && lines > 1) {
+ const gapValue = normalizeDimension(gap);
+
+ return (
+
+ {Array.from({ length: lines }).map((_, index) => {
+ const isLast = index === lines - 1;
+ const lineWidth = isLast && lastLineShort ? '60%' : width || '100%';
+
+ return (
+
+ );
+ })}
+
+ );
+ }
+
+ return (
+
+ );
+ }
+);
+
+Skeleton.displayName = 'Skeleton';
+
+/**
+ * SkeletonText component
+ *
+ * A preset for text loading placeholders.
+ *
+ * @example
+ * ```tsx
+ * import { SkeletonText } from '@tpmjs/ui/Skeleton/Skeleton';
+ *
+ * function MyComponent() {
+ * return ;
+ * }
+ * ```
+ */
+export const SkeletonText = forwardRef(
+ (
+ {
+ lines = 3,
+ gap = '0.5rem',
+ width,
+ animation = 'pulse',
+ className,
+ style,
+ ...props
+ },
+ ref
+ ) => {
+ const gapValue = normalizeDimension(gap);
+ const widths = Array.isArray(width) ? width : undefined;
+
+ return (
+
+ {Array.from({ length: lines }).map((_, index) => {
+ let lineWidth: string | number = '100%';
+
+ if (widths && widths[index] !== undefined) {
+ lineWidth = widths[index];
+ } else if (!Array.isArray(width) && width !== undefined) {
+ lineWidth = width;
+ } else if (index === lines - 1) {
+ // Make last line shorter by default
+ lineWidth = '60%';
+ }
+
+ return (
+
+ );
+ })}
+
+ );
+ }
+);
+
+SkeletonText.displayName = 'SkeletonText';
+
+/**
+ * SkeletonAvatar component
+ *
+ * A preset for avatar loading placeholders.
+ *
+ * @example
+ * ```tsx
+ * import { SkeletonAvatar } from '@tpmjs/ui/Skeleton/Skeleton';
+ *
+ * function MyComponent() {
+ * return ;
+ * }
+ * ```
+ */
+export const SkeletonAvatar = forwardRef(
+ ({ size = 'md', animation = 'pulse', className, ...props }, ref) => (
+
+ )
+);
+
+SkeletonAvatar.displayName = 'SkeletonAvatar';
+
+/**
+ * SkeletonCard component
+ *
+ * A preset for card loading placeholders.
+ *
+ * @example
+ * ```tsx
+ * import { SkeletonCard } from '@tpmjs/ui/Skeleton/Skeleton';
+ *
+ * function MyComponent() {
+ * return ;
+ * }
+ * ```
+ */
+export const SkeletonCard = forwardRef(
+ (
+ {
+ showImage = true,
+ lines = 3,
+ animation = 'pulse',
+ className,
+ ...props
+ },
+ ref
+ ) => (
+
+ {showImage && (
+
+ )}
+
+ {/* Title skeleton */}
+
+ {/* Content skeletons */}
+ {Array.from({ length: lines }).map((_, index) => (
+
+ ))}
+
+
+ )
+);
+
+SkeletonCard.displayName = 'SkeletonCard';
+
+/**
+ * SkeletonTable component
+ *
+ * A preset for table loading placeholders.
+ *
+ * @example
+ * ```tsx
+ * import { SkeletonTable } from '@tpmjs/ui/Skeleton/Skeleton';
+ *
+ * function MyComponent() {
+ * return ;
+ * }
+ * ```
+ */
+export interface SkeletonTableProps extends React.HTMLAttributes {
+ rows?: number;
+ columns?: number;
+ animation?: 'pulse' | 'wave' | 'none';
+}
+
+export const SkeletonTable = forwardRef(
+ ({ rows = 5, columns = 4, animation = 'pulse', className, ...props }, ref) => (
+
+ {/* Header */}
+
+ {Array.from({ length: columns }).map((_, colIndex) => (
+
+ ))}
+
+ {/* Rows */}
+ {Array.from({ length: rows }).map((_, rowIndex) => (
+
+ {Array.from({ length: columns }).map((_, colIndex) => (
+
+ ))}
+
+ ))}
+
+ )
+);
+
+SkeletonTable.displayName = 'SkeletonTable';
diff --git a/packages/ui/src/Skeleton/types.ts b/packages/ui/src/Skeleton/types.ts
new file mode 100644
index 0000000..a33870c
--- /dev/null
+++ b/packages/ui/src/Skeleton/types.ts
@@ -0,0 +1,129 @@
+import type { HTMLAttributes } from 'react';
+
+/**
+ * Skeleton variant types
+ */
+export type SkeletonVariant = 'text' | 'circular' | 'rectangular' | 'rounded';
+
+/**
+ * Skeleton animation types
+ */
+export type SkeletonAnimation = 'pulse' | 'wave' | 'none';
+
+/**
+ * Skeleton component props
+ */
+export interface SkeletonProps extends HTMLAttributes {
+ /**
+ * Shape variant
+ * @default 'text'
+ */
+ variant?: SkeletonVariant;
+
+ /**
+ * Animation type
+ * @default 'pulse'
+ */
+ animation?: SkeletonAnimation;
+
+ /**
+ * Width (CSS value)
+ */
+ width?: string | number;
+
+ /**
+ * Height (CSS value)
+ */
+ height?: string | number;
+
+ /**
+ * Number of skeleton lines (for text variant)
+ * @default 1
+ */
+ lines?: number;
+
+ /**
+ * Gap between lines (for text variant)
+ * @default '0.5rem'
+ */
+ gap?: string | number;
+
+ /**
+ * Whether the last line should be shorter (for text variant)
+ * @default false
+ */
+ lastLineShort?: boolean;
+}
+
+/**
+ * SkeletonText component props
+ */
+export interface SkeletonTextProps extends HTMLAttributes {
+ /**
+ * Number of lines
+ * @default 3
+ */
+ lines?: number;
+
+ /**
+ * Gap between lines
+ * @default '0.5rem'
+ */
+ gap?: string | number;
+
+ /**
+ * Width of each line (can be string, number, or array)
+ */
+ width?: string | number | (string | number)[];
+
+ /**
+ * Animation type
+ * @default 'pulse'
+ */
+ animation?: SkeletonAnimation;
+}
+
+/**
+ * SkeletonAvatar component props
+ */
+export interface SkeletonAvatarProps extends HTMLAttributes {
+ /**
+ * Size of the avatar
+ * @default 'md'
+ */
+ size?: 'sm' | 'md' | 'lg' | 'xl';
+
+ /**
+ * Animation type
+ * @default 'pulse'
+ */
+ animation?: SkeletonAnimation;
+}
+
+/**
+ * SkeletonCard component props
+ */
+export interface SkeletonCardProps extends HTMLAttributes {
+ /**
+ * Whether to show an image placeholder
+ * @default true
+ */
+ showImage?: boolean;
+
+ /**
+ * Number of text lines
+ * @default 3
+ */
+ lines?: number;
+
+ /**
+ * Animation type
+ * @default 'pulse'
+ */
+ animation?: SkeletonAnimation;
+}
+
+/**
+ * Skeleton ref type
+ */
+export type SkeletonRef = HTMLDivElement;
diff --git a/packages/ui/src/Skeleton/variants.ts b/packages/ui/src/Skeleton/variants.ts
new file mode 100644
index 0000000..e037d89
--- /dev/null
+++ b/packages/ui/src/Skeleton/variants.ts
@@ -0,0 +1,111 @@
+import { createVariants } from '../system/variants';
+
+/**
+ * Skeleton base variant definitions
+ */
+export const skeletonVariants = createVariants({
+ base: [
+ 'bg-accent/20',
+ ].join(' '),
+
+ variants: {
+ variant: {
+ text: 'h-4 rounded-none',
+ circular: 'rounded-full',
+ rectangular: 'rounded-none',
+ rounded: 'rounded-sm',
+ },
+ animation: {
+ pulse: 'animate-pulse',
+ wave: 'skeleton-wave',
+ none: '',
+ },
+ },
+
+ defaultVariants: {
+ variant: 'text',
+ animation: 'pulse',
+ },
+});
+
+/**
+ * Skeleton text container variant definitions
+ */
+export const skeletonTextContainerVariants = createVariants({
+ base: [
+ 'flex flex-col',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Skeleton avatar variant definitions
+ */
+export const skeletonAvatarVariants = createVariants({
+ base: [
+ 'rounded-full',
+ 'bg-accent/20',
+ 'flex-shrink-0',
+ ].join(' '),
+
+ variants: {
+ size: {
+ sm: 'w-8 h-8',
+ md: 'w-10 h-10',
+ lg: 'w-12 h-12',
+ xl: 'w-16 h-16',
+ },
+ animation: {
+ pulse: 'animate-pulse',
+ wave: 'skeleton-wave',
+ none: '',
+ },
+ },
+
+ defaultVariants: {
+ size: 'md',
+ animation: 'pulse',
+ },
+});
+
+/**
+ * Skeleton card variant definitions
+ */
+export const skeletonCardVariants = createVariants({
+ base: [
+ 'border border-border',
+ 'rounded-none',
+ 'p-4',
+ 'space-y-4',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Skeleton card image variant definitions
+ */
+export const skeletonCardImageVariants = createVariants({
+ base: [
+ 'w-full h-32',
+ 'bg-accent/20',
+ 'rounded-none',
+ ].join(' '),
+
+ variants: {
+ animation: {
+ pulse: 'animate-pulse',
+ wave: 'skeleton-wave',
+ none: '',
+ },
+ },
+
+ defaultVariants: {
+ animation: 'pulse',
+ },
+});
diff --git a/packages/ui/src/Tabs/variants.ts b/packages/ui/src/Tabs/variants.ts
index a3cd41e..403e040 100644
--- a/packages/ui/src/Tabs/variants.ts
+++ b/packages/ui/src/Tabs/variants.ts
@@ -18,8 +18,8 @@ export const tabsContainerVariants = createVariants({
lg: 'gap-1',
},
variant: {
- default: 'border-b border-border',
- blueprint: 'border-b border-dotted border-border',
+ default: 'border-b border-dashed border-border',
+ blueprint: 'border-b border-dashed border-border',
},
},
@@ -38,10 +38,10 @@ export const tabButtonVariants = createVariants({
base: [
// Display
'inline-flex items-center gap-2',
- // Font
- 'font-medium whitespace-nowrap',
- // Transition
- 'transition-colors duration-200',
+ // Font - Monospace, lowercase
+ 'font-mono font-medium lowercase whitespace-nowrap',
+ // Transition - Fast, subtle
+ 'transition-colors duration-150',
// Cursor
'cursor-pointer',
].join(' '),
@@ -59,7 +59,7 @@ export const tabButtonVariants = createVariants({
},
variant: {
default: 'border-b-2',
- blueprint: 'border-b-2 border-dotted',
+ blueprint: 'border-b-2 border-dashed',
},
},
@@ -83,12 +83,12 @@ export const tabCountVariants = createVariants({
'min-w-[1.25rem] h-5',
// Padding
'px-1.5',
- // Font
- 'text-xs font-medium tabular-nums',
+ // Font - Monospace
+ 'font-mono text-xs font-medium tabular-nums',
// Background
'bg-surface-elevated',
- // Border
- 'rounded-full',
+ // Border - Sharp corners
+ 'rounded-none',
].join(' '),
variants: {
diff --git a/packages/ui/src/Textarea/variants.ts b/packages/ui/src/Textarea/variants.ts
index 5e10b8d..8f04c72 100644
--- a/packages/ui/src/Textarea/variants.ts
+++ b/packages/ui/src/Textarea/variants.ts
@@ -8,16 +8,16 @@ export const textareaVariants = createVariants({
base: [
// Layout
'flex w-full',
- // Typography
- 'font-sans',
- // Borders & Radius
- 'rounded-md border',
+ // Typography - Monospace
+ 'font-mono',
+ // Borders & Radius - SHARP CORNERS
+ 'rounded-none border',
// Background - Pure white to stand out
'bg-surface',
- // Transitions
- 'transition-base',
- // Focus
- 'focus-ring',
+ // Transitions - Fast, subtle
+ 'transition-colors duration-150',
+ // Focus - Copper accent
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
// Placeholder
'placeholder:text-foreground-tertiary',
// Disabled state
diff --git a/packages/ui/src/Toast/Toast.tsx b/packages/ui/src/Toast/Toast.tsx
new file mode 100644
index 0000000..baae425
--- /dev/null
+++ b/packages/ui/src/Toast/Toast.tsx
@@ -0,0 +1,357 @@
+'use client';
+
+import { cn } from '@tpmjs/utils/cn';
+import {
+ createContext,
+ forwardRef,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { createPortal } from 'react-dom';
+import { Icon } from '../Icon/Icon';
+import type {
+ ToastContainerProps,
+ ToastContextValue,
+ ToastPosition,
+ ToastProps,
+ ToastState,
+ ToastVariant,
+} from './types';
+import {
+ toastActionVariants,
+ toastCloseButtonVariants,
+ toastContainerVariants,
+ toastContentVariants,
+ toastDescriptionVariants,
+ toastIconVariants,
+ toastTitleVariants,
+ toastVariants,
+} from './variants';
+
+/**
+ * Get the default icon for a toast variant
+ */
+function getVariantIcon(variant: ToastVariant): 'checkCircle' | 'xCircle' | 'alertTriangle' | 'info' | 'bell' {
+ switch (variant) {
+ case 'success':
+ return 'checkCircle';
+ case 'error':
+ return 'xCircle';
+ case 'warning':
+ return 'alertTriangle';
+ case 'info':
+ return 'info';
+ default:
+ return 'bell';
+ }
+}
+
+/**
+ * Toast component
+ *
+ * A notification component that displays brief messages to the user.
+ *
+ * @example
+ * ```tsx
+ * import { Toast } from '@tpmjs/ui/Toast/Toast';
+ *
+ * function MyComponent() {
+ * const [open, setOpen] = useState(true);
+ *
+ * return (
+ * setOpen(false)}
+ * title="Success"
+ * description="Your changes have been saved."
+ * variant="success"
+ * />
+ * );
+ * }
+ * ```
+ */
+export const Toast = forwardRef(
+ (
+ {
+ open,
+ onClose,
+ title,
+ description,
+ variant = 'default',
+ action,
+ duration = 5000,
+ showCloseButton = true,
+ icon,
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ const timerRef = useRef | undefined>(undefined);
+
+ // Auto-dismiss timer
+ useEffect(() => {
+ if (!open || duration === 0) return;
+
+ timerRef.current = setTimeout(() => {
+ onClose();
+ }, duration);
+
+ return () => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ }
+ };
+ }, [open, duration, onClose]);
+
+ // Pause timer on hover
+ const handleMouseEnter = useCallback(() => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ }
+ }, []);
+
+ const handleMouseLeave = useCallback(() => {
+ if (duration === 0) return;
+ timerRef.current = setTimeout(() => {
+ onClose();
+ }, duration);
+ }, [duration, onClose]);
+
+ if (!open) return null;
+
+ const defaultIcon = getVariantIcon(variant);
+
+ return (
+
+ {/* Icon */}
+
+ {icon ?? }
+
+
+ {/* Content */}
+
+ {title &&
{title}
}
+ {description && (
+
{description}
+ )}
+ {action &&
{action}
}
+
+
+ {/* Close button */}
+ {showCloseButton && (
+
+
+
+ )}
+
+ );
+ }
+);
+
+Toast.displayName = 'Toast';
+
+/**
+ * ToastContainer component
+ *
+ * A container that positions toast notifications on the screen.
+ *
+ * @example
+ * ```tsx
+ * import { ToastContainer, Toast } from '@tpmjs/ui/Toast/Toast';
+ *
+ * function MyComponent() {
+ * return (
+ *
+ * {}} title="Hello" />
+ *
+ * );
+ * }
+ * ```
+ */
+export const ToastContainer = forwardRef(
+ ({ position = 'bottom-right', children, className, ...props }, ref) => {
+ // Only render in browser (for SSR compatibility)
+ if (typeof window === 'undefined') return null;
+
+ return createPortal(
+
+ {children}
+
,
+ document.body
+ );
+ }
+);
+
+ToastContainer.displayName = 'ToastContainer';
+
+// Toast context for programmatic usage
+const ToastContext = createContext(null);
+
+/**
+ * Toast provider props
+ */
+export interface ToastProviderProps {
+ children: React.ReactNode;
+ position?: ToastPosition;
+ maxToasts?: number;
+}
+
+/**
+ * ToastProvider component
+ *
+ * Provides toast functionality to the application.
+ *
+ * @example
+ * ```tsx
+ * import { ToastProvider, useToast } from '@tpmjs/ui/Toast/Toast';
+ *
+ * function App() {
+ * return (
+ *
+ *
+ *
+ * );
+ * }
+ *
+ * function MyComponent() {
+ * const { toast, dismiss } = useToast();
+ *
+ * return (
+ * toast({ title: 'Hello', variant: 'success' })}>
+ * Show Toast
+ *
+ * );
+ * }
+ * ```
+ */
+export function ToastProvider({
+ children,
+ position = 'bottom-right',
+ maxToasts = 5,
+}: ToastProviderProps) {
+ const [toasts, setToasts] = useState([]);
+
+ const toast = useCallback(
+ (props: Omit): string => {
+ const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
+ const newToast: ToastState = {
+ ...props,
+ id,
+ open: true,
+ };
+
+ setToasts((prev) => {
+ const updated = [...prev, newToast];
+ // Remove oldest toasts if exceeding max
+ if (updated.length > maxToasts) {
+ return updated.slice(-maxToasts);
+ }
+ return updated;
+ });
+
+ return id;
+ },
+ [maxToasts]
+ );
+
+ const dismiss = useCallback((id: string) => {
+ setToasts((prev) =>
+ prev.map((t) => (t.id === id ? { ...t, open: false } : t))
+ );
+ // Remove from DOM after animation
+ setTimeout(() => {
+ setToasts((prev) => prev.filter((t) => t.id !== id));
+ }, 200);
+ }, []);
+
+ const dismissAll = useCallback(() => {
+ setToasts((prev) => prev.map((t) => ({ ...t, open: false })));
+ setTimeout(() => {
+ setToasts([]);
+ }, 200);
+ }, []);
+
+ const contextValue = useMemo(
+ () => ({
+ toast,
+ dismiss,
+ dismissAll,
+ }),
+ [toast, dismiss, dismissAll]
+ );
+
+ return (
+
+ {children}
+
+ {toasts.map((t) => (
+ dismiss(t.id)}
+ title={t.title}
+ description={t.description}
+ variant={t.variant}
+ action={t.action}
+ duration={t.duration}
+ showCloseButton={t.showCloseButton}
+ icon={t.icon}
+ />
+ ))}
+
+
+ );
+}
+
+/**
+ * useToast hook
+ *
+ * Hook to access toast functionality from ToastProvider.
+ *
+ * @example
+ * ```tsx
+ * const { toast, dismiss, dismissAll } = useToast();
+ *
+ * // Show a success toast
+ * const id = toast({
+ * title: 'Success',
+ * description: 'Your changes have been saved.',
+ * variant: 'success',
+ * });
+ *
+ * // Dismiss a specific toast
+ * dismiss(id);
+ *
+ * // Dismiss all toasts
+ * dismissAll();
+ * ```
+ */
+export function useToast(): ToastContextValue {
+ const context = useContext(ToastContext);
+ if (!context) {
+ throw new Error('useToast must be used within a ToastProvider');
+ }
+ return context;
+}
diff --git a/packages/ui/src/Toast/types.ts b/packages/ui/src/Toast/types.ts
new file mode 100644
index 0000000..c62e0e9
--- /dev/null
+++ b/packages/ui/src/Toast/types.ts
@@ -0,0 +1,108 @@
+import type { HTMLAttributes, ReactNode } from 'react';
+
+/**
+ * Toast variant types
+ */
+export type ToastVariant = 'default' | 'success' | 'error' | 'warning' | 'info';
+
+/**
+ * Toast position types
+ */
+export type ToastPosition =
+ | 'top-left'
+ | 'top-center'
+ | 'top-right'
+ | 'bottom-left'
+ | 'bottom-center'
+ | 'bottom-right';
+
+/**
+ * Toast component props
+ */
+export interface ToastProps extends Omit, 'title'> {
+ /**
+ * Whether the toast is visible
+ */
+ open: boolean;
+
+ /**
+ * Callback when the toast should close
+ */
+ onClose: () => void;
+
+ /**
+ * Toast title
+ */
+ title?: ReactNode;
+
+ /**
+ * Toast description/message
+ */
+ description?: ReactNode;
+
+ /**
+ * Visual variant
+ * @default 'default'
+ */
+ variant?: ToastVariant;
+
+ /**
+ * Action button (optional)
+ */
+ action?: ReactNode;
+
+ /**
+ * Auto-dismiss duration in milliseconds (0 = no auto-dismiss)
+ * @default 5000
+ */
+ duration?: number;
+
+ /**
+ * Whether to show the close button
+ * @default true
+ */
+ showCloseButton?: boolean;
+
+ /**
+ * Icon to display (auto-detected from variant if not provided)
+ */
+ icon?: ReactNode;
+}
+
+/**
+ * ToastContainer component props
+ */
+export interface ToastContainerProps extends HTMLAttributes {
+ /**
+ * Position of the toast container
+ * @default 'bottom-right'
+ */
+ position?: ToastPosition;
+
+ /**
+ * Children (Toast components)
+ */
+ children: ReactNode;
+}
+
+/**
+ * Toast context for managing toasts
+ */
+export interface ToastContextValue {
+ toast: (props: Omit) => string;
+ dismiss: (id: string) => void;
+ dismissAll: () => void;
+}
+
+/**
+ * Internal toast state
+ */
+export interface ToastState extends Omit {
+ id: string;
+ open: boolean;
+}
+
+/**
+ * Toast ref type
+ */
+export type ToastRef = HTMLDivElement;
diff --git a/packages/ui/src/Toast/variants.ts b/packages/ui/src/Toast/variants.ts
new file mode 100644
index 0000000..3b7d515
--- /dev/null
+++ b/packages/ui/src/Toast/variants.ts
@@ -0,0 +1,166 @@
+import { createVariants } from '../system/variants';
+
+/**
+ * Toast container variant definitions (holds all toasts)
+ */
+export const toastContainerVariants = createVariants({
+ base: [
+ 'fixed',
+ 'z-[var(--z-toast)]',
+ 'flex flex-col gap-3',
+ 'p-4',
+ 'pointer-events-none',
+ 'max-h-screen overflow-hidden',
+ ].join(' '),
+
+ variants: {
+ position: {
+ 'top-left': 'top-0 left-0 items-start',
+ 'top-center': 'top-0 left-1/2 -translate-x-1/2 items-center',
+ 'top-right': 'top-0 right-0 items-end',
+ 'bottom-left': 'bottom-0 left-0 items-start',
+ 'bottom-center': 'bottom-0 left-1/2 -translate-x-1/2 items-center',
+ 'bottom-right': 'bottom-0 right-0 items-end',
+ },
+ },
+
+ defaultVariants: {
+ position: 'bottom-right',
+ },
+});
+
+/**
+ * Toast variant definitions
+ */
+export const toastVariants = createVariants({
+ base: [
+ // Layout
+ 'relative w-full max-w-sm',
+ 'flex items-start gap-3',
+ 'p-4',
+ // Styling - Sharp corners, blueprint aesthetic
+ 'bg-surface border border-border',
+ 'rounded-none',
+ // Shadow
+ 'shadow-lg',
+ // Animation
+ 'transition-all duration-200',
+ // Pointer events
+ 'pointer-events-auto',
+ ].join(' '),
+
+ variants: {
+ variant: {
+ default: 'border-border',
+ success: 'border-success bg-success/5',
+ error: 'border-error bg-error/5',
+ warning: 'border-warning bg-warning/5',
+ info: 'border-primary bg-primary/5',
+ },
+ state: {
+ entering: 'opacity-0 translate-x-4',
+ entered: 'opacity-100 translate-x-0',
+ exiting: 'opacity-0 translate-x-4',
+ },
+ },
+
+ defaultVariants: {
+ variant: 'default',
+ state: 'entered',
+ },
+});
+
+/**
+ * Toast icon variant definitions
+ */
+export const toastIconVariants = createVariants({
+ base: ['flex-shrink-0', 'mt-0.5'].join(' '),
+
+ variants: {
+ variant: {
+ default: 'text-foreground-muted',
+ success: 'text-success',
+ error: 'text-error',
+ warning: 'text-warning',
+ info: 'text-primary',
+ },
+ },
+
+ defaultVariants: {
+ variant: 'default',
+ },
+});
+
+/**
+ * Toast content variant definitions
+ */
+export const toastContentVariants = createVariants({
+ base: ['flex-1 min-w-0'].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Toast title variant definitions
+ */
+export const toastTitleVariants = createVariants({
+ base: [
+ 'font-mono font-semibold text-sm',
+ 'text-foreground',
+ 'lowercase',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Toast description variant definitions
+ */
+export const toastDescriptionVariants = createVariants({
+ base: [
+ 'font-mono text-sm',
+ 'text-foreground-muted',
+ 'mt-1',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Toast close button variant definitions
+ */
+export const toastCloseButtonVariants = createVariants({
+ base: [
+ 'flex-shrink-0',
+ 'p-1 -m-1',
+ 'text-foreground-muted',
+ 'hover:text-foreground hover:bg-accent/10',
+ 'transition-colors duration-150',
+ 'rounded-none',
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * Toast action variant definitions
+ */
+export const toastActionVariants = createVariants({
+ base: [
+ 'mt-2',
+ 'flex gap-2',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
diff --git a/packages/ui/src/ToolCard/ToolCard.tsx b/packages/ui/src/ToolCard/ToolCard.tsx
new file mode 100644
index 0000000..9ebaee4
--- /dev/null
+++ b/packages/ui/src/ToolCard/ToolCard.tsx
@@ -0,0 +1,257 @@
+'use client';
+
+import { cn } from '@tpmjs/utils/cn';
+import { forwardRef, useMemo } from 'react';
+import { Icon } from '../Icon/Icon';
+import type { ToolCardProps } from './types';
+import {
+ toolCardActionVariants,
+ toolCardDescriptionVariants,
+ toolCardHeaderVariants,
+ toolCardIconVariants,
+ toolCardMetaItemVariants,
+ toolCardMetaVariants,
+ toolCardOfficialBadgeVariants,
+ toolCardTierBadgeVariants,
+ toolCardTitleVariants,
+ toolCardVariants,
+ toolCardVersionVariants,
+} from './variants';
+
+/**
+ * Format download count
+ */
+function formatDownloads(count: number): string {
+ if (count >= 1_000_000) {
+ return `${(count / 1_000_000).toFixed(1)}M`;
+ }
+ if (count >= 1_000) {
+ return `${(count / 1_000).toFixed(1)}K`;
+ }
+ return count.toString();
+}
+
+/**
+ * Format relative time
+ */
+function formatRelativeTime(date: Date | string): string {
+ const d = typeof date === 'string' ? new Date(date) : date;
+ const now = new Date();
+ const diffMs = now.getTime() - d.getTime();
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+
+ if (diffDays === 0) return 'today';
+ if (diffDays === 1) return 'yesterday';
+ if (diffDays < 7) return `${diffDays}d ago`;
+ if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
+ if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
+ return `${Math.floor(diffDays / 365)}y ago`;
+}
+
+/**
+ * ToolCard component
+ *
+ * A card component for displaying tool/package information.
+ *
+ * @example
+ * ```tsx
+ * import { ToolCard } from '@tpmjs/ui/ToolCard/ToolCard';
+ *
+ * function MyComponent() {
+ * return (
+ *
+ * );
+ * }
+ * ```
+ */
+export const ToolCard = forwardRef(
+ (
+ {
+ name,
+ displayName,
+ version,
+ description,
+ author,
+ tier,
+ qualityScore,
+ downloads,
+ stars,
+ category,
+ isOfficial,
+ updatedAt,
+ variant = 'default',
+ href,
+ action,
+ icon,
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ const displayTitle = displayName || name;
+ const isClickable = !!href;
+
+ // Format downloads
+ const formattedDownloads = useMemo(() => {
+ if (downloads === undefined) return null;
+ return formatDownloads(downloads);
+ }, [downloads]);
+
+ // Format update time
+ const formattedTime = useMemo(() => {
+ if (!updatedAt) return null;
+ return formatRelativeTime(updatedAt);
+ }, [updatedAt]);
+
+ const cardClassName = cn(toolCardVariants({ variant, clickable: isClickable ? 'true' : 'false' }), className);
+
+ const cardContent = (
+ <>
+ {/* Header */}
+
+ {/* Icon */}
+ {icon && (
+
+ {icon}
+
+ )}
+
+ {/* Title area */}
+
+
+
+ {displayTitle}
+
+ {version && (
+
+ v{version}
+
+ )}
+
+
+ {/* Badges */}
+
+ {isOfficial && (
+
+
+ official
+
+ )}
+ {tier && (
+
+ {tier}
+
+ )}
+ {category && (
+
+ {category}
+
+ )}
+
+
+
+ {/* Quality score (compact display) */}
+ {qualityScore !== undefined && variant !== 'compact' && (
+
+
+ {Math.round(qualityScore)}
+
+
+ score
+
+
+ )}
+
+
+ {/* Description */}
+ {description && (
+
+ {description}
+
+ )}
+
+ {/* Meta info */}
+
+ {formattedDownloads && (
+
+
+ {formattedDownloads}
+
+ )}
+
+ {stars !== undefined && (
+
+
+ {stars}
+
+ )}
+
+ {author && (
+
+
+ {author}
+
+ )}
+
+ {formattedTime && (
+
+
+ {formattedTime}
+
+ )}
+
+ {/* Quality score for compact variant */}
+ {qualityScore !== undefined && variant === 'compact' && (
+
+
+ {Math.round(qualityScore)}
+
+
+ )}
+
+
+ {/* Action slot */}
+ {action && (
+
+ {action}
+
+ )}
+ >
+ );
+
+ if (isClickable) {
+ return (
+ }
+ href={href}
+ className={cardClassName}
+ {...(props as React.AnchorHTMLAttributes)}
+ >
+ {cardContent}
+
+ );
+ }
+
+ return (
+
+ {cardContent}
+
+ );
+ }
+);
+
+ToolCard.displayName = 'ToolCard';
diff --git a/packages/ui/src/ToolCard/types.ts b/packages/ui/src/ToolCard/types.ts
new file mode 100644
index 0000000..451bb46
--- /dev/null
+++ b/packages/ui/src/ToolCard/types.ts
@@ -0,0 +1,102 @@
+import type { HTMLAttributes, ReactNode } from 'react';
+
+/**
+ * Tool tier types
+ */
+export type ToolTier = 'minimal' | 'rich';
+
+/**
+ * ToolCard variant types
+ */
+export type ToolCardVariant = 'default' | 'compact' | 'featured';
+
+/**
+ * ToolCard component props
+ */
+export interface ToolCardProps extends HTMLAttributes {
+ /**
+ * Package name
+ */
+ name: string;
+
+ /**
+ * Package display name (optional, defaults to name)
+ */
+ displayName?: string;
+
+ /**
+ * Package version
+ */
+ version?: string;
+
+ /**
+ * Short description
+ */
+ description?: string;
+
+ /**
+ * Package author/maintainer
+ */
+ author?: string;
+
+ /**
+ * Tool tier (minimal or rich)
+ */
+ tier?: ToolTier;
+
+ /**
+ * Quality score (0-100)
+ */
+ qualityScore?: number;
+
+ /**
+ * Monthly downloads count
+ */
+ downloads?: number;
+
+ /**
+ * GitHub stars count
+ */
+ stars?: number;
+
+ /**
+ * Category/tags
+ */
+ category?: string;
+
+ /**
+ * Whether the tool is official/verified
+ */
+ isOfficial?: boolean;
+
+ /**
+ * Last updated date
+ */
+ updatedAt?: Date | string;
+
+ /**
+ * Visual variant
+ * @default 'default'
+ */
+ variant?: ToolCardVariant;
+
+ /**
+ * Link href for the card
+ */
+ href?: string;
+
+ /**
+ * Custom action slot (e.g., install button)
+ */
+ action?: ReactNode;
+
+ /**
+ * Icon/logo for the tool
+ */
+ icon?: ReactNode;
+}
+
+/**
+ * ToolCard ref type
+ */
+export type ToolCardRef = HTMLDivElement;
diff --git a/packages/ui/src/ToolCard/variants.ts b/packages/ui/src/ToolCard/variants.ts
new file mode 100644
index 0000000..e3f2e24
--- /dev/null
+++ b/packages/ui/src/ToolCard/variants.ts
@@ -0,0 +1,244 @@
+import { createVariants } from '../system/variants';
+
+/**
+ * ToolCard container variant definitions
+ */
+export const toolCardVariants = createVariants({
+ base: [
+ 'group',
+ 'block w-full',
+ 'border border-border',
+ 'rounded-none',
+ 'bg-surface',
+ 'transition-all duration-150',
+ 'hover:border-primary/50',
+ 'hover:shadow-md',
+ ].join(' '),
+
+ variants: {
+ variant: {
+ default: 'p-4',
+ compact: 'p-3',
+ featured: 'p-6 border-2',
+ },
+ clickable: {
+ 'true': 'cursor-pointer',
+ 'false': '',
+ },
+ },
+
+ defaultVariants: {
+ variant: 'default',
+ clickable: 'false',
+ },
+});
+
+/**
+ * ToolCard header variant definitions
+ */
+export const toolCardHeaderVariants = createVariants({
+ base: [
+ 'flex items-start gap-3',
+ 'mb-3',
+ ].join(' '),
+
+ variants: {
+ variant: {
+ default: '',
+ compact: 'mb-2',
+ featured: 'mb-4',
+ },
+ },
+
+ defaultVariants: {
+ variant: 'default',
+ },
+});
+
+/**
+ * ToolCard icon variant definitions
+ */
+export const toolCardIconVariants = createVariants({
+ base: [
+ 'flex-shrink-0',
+ 'flex items-center justify-center',
+ 'bg-accent/10 border border-border',
+ 'rounded-none',
+ ].join(' '),
+
+ variants: {
+ variant: {
+ default: 'w-10 h-10',
+ compact: 'w-8 h-8',
+ featured: 'w-12 h-12',
+ },
+ },
+
+ defaultVariants: {
+ variant: 'default',
+ },
+});
+
+/**
+ * ToolCard title variant definitions
+ */
+export const toolCardTitleVariants = createVariants({
+ base: [
+ 'font-mono font-semibold',
+ 'text-foreground',
+ 'lowercase',
+ 'group-hover:text-primary',
+ 'transition-colors duration-150',
+ ].join(' '),
+
+ variants: {
+ variant: {
+ default: 'text-sm',
+ compact: 'text-sm',
+ featured: 'text-base',
+ },
+ },
+
+ defaultVariants: {
+ variant: 'default',
+ },
+});
+
+/**
+ * ToolCard version variant definitions
+ */
+export const toolCardVersionVariants = createVariants({
+ base: [
+ 'font-mono text-xs',
+ 'text-foreground-muted',
+ 'ml-2',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * ToolCard description variant definitions
+ */
+export const toolCardDescriptionVariants = createVariants({
+ base: [
+ 'font-mono',
+ 'text-foreground-muted',
+ 'line-clamp-2',
+ ].join(' '),
+
+ variants: {
+ variant: {
+ default: 'text-sm mb-3',
+ compact: 'text-xs mb-2',
+ featured: 'text-sm mb-4',
+ },
+ },
+
+ defaultVariants: {
+ variant: 'default',
+ },
+});
+
+/**
+ * ToolCard meta variant definitions
+ */
+export const toolCardMetaVariants = createVariants({
+ base: [
+ 'flex items-center gap-4',
+ 'font-mono text-xs',
+ 'text-foreground-muted',
+ ].join(' '),
+
+ variants: {
+ variant: {
+ default: '',
+ compact: 'gap-3',
+ featured: 'gap-4',
+ },
+ },
+
+ defaultVariants: {
+ variant: 'default',
+ },
+});
+
+/**
+ * ToolCard meta item variant definitions
+ */
+export const toolCardMetaItemVariants = createVariants({
+ base: [
+ 'flex items-center gap-1',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * ToolCard tier badge variant definitions
+ */
+export const toolCardTierBadgeVariants = createVariants({
+ base: [
+ 'inline-flex items-center',
+ 'px-1.5 py-0.5',
+ 'font-mono text-[10px] font-medium',
+ 'uppercase tracking-wider',
+ 'border rounded-none',
+ ].join(' '),
+
+ variants: {
+ tier: {
+ minimal: 'border-border text-foreground-muted bg-accent/5',
+ rich: 'border-primary/50 text-primary bg-primary/5',
+ },
+ },
+
+ defaultVariants: {
+ tier: 'minimal',
+ },
+});
+
+/**
+ * ToolCard official badge variant definitions
+ */
+export const toolCardOfficialBadgeVariants = createVariants({
+ base: [
+ 'inline-flex items-center gap-1',
+ 'px-1.5 py-0.5',
+ 'font-mono text-[10px] font-medium',
+ 'uppercase tracking-wider',
+ 'border border-success/50 text-success bg-success/5',
+ 'rounded-none',
+ ].join(' '),
+
+ variants: {},
+
+ defaultVariants: {},
+});
+
+/**
+ * ToolCard action variant definitions
+ */
+export const toolCardActionVariants = createVariants({
+ base: [
+ 'mt-3',
+ 'pt-3',
+ 'border-t border-border',
+ ].join(' '),
+
+ variants: {
+ variant: {
+ default: '',
+ compact: 'mt-2 pt-2',
+ featured: 'mt-4 pt-4',
+ },
+ },
+
+ defaultVariants: {
+ variant: 'default',
+ },
+});
diff --git a/packages/ui/src/Tooltip/Tooltip.tsx b/packages/ui/src/Tooltip/Tooltip.tsx
new file mode 100644
index 0000000..265607c
--- /dev/null
+++ b/packages/ui/src/Tooltip/Tooltip.tsx
@@ -0,0 +1,266 @@
+'use client';
+
+import { cn } from '@tpmjs/utils/cn';
+import {
+ cloneElement,
+ forwardRef,
+ isValidElement,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import { createPortal } from 'react-dom';
+import type { TooltipPlacement, TooltipProps } from './types';
+import { tooltipArrowVariants, tooltipContentVariants } from './variants';
+
+/**
+ * Calculate position based on trigger and placement
+ */
+function calculatePosition(
+ triggerRect: DOMRect,
+ contentRect: DOMRect,
+ placement: TooltipPlacement,
+ offset: number
+): { top: number; left: number } {
+ let top = 0;
+ let left = 0;
+
+ const scrollX = window.scrollX;
+ const scrollY = window.scrollY;
+
+ switch (placement) {
+ case 'top':
+ top = triggerRect.top + scrollY - contentRect.height - offset;
+ left = triggerRect.left + scrollX + (triggerRect.width - contentRect.width) / 2;
+ break;
+ case 'top-start':
+ top = triggerRect.top + scrollY - contentRect.height - offset;
+ left = triggerRect.left + scrollX;
+ break;
+ case 'top-end':
+ top = triggerRect.top + scrollY - contentRect.height - offset;
+ left = triggerRect.right + scrollX - contentRect.width;
+ break;
+ case 'bottom':
+ top = triggerRect.bottom + scrollY + offset;
+ left = triggerRect.left + scrollX + (triggerRect.width - contentRect.width) / 2;
+ break;
+ case 'bottom-start':
+ top = triggerRect.bottom + scrollY + offset;
+ left = triggerRect.left + scrollX;
+ break;
+ case 'bottom-end':
+ top = triggerRect.bottom + scrollY + offset;
+ left = triggerRect.right + scrollX - contentRect.width;
+ break;
+ case 'left':
+ top = triggerRect.top + scrollY + (triggerRect.height - contentRect.height) / 2;
+ left = triggerRect.left + scrollX - contentRect.width - offset;
+ break;
+ case 'left-start':
+ top = triggerRect.top + scrollY;
+ left = triggerRect.left + scrollX - contentRect.width - offset;
+ break;
+ case 'left-end':
+ top = triggerRect.bottom + scrollY - contentRect.height;
+ left = triggerRect.left + scrollX - contentRect.width - offset;
+ break;
+ case 'right':
+ top = triggerRect.top + scrollY + (triggerRect.height - contentRect.height) / 2;
+ left = triggerRect.right + scrollX + offset;
+ break;
+ case 'right-start':
+ top = triggerRect.top + scrollY;
+ left = triggerRect.right + scrollX + offset;
+ break;
+ case 'right-end':
+ top = triggerRect.bottom + scrollY - contentRect.height;
+ left = triggerRect.right + scrollX + offset;
+ break;
+ }
+
+ return { top, left };
+}
+
+/**
+ * Tooltip component
+ *
+ * A lightweight floating label that appears on hover/focus.
+ *
+ * @example
+ * ```tsx
+ * import { Tooltip } from '@tpmjs/ui/Tooltip/Tooltip';
+ * import { Button } from '@tpmjs/ui/Button/Button';
+ *
+ * function MyComponent() {
+ * return (
+ *
+ * Hover me
+ *
+ * );
+ * }
+ * ```
+ */
+export const Tooltip = forwardRef(
+ (
+ {
+ children,
+ content,
+ placement = 'top',
+ offset = 6,
+ showDelay = 200,
+ hideDelay = 0,
+ hasArrow = true,
+ disabled = false,
+ open: controlledOpen,
+ onOpenChange,
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ const isControlled = controlledOpen !== undefined;
+ const [internalOpen, setInternalOpen] = useState(false);
+ const isOpen = isControlled ? controlledOpen : internalOpen;
+
+ const triggerRef = useRef(null);
+ const contentRef = useRef(null);
+ const showTimeoutRef = useRef | undefined>(undefined);
+ const hideTimeoutRef = useRef | undefined>(undefined);
+
+ const [position, setPosition] = useState({ top: 0, left: 0 });
+
+ const setOpen = useCallback(
+ (value: boolean) => {
+ if (!isControlled) {
+ setInternalOpen(value);
+ }
+ onOpenChange?.(value);
+ },
+ [isControlled, onOpenChange]
+ );
+
+ const handleOpen = useCallback(() => {
+ if (disabled) return;
+
+ if (hideTimeoutRef.current) {
+ clearTimeout(hideTimeoutRef.current);
+ }
+
+ if (showDelay > 0) {
+ showTimeoutRef.current = setTimeout(() => {
+ setOpen(true);
+ }, showDelay);
+ } else {
+ setOpen(true);
+ }
+ }, [disabled, showDelay, setOpen]);
+
+ const handleClose = useCallback(() => {
+ if (showTimeoutRef.current) {
+ clearTimeout(showTimeoutRef.current);
+ }
+
+ if (hideDelay > 0) {
+ hideTimeoutRef.current = setTimeout(() => {
+ setOpen(false);
+ }, hideDelay);
+ } else {
+ setOpen(false);
+ }
+ }, [hideDelay, setOpen]);
+
+ // Update position when open
+ useEffect(() => {
+ if (!isOpen || !triggerRef.current || !contentRef.current) return;
+
+ const updatePosition = () => {
+ const triggerRect = triggerRef.current!.getBoundingClientRect();
+ const contentRect = contentRef.current!.getBoundingClientRect();
+ const newPosition = calculatePosition(triggerRect, contentRect, placement, offset);
+ setPosition(newPosition);
+ };
+
+ updatePosition();
+
+ // Update on scroll/resize
+ window.addEventListener('scroll', updatePosition, true);
+ window.addEventListener('resize', updatePosition);
+
+ return () => {
+ window.removeEventListener('scroll', updatePosition, true);
+ window.removeEventListener('resize', updatePosition);
+ };
+ }, [isOpen, placement, offset]);
+
+ // Cleanup timeouts on unmount
+ useEffect(() => {
+ return () => {
+ if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current);
+ if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
+ };
+ }, []);
+
+ // Clone trigger element with event handlers
+ const triggerElement = isValidElement(children)
+ ? cloneElement(children as React.ReactElement, {
+ ref: triggerRef,
+ onMouseEnter: (e: React.MouseEvent) => {
+ (children as React.ReactElement).props.onMouseEnter?.(e);
+ handleOpen();
+ },
+ onMouseLeave: (e: React.MouseEvent) => {
+ (children as React.ReactElement).props.onMouseLeave?.(e);
+ handleClose();
+ },
+ onFocus: (e: React.FocusEvent) => {
+ (children as React.ReactElement).props.onFocus?.(e);
+ handleOpen();
+ },
+ onBlur: (e: React.FocusEvent) => {
+ (children as React.ReactElement).props.onBlur?.(e);
+ handleClose();
+ },
+ })
+ : children;
+
+ // Only render portal in browser
+ const canRenderPortal = typeof window !== 'undefined';
+
+ return (
+ <>
+ {triggerElement}
+ {canRenderPortal &&
+ isOpen &&
+ createPortal(
+ {
+ (contentRef as React.MutableRefObject
).current = node;
+ if (typeof ref === 'function') {
+ ref(node);
+ } else if (ref) {
+ ref.current = node;
+ }
+ }}
+ role="tooltip"
+ className={cn(tooltipContentVariants({ state: 'entered' }), className)}
+ style={{
+ top: position.top,
+ left: position.left,
+ }}
+ {...props}
+ >
+ {hasArrow && (
+
+ )}
+ {content}
+ ,
+ document.body
+ )}
+ >
+ );
+ }
+);
+
+Tooltip.displayName = 'Tooltip';
diff --git a/packages/ui/src/Tooltip/types.ts b/packages/ui/src/Tooltip/types.ts
new file mode 100644
index 0000000..abdf78b
--- /dev/null
+++ b/packages/ui/src/Tooltip/types.ts
@@ -0,0 +1,84 @@
+import type { HTMLAttributes, ReactNode } from 'react';
+
+/**
+ * Tooltip placement types
+ */
+export type TooltipPlacement =
+ | 'top'
+ | 'top-start'
+ | 'top-end'
+ | 'bottom'
+ | 'bottom-start'
+ | 'bottom-end'
+ | 'left'
+ | 'left-start'
+ | 'left-end'
+ | 'right'
+ | 'right-start'
+ | 'right-end';
+
+/**
+ * Tooltip component props
+ */
+export interface TooltipProps extends Omit, 'content'> {
+ /**
+ * The trigger element (must accept ref)
+ */
+ children: ReactNode;
+
+ /**
+ * The tooltip content (text or ReactNode)
+ */
+ content: ReactNode;
+
+ /**
+ * Placement of the tooltip relative to trigger
+ * @default 'top'
+ */
+ placement?: TooltipPlacement;
+
+ /**
+ * Offset from the trigger element in pixels
+ * @default 6
+ */
+ offset?: number;
+
+ /**
+ * Delay before showing in milliseconds
+ * @default 200
+ */
+ showDelay?: number;
+
+ /**
+ * Delay before hiding in milliseconds
+ * @default 0
+ */
+ hideDelay?: number;
+
+ /**
+ * Whether the tooltip has an arrow
+ * @default true
+ */
+ hasArrow?: boolean;
+
+ /**
+ * Whether the tooltip is disabled
+ * @default false
+ */
+ disabled?: boolean;
+
+ /**
+ * Whether the tooltip is open (controlled mode)
+ */
+ open?: boolean;
+
+ /**
+ * Callback when open state changes
+ */
+ onOpenChange?: (open: boolean) => void;
+}
+
+/**
+ * Tooltip ref type
+ */
+export type TooltipRef = HTMLDivElement;
diff --git a/packages/ui/src/Tooltip/variants.ts b/packages/ui/src/Tooltip/variants.ts
new file mode 100644
index 0000000..adfa7ca
--- /dev/null
+++ b/packages/ui/src/Tooltip/variants.ts
@@ -0,0 +1,68 @@
+import { createVariants } from '../system/variants';
+
+/**
+ * Tooltip content variant definitions
+ */
+export const tooltipContentVariants = createVariants({
+ base: [
+ // Layout
+ 'absolute',
+ 'z-[var(--z-tooltip)]',
+ 'px-2 py-1',
+ 'max-w-xs',
+ // Styling - Sharp corners, inverted colors for contrast
+ 'bg-foreground text-background',
+ 'rounded-none',
+ // Typography
+ 'font-mono text-xs',
+ // Animation
+ 'transition-opacity duration-150',
+ // Pointer events
+ 'pointer-events-none',
+ ].join(' '),
+
+ variants: {
+ state: {
+ entering: 'opacity-0',
+ entered: 'opacity-100',
+ exiting: 'opacity-0',
+ },
+ },
+
+ defaultVariants: {
+ state: 'entered',
+ },
+});
+
+/**
+ * Tooltip arrow variant definitions
+ */
+export const tooltipArrowVariants = createVariants({
+ base: [
+ 'absolute',
+ 'w-2 h-2',
+ 'bg-foreground',
+ 'rotate-45',
+ ].join(' '),
+
+ variants: {
+ placement: {
+ top: 'bottom-[-4px] left-1/2 -translate-x-1/2',
+ 'top-start': 'bottom-[-4px] left-3',
+ 'top-end': 'bottom-[-4px] right-3',
+ bottom: 'top-[-4px] left-1/2 -translate-x-1/2',
+ 'bottom-start': 'top-[-4px] left-3',
+ 'bottom-end': 'top-[-4px] right-3',
+ left: 'right-[-4px] top-1/2 -translate-y-1/2',
+ 'left-start': 'right-[-4px] top-2',
+ 'left-end': 'right-[-4px] bottom-2',
+ right: 'left-[-4px] top-1/2 -translate-y-1/2',
+ 'right-start': 'left-[-4px] top-2',
+ 'right-end': 'left-[-4px] bottom-2',
+ },
+ },
+
+ defaultVariants: {
+ placement: 'top',
+ },
+});
diff --git a/packages/ui/src/system/formVariants.ts b/packages/ui/src/system/formVariants.ts
index 1d019b1..d9b4c43 100644
--- a/packages/ui/src/system/formVariants.ts
+++ b/packages/ui/src/system/formVariants.ts
@@ -10,18 +10,18 @@
export const formInputBase = [
// Layout
'flex w-full',
- // Typography
- 'font-sans text-base',
- // Borders and radius
- 'rounded-md border border-border',
+ // Typography - Monospace for inputs
+ 'font-mono text-base',
+ // Borders and radius - SHARP CORNERS
+ 'rounded-none border border-border',
// Colors - Pure white background to stand out
'bg-surface text-foreground',
// Placeholder
'placeholder:text-foreground-tertiary',
- // Transitions
- 'transition-base',
- // Focus state
- 'focus-ring',
+ // Transitions - Fast, subtle
+ 'transition-colors duration-150',
+ // Focus state - Copper accent
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
// Hover state
'hover:border-border-strong',
// Disabled state