feat(ui): add comprehensive design system with 14 new components

New UI Components:
- Modal/Dialog with sizes, focus trap, backdrop
- Toast/Notification with variants, stacking, actions
- Drawer/Sheet with directions and widths
- Popover with triggers and positioning
- Tooltip with delays and placements
- DropdownMenu with items, dividers, keyboard nav
- Breadcrumbs with separators and collapsing
- Pagination with full/simple/minimal variants
- Accordion with single/multi expand modes
- Skeleton with text/avatar/card/table variants
- InstallSnippet with package manager toggle
- QualityScore with tier badges
- ToolCard for registry display

Design System Enhancements:
- Complete token specification (shadows, radius, z-index, opacity)
- Full dark mode palette in globals.css
- Updated component variants for dark mode support

Style Guide Expansion:
- Split into 21 modular section files
- Pattern library: navigation, forms, feedback, tables, search
- Governance: a11y checklists, content guidelines, icon system
- Interactive examples throughout
This commit is contained in:
Ajax Davis 2026-01-16 05:51:12 +10:00
parent 46b212e65a
commit 066e599293
77 changed files with 12045 additions and 195 deletions

View file

@ -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);
}
}

View file

@ -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 (
<div className="min-h-screen flex flex-col bg-background">
<AppHeader />
<main className="flex-1 py-24">
<Container size="lg" padding="lg">
{/* Hero Section */}
<div className="mb-24">
<h1 className="font-mono text-4xl md:text-5xl lg:text-6xl font-semibold mb-6 text-foreground lowercase tracking-tight">
tpmjs design system
</h1>
<p className="font-sans text-lg text-foreground-secondary max-w-2xl leading-relaxed mb-8">
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.
</p>
<div className="flex flex-wrap gap-4">
<Badge variant="default">v2.0</Badge>
<Badge variant="outline">wcag aa</Badge>
<Badge variant="outline">dark mode</Badge>
</div>
</div>
{/* Table of Contents */}
<FieldsetSection title="table of contents" id="toc">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="space-y-2">
<p className="font-mono text-xs text-foreground-tertiary uppercase tracking-wide mb-3">foundations</p>
<NavItem href="#principles">1. design principles</NavItem>
<NavItem href="#colors">2. color system</NavItem>
<NavItem href="#typography">3. typography</NavItem>
<NavItem href="#spacing">4. spacing</NavItem>
<NavItem href="#motion">5. motion</NavItem>
</div>
<div className="space-y-2">
<p className="font-mono text-xs text-foreground-tertiary uppercase tracking-wide mb-3">systems</p>
<NavItem href="#accessibility">6. accessibility</NavItem>
<NavItem href="#layout">7. layout &amp; responsiveness</NavItem>
<NavItem href="#content">8. content guidelines</NavItem>
<NavItem href="#data-viz">9. data visualization</NavItem>
<NavItem href="#icons">10. iconography</NavItem>
</div>
<div className="space-y-2">
<p className="font-mono text-xs text-foreground-tertiary uppercase tracking-wide mb-3">implementation</p>
<NavItem href="#theming">11. theming</NavItem>
<NavItem href="#components">12. components</NavItem>
<NavItem href="#api">13. component apis</NavItem>
</div>
<div className="space-y-2">
<p className="font-mono text-xs text-foreground-tertiary uppercase tracking-wide mb-3">patterns</p>
<NavItem href="#nav-patterns">14. navigation</NavItem>
<NavItem href="#form-patterns">15. forms</NavItem>
<NavItem href="#feedback-patterns">16. feedback</NavItem>
<NavItem href="#table-patterns">17. tables</NavItem>
<NavItem href="#search-patterns">18. search &amp; filtering</NavItem>
</div>
<div className="space-y-2">
<p className="font-mono text-xs text-foreground-tertiary uppercase tracking-wide mb-3">governance</p>
<NavItem href="#a11y-checklists">19. a11y checklists</NavItem>
<NavItem href="#content-guidelines">20. content &amp; writing</NavItem>
<NavItem href="#icon-system">21. icon system</NavItem>
</div>
</div>
</FieldsetSection>
{/* Foundation Sections */}
<SectionPrinciples />
<SectionColors />
<SectionTypography />
<SectionSpacing />
<SectionMotion />
{/* Systems Sections */}
<SectionAccessibility />
<SectionLayout density={density} onDensityChange={setDensity} />
<SectionContent />
<SectionDataViz counterValue={counterValue} onCounterChange={setCounterValue} />
<SectionIcons />
{/* Implementation Sections */}
<SectionTheming />
<SectionComponents
activeTab={activeTab}
onTabChange={setActiveTab}
radioValue={radioValue}
onRadioChange={setRadioValue}
/>
<SectionComponentAPIs />
{/* Pattern Library Sections */}
<SectionPatternNavigation />
<SectionPatternForms />
<SectionPatternFeedback />
<SectionPatternTables />
<SectionPatternSearch />
{/* Governance Sections */}
<SectionA11yChecklists />
<SectionContentGuidelines />
<SectionIconSystem />
{/* Footer */}
<div className="text-center py-12 border-t border-dashed border-border mt-16">
<p className="font-mono text-sm text-foreground-secondary">
tpmjs design system v2.0
</p>
<p className="font-sans text-xs text-foreground-tertiary mt-2">
built with @tpmjs/ui inspired by turbopuffer.com
</p>
</div>
</Container>
</main>
</div>
);
}

View file

@ -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 (
<div className="mb-8">
<h4 className="font-mono text-sm font-medium mb-4 pb-2 border-b border-dashed border-border">
{title}
</h4>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>component</TableHead>
<TableHead>aria pattern</TableHead>
<TableHead>focus trap</TableHead>
<TableHead>keyboard</TableHead>
<TableHead>reduced motion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{components.map((comp) => (
<TableRow key={comp.component}>
<TableCell className="font-mono text-sm">{comp.component}</TableCell>
<TableCell>
<code className="text-xs bg-surface-2 px-2 py-0.5">{comp.ariaPattern}</code>
</TableCell>
<TableCell>
{comp.focusTrap ? (
<Badge variant="success" size="sm">yes</Badge>
) : (
<Badge variant="outline" size="sm">no</Badge>
)}
</TableCell>
<TableCell>
<div className="space-y-1">
{comp.keyboard.map((k, i) => (
<div key={i} className="text-xs text-foreground-secondary">{k}</div>
))}
</div>
</TableCell>
<TableCell className="text-xs text-foreground-secondary">
{comp.reducedMotion}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
export function SectionA11yChecklists(): React.ReactElement {
return (
<FieldsetSection title="19. accessibility checklists" id="a11y-checklists">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
Component-level accessibility requirements and implementation guides.
All components follow WCAG 2.1 AA standards.
</p>
<SubSection title="aria patterns & keyboard">
<p className="font-sans text-sm text-foreground-secondary mb-6">
Each component has specific ARIA patterns and keyboard interactions that must be implemented.
</p>
<A11yTable components={overlayComponents} title="overlay components" />
<A11yTable components={formComponents} title="form components" />
<A11yTable components={navigationComponents} title="navigation components" />
</SubSection>
<SubSection title="focus management">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Proper focus management is critical for keyboard and screen reader users.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4 flex items-center gap-2">
<Icon icon="check" size="sm" className="text-success" />
focus trap required
</h4>
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
<li>Modal dialogs - trap focus inside</li>
<li>Drawer sheets - trap until closed</li>
<li>Full-screen overlays</li>
</ul>
</div>
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4 flex items-center gap-2">
<Icon icon="arrowRight" size="sm" className="text-accent" />
focus restoration
</h4>
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
<li>Return focus to trigger on close</li>
<li>Save and restore focus position</li>
<li>Skip links for long content</li>
</ul>
</div>
</div>
<div className="mt-6 bg-surface p-4 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-3">implementation pattern</h4>
<pre className="text-xs font-mono text-foreground-secondary overflow-x-auto">
{`// Focus trap implementation
const dialogRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(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();
};`}
</pre>
</div>
</SubSection>
<SubSection title="screen reader announcements">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Use live regions to announce dynamic content changes.
</p>
<div className="space-y-4">
<div className="bg-surface p-4 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-3">aria-live regions</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-3 bg-surface-2">
<code className="text-xs">aria-live="polite"</code>
<p className="text-xs text-foreground-secondary mt-2">
Toast notifications, status updates
</p>
</div>
<div className="p-3 bg-surface-2">
<code className="text-xs">aria-live="assertive"</code>
<p className="text-xs text-foreground-secondary mt-2">
Error messages, critical alerts
</p>
</div>
<div className="p-3 bg-surface-2">
<code className="text-xs">role="status"</code>
<p className="text-xs text-foreground-secondary mt-2">
Loading states, progress updates
</p>
</div>
</div>
</div>
<div className="bg-surface p-4 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-3">component announcements</h4>
<Table>
<TableHeader>
<TableRow>
<TableHead>event</TableHead>
<TableHead>announcement</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-mono text-sm">Toast shown</TableCell>
<TableCell className="text-sm text-foreground-secondary">"[message content]"</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-mono text-sm">Modal opened</TableCell>
<TableCell className="text-sm text-foreground-secondary">"[dialog title], dialog"</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-mono text-sm">Form error</TableCell>
<TableCell className="text-sm text-foreground-secondary">"Error: [field name], [error message]"</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-mono text-sm">Loading complete</TableCell>
<TableCell className="text-sm text-foreground-secondary">"Loading complete, [N] results"</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</SubSection>
<SubSection title="color contrast requirements">
<p className="font-sans text-sm text-foreground-secondary mb-4">
All text must meet WCAG AA contrast requirements (4.5:1 for normal text, 3:1 for large text).
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-background border border-border">
<span className="text-foreground">foreground on background</span>
<Badge variant="success" size="sm">9.2:1</Badge>
</div>
<div className="flex items-center justify-between p-3 bg-surface border border-border">
<span className="text-foreground-secondary">secondary on surface</span>
<Badge variant="success" size="sm">5.8:1</Badge>
</div>
<div className="flex items-center justify-between p-3 bg-surface border border-border">
<span className="text-foreground-tertiary">tertiary on surface</span>
<Badge variant="warning" size="sm">4.5:1</Badge>
</div>
</div>
<div className="bg-surface p-4 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-3">contrast verification</h4>
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
<li> Test all color combinations in both themes</li>
<li> Verify focus indicators (3:1 minimum)</li>
<li> Ensure error states meet requirements</li>
<li> Check interactive state contrast changes</li>
</ul>
</div>
</div>
</SubSection>
<SubSection title="reduced motion support">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Respect the <code className="px-1 bg-surface-2 text-xs">prefers-reduced-motion</code> media query
for users who experience motion sickness or vestibular disorders.
</p>
<div className="bg-surface p-4 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-3">implementation</h4>
<pre className="text-xs font-mono text-foreground-secondary overflow-x-auto">
{`// 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 (
<div
className={prefersReducedMotion ? 'instant' : 'animated'}
/>
);
}`}
</pre>
</div>
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-3 bg-success-light border border-success">
<p className="font-mono text-sm text-success mb-1">always animate</p>
<p className="text-xs text-foreground-secondary">Progress bars, loading spinners</p>
</div>
<div className="p-3 bg-warning-light border border-warning">
<p className="font-mono text-sm text-warning mb-1">respect preference</p>
<p className="text-xs text-foreground-secondary">Page transitions, modal animations</p>
</div>
<div className="p-3 bg-error-light border border-error">
<p className="font-mono text-sm text-error mb-1">never animate</p>
<p className="text-xs text-foreground-secondary">Auto-playing video, parallax</p>
</div>
</div>
</SubSection>
<SubSection title="testing checklist">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Before shipping any component, verify accessibility with these tests.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-surface p-4 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-3 flex items-center gap-2">
<Icon icon="terminal" size="sm" />
keyboard testing
</h4>
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
<li className="flex items-center gap-2">
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
Tab through all interactive elements
</li>
<li className="flex items-center gap-2">
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
Verify focus is visible at all times
</li>
<li className="flex items-center gap-2">
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
Test Enter/Space activation
</li>
<li className="flex items-center gap-2">
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
Verify Escape closes overlays
</li>
<li className="flex items-center gap-2">
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
Test arrow key navigation
</li>
</ul>
</div>
<div className="bg-surface p-4 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-3 flex items-center gap-2">
<Icon icon="search" size="sm" />
screen reader testing
</h4>
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
<li className="flex items-center gap-2">
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
Test with VoiceOver (macOS/iOS)
</li>
<li className="flex items-center gap-2">
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
Test with NVDA (Windows)
</li>
<li className="flex items-center gap-2">
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
Verify all content is announced
</li>
<li className="flex items-center gap-2">
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
Check landmark navigation
</li>
<li className="flex items-center gap-2">
<Icon icon="box" size="xs" className="text-foreground-tertiary" />
Test live region updates
</li>
</ul>
</div>
</div>
</SubSection>
</FieldsetSection>
);
}

View file

@ -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 (
<FieldsetSection title="6. accessibility" id="accessibility">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
TPMJS targets WCAG 2.1 AA compliance. Accessibility is not optionalit's
a core requirement for every component.
</p>
<SubSection title="standards">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-surface p-6 border border-dashed border-border text-center">
<p className="font-mono text-4xl font-bold text-accent mb-2">AA</p>
<p className="font-mono text-sm text-foreground-secondary">WCAG 2.1 target</p>
</div>
<div className="bg-surface p-6 border border-dashed border-border text-center">
<p className="font-mono text-4xl font-bold text-foreground mb-2">4.5:1</p>
<p className="font-mono text-sm text-foreground-secondary">min contrast (text)</p>
</div>
<div className="bg-surface p-6 border border-dashed border-border text-center">
<p className="font-mono text-4xl font-bold text-foreground mb-2">3:1</p>
<p className="font-mono text-sm text-foreground-secondary">min contrast (UI)</p>
</div>
</div>
</SubSection>
<SubSection title="focus management">
<div className="space-y-6">
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">focus ring design</h4>
<p className="font-sans text-sm text-foreground-secondary mb-4">
Focus rings use the copper accent color with 2px width and 2px offset.
They are <strong>never removed</strong> from interactive elements.
</p>
<div className="flex gap-4">
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
tab to me
</Button>
<Input placeholder="or me" className="max-w-xs" />
</div>
</div>
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">keyboard navigation</h4>
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
<li> <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">Tab</kbd> moves focus forward through interactive elements</li>
<li> <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">Shift+Tab</kbd> moves focus backward</li>
<li> <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">Enter</kbd> / <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">Space</kbd> activates buttons and links</li>
<li> <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">Esc</kbd> closes modals and dropdowns</li>
<li> Arrow keys navigate within components (tabs, radios, menus)</li>
</ul>
</div>
</div>
</SubSection>
<SubSection title="color independence">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<DoDontCard type="do" title="Use icons + color for states">
<div className="flex gap-4 items-center">
<div className="flex items-center gap-2 text-success">
<Icon icon="check" size="sm" />
<span className="text-sm">Published</span>
</div>
<div className="flex items-center gap-2 text-error">
<Icon icon="x" size="sm" />
<span className="text-sm">Failed</span>
</div>
</div>
</DoDontCard>
<DoDontCard type="dont" title="Don't rely on color alone">
<div className="flex gap-4 items-center">
<span className="text-sm text-success">Published</span>
<span className="text-sm text-error">Failed</span>
</div>
</DoDontCard>
</div>
</SubSection>
<SubSection title="screen reader support">
<div className="bg-surface p-6 border border-dashed border-border">
<ul className="space-y-3 text-sm text-foreground-secondary font-sans">
<li> All interactive elements have accessible names (aria-label or visible text)</li>
<li> Images include alt text describing content</li>
<li> Form inputs are associated with labels</li>
<li> Error messages are announced via aria-live regions</li>
<li> Loading states communicate progress to screen readers</li>
<li> Decorative elements are hidden from assistive technology</li>
</ul>
</div>
</SubSection>
</FieldsetSection>
);
}

View file

@ -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 (
<FieldsetSection title="2. color system" id="colors">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
A warm earthy palette with copper accent. Colors are designed for clarity,
hierarchy, and accessibility.
</p>
{/* Color Palette Display */}
<SubSection title="backgrounds">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<ColorCard name="bg" color="bg-background" hex="#F5F3F0" desc="base background" />
<ColorCard name="surface" color="bg-surface" hex="#FFFFFF" desc="cards, panels" />
<ColorCard name="surface-2" color="bg-surface-2" hex="#FAF8F6" desc="elevated surfaces" />
<ColorCard name="surface-3" color="bg-surface-3" hex="#F0EDEA" desc="highest elevation" />
</div>
</SubSection>
<SubSection title="borders">
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<ColorCard name="border" color="bg-border" hex="#CEC9C3" desc="default borders" />
<ColorCard name="border-strong" color="bg-border-strong" hex="#857F77" desc="emphasized borders" />
<ColorCard name="border-subtle" color="bg-border-subtle" hex="#E3E0DC" desc="subtle borders" />
</div>
</SubSection>
<SubSection title="text">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<ColorCard name="text" color="bg-foreground" hex="#1A1715" desc="primary text" textLight />
<ColorCard name="text-secondary" color="bg-foreground-secondary" hex="#5C564F" desc="secondary text" textLight />
<ColorCard name="text-tertiary" color="bg-foreground-tertiary" hex="#817B74" desc="tertiary text" textLight />
<ColorCard name="text-muted" color="bg-foreground-muted" hex="#9A958F" desc="muted text" textLight />
</div>
</SubSection>
<SubSection title="accent (copper)">
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<ColorCard name="accent" color="bg-accent" hex="#A6592D" desc="brand accent" textLight />
<ColorCard name="accent-strong" color="bg-accent-strong" hex="#8F4722" desc="hover accent" textLight />
<ColorCard name="accent-muted" color="bg-accent-muted" hex="#EDE5DF" desc="soft tint" />
</div>
</SubSection>
<SubSection title="status">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<ColorCard name="success" color="bg-success" hex="#327D52" desc="success states" textLight />
<ColorCard name="warning" color="bg-warning" hex="#D9A020" desc="warning states" />
<ColorCard name="error" color="bg-error" hex="#C44545" desc="error states" textLight />
<ColorCard name="info" color="bg-info" hex="#3380CC" desc="info states" textLight />
</div>
</SubSection>
<SubSection title="status (light backgrounds)">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<ColorCard name="success-light" color="bg-success-light" hex="#E5F3EC" desc="success bg" />
<ColorCard name="warning-light" color="bg-warning-light" hex="#F8F2E0" desc="warning bg" />
<ColorCard name="error-light" color="bg-error-light" hex="#F9EDED" desc="error bg" />
<ColorCard name="info-light" color="bg-info-light" hex="#EDF4FB" desc="info bg" />
</div>
</SubSection>
{/* Color Usage Rules */}
<SubSection title="color usage rules">
<div className="space-y-6 mb-8">
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">when to use copper vs neutral</h4>
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
<li> <strong>Copper:</strong> Primary actions, active states, links, key metrics</li>
<li> <strong>Neutral:</strong> Secondary actions, borders, backgrounds, body text</li>
<li> <strong>Rule:</strong> Copper should be max ~10% of visible screen area</li>
</ul>
</div>
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">semantic color priority</h4>
<div className="flex gap-2 items-center">
<Badge variant="error">error</Badge>
<span className="text-foreground-tertiary">{'>'}</span>
<Badge variant="warning">warning</Badge>
<span className="text-foreground-tertiary">{'>'}</span>
<Badge variant="success">success</Badge>
<span className="text-foreground-tertiary">{'>'}</span>
<Badge variant="info">info</Badge>
</div>
<p className="text-xs text-foreground-tertiary mt-3 font-sans">
When multiple states apply, show the highest priority color.
</p>
</div>
</div>
{/* Do/Don't Examples */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<DoDontCard type="do" title="Use copper for primary CTAs">
<div className="flex gap-3">
<Button>publish tool</Button>
<Button variant="secondary">cancel</Button>
</div>
</DoDontCard>
<DoDontCard type="dont" title="Don't use copper for everything">
<div className="flex gap-3">
<Button>publish</Button>
<Button>cancel</Button>
<Button>back</Button>
</div>
</DoDontCard>
<DoDontCard type="do" title="Use status colors with meaning">
<div className="flex gap-3 items-center">
<Badge variant="success">published</Badge>
<Badge variant="warning">pending</Badge>
<Badge variant="error">failed</Badge>
</div>
</DoDontCard>
<DoDontCard type="dont" title="Don't use colors decoratively">
<div className="flex gap-3 items-center">
<Badge variant="success">tools</Badge>
<Badge variant="warning">agents</Badge>
<Badge variant="error">users</Badge>
</div>
</DoDontCard>
</div>
</SubSection>
<SubSection title="contrast ratios (wcag aa)">
<div className="overflow-x-auto">
<Table variant="bordered">
<TableHeader>
<TableRow>
<TableHead>combination</TableHead>
<TableHead>ratio</TableHead>
<TableHead>normal text</TableHead>
<TableHead>large text</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-mono">foreground / background</TableCell>
<TableCell className="font-mono">12.5:1</TableCell>
<TableCell><Badge variant="success" size="sm">pass</Badge></TableCell>
<TableCell><Badge variant="success" size="sm">pass</Badge></TableCell>
</TableRow>
<TableRow>
<TableCell className="font-mono">accent / background</TableCell>
<TableCell className="font-mono">5.2:1</TableCell>
<TableCell><Badge variant="success" size="sm">pass</Badge></TableCell>
<TableCell><Badge variant="success" size="sm">pass</Badge></TableCell>
</TableRow>
<TableRow>
<TableCell className="font-mono">foreground-secondary / background</TableCell>
<TableCell className="font-mono">5.8:1</TableCell>
<TableCell><Badge variant="success" size="sm">pass</Badge></TableCell>
<TableCell><Badge variant="success" size="sm">pass</Badge></TableCell>
</TableRow>
<TableRow>
<TableCell className="font-mono">foreground-tertiary / background</TableCell>
<TableCell className="font-mono">3.9:1</TableCell>
<TableCell><Badge variant="warning" size="sm">large only</Badge></TableCell>
<TableCell><Badge variant="success" size="sm">pass</Badge></TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</SubSection>
</FieldsetSection>
);
}

View file

@ -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 (
<FieldsetSection title="13. component apis" id="api">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
Complete API documentation for each component with props, types,
and usage examples.
</p>
<SubSection title="button api">
<div className="space-y-6">
<Table variant="bordered">
<TableHeader>
<TableRow>
<TableHead>prop</TableHead>
<TableHead>type</TableHead>
<TableHead>default</TableHead>
<TableHead>description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-mono">variant</TableCell>
<TableCell className="font-mono text-xs">&apos;default&apos; | &apos;secondary&apos; | &apos;destructive&apos; | &apos;outline&apos; | &apos;ghost&apos; | &apos;link&apos;</TableCell>
<TableCell className="font-mono">&apos;default&apos;</TableCell>
<TableCell>visual style</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-mono">size</TableCell>
<TableCell className="font-mono text-xs">&apos;sm&apos; | &apos;md&apos; | &apos;lg&apos; | &apos;icon&apos;</TableCell>
<TableCell className="font-mono">&apos;md&apos;</TableCell>
<TableCell>button size</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-mono">loading</TableCell>
<TableCell className="font-mono text-xs">boolean</TableCell>
<TableCell className="font-mono">false</TableCell>
<TableCell>show spinner</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-mono">disabled</TableCell>
<TableCell className="font-mono text-xs">boolean</TableCell>
<TableCell className="font-mono">false</TableCell>
<TableCell>disable interaction</TableCell>
</TableRow>
</TableBody>
</Table>
<CodeBlock
code={`// Basic usage
<Button>click me</Button>
// With variant and size
<Button variant="destructive" size="lg">
delete tool
</Button>
// Loading state
<Button loading>publishing...</Button>
// With icon
<Button>
<Icon icon="plus" size="sm" className="mr-2" />
add tool
</Button>`}
language="tsx"
/>
</div>
</SubSection>
<SubSection title="input api">
<div className="space-y-6">
<Table variant="bordered">
<TableHeader>
<TableRow>
<TableHead>prop</TableHead>
<TableHead>type</TableHead>
<TableHead>default</TableHead>
<TableHead>description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-mono">state</TableCell>
<TableCell className="font-mono text-xs">&apos;default&apos; | &apos;error&apos; | &apos;success&apos;</TableCell>
<TableCell className="font-mono">&apos;default&apos;</TableCell>
<TableCell>visual state</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-mono">size</TableCell>
<TableCell className="font-mono text-xs">&apos;sm&apos; | &apos;md&apos; | &apos;lg&apos;</TableCell>
<TableCell className="font-mono">&apos;md&apos;</TableCell>
<TableCell>input size</TableCell>
</TableRow>
</TableBody>
</Table>
<CodeBlock
code={`// Basic usage
<Input placeholder="enter text..." />
// With error state
<Input
state="error"
placeholder="invalid input"
/>
// With FormField wrapper
<FormField
label="Email"
error="Invalid email address"
>
<Input state="error" />
</FormField>`}
language="tsx"
/>
</div>
</SubSection>
<SubSection title="anti-patterns">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-error-light p-6 border border-error">
<h4 className="font-mono text-sm font-medium mb-4 text-error">don&apos;t do this</h4>
<CodeBlock
code={`// ❌ Using inline styles
<Button style={{ backgroundColor: 'red' }}>
delete
</Button>
// ❌ Nesting buttons
<Button>
<Button>nested</Button>
</Button>
// ❌ Missing accessible name
<Button size="icon">
<Icon icon="plus" />
</Button>`}
language="tsx"
size="sm"
/>
</div>
<div className="bg-success-light p-6 border border-success">
<h4 className="font-mono text-sm font-medium mb-4 text-success">do this instead</h4>
<CodeBlock
code={`// ✅ Using variants
<Button variant="destructive">
delete
</Button>
// ✅ Single button
<Button>action</Button>
// ✅ With aria-label
<Button size="icon" aria-label="Add item">
<Icon icon="plus" />
</Button>`}
language="tsx"
size="sm"
/>
</div>
</div>
</SubSection>
</FieldsetSection>
);
}

View file

@ -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 (
<FieldsetSection title="12. components" id="components">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
Complete component catalog with all variants and states.
</p>
{/* Button */}
<SubSection title="button">
<div className="space-y-6">
<div>
<p className="font-mono text-xs text-foreground-secondary mb-3">variants</p>
<div className="flex flex-wrap gap-4 items-center">
<Button variant="default">default</Button>
<Button variant="secondary">secondary</Button>
<Button variant="destructive">destructive</Button>
<Button variant="outline">outline</Button>
<Button variant="outline-dotted">outline dotted</Button>
<Button variant="blueprint">blueprint</Button>
<Button variant="ghost">ghost</Button>
<Button variant="link">link</Button>
</div>
</div>
<div>
<p className="font-mono text-xs text-foreground-secondary mb-3">sizes</p>
<div className="flex flex-wrap gap-4 items-center">
<Button size="sm">small</Button>
<Button size="md">medium</Button>
<Button size="lg">large</Button>
<Button size="icon"><Icon icon="plus" size="sm" /></Button>
</div>
</div>
<div>
<p className="font-mono text-xs text-foreground-secondary mb-3">states</p>
<div className="flex flex-wrap gap-4 items-center">
<Button>normal</Button>
<Button disabled>disabled</Button>
<Button loading>loading</Button>
</div>
</div>
</div>
</SubSection>
{/* Badge */}
<SubSection title="badge">
<div className="space-y-6">
<div>
<p className="font-mono text-xs text-foreground-secondary mb-3">variants</p>
<div className="flex flex-wrap gap-4 items-center">
<Badge variant="default">default</Badge>
<Badge variant="secondary">secondary</Badge>
<Badge variant="outline">outline</Badge>
<Badge variant="success">success</Badge>
<Badge variant="error">error</Badge>
<Badge variant="warning">warning</Badge>
<Badge variant="info">info</Badge>
</div>
</div>
<div>
<p className="font-mono text-xs text-foreground-secondary mb-3">sizes</p>
<div className="flex flex-wrap gap-4 items-center">
<Badge size="sm">small</Badge>
<Badge size="md">medium</Badge>
<Badge size="lg">large</Badge>
</div>
</div>
</div>
</SubSection>
{/* Card */}
<SubSection title="card">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{(['default', 'elevated', 'outline', 'blueprint', 'featured', 'brutalist'] as const).map((variant) => (
<Card key={variant} variant={variant}>
<CardHeader>
<CardTitle>{variant} card</CardTitle>
<CardDescription>card variant example</CardDescription>
</CardHeader>
<CardContent>
<p className="text-foreground-secondary text-sm">card content goes here.</p>
</CardContent>
<CardFooter>
<Button size="sm">action</Button>
</CardFooter>
</Card>
))}
</div>
</SubSection>
{/* Form Elements */}
<SubSection title="form elements">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-6">
<FormField label="input">
<Input placeholder="enter text..." />
</FormField>
<FormField label="textarea">
<Textarea placeholder="enter text..." rows={3} />
</FormField>
<FormField label="select">
<Select
placeholder="select option..."
options={[
{ value: '1', label: 'Option 1' },
{ value: '2', label: 'Option 2' },
{ value: '3', label: 'Option 3' },
]}
/>
</FormField>
</div>
<div className="space-y-6">
<div>
<p className="font-mono text-xs text-foreground-secondary mb-3">checkbox</p>
<div className="flex gap-6">
<Checkbox label="unchecked" />
<Checkbox label="checked" checked onChange={() => {}} />
<Checkbox label="disabled" disabled />
</div>
</div>
<div>
<p className="font-mono text-xs text-foreground-secondary mb-3">switch</p>
<div className="flex gap-6">
<Switch label="off" />
<Switch label="on" checked onChange={() => {}} />
<Switch label="disabled" disabled />
</div>
</div>
<div>
<p className="font-mono text-xs text-foreground-secondary mb-3">radio group</p>
<RadioGroup
name="demo-radio"
value={radioValue}
onChange={onRadioChange}
orientation="horizontal"
>
<Radio value="option1" label="option 1" />
<Radio value="option2" label="option 2" />
<Radio value="option3" label="option 3" />
</RadioGroup>
</div>
</div>
</div>
</SubSection>
{/* Tabs */}
<SubSection title="tabs">
<div className="space-y-6">
<Tabs
tabs={[
{ id: 'all', label: 'all items', count: 128 },
{ id: 'active', label: 'active', count: 42 },
{ id: 'archived', label: 'archived', count: 86 },
]}
activeTab={activeTab}
onTabChange={onTabChange}
variant="default"
/>
</div>
</SubSection>
{/* Table */}
<SubSection title="table">
<Table>
<TableCaption>example data table</TableCaption>
<TableHeader>
<TableRow>
<TableHead>name</TableHead>
<TableHead>category</TableHead>
<TableHead className="text-right">downloads</TableHead>
<TableHead className="text-right">score</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-mono">@tpmjs/parser</TableCell>
<TableCell>utility</TableCell>
<TableCell className="text-right font-mono">125,432</TableCell>
<TableCell className="text-right font-mono">0.92</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-mono">@tpmjs/validator</TableCell>
<TableCell>validation</TableCell>
<TableCell className="text-right font-mono">89,231</TableCell>
<TableCell className="text-right font-mono">0.87</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-mono">@tpmjs/transform</TableCell>
<TableCell>data</TableCell>
<TableCell className="text-right font-mono">45,678</TableCell>
<TableCell className="text-right font-mono">0.81</TableCell>
</TableRow>
</TableBody>
</Table>
</SubSection>
{/* Spinner */}
<SubSection title="spinner">
<div className="flex flex-wrap gap-12 items-center">
<div className="text-center">
<Spinner size="xs" />
<p className="font-mono text-xs text-foreground-secondary mt-4">xs</p>
</div>
<div className="text-center">
<Spinner size="sm" />
<p className="font-mono text-xs text-foreground-secondary mt-4">sm</p>
</div>
<div className="text-center">
<Spinner size="md" />
<p className="font-mono text-xs text-foreground-secondary mt-4">md</p>
</div>
<div className="text-center">
<Spinner size="lg" />
<p className="font-mono text-xs text-foreground-secondary mt-4">lg</p>
</div>
<div className="text-center">
<Spinner size="xl" />
<p className="font-mono text-xs text-foreground-secondary mt-4">xl</p>
</div>
</div>
</SubSection>
{/* Code Block */}
<SubSection title="code block">
<CodeBlock
code={`import { Button } from '@tpmjs/ui/Button/Button';
export function MyComponent() {
return (
<Button variant="default">
click me
</Button>
);
}`}
language="typescript"
/>
</SubSection>
</FieldsetSection>
);
}

View file

@ -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 (
<FieldsetSection title="8. content guidelines" id="content">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
Voice and tone guidelines for consistent, helpful communication
across the platform.
</p>
<SubSection title="voice & tone">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div className="bg-surface p-6 border-2 border-success">
<h4 className="font-mono text-sm font-medium mb-4 text-success">we are</h4>
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
<li> <strong>Technical:</strong> Precise, accurate, developer-friendly</li>
<li> <strong>Direct:</strong> Say what needs to be said, no fluff</li>
<li> <strong>Helpful:</strong> Guide users to success</li>
<li> <strong>Neutral:</strong> Professional, not corporate</li>
</ul>
</div>
<div className="bg-surface p-6 border-2 border-error">
<h4 className="font-mono text-sm font-medium mb-4 text-error">we are not</h4>
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
<li> <strong>Marketing-speak:</strong> No "revolutionary" or "game-changing"</li>
<li> <strong>Cute:</strong> No jokes, puns, or playful language</li>
<li> <strong>Vague:</strong> No "something went wrong"</li>
<li> <strong>Condescending:</strong> No "simply" or "just"</li>
</ul>
</div>
</div>
</SubSection>
<SubSection title="microcopy rules">
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<DoDontCard type="do" title="Use action verbs for buttons">
<div className="flex gap-3">
<Button size="sm">publish</Button>
<Button size="sm" variant="secondary">save draft</Button>
<Button size="sm" variant="destructive">delete</Button>
</div>
</DoDontCard>
<DoDontCard type="dont" title="Avoid generic labels">
<div className="flex gap-3">
<Button size="sm">submit</Button>
<Button size="sm" variant="secondary">ok</Button>
<Button size="sm" variant="destructive">yes</Button>
</div>
</DoDontCard>
</div>
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">error message format</h4>
<p className="font-sans text-sm text-foreground-secondary mb-4">
Error messages should explain <strong>what happened</strong> and
<strong> what to do next</strong>.
</p>
<div className="space-y-3">
<div className="p-3 bg-error-light border border-error text-sm">
<strong>Good:</strong> "API key is invalid. Generate a new key in your dashboard settings."
</div>
<div className="p-3 bg-error-light border border-error text-sm">
<strong>Bad:</strong> "Error: Invalid credentials"
</div>
</div>
</div>
</div>
</SubSection>
<SubSection title="empty states">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card variant="outline">
<CardContent className="text-center py-12">
<Icon icon="folder" size="lg" className="text-foreground-tertiary mx-auto mb-4" />
<p className="font-mono text-sm text-foreground mb-2">no tools yet</p>
<p className="font-sans text-xs text-foreground-secondary mb-4">
Publish your first tool to get started.
</p>
<Button size="sm">publish tool</Button>
</CardContent>
</Card>
<Card variant="outline">
<CardContent className="text-center py-12">
<Icon icon="search" size="lg" className="text-foreground-tertiary mx-auto mb-4" />
<p className="font-mono text-sm text-foreground mb-2">no results found</p>
<p className="font-sans text-xs text-foreground-secondary mb-4">
Try adjusting your search or filters.
</p>
<Button size="sm" variant="outline">clear filters</Button>
</CardContent>
</Card>
<Card variant="outline">
<CardContent className="text-center py-12">
<Icon icon="alertCircle" size="lg" className="text-error mx-auto mb-4" />
<p className="font-mono text-sm text-foreground mb-2">failed to load</p>
<p className="font-sans text-xs text-foreground-secondary mb-4">
Check your connection and try again.
</p>
<Button size="sm" variant="outline">retry</Button>
</CardContent>
</Card>
</div>
</SubSection>
<SubSection title="loading states">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">skeleton loading</h4>
<p className="font-sans text-xs text-foreground-secondary mb-4">
Use for content that will load quickly (&lt;2s).
</p>
<div className="space-y-3">
<div className="h-4 bg-muted animate-pulse w-3/4" />
<div className="h-4 bg-muted animate-pulse w-1/2" />
<div className="h-4 bg-muted animate-pulse w-5/6" />
</div>
</div>
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">spinner loading</h4>
<p className="font-sans text-xs text-foreground-secondary mb-4">
Use for actions or longer operations.
</p>
<div className="flex items-center gap-4">
<Spinner size="sm" />
<span className="font-mono text-sm text-foreground-secondary">publishing tool...</span>
</div>
</div>
</div>
</SubSection>
</FieldsetSection>
);
}

View file

@ -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 (
<FieldsetSection title="20. content & writing" id="content-guidelines">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
Consistent language and terminology across the platform.
These guidelines ensure clarity for users and maintainability for developers.
</p>
<SubSection title="glossary">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Use these terms consistently throughout the platform.
</p>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>term</TableHead>
<TableHead>definition</TableHead>
<TableHead>avoid</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{glossaryTerms.map((item) => (
<TableRow key={item.term}>
<TableCell className="font-mono text-sm font-medium">{item.term}</TableCell>
<TableCell className="text-sm text-foreground-secondary">{item.definition}</TableCell>
<TableCell>
<span className="text-xs text-foreground-tertiary line-through">{item.avoid}</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SubSection>
<SubSection title="verb consistency">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Use these verbs for common actions to maintain consistency.
</p>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>action</TableHead>
<TableHead>use</TableHead>
<TableHead>avoid</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{verbConsistency.map((item) => (
<TableRow key={item.action}>
<TableCell className="text-sm text-foreground-secondary">{item.action}</TableCell>
<TableCell>
<Badge variant="default" size="sm">{item.use}</Badge>
</TableCell>
<TableCell>
<span className="text-xs text-foreground-tertiary line-through">{item.avoid}</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SubSection>
<SubSection title="capitalization">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Follow these capitalization rules for UI text.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<DoDontCard type="do" title="Lowercase for UI elements">
<div className="space-y-2 text-sm font-mono">
<div className="p-2 bg-surface-2">create tool</div>
<div className="p-2 bg-surface-2">view details</div>
<div className="p-2 bg-surface-2">my collections</div>
</div>
</DoDontCard>
<DoDontCard type="dont" title="Avoid title case in buttons">
<div className="space-y-2 text-sm font-mono">
<div className="p-2 bg-surface-2">Create Tool</div>
<div className="p-2 bg-surface-2">View Details</div>
<div className="p-2 bg-surface-2">My Collections</div>
</div>
</DoDontCard>
</div>
<div className="mt-4 bg-surface p-4 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-3">exceptions</h4>
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
<li> Proper nouns: "GitHub", "Anthropic", "Claude"</li>
<li> Product names: "TPMJS", "MCP"</li>
<li> Start of sentences in paragraphs</li>
</ul>
</div>
</SubSection>
<SubSection title="numbers & formatting">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Format numbers and data consistently.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-surface p-4 border border-dashed border-border">
<h4 className="font-mono text-xs text-foreground-tertiary mb-3">numbers</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-foreground-secondary">Small:</span>
<span className="font-mono">1, 42, 100</span>
</div>
<div className="flex justify-between">
<span className="text-foreground-secondary">Large:</span>
<span className="font-mono">1,234</span>
</div>
<div className="flex justify-between">
<span className="text-foreground-secondary">Abbreviated:</span>
<span className="font-mono">12.5k, 1.2M</span>
</div>
</div>
</div>
<div className="bg-surface p-4 border border-dashed border-border">
<h4 className="font-mono text-xs text-foreground-tertiary mb-3">dates</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-foreground-secondary">Full:</span>
<span className="font-mono">Jan 15, 2025</span>
</div>
<div className="flex justify-between">
<span className="text-foreground-secondary">Relative:</span>
<span className="font-mono">2 hours ago</span>
</div>
<div className="flex justify-between">
<span className="text-foreground-secondary">ISO:</span>
<span className="font-mono">2025-01-15</span>
</div>
</div>
</div>
<div className="bg-surface p-4 border border-dashed border-border">
<h4 className="font-mono text-xs text-foreground-tertiary mb-3">units</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-foreground-secondary">Bytes:</span>
<span className="font-mono">2.4 MB</span>
</div>
<div className="flex justify-between">
<span className="text-foreground-secondary">Duration:</span>
<span className="font-mono">3m 24s</span>
</div>
<div className="flex justify-between">
<span className="text-foreground-secondary">Version:</span>
<span className="font-mono">v1.2.3</span>
</div>
</div>
</div>
</div>
</SubSection>
<SubSection title="error messages">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Write error messages that are helpful and actionable.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<DoDontCard type="do" title="Helpful and specific">
<div className="space-y-3">
<div className="p-3 bg-error-light border-l-2 border-error text-sm">
<p className="font-medium">Invalid email format</p>
<p className="text-foreground-secondary text-xs mt-1">Please enter a valid email address like user@example.com</p>
</div>
<div className="p-3 bg-error-light border-l-2 border-error text-sm">
<p className="font-medium">Tool name already exists</p>
<p className="text-foreground-secondary text-xs mt-1">Choose a different name or update the existing tool</p>
</div>
</div>
</DoDontCard>
<DoDontCard type="dont" title="Vague or technical">
<div className="space-y-3">
<div className="p-3 bg-error-light border-l-2 border-error text-sm">
<p className="font-medium">Invalid input</p>
</div>
<div className="p-3 bg-error-light border-l-2 border-error text-sm">
<p className="font-medium">Error: EEXIST</p>
</div>
<div className="p-3 bg-error-light border-l-2 border-error text-sm">
<p className="font-medium">Something went wrong</p>
</div>
</div>
</DoDontCard>
</div>
</SubSection>
</FieldsetSection>
);
}
export function SectionIconSystem(): React.ReactElement {
return (
<FieldsetSection title="21. icon system" id="icon-system">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
Icons from Lucide React, used consistently across components.
All icons use 2px stroke weight and are available in multiple sizes.
</p>
<SubSection title="icon library">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Source: <a href="https://lucide.dev" className="text-accent hover:underline">Lucide React</a>.
Icons should be imported from <code className="text-xs bg-surface-2 px-1">@tpmjs/ui/Icon/Icon</code>.
</p>
<div className="bg-surface p-4 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">common icons</h4>
<div className="grid grid-cols-4 md:grid-cols-8 gap-4">
{iconUsageRules.map((item) => (
<div key={item.icon} className="flex flex-col items-center gap-2 p-3 bg-surface-2">
<Icon icon={item.icon} size="md" />
<span className="font-mono text-[10px] text-foreground-tertiary">{item.icon}</span>
</div>
))}
</div>
</div>
</SubSection>
<SubSection title="icon sizes">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Choose icon size based on context and surrounding content.
</p>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-surface p-4 border border-dashed border-border text-center">
<Icon icon="edit" size="xs" className="mx-auto" />
<p className="font-mono text-xs mt-2">xs (12px)</p>
<p className="text-[10px] text-foreground-tertiary mt-1">Inline, tight spaces</p>
</div>
<div className="bg-surface p-4 border border-dashed border-border text-center">
<Icon icon="edit" size="sm" className="mx-auto" />
<p className="font-mono text-xs mt-2">sm (16px)</p>
<p className="text-[10px] text-foreground-tertiary mt-1">Buttons, inputs</p>
</div>
<div className="bg-surface p-4 border border-dashed border-border text-center">
<Icon icon="edit" size="md" className="mx-auto" />
<p className="font-mono text-xs mt-2">md (20px)</p>
<p className="text-[10px] text-foreground-tertiary mt-1">Default, nav items</p>
</div>
<div className="bg-surface p-4 border border-dashed border-border text-center">
<Icon icon="edit" size="lg" className="mx-auto" />
<p className="font-mono text-xs mt-2">lg (24px)</p>
<p className="text-[10px] text-foreground-tertiary mt-1">Empty states, hero</p>
</div>
</div>
</SubSection>
<SubSection title="icon usage guidelines">
<p className="font-sans text-sm text-foreground-secondary mb-4">
When and how to use specific icons.
</p>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>icon</TableHead>
<TableHead>usage</TableHead>
<TableHead>recommended size</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{iconUsageRules.map((item) => (
<TableRow key={item.icon}>
<TableCell>
<div className="flex items-center gap-2">
<Icon icon={item.icon} size="sm" />
<code className="text-xs">{item.icon}</code>
</div>
</TableCell>
<TableCell className="text-sm text-foreground-secondary">{item.usage}</TableCell>
<TableCell>
<Badge variant="outline" size="sm">{item.size}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SubSection>
<SubSection title="icon styling">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Consistent styling rules for icons in different contexts.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-surface p-4 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">colors</h4>
<div className="space-y-3">
<div className="flex items-center gap-3">
<Icon icon="check" size="sm" className="text-success" />
<span className="text-sm text-foreground-secondary">Success states</span>
</div>
<div className="flex items-center gap-3">
<Icon icon="alertCircle" size="sm" className="text-error" />
<span className="text-sm text-foreground-secondary">Error states</span>
</div>
<div className="flex items-center gap-3">
<Icon icon="alertTriangle" size="sm" className="text-warning" />
<span className="text-sm text-foreground-secondary">Warning states</span>
</div>
<div className="flex items-center gap-3">
<Icon icon="info" size="sm" className="text-accent" />
<span className="text-sm text-foreground-secondary">Information</span>
</div>
<div className="flex items-center gap-3">
<Icon icon="edit" size="sm" className="text-foreground-secondary" />
<span className="text-sm text-foreground-secondary">Neutral/default</span>
</div>
</div>
</div>
<div className="bg-surface p-4 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">with text</h4>
<div className="space-y-3">
<button className="flex items-center gap-2 px-3 py-2 bg-accent text-white">
<Icon icon="plus" size="sm" />
<span className="font-mono text-sm">create tool</span>
</button>
<button className="flex items-center gap-2 px-3 py-2 border border-border">
<span className="font-mono text-sm">settings</span>
<Icon icon="chevronRight" size="xs" />
</button>
<div className="flex items-center gap-2 text-foreground-secondary">
<Icon icon="clock" size="xs" />
<span className="text-xs">2 hours ago</span>
</div>
</div>
</div>
</div>
</SubSection>
<SubSection title="adding new icons">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Process for adding new icons to the system.
</p>
<div className="bg-surface p-4 border border-dashed border-border">
<ol className="space-y-3 text-sm text-foreground-secondary font-sans">
<li className="flex gap-3">
<span className="font-mono text-accent">1.</span>
Check if a suitable icon exists in <a href="https://lucide.dev/icons" className="text-accent hover:underline">Lucide</a>
</li>
<li className="flex gap-3">
<span className="font-mono text-accent">2.</span>
Add the icon import to <code className="text-xs bg-surface-2 px-1">packages/ui/src/Icon/icons.ts</code>
</li>
<li className="flex gap-3">
<span className="font-mono text-accent">3.</span>
Add the icon name to the <code className="text-xs bg-surface-2 px-1">IconName</code> type
</li>
<li className="flex gap-3">
<span className="font-mono text-accent">4.</span>
Document the usage in this style guide
</li>
</ol>
<div className="mt-4 p-3 bg-surface-2">
<p className="font-mono text-xs text-foreground-tertiary mb-2">example: adding a new icon</p>
<pre className="text-xs font-mono overflow-x-auto">
{`// icons.ts
import { Heart } from 'lucide-react';
export const icons = {
// ...existing icons
heart: Heart,
};
export type IconName = keyof typeof icons;`}
</pre>
</div>
</div>
</SubSection>
</FieldsetSection>
);
}

View file

@ -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 (
<FieldsetSection title="9. data visualization" id="data-viz">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
Guidelines for charts, metrics, and data displays. Semantic colors only
no rainbow charts.
</p>
<SubSection title="data color palette">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="bg-accent p-4 text-center">
<p className="font-mono text-sm text-white">primary data</p>
</div>
<div className="bg-foreground-secondary p-4 text-center">
<p className="font-mono text-sm text-white">secondary data</p>
</div>
<div className="bg-foreground-tertiary p-4 text-center">
<p className="font-mono text-sm text-white">tertiary data</p>
</div>
<div className="bg-border-strong p-4 text-center">
<p className="font-mono text-sm text-white">baseline</p>
</div>
</div>
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">color rules for data</h4>
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
<li> Use copper for primary/highlighted data series</li>
<li> Use grayscale for comparison and secondary series</li>
<li> Reserve semantic colors (success/warning/error) for threshold indicators</li>
<li> Create emphasis through opacity variation, not hue changes</li>
<li> Maximum 4 colors in any single visualization</li>
</ul>
</div>
</SubSection>
<SubSection title="quality score visualization">
<p className="font-sans text-sm text-foreground-secondary mb-6">
Tool quality scores use a consistent visual language across the platform.
</p>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<StatCard
label="excellent"
value={95}
suffix="%"
showBar
barProgress={95}
/>
<StatCard
label="good"
value={75}
suffix="%"
showBar
barProgress={75}
/>
<StatCard
label="fair"
value={50}
suffix="%"
showBar
barProgress={50}
/>
<StatCard
label="poor"
value={25}
suffix="%"
showBar
barProgress={25}
/>
</div>
</SubSection>
<SubSection title="progress indicators">
<div className="space-y-6 max-w-xl">
<div>
<p className="font-mono text-sm text-foreground-secondary mb-2">primary (default)</p>
<ProgressBar value={75} variant="primary" />
</div>
<div>
<p className="font-mono text-sm text-foreground-secondary mb-2">success (completion)</p>
<ProgressBar value={100} variant="success" />
</div>
<div>
<p className="font-mono text-sm text-foreground-secondary mb-2">warning (approaching limit)</p>
<ProgressBar value={85} variant="warning" />
</div>
<div>
<p className="font-mono text-sm text-foreground-secondary mb-2">danger (at limit)</p>
<ProgressBar value={98} variant="danger" />
</div>
</div>
</SubSection>
<SubSection title="animated counters">
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="text-center">
<div className="font-mono text-3xl font-semibold">
<AnimatedCounter value={counterValue} />
</div>
<p className="font-mono text-xs text-foreground-secondary mt-2">downloads</p>
</div>
<div className="text-center">
<div className="font-mono text-3xl font-semibold">
<AnimatedCounter value={Math.floor(counterValue * 0.8)} />
</div>
<p className="font-mono text-xs text-foreground-secondary mt-2">users</p>
</div>
<div className="text-center">
<div className="font-mono text-3xl font-semibold">
<AnimatedCounter value={Math.floor(counterValue * 0.05)} />
</div>
<p className="font-mono text-xs text-foreground-secondary mt-2">tools</p>
</div>
<div className="text-center">
<div className="font-mono text-3xl font-semibold">
<AnimatedCounter value={Math.floor(counterValue * 0.01)} />%
</div>
<p className="font-mono text-xs text-foreground-secondary mt-2">uptime</p>
</div>
</div>
<div className="flex gap-2 mt-6 justify-center">
<Button size="sm" onClick={() => onCounterChange(counterValue + 1000)}>+1000</Button>
<Button size="sm" variant="outline" onClick={() => onCounterChange(Math.max(0, counterValue - 1000))}>-1000</Button>
<Button size="sm" variant="secondary" onClick={() => onCounterChange(Math.floor(Math.random() * 100000))}>random</Button>
</div>
</SubSection>
</FieldsetSection>
);
}

View file

@ -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 (
<FieldsetSection title="10. iconography" id="icons">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
Consistent icon usage for clear visual communication.
</p>
<SubSection title="icon sizes">
<div className="flex flex-wrap gap-8 items-end mb-8">
<div className="text-center">
<Icon icon="github" size="xs" />
<p className="font-mono text-xs text-foreground-secondary mt-3">xs (12px)</p>
</div>
<div className="text-center">
<Icon icon="github" size="sm" />
<p className="font-mono text-xs text-foreground-secondary mt-3">sm (16px)</p>
</div>
<div className="text-center">
<Icon icon="github" size="md" />
<p className="font-mono text-xs text-foreground-secondary mt-3">md (20px)</p>
</div>
<div className="text-center">
<Icon icon="github" size="lg" />
<p className="font-mono text-xs text-foreground-secondary mt-3">lg (24px)</p>
</div>
</div>
</SubSection>
<SubSection title="usage rules">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">icon-only buttons</h4>
<p className="font-sans text-sm text-foreground-secondary mb-4">
Only allowed when the action is universally understood
(close, search, menu) AND space is limited.
</p>
<div className="flex gap-2">
<Button size="icon" variant="ghost"><Icon icon="x" size="sm" /></Button>
<Button size="icon" variant="ghost"><Icon icon="search" size="sm" /></Button>
<Button size="icon" variant="ghost"><Icon icon="menu" size="sm" /></Button>
</div>
</div>
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">icon + label (preferred)</h4>
<p className="font-sans text-sm text-foreground-secondary mb-4">
Always include text labels when space permits for clarity.
</p>
<div className="flex gap-2">
<Button size="sm"><Icon icon="plus" size="sm" className="mr-2" /> add tool</Button>
<Button size="sm" variant="outline"><Icon icon="upload" size="sm" className="mr-2" /> upload</Button>
</div>
</div>
</div>
</SubSection>
<SubSection title="semantic icons">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-surface p-4 border border-dashed border-border flex items-center gap-3">
<Icon icon="check" size="md" className="text-success" />
<span className="font-mono text-sm">success</span>
</div>
<div className="bg-surface p-4 border border-dashed border-border flex items-center gap-3">
<Icon icon="alertCircle" size="md" className="text-warning" />
<span className="font-mono text-sm">warning</span>
</div>
<div className="bg-surface p-4 border border-dashed border-border flex items-center gap-3">
<Icon icon="x" size="md" className="text-error" />
<span className="font-mono text-sm">error</span>
</div>
<div className="bg-surface p-4 border border-dashed border-border flex items-center gap-3">
<Icon icon="info" size="md" className="text-info" />
<span className="font-mono text-sm">info</span>
</div>
</div>
</SubSection>
<SubSection title="all icons">
<div className="grid grid-cols-6 md:grid-cols-8 lg:grid-cols-12 gap-6">
{[
'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) => (
<div key={iconName} className="flex flex-col items-center gap-2" title={iconName}>
<Icon icon={iconName as Parameters<typeof Icon>[0]['icon']} size="md" className="text-foreground" />
<span className="font-mono text-[10px] text-foreground-tertiary truncate max-w-full">{iconName}</span>
</div>
))}
</div>
</SubSection>
</FieldsetSection>
);
}

View file

@ -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 (
<FieldsetSection title="7. layout & responsiveness" id="layout">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
Mobile-first responsive design with clear breakpoints and density modes
for different use cases.
</p>
<SubSection title="breakpoints">
<div className="space-y-4 mb-8">
<TokenRow name="sm" value="640px" preview={<Badge size="sm">mobile landscape</Badge>} />
<TokenRow name="md" value="768px" preview={<Badge size="sm">tablet</Badge>} />
<TokenRow name="lg" value="1024px" preview={<Badge size="sm">desktop</Badge>} />
<TokenRow name="xl" value="1280px" preview={<Badge size="sm">wide</Badge>} />
<TokenRow name="2xl" value="1536px" preview={<Badge size="sm">ultrawide</Badge>} />
</div>
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">responsive behaviors</h4>
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
<li> <strong>Sidebar:</strong> Collapsible on mobile, visible on lg+</li>
<li> <strong>Tables:</strong> Horizontal scroll on mobile, full width on lg+</li>
<li> <strong>Cards:</strong> Single column on mobile, grid on md+</li>
<li> <strong>Navigation:</strong> Hamburger menu on mobile, horizontal on lg+</li>
</ul>
</div>
</SubSection>
<SubSection title="density modes">
<p className="font-sans text-sm text-foreground-secondary mb-6">
Density modes adjust spacing, font size, and row heights for different contexts.
Essential for data-dense developer tools.
</p>
<div className="flex gap-4 mb-6">
<Button
variant={density === 'compact' ? 'default' : 'outline'}
size="sm"
onClick={() => onDensityChange('compact')}
>
compact
</Button>
<Button
variant={density === 'comfortable' ? 'default' : 'outline'}
size="sm"
onClick={() => onDensityChange('comfortable')}
>
comfortable
</Button>
<Button
variant={density === 'spacious' ? 'default' : 'outline'}
size="sm"
onClick={() => onDensityChange('spacious')}
>
spacious
</Button>
</div>
<div data-density={density} className="border border-dashed border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="density-cell">name</TableHead>
<TableHead className="density-cell">downloads</TableHead>
<TableHead className="density-cell">status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="density-cell font-mono">@tpmjs/parser</TableCell>
<TableCell className="density-cell font-mono">125,432</TableCell>
<TableCell className="density-cell"><Badge variant="success" size="sm">active</Badge></TableCell>
</TableRow>
<TableRow>
<TableCell className="density-cell font-mono">@tpmjs/validator</TableCell>
<TableCell className="density-cell font-mono">89,231</TableCell>
<TableCell className="density-cell"><Badge variant="success" size="sm">active</Badge></TableCell>
</TableRow>
<TableRow>
<TableCell className="density-cell font-mono">@tpmjs/transform</TableCell>
<TableCell className="density-cell font-mono">45,678</TableCell>
<TableCell className="density-cell"><Badge variant="warning" size="sm">beta</Badge></TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div className="mt-6 grid grid-cols-3 gap-4">
<div className="bg-surface p-4 border border-dashed border-border text-center">
<p className="font-mono text-xs text-foreground-secondary mb-1">compact</p>
<p className="font-mono text-lg">36px rows</p>
</div>
<div className="bg-surface p-4 border border-dashed border-border text-center">
<p className="font-mono text-xs text-foreground-secondary mb-1">comfortable</p>
<p className="font-mono text-lg">48px rows</p>
</div>
<div className="bg-surface p-4 border border-dashed border-border text-center">
<p className="font-mono text-xs text-foreground-secondary mb-1">spacious</p>
<p className="font-mono text-lg">64px rows</p>
</div>
</div>
</SubSection>
<SubSection title="grid system">
<div className="space-y-6">
<div>
<p className="font-mono text-xs text-foreground-secondary mb-3">12-column grid</p>
<div className="grid grid-cols-12 gap-2">
{[...Array(12)].map((_, i) => (
<div key={i} className="bg-surface border border-dashed border-border p-2 text-center">
<span className="font-mono text-[10px] text-foreground-tertiary">{i + 1}</span>
</div>
))}
</div>
</div>
<div>
<p className="font-mono text-xs text-foreground-secondary mb-3">sidebar + content (3 + 9)</p>
<div className="grid grid-cols-12 gap-4">
<div className="col-span-3 bg-surface border border-dashed border-border p-4">
<span className="font-mono text-xs">sidebar</span>
</div>
<div className="col-span-9 bg-surface border border-dashed border-border p-4">
<span className="font-mono text-xs">main content</span>
</div>
</div>
</div>
</div>
</SubSection>
</FieldsetSection>
);
}

View file

@ -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 (
<FieldsetSection title="5. motion" id="motion">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
Motion communicates state changes, not decoration. Animations are fast
by default and respect user preferences.
</p>
<SubSection title="motion principles">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">fast by default</h4>
<p className="font-sans text-sm text-foreground-secondary">
Most transitions complete in 150-200ms. Users should never wait
for animations to finish before interacting.
</p>
</div>
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">state communication</h4>
<p className="font-sans text-sm text-foreground-secondary">
Motion indicates something changed: a panel opened, an item was
selected, data updated. Never animate just for visual interest.
</p>
</div>
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">linear for data</h4>
<p className="font-sans text-sm text-foreground-secondary">
Data updates (counters, progress bars, charts) use linear easing.
This feels more precise and mechanical.
</p>
</div>
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">respect preferences</h4>
<p className="font-sans text-sm text-foreground-secondary">
Honor prefers-reduced-motion. All animations disable when the
user has requested reduced motion.
</p>
</div>
</div>
</SubSection>
<SubSection title="duration tokens">
<div className="space-y-4">
<TokenRow name="--motion-instant" value="0ms" preview={<div className="w-full h-2 bg-accent" />} />
<TokenRow name="--motion-fast" value="150ms" preview={<div className="w-full h-2 bg-accent motion-fast" />} />
<TokenRow name="--motion-base" value="200ms" preview={<div className="w-full h-2 bg-accent motion-base" />} />
<TokenRow name="--motion-slow" value="300ms" preview={<div className="w-full h-2 bg-accent motion-slow" />} />
<TokenRow name="--motion-slower" value="500ms" preview={<div className="w-full h-2 bg-accent" />} />
</div>
</SubSection>
<SubSection title="easing tokens">
<div className="space-y-4">
<TokenRow name="--easing-standard" value="cubic-bezier(0.4, 0, 0.2, 1)" />
<TokenRow name="--easing-decelerate" value="cubic-bezier(0, 0, 0.2, 1)" />
<TokenRow name="--easing-accelerate" value="cubic-bezier(0.4, 0, 1, 1)" />
<TokenRow name="--easing-linear" value="linear" />
<TokenRow name="--easing-spring" value="cubic-bezier(0.175, 0.885, 0.32, 1.275)" />
</div>
</SubSection>
<SubSection title="interactive demo">
<div className="flex flex-wrap gap-4">
<Button className="motion-fast transition-all hover:scale-105">
fast (150ms)
</Button>
<Button variant="secondary" className="motion-base transition-all hover:scale-105">
base (200ms)
</Button>
<Button variant="outline" className="motion-slow transition-all hover:scale-105">
slow (300ms)
</Button>
</div>
</SubSection>
</FieldsetSection>
);
}

View file

@ -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 (
<FieldsetSection title="16. feedback patterns" id="feedback-patterns">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
Feedback patterns communicate status, progress, and system responses.
Choose the right pattern based on context and urgency.
</p>
<SubSection title="feedback decision tree">
<div className="bg-surface p-6 border border-dashed border-border mb-6">
<div className="space-y-4">
<div className="flex items-start gap-4">
<Badge variant="info" className="mt-1">Q1</Badge>
<div>
<p className="font-mono text-sm mb-2">Is this a page-level system message?</p>
<p className="font-sans text-xs text-foreground-secondary">
Yes Use <strong>Banner</strong> (maintenance, outage, announcement)
</p>
</div>
</div>
<div className="flex items-start gap-4">
<Badge variant="info" className="mt-1">Q2</Badge>
<div>
<p className="font-mono text-sm mb-2">Is it a response to a user action?</p>
<p className="font-sans text-xs text-foreground-secondary">
Yes Use <strong>Toast</strong> (save, submit, delete confirmation)
</p>
</div>
</div>
<div className="flex items-start gap-4">
<Badge variant="info" className="mt-1">Q3</Badge>
<div>
<p className="font-mono text-sm mb-2">Is it contextual to a specific element?</p>
<p className="font-sans text-xs text-foreground-secondary">
Yes Use <strong>Inline Alert</strong> (form errors, field hints)
</p>
</div>
</div>
</div>
</div>
</SubSection>
<SubSection title="toast notifications">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Ephemeral messages that appear briefly and auto-dismiss.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Success toast */}
<div className="bg-surface p-4 border-l-4 border-success flex items-start gap-3">
<Icon icon="check" size="sm" className="text-success mt-0.5" />
<div>
<p className="font-mono text-sm font-medium">tool published</p>
<p className="font-sans text-xs text-foreground-secondary">
@tpmjs/parser v1.0.0 is now live
</p>
</div>
</div>
{/* Error toast */}
<div className="bg-surface p-4 border-l-4 border-error flex items-start gap-3">
<Icon icon="x" size="sm" className="text-error mt-0.5" />
<div>
<p className="font-mono text-sm font-medium">publish failed</p>
<p className="font-sans text-xs text-foreground-secondary">
Package validation errors. Check your config.
</p>
</div>
</div>
{/* Warning toast */}
<div className="bg-surface p-4 border-l-4 border-warning flex items-start gap-3">
<Icon icon="alertTriangle" size="sm" className="text-warning mt-0.5" />
<div>
<p className="font-mono text-sm font-medium">api rate limit</p>
<p className="font-sans text-xs text-foreground-secondary">
You've used 90% of your monthly quota
</p>
</div>
</div>
{/* Info toast */}
<div className="bg-surface p-4 border-l-4 border-info flex items-start gap-3">
<Icon icon="info" size="sm" className="text-info mt-0.5" />
<div>
<p className="font-mono text-sm font-medium">new version available</p>
<p className="font-sans text-xs text-foreground-secondary">
Refresh to get the latest features
</p>
</div>
</div>
</div>
<div className="mt-4 bg-surface p-4 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-3">toast guidelines</h4>
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
<li> Auto-dismiss after 5 seconds (except errors)</li>
<li> Errors should require manual dismissal</li>
<li> Stack from bottom-right, newest at bottom</li>
<li> Maximum 3 visible toasts at once</li>
</ul>
</div>
</SubSection>
<SubSection title="inline alerts">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Persistent messages within content flow.
</p>
<div className="space-y-4">
<div className="bg-success-light border border-success p-4 flex items-start gap-3">
<Icon icon="check" size="sm" className="text-success mt-0.5" />
<div>
<p className="font-mono text-sm font-medium text-success">verification complete</p>
<p className="font-sans text-xs text-foreground-secondary">
Your email has been verified. You can now publish tools.
</p>
</div>
</div>
<div className="bg-warning-light border border-warning p-4 flex items-start gap-3">
<Icon icon="alertTriangle" size="sm" className="text-warning mt-0.5" />
<div>
<p className="font-mono text-sm font-medium text-warning">deprecated package</p>
<p className="font-sans text-xs text-foreground-secondary">
This package is deprecated. Consider migrating to @tpmjs/v2.
</p>
</div>
</div>
<div className="bg-error-light border border-error p-4 flex items-start gap-3">
<Icon icon="alertCircle" size="sm" className="text-error mt-0.5" />
<div>
<p className="font-mono text-sm font-medium text-error">critical security issue</p>
<p className="font-sans text-xs text-foreground-secondary">
This version has known vulnerabilities. Update immediately.
</p>
<Button size="sm" variant="destructive" className="mt-3">update now</Button>
</div>
</div>
</div>
</SubSection>
<SubSection title="global banners">
<p className="font-sans text-sm text-foreground-secondary mb-4">
System-wide announcements that span the full width.
</p>
<div className="space-y-4">
{/* Maintenance banner */}
<div className="bg-warning text-foreground px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<Icon icon="alertTriangle" size="sm" />
<span className="font-mono text-sm">
Scheduled maintenance: Jan 15, 2-4am UTC. Some services may be unavailable.
</span>
</div>
<Button variant="ghost" size="sm" className="text-foreground hover:bg-warning-light">
<Icon icon="x" size="sm" />
</Button>
</div>
{/* Outage banner */}
<div className="bg-error text-white px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<Icon icon="alertCircle" size="sm" />
<span className="font-mono text-sm">
Service degradation detected. Some API calls may fail. We are investigating.
</span>
</div>
<a href="#" className="font-mono text-sm underline hover:no-underline">status page</a>
</div>
{/* Announcement banner */}
<div className="bg-accent text-white px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<Icon icon="star" size="sm" />
<span className="font-mono text-sm">
New: AI-powered code review is now available for all tools!
</span>
</div>
<Button variant="secondary" size="sm">learn more</Button>
</div>
</div>
</SubSection>
<SubSection title="progress indicators">
<p className="font-sans text-sm text-foreground-secondary mb-4">
For long-running operations, show determinate progress when possible.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Determinate progress */}
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">determinate progress</h4>
<p className="font-sans text-xs text-foreground-secondary mb-4">
Use when you can calculate progress (file upload, batch processing).
</p>
<div className="space-y-2">
<div className="flex justify-between text-xs font-mono">
<span>uploading package.tgz</span>
<span>67%</span>
</div>
<ProgressBar value={67} variant="primary" />
</div>
</div>
{/* Indeterminate progress */}
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">indeterminate progress</h4>
<p className="font-sans text-xs text-foreground-secondary mb-4">
Use when duration is unknown (API calls, processing).
</p>
<div className="flex items-center gap-4">
<Spinner size="sm" />
<span className="font-mono text-sm text-foreground-secondary">
validating package...
</span>
</div>
</div>
</div>
</SubSection>
<SubSection title="retry patterns">
<p className="font-sans text-sm text-foreground-secondary mb-4">
When operations fail, provide clear retry options.
</p>
<div className="bg-surface p-6 border border-dashed border-border">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-error-light flex items-center justify-center flex-shrink-0">
<Icon icon="alertCircle" size="md" className="text-error" />
</div>
<div className="flex-1">
<p className="font-mono text-sm font-medium mb-1">failed to load tools</p>
<p className="font-sans text-xs text-foreground-secondary mb-4">
We couldn't connect to the server. This could be a network issue or the service may be temporarily unavailable.
</p>
<div className="flex gap-2">
<Button size="sm">retry</Button>
<Button size="sm" variant="ghost">view details</Button>
</div>
</div>
</div>
</div>
</SubSection>
</FieldsetSection>
);
}

View file

@ -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 (
<FieldsetSection title="15. form patterns" id="form-patterns">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
Consistent form patterns for data entry, validation, and submission.
Forms should guide users through tasks with clear feedback.
</p>
<SubSection title="validation timing">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Choose validation timing based on the field type and user expectations.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">validate on blur</h4>
<p className="font-sans text-xs text-foreground-secondary mb-4">
Best for: format validation, required fields
</p>
<FormField label="email" error="Please enter a valid email address">
<Input placeholder="user@example.com" state="error" />
</FormField>
</div>
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">validate on submit</h4>
<p className="font-sans text-xs text-foreground-secondary mb-4">
Best for: async validation, complex rules
</p>
<FormField label="package name" helperText="We'll check if this name is available">
<Input placeholder="@scope/package-name" />
</FormField>
</div>
</div>
</SubSection>
<SubSection title="error summary">
<p className="font-sans text-sm text-foreground-secondary mb-4">
For forms with multiple errors, show a summary at the top.
</p>
<div className="bg-surface border border-dashed border-border p-6">
{/* Error summary */}
<div className="bg-error-light border border-error p-4 mb-6">
<div className="flex items-center gap-2 mb-3">
<Icon icon="alertCircle" size="sm" className="text-error" />
<span className="font-mono text-sm font-medium text-error">please fix 3 errors</span>
</div>
<ul className="space-y-1 text-sm text-foreground-secondary font-sans">
<li><a href="#name" className="text-error hover:underline">Name is required</a></li>
<li><a href="#email" className="text-error hover:underline">Email format is invalid</a></li>
<li><a href="#description" className="text-error hover:underline">Description must be at least 50 characters</a></li>
</ul>
</div>
{/* Form fields */}
<div className="space-y-4">
<FormField label="name" error="Name is required" required>
<Input placeholder="Enter name" state="error" />
</FormField>
<FormField label="email" error="Email format is invalid" required>
<Input placeholder="user@example.com" state="error" />
</FormField>
<FormField label="description" error="Description must be at least 50 characters">
<Textarea placeholder="Describe your tool..." rows={3} />
</FormField>
</div>
</div>
</SubSection>
<SubSection title="help text placement">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<DoDontCard type="do" title="Help text below the input">
<FormField
label="api key"
helperText="You can find this in your dashboard settings"
>
<Input placeholder="sk-xxxx..." />
</FormField>
</DoDontCard>
<DoDontCard type="dont" title="Don't put help text above">
<div>
<Label>api key</Label>
<p className="text-xs text-foreground-secondary mb-2">
You can find this in your dashboard settings
</p>
<Input placeholder="sk-xxxx..." />
</div>
</DoDontCard>
</div>
</SubSection>
<SubSection title="required vs optional">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-4">
<div className="bg-surface p-6 border border-dashed border-border">
<p className="font-mono text-xs text-foreground-tertiary mb-4">when most fields are required</p>
<div className="space-y-4">
<FormField label="name" required>
<Input placeholder="Tool name" />
</FormField>
<FormField label="version" required>
<Input placeholder="1.0.0" />
</FormField>
<FormField label="description" helperText="(optional)">
<Textarea placeholder="Describe your tool..." rows={2} />
</FormField>
</div>
</div>
<div className="bg-surface p-6 border border-dashed border-border">
<p className="font-mono text-xs text-foreground-tertiary mb-4">when most fields are optional</p>
<div className="space-y-4">
<FormField label="website">
<Input placeholder="https://example.com" />
</FormField>
<FormField label="twitter handle">
<Input placeholder="@username" />
</FormField>
<FormField label="bio" helperText="required">
<Textarea placeholder="Tell us about yourself..." rows={2} />
</FormField>
</div>
</div>
</div>
<div className="bg-surface p-4 border border-dashed border-border">
<p className="font-sans text-sm text-foreground-secondary">
<strong>Rule:</strong> Mark the minority. If most fields are required, mark optional fields.
If most are optional, mark required fields.
</p>
</div>
</SubSection>
<SubSection title="field grouping">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Group related fields with visual hierarchy.
</p>
<div className="bg-surface border border-dashed border-border p-6">
<div className="space-y-8">
{/* Group 1 */}
<div>
<h4 className="font-mono text-sm font-medium mb-4 pb-2 border-b border-dashed border-border">
package details
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField label="package name" required>
<Input placeholder="@scope/package" />
</FormField>
<FormField label="version" required>
<Input placeholder="1.0.0" />
</FormField>
</div>
</div>
{/* Group 2 */}
<div>
<h4 className="font-mono text-sm font-medium mb-4 pb-2 border-b border-dashed border-border">
author information
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField label="author name" required>
<Input placeholder="Your name" />
</FormField>
<FormField label="email" required>
<Input placeholder="author@example.com" />
</FormField>
</div>
</div>
{/* Group 3 */}
<div>
<h4 className="font-mono text-sm font-medium mb-4 pb-2 border-b border-dashed border-border">
repository
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField label="repository type">
<Select
placeholder="Select type..."
options={[
{ value: 'git', label: 'Git' },
{ value: 'svn', label: 'SVN' },
]}
/>
</FormField>
<FormField label="repository url">
<Input placeholder="https://github.com/..." />
</FormField>
</div>
</div>
</div>
</div>
</SubSection>
<SubSection title="danger zone">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Destructive actions require explicit confirmation.
</p>
<div className="bg-surface border border-error border-dashed p-6">
<div className="flex items-center gap-2 mb-4">
<Icon icon="alertTriangle" size="sm" className="text-error" />
<h4 className="font-mono text-sm font-medium text-error">danger zone</h4>
</div>
<div className="flex items-center justify-between py-4 border-t border-dashed border-error">
<div>
<p className="font-mono text-sm font-medium">delete this tool</p>
<p className="font-sans text-xs text-foreground-secondary">
Once deleted, this tool cannot be recovered.
</p>
</div>
<Button variant="destructive" size="sm">delete tool</Button>
</div>
<div className="flex items-center justify-between py-4 border-t border-dashed border-error">
<div>
<p className="font-mono text-sm font-medium">transfer ownership</p>
<p className="font-sans text-xs text-foreground-secondary">
Transfer this tool to another user or organization.
</p>
</div>
<Button variant="outline" size="sm">transfer</Button>
</div>
</div>
</SubSection>
</FieldsetSection>
);
}

View file

@ -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 (
<FieldsetSection title="14. navigation patterns" id="nav-patterns">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
Navigation patterns for consistent wayfinding across the platform. Each pattern
has specific use cases and accessibility requirements.
</p>
<SubSection title="global navigation (header)">
<p className="font-sans text-sm text-foreground-secondary mb-4">
The primary header navigation appears on every page and provides access to main sections.
</p>
<div className="bg-surface border border-dashed border-border">
{/* Mock header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-8">
<span className="font-mono text-lg font-semibold">tpmjs</span>
<nav className="flex items-center gap-6">
<a href="#" className="font-mono text-sm text-foreground hover:text-accent transition-colors">tools</a>
<a href="#" className="font-mono text-sm text-foreground-secondary hover:text-accent transition-colors">agents</a>
<a href="#" className="font-mono text-sm text-foreground-secondary hover:text-accent transition-colors">docs</a>
<a href="#" className="font-mono text-sm text-foreground-secondary hover:text-accent transition-colors">pricing</a>
</nav>
</div>
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm">sign in</Button>
<Button size="sm">get started</Button>
</div>
</div>
</div>
<div className="mt-4 bg-surface p-4 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-3">guidelines</h4>
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
<li> Maximum 5-6 top-level items to avoid cognitive overload</li>
<li> Use visible active states (underline or background)</li>
<li> Logo always links to home</li>
<li> Auth actions stay in top-right corner</li>
</ul>
</div>
</SubSection>
<SubSection title="sidebar navigation">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Vertical sidebar for section-level navigation within the app.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Expanded sidebar */}
<div className="bg-surface border border-dashed border-border p-4">
<p className="font-mono text-xs text-foreground-tertiary mb-4">expanded state</p>
<nav className="space-y-1">
<a href="#" className="flex items-center gap-3 px-3 py-2 bg-accent/10 text-accent font-mono text-sm">
<Icon icon="home" size="sm" />
<span>dashboard</span>
</a>
<a href="#" className="flex items-center gap-3 px-3 py-2 text-foreground-secondary hover:bg-surface-2 font-mono text-sm">
<Icon icon="puzzle" size="sm" />
<span>tools</span>
<Badge size="sm" variant="outline" className="ml-auto">12</Badge>
</a>
<a href="#" className="flex items-center gap-3 px-3 py-2 text-foreground-secondary hover:bg-surface-2 font-mono text-sm">
<Icon icon="key" size="sm" />
<span>api keys</span>
</a>
<a href="#" className="flex items-center gap-3 px-3 py-2 text-foreground-secondary hover:bg-surface-2 font-mono text-sm">
<Icon icon="user" size="sm" />
<span>settings</span>
</a>
</nav>
</div>
{/* Collapsed sidebar */}
<div className="bg-surface border border-dashed border-border p-4">
<p className="font-mono text-xs text-foreground-tertiary mb-4">collapsed state</p>
<nav className="flex flex-col items-center space-y-2">
<button className="p-3 bg-accent/10 text-accent" title="Dashboard">
<Icon icon="home" size="sm" />
</button>
<button className="p-3 text-foreground-secondary hover:bg-surface-2" title="Tools">
<Icon icon="puzzle" size="sm" />
</button>
<button className="p-3 text-foreground-secondary hover:bg-surface-2" title="API Keys">
<Icon icon="key" size="sm" />
</button>
<button className="p-3 text-foreground-secondary hover:bg-surface-2" title="Settings">
<Icon icon="user" size="sm" />
</button>
</nav>
</div>
</div>
<div className="mt-4 bg-surface p-4 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-3">keyboard shortcuts</h4>
<div className="grid grid-cols-2 gap-4 text-sm text-foreground-secondary font-sans">
<div><kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">[</kbd> collapse/expand sidebar</div>
<div><kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">g then h</kbd> go to home</div>
<div><kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">g then t</kbd> go to tools</div>
<div><kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">g then s</kbd> go to settings</div>
</div>
</div>
</SubSection>
<SubSection title="breadcrumbs">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Show hierarchical location and enable navigation back to parent pages.
</p>
<div className="space-y-4">
{/* Simple breadcrumb */}
<div className="bg-surface p-4 border border-dashed border-border">
<p className="font-mono text-xs text-foreground-tertiary mb-3">default</p>
<Breadcrumbs>
<BreadcrumbItem>
<BreadcrumbLink href="#">home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#">tools</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>@tpmjs/parser</BreadcrumbPage>
</BreadcrumbItem>
</Breadcrumbs>
</div>
{/* With icons */}
<div className="bg-surface p-4 border border-dashed border-border">
<p className="font-mono text-xs text-foreground-tertiary mb-3">with icons</p>
<Breadcrumbs>
<BreadcrumbItem>
<BreadcrumbLink href="#" className="flex items-center gap-1">
<Icon icon="home" size="xs" />
<span>home</span>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#" className="flex items-center gap-1">
<Icon icon="folder" size="xs" />
<span>projects</span>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>my-tool</BreadcrumbPage>
</BreadcrumbItem>
</Breadcrumbs>
</div>
</div>
</SubSection>
<SubSection title="tab navigation">
<p className="font-sans text-sm text-foreground-secondary mb-4">
For switching between views within a page context.
</p>
<div className="space-y-4">
<div className="bg-surface p-4 border border-dashed border-border">
<Tabs
tabs={[
{ id: 'overview', label: 'overview' },
{ id: 'readme', label: 'readme' },
{ id: 'versions', label: 'versions', count: 24 },
{ id: 'dependencies', label: 'dependencies', count: 3 },
]}
activeTab={activeTab}
onTabChange={setActiveTab}
variant="default"
/>
</div>
<div className="bg-surface p-4 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-3">keyboard behavior</h4>
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
<li> <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs"></kbd> / <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs"></kbd> navigate between tabs</li>
<li> <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">Home</kbd> focus first tab</li>
<li> <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">End</kbd> focus last tab</li>
<li> <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">Enter</kbd> / <kbd className="px-2 py-1 bg-surface-2 font-mono text-xs">Space</kbd> activate focused tab</li>
</ul>
</div>
</div>
</SubSection>
<SubSection title="mobile navigation">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Hamburger menu pattern for narrow viewports.
</p>
<div className="max-w-sm">
<div className="bg-surface border border-dashed border-border">
{/* Mock mobile header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="font-mono text-sm font-semibold">tpmjs</span>
<Button variant="ghost" size="icon">
<Icon icon="menu" size="sm" />
</Button>
</div>
{/* Expanded menu preview */}
<div className="p-4 space-y-2">
<a href="#" className="block px-3 py-2 font-mono text-sm text-foreground bg-accent/10">tools</a>
<a href="#" className="block px-3 py-2 font-mono text-sm text-foreground-secondary">agents</a>
<a href="#" className="block px-3 py-2 font-mono text-sm text-foreground-secondary">docs</a>
<a href="#" className="block px-3 py-2 font-mono text-sm text-foreground-secondary">pricing</a>
<div className="pt-4 border-t border-border space-y-2">
<Button variant="outline" size="sm" className="w-full">sign in</Button>
<Button size="sm" className="w-full">get started</Button>
</div>
</div>
</div>
</div>
</SubSection>
</FieldsetSection>
);
}

View file

@ -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 (
<FieldsetSection title="18. search & filtering" id="search-patterns">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
Search and filter patterns help users find content quickly.
Design for progressive disclosure and instant feedback.
</p>
<SubSection title="search box states">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Search boxes should show clear states and provide helpful feedback.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Default state */}
<div className="bg-surface p-4 border border-dashed border-border">
<p className="font-mono text-xs text-foreground-tertiary mb-3">default</p>
<div className="relative">
<Icon icon="search" size="sm" className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary" />
<Input placeholder="search tools..." className="pl-10" />
</div>
</div>
{/* Active state */}
<div className="bg-surface p-4 border border-dashed border-border">
<p className="font-mono text-xs text-foreground-tertiary mb-3">active / has value</p>
<div className="relative">
<Icon icon="search" size="sm" className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary" />
<Input defaultValue="parser" className="pl-10 pr-10" />
<button className="absolute right-3 top-1/2 -translate-y-1/2 text-foreground-tertiary hover:text-foreground">
<Icon icon="x" size="sm" />
</button>
</div>
</div>
{/* Loading state */}
<div className="bg-surface p-4 border border-dashed border-border">
<p className="font-mono text-xs text-foreground-tertiary mb-3">loading</p>
<div className="relative">
<Icon icon="loader" size="sm" className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary animate-spin" />
<Input defaultValue="validator" className="pl-10" readOnly />
</div>
</div>
{/* No results state */}
<div className="bg-surface p-4 border border-dashed border-border">
<p className="font-mono text-xs text-foreground-tertiary mb-3">no results</p>
<div className="relative">
<Icon icon="search" size="sm" className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary" />
<Input defaultValue="xyzabc123" className="pl-10 pr-10" />
<button className="absolute right-3 top-1/2 -translate-y-1/2 text-foreground-tertiary hover:text-foreground">
<Icon icon="x" size="sm" />
</button>
</div>
<p className="text-xs text-foreground-tertiary mt-2">No results found</p>
</div>
</div>
</SubSection>
<SubSection title="filter chips">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Use chips to show active filters with easy removal.
</p>
<div className="bg-surface p-4 border border-dashed border-border">
<div className="flex flex-wrap gap-2 mb-4">
<Badge variant="default" className="pr-1 flex items-center gap-1">
category: utility
<button className="ml-1 hover:bg-accent-strong rounded">
<Icon icon="x" size="xs" />
</button>
</Badge>
<Badge variant="default" className="pr-1 flex items-center gap-1">
status: active
<button className="ml-1 hover:bg-accent-strong rounded">
<Icon icon="x" size="xs" />
</button>
</Badge>
<Badge variant="default" className="pr-1 flex items-center gap-1">
downloads: &gt;10k
<button className="ml-1 hover:bg-accent-strong rounded">
<Icon icon="x" size="xs" />
</button>
</Badge>
<button className="font-mono text-xs text-accent hover:underline">clear all</button>
</div>
<p className="font-mono text-xs text-foreground-secondary">
showing 42 of 128 tools
</p>
</div>
</SubSection>
<SubSection title="filter panel">
<p className="font-sans text-sm text-foreground-secondary mb-4">
For complex filtering, use a dedicated panel with grouped options.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Filter panel */}
<div className="bg-surface border border-dashed border-border p-4">
<h4 className="font-mono text-sm font-medium mb-4">filters</h4>
<div className="space-y-4">
<div>
<label className="font-mono text-xs text-foreground-secondary block mb-2">category</label>
<Select
placeholder="all categories"
options={[
{ value: 'utility', label: 'Utility' },
{ value: 'validation', label: 'Validation' },
{ value: 'data', label: 'Data' },
]}
/>
</div>
<div>
<label className="font-mono text-xs text-foreground-secondary block mb-2">status</label>
<Select
placeholder="all statuses"
options={[
{ value: 'active', label: 'Active' },
{ value: 'beta', label: 'Beta' },
{ value: 'deprecated', label: 'Deprecated' },
]}
/>
</div>
<div>
<label className="font-mono text-xs text-foreground-secondary block mb-2">min downloads</label>
<Input type="number" placeholder="0" />
</div>
<div className="pt-4 border-t border-dashed border-border">
<Button size="sm" className="w-full">apply filters</Button>
</div>
</div>
</div>
{/* Results preview */}
<div className="md:col-span-2 bg-surface border border-dashed border-border p-4">
<p className="font-mono text-xs text-foreground-tertiary mb-4">results area</p>
<div className="space-y-2">
{['@tpmjs/parser', '@tpmjs/validator', '@tpmjs/transform'].map((name) => (
<div key={name} className="p-3 border border-dashed border-border flex items-center justify-between">
<span className="font-mono text-sm">{name}</span>
<Badge variant="success" size="sm">active</Badge>
</div>
))}
</div>
</div>
</div>
</SubSection>
<SubSection title="saved views">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Allow users to save and recall filter combinations.
</p>
<div className="bg-surface p-4 border border-dashed border-border">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-medium">saved views</span>
<Badge variant="outline" size="sm">3</Badge>
</div>
<Button size="sm" variant="ghost">
<Icon icon="plus" size="sm" className="mr-2" />
save current
</Button>
</div>
<div className="space-y-2">
<button className="w-full text-left px-3 py-2 bg-accent/10 font-mono text-sm flex items-center justify-between">
<span>my active tools</span>
<div className="flex items-center gap-2">
<Badge variant="outline" size="sm">42 results</Badge>
<Icon icon="check" size="sm" className="text-accent" />
</div>
</button>
<button className="w-full text-left px-3 py-2 hover:bg-surface-2 font-mono text-sm flex items-center justify-between">
<span>deprecated packages</span>
<Badge variant="outline" size="sm">8 results</Badge>
</button>
<button className="w-full text-left px-3 py-2 hover:bg-surface-2 font-mono text-sm flex items-center justify-between">
<span>high-download tools</span>
<Badge variant="outline" size="sm">15 results</Badge>
</button>
</div>
</div>
</SubSection>
<SubSection title="query syntax display">
<p className="font-sans text-sm text-foreground-secondary mb-4">
For power users, display the underlying query syntax.
</p>
<div className="bg-surface p-4 border border-dashed border-border">
<div className="flex items-center justify-between mb-3">
<span className="font-mono text-sm font-medium">query</span>
<button className="font-mono text-xs text-accent hover:underline flex items-center gap-1">
<Icon icon="copy" size="xs" />
copy
</button>
</div>
<div className="bg-surface-2 p-3 font-mono text-xs">
<span className="text-accent">category:</span>
<span className="text-foreground">utility</span>
<span className="text-foreground-tertiary"> AND </span>
<span className="text-accent">status:</span>
<span className="text-foreground">active</span>
<span className="text-foreground-tertiary"> AND </span>
<span className="text-accent">downloads:</span>
<span className="text-foreground">&gt;10000</span>
</div>
<p className="font-sans text-xs text-foreground-tertiary mt-3">
Tip: Use this syntax directly in the search box for quick filtering.
</p>
</div>
</SubSection>
<SubSection title="command palette">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Quick access to search and navigation via keyboard shortcut.
</p>
<div className="max-w-md mx-auto">
<div className="bg-surface border border-border shadow-lg">
{/* Search input */}
<div className="border-b border-border p-3">
<div className="relative">
<Icon icon="search" size="sm" className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary" />
<Input placeholder="search or type a command..." className="pl-10 border-0 focus:ring-0" />
</div>
</div>
{/* Results groups */}
<div className="max-h-80 overflow-y-auto">
{/* Recent */}
<div className="p-2">
<p className="font-mono text-[10px] text-foreground-tertiary uppercase tracking-wide px-2 py-1">recent</p>
<button className="w-full text-left px-3 py-2 hover:bg-surface-2 flex items-center gap-3">
<Icon icon="clock" size="sm" className="text-foreground-tertiary" />
<span className="font-mono text-sm">@tpmjs/parser</span>
</button>
</div>
{/* Actions */}
<div className="p-2 border-t border-border">
<p className="font-mono text-[10px] text-foreground-tertiary uppercase tracking-wide px-2 py-1">actions</p>
<button className="w-full text-left px-3 py-2 hover:bg-surface-2 flex items-center justify-between">
<div className="flex items-center gap-3">
<Icon icon="plus" size="sm" className="text-foreground-tertiary" />
<span className="font-mono text-sm">create new tool</span>
</div>
<kbd className="font-mono text-[10px] bg-surface-2 px-2 py-0.5">N</kbd>
</button>
<button className="w-full text-left px-3 py-2 hover:bg-surface-2 flex items-center justify-between">
<div className="flex items-center gap-3">
<Icon icon="home" size="sm" className="text-foreground-tertiary" />
<span className="font-mono text-sm">go to dashboard</span>
</div>
<kbd className="font-mono text-[10px] bg-surface-2 px-2 py-0.5">D</kbd>
</button>
<button className="w-full text-left px-3 py-2 hover:bg-surface-2 flex items-center justify-between">
<div className="flex items-center gap-3">
<Icon icon="user" size="sm" className="text-foreground-tertiary" />
<span className="font-mono text-sm">go to settings</span>
</div>
<kbd className="font-mono text-[10px] bg-surface-2 px-2 py-0.5">,</kbd>
</button>
</div>
</div>
{/* Footer */}
<div className="border-t border-border px-3 py-2 flex items-center justify-between text-[10px] text-foreground-tertiary">
<div className="flex items-center gap-3">
<span><kbd className="bg-surface-2 px-1.5 py-0.5"></kbd> navigate</span>
<span><kbd className="bg-surface-2 px-1.5 py-0.5"></kbd> select</span>
<span><kbd className="bg-surface-2 px-1.5 py-0.5">esc</kbd> close</span>
</div>
</div>
</div>
</div>
<div className="mt-4 text-center">
<kbd className="font-mono text-xs bg-surface px-3 py-1.5 border border-border">K</kbd>
<span className="font-sans text-xs text-foreground-secondary ml-2">to open command palette</span>
</div>
</SubSection>
</FieldsetSection>
);
}

View file

@ -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<string[]>([]);
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 (
<FieldsetSection title="17. table patterns" id="table-patterns">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
Data table patterns for displaying, sorting, filtering, and acting on tabular data.
</p>
<SubSection title="sortable columns">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Click column headers to sort. Show sort direction with icons.
</p>
<div className="bg-surface border border-dashed border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="cursor-pointer hover:bg-surface-2">
<div className="flex items-center gap-2">
name
<Icon icon="chevronDown" size="xs" className="text-foreground-tertiary" />
</div>
</TableHead>
<TableHead>category</TableHead>
<TableHead className="text-right cursor-pointer hover:bg-surface-2">
<div className="flex items-center gap-2 justify-end">
downloads
<Icon icon="chevronDown" size="xs" className="text-accent" />
</div>
</TableHead>
<TableHead className="text-right">score</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mockData.slice(0, 3).map((row) => (
<TableRow key={row.id}>
<TableCell className="font-mono">{row.name}</TableCell>
<TableCell>{row.category}</TableCell>
<TableCell className="text-right font-mono">{row.downloads.toLocaleString()}</TableCell>
<TableCell className="text-right font-mono">{row.score}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SubSection>
<SubSection title="row selection + bulk actions">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Select rows to enable bulk actions. Show action bar when rows are selected.
</p>
<div className="bg-surface border border-dashed border-border">
{/* Bulk action bar */}
{selectedRows.length > 0 && (
<div className="flex items-center justify-between px-4 py-3 bg-accent/10 border-b border-border">
<span className="font-mono text-sm">
{selectedRows.length} item{selectedRows.length !== 1 ? 's' : ''} selected
</span>
<div className="flex gap-2">
<Button size="sm" variant="outline">export</Button>
<Button size="sm" variant="destructive">delete</Button>
</div>
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={selectedRows.length === mockData.length}
onChange={toggleAll}
aria-label="Select all"
/>
</TableHead>
<TableHead>name</TableHead>
<TableHead>category</TableHead>
<TableHead>status</TableHead>
<TableHead className="text-right">downloads</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mockData.map((row) => (
<TableRow key={row.id} className={selectedRows.includes(row.id) ? 'bg-accent/5' : ''}>
<TableCell>
<Checkbox
checked={selectedRows.includes(row.id)}
onChange={() => toggleRow(row.id)}
aria-label={`Select ${row.name}`}
/>
</TableCell>
<TableCell className="font-mono">{row.name}</TableCell>
<TableCell>{row.category}</TableCell>
<TableCell>
<Badge
variant={row.status === 'active' ? 'success' : row.status === 'deprecated' ? 'error' : 'warning'}
size="sm"
>
{row.status}
</Badge>
</TableCell>
<TableCell className="text-right font-mono">{row.downloads.toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SubSection>
<SubSection title="pagination">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Use pagination for large datasets. Show page info and navigation.
</p>
<div className="space-y-6">
{/* Full pagination */}
<div className="bg-surface p-4 border border-dashed border-border">
<p className="font-mono text-xs text-foreground-tertiary mb-4">full pagination</p>
<Pagination
page={page}
totalPages={10}
onPageChange={setPage}
showFirstLast
showPrevNext
/>
</div>
{/* Simple pagination */}
<div className="bg-surface p-4 border border-dashed border-border">
<p className="font-mono text-xs text-foreground-tertiary mb-4">simple pagination</p>
<Pagination
page={page}
totalPages={10}
onPageChange={setPage}
variant="simple"
/>
</div>
{/* Minimal pagination */}
<div className="bg-surface p-4 border border-dashed border-border">
<p className="font-mono text-xs text-foreground-tertiary mb-4">minimal pagination</p>
<Pagination
page={page}
totalPages={10}
onPageChange={setPage}
variant="minimal"
/>
</div>
</div>
</SubSection>
<SubSection title="empty & loading states">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Empty state */}
<div className="bg-surface border border-dashed border-border">
<div className="p-12 text-center">
<Icon icon="folder" size="lg" className="text-foreground-tertiary mx-auto mb-4" />
<p className="font-mono text-sm text-foreground mb-2">no tools found</p>
<p className="font-sans text-xs text-foreground-secondary mb-4">
Try adjusting your filters or create your first tool.
</p>
<Button size="sm">create tool</Button>
</div>
</div>
{/* Loading state */}
<div className="bg-surface border border-dashed border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>name</TableHead>
<TableHead>category</TableHead>
<TableHead className="text-right">downloads</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[1, 2, 3].map((i) => (
<TableRow key={i}>
<TableCell><div className="h-4 bg-muted animate-pulse w-32" /></TableCell>
<TableCell><div className="h-4 bg-muted animate-pulse w-20" /></TableCell>
<TableCell className="text-right"><div className="h-4 bg-muted animate-pulse w-16 ml-auto" /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</SubSection>
<SubSection title="density integration">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Tables respect the page density mode for compact views.
</p>
<div className="bg-surface p-4 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-3">row heights by density</h4>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<p className="font-mono text-xs text-foreground-tertiary">compact</p>
<p className="font-mono text-lg">36px</p>
</div>
<div className="text-center">
<p className="font-mono text-xs text-foreground-tertiary">comfortable</p>
<p className="font-mono text-lg">48px</p>
</div>
<div className="text-center">
<p className="font-mono text-xs text-foreground-tertiary">spacious</p>
<p className="font-mono text-lg">64px</p>
</div>
</div>
</div>
</SubSection>
</FieldsetSection>
);
}

View file

@ -0,0 +1,62 @@
'use client';
import { FieldsetSection, PrincipleCard, SubSection } from './shared';
export function SectionPrinciples(): React.ReactElement {
return (
<FieldsetSection title="1. design principles" id="principles">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
These principles guide every design decision in TPMJS. They ensure consistency
and prevent drift as the system grows.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10">
<PrincipleCard
icon="terminal"
title="clarity over decoration"
description="Every element serves a purpose. Remove anything that doesn't help users accomplish their goals. Developer tools should feel efficient, not playful."
/>
<PrincipleCard
icon="box"
title="mechanical precision"
description="Sharp corners communicate precision and control. Tools feel engineered, not organic. The interface should feel like a well-built instrument."
/>
<PrincipleCard
icon="star"
title="copper = active signal"
description="The copper accent (#A6592D) indicates interactivity and importance. It's the 'hot metal' that draws attention to actions and key information."
/>
<PrincipleCard
icon="sun"
title="generous whitespace"
description="Reduce cognitive load through breathing room. Dense interfaces slow users down. Space creates hierarchy and improves scanability."
/>
<PrincipleCard
icon="key"
title="accessible by default"
description="WCAG AA compliance is the minimum. Focus states are never removed. Color is never the only indicator of meaning."
/>
<PrincipleCard
icon="globe"
title="developer-first"
description="The system is built for developers using developer tools. Technical accuracy over marketing speak. Show don't tell."
/>
</div>
<SubSection title="design philosophy">
<div className="bg-surface-2 p-6 border border-dashed border-border prose-width">
<p className="font-sans text-foreground leading-relaxed mb-4">
TPMJS is a <strong>developer platform</strong> for AI tools. The design reflects
this through industrial aesthetics: sharp edges, technical typography, and
a muted palette with copper as the signal color.
</p>
<p className="font-sans text-foreground-secondary leading-relaxed">
Unlike consumer products that aim for delight, TPMJS aims for
<strong> efficiency and trust</strong>. Users should feel confident that the
interface will behave predictably and help them accomplish tasks quickly.
</p>
</div>
</SubSection>
</FieldsetSection>
);
}

View file

@ -0,0 +1,39 @@
'use client';
import { FieldsetSection, SubSection, TokenRow } from './shared';
export function SectionSpacing(): React.ReactElement {
return (
<FieldsetSection title="4. spacing" id="spacing">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
Consistent spacing creates visual rhythm. Use the 4px base unit
and generous whitespace for clarity.
</p>
<SubSection title="spacing scale">
<div className="space-y-4">
{[1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24, 32, 40, 48, 64].map((space) => (
<div key={space} className="flex items-center gap-4">
<span className="font-mono text-xs text-foreground-secondary w-12">{space}</span>
<div
className="bg-accent h-4"
style={{ width: `${space * 4}px` }}
/>
<span className="font-mono text-xs text-foreground-tertiary">{space * 4}px</span>
</div>
))}
</div>
</SubSection>
<SubSection title="semantic spacing">
<div className="space-y-4">
<TokenRow name="--spacing-tight" value="16px (4)" />
<TokenRow name="--spacing-element" value="32px (8)" />
<TokenRow name="--spacing-comfortable" value="48px (12)" />
<TokenRow name="--spacing-component" value="64px (16)" />
<TokenRow name="--spacing-section" value="128px (32)" />
</div>
</SubSection>
</FieldsetSection>
);
}

View file

@ -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 (
<FieldsetSection title="11. theming" id="theming">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
Theme architecture and customization guidelines for consistent extension.
</p>
<SubSection title="token architecture">
<div className="bg-surface p-6 border border-dashed border-border mb-8">
<div className="space-y-4">
<div className="flex items-center gap-4">
<Badge variant="outline">core</Badge>
<span className="font-mono text-sm text-foreground-secondary">
Raw values: HSL colors, pixel sizes, timing functions
</span>
</div>
<div className="flex items-center gap-4">
<Badge variant="outline">semantic</Badge>
<span className="font-mono text-sm text-foreground-secondary">
Named tokens: --primary, --background, --motion-fast
</span>
</div>
<div className="flex items-center gap-4">
<Badge variant="outline">component</Badge>
<span className="font-mono text-sm text-foreground-secondary">
Component-specific: button-bg, input-border, card-shadow
</span>
</div>
</div>
</div>
<CodeBlock
code={`/* Core tokens (don't override) */
--copper-500: 22 57% 41%;
/* Semantic tokens (safe to override) */
--primary: var(--copper-500);
--primary-hover: 22 57% 35%;
--accent: var(--primary);
/* Component tokens (for advanced customization) */
--button-bg: hsl(var(--primary));
--button-hover-bg: hsl(var(--primary-hover));`}
language="css"
/>
</SubSection>
<SubSection title="dark mode">
<p className="font-sans text-sm text-foreground-secondary mb-4">
Dark mode is opt-in via the <code className="font-mono bg-surface px-1">.dark</code> class
on the root element. All semantic tokens have dark mode variants.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-background p-6 border border-dashed border-border">
<p className="font-mono text-sm mb-4">light mode</p>
<div className="flex gap-2">
<div className="w-8 h-8 bg-background border border-border" />
<div className="w-8 h-8 bg-surface border border-border" />
<div className="w-8 h-8 bg-accent" />
<div className="w-8 h-8 bg-foreground" />
</div>
</div>
<div className="bg-[#0F0E0D] p-6 border border-[#363230]">
<p className="font-mono text-sm text-[#E8E5E2] mb-4">dark mode</p>
<div className="flex gap-2">
<div className="w-8 h-8 bg-[#0F0E0D] border border-[#363230]" />
<div className="w-8 h-8 bg-[#171514] border border-[#363230]" />
<div className="w-8 h-8 bg-[#C96A38]" />
<div className="w-8 h-8 bg-[#E8E5E2]" />
</div>
</div>
</div>
</SubSection>
<SubSection title="embedding guidance">
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">for sdk users embedding tpmjs ui</h4>
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
<li> Import the CSS variables from <code className="font-mono bg-surface-2 px-1">@tpmjs/ui/styles</code></li>
<li> Override semantic tokens in your own CSS to match your brand</li>
<li> Do not override core tokens (raw values)</li>
<li> Test both light and dark modes if supporting theme switching</li>
<li> Use <code className="font-mono bg-surface-2 px-1">data-density</code> attribute for density control</li>
</ul>
</div>
</SubSection>
</FieldsetSection>
);
}

View file

@ -0,0 +1,104 @@
'use client';
import { FieldsetSection, SubSection, TokenRow } from './shared';
export function SectionTypography(): React.ReactElement {
return (
<FieldsetSection title="3. typography" id="typography">
<p className="text-foreground-secondary mb-8 font-sans prose-width">
Two font families create clear hierarchy: monospace for headings and technical
content, sans-serif for body text and descriptions.
</p>
<SubSection title="font families">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div className="bg-surface p-6 border border-dashed border-border">
<p className="font-mono text-2xl mb-2">JetBrains Mono</p>
<p className="font-mono text-sm text-foreground-secondary mb-4">--font-mono</p>
<p className="font-mono text-sm">Used for: headings, code, data, labels, technical content</p>
</div>
<div className="bg-surface p-6 border border-dashed border-border">
<p className="font-sans text-2xl mb-2">Inter</p>
<p className="font-mono text-sm text-foreground-secondary mb-4">--font-sans</p>
<p className="font-sans text-sm">Used for: body text, descriptions, long-form content</p>
</div>
</div>
</SubSection>
<SubSection title="type scale">
<div className="space-y-4">
<TokenRow name="--text-xs" value="12px / 0.75rem" preview={<span className="text-xs font-mono">Aa</span>} />
<TokenRow name="--text-sm" value="14px / 0.875rem" preview={<span className="text-sm font-mono">Aa</span>} />
<TokenRow name="--text-base" value="16px / 1rem" preview={<span className="text-base font-mono">Aa</span>} />
<TokenRow name="--text-lg" value="18px / 1.125rem" preview={<span className="text-lg font-mono">Aa</span>} />
<TokenRow name="--text-xl" value="20px / 1.25rem" preview={<span className="text-xl font-mono">Aa</span>} />
<TokenRow name="--text-2xl" value="24px / 1.5rem" preview={<span className="text-2xl font-mono">Aa</span>} />
<TokenRow name="--text-3xl" value="32px / 2rem" preview={<span className="text-3xl font-mono">Aa</span>} />
<TokenRow name="--text-4xl" value="40px / 2.5rem" preview={<span className="text-4xl font-mono">Aa</span>} />
<TokenRow name="--text-5xl" value="48px / 3rem" preview={<span className="text-5xl font-mono">Aa</span>} />
</div>
</SubSection>
<SubSection title="line height">
<div className="space-y-4">
<TokenRow name="--leading-tight" value="1.2" preview={<div className="w-full h-3 bg-accent/20" />} />
<TokenRow name="--leading-snug" value="1.4" preview={<div className="w-full h-4 bg-accent/20" />} />
<TokenRow name="--leading-normal" value="1.6" preview={<div className="w-full h-5 bg-accent/20" />} />
<TokenRow name="--leading-relaxed" value="1.7" preview={<div className="w-full h-6 bg-accent/20" />} />
<TokenRow name="--leading-loose" value="1.8" preview={<div className="w-full h-7 bg-accent/20" />} />
</div>
</SubSection>
<SubSection title="max line width">
<div className="space-y-4 mb-8">
<TokenRow name="--prose-width-narrow" value="45ch" />
<TokenRow name="--prose-width" value="65ch (default)" />
<TokenRow name="--prose-width-wide" value="80ch" />
</div>
<div className="bg-surface p-6 border border-dashed border-border">
<p className="font-sans text-sm text-foreground-secondary leading-relaxed prose-width">
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.
</p>
</div>
</SubSection>
<SubSection title="usage rules">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">monospace (JetBrains Mono)</h4>
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
<li> Page titles and section headings</li>
<li> Code snippets and technical content</li>
<li> Data values, metrics, timestamps</li>
<li> Form labels and button text</li>
<li> Table headers and numeric columns</li>
</ul>
</div>
<div className="bg-surface p-6 border border-dashed border-border">
<h4 className="font-mono text-sm font-medium mb-4">sans-serif (Inter)</h4>
<ul className="space-y-2 text-sm text-foreground-secondary font-sans">
<li> Body paragraphs and descriptions</li>
<li> Help text and instructions</li>
<li> Error messages and notifications</li>
<li> Marketing and explanatory content</li>
<li> Long-form documentation</li>
</ul>
</div>
</div>
</SubSection>
<SubSection title="heading hierarchy">
<div className="space-y-6">
<h1 className="font-mono text-5xl font-semibold tracking-tight lowercase">heading 1 (48px)</h1>
<h2 className="font-mono text-4xl font-semibold tracking-tight lowercase">heading 2 (40px)</h2>
<h3 className="font-mono text-3xl font-semibold tracking-tight lowercase">heading 3 (32px)</h3>
<h4 className="font-mono text-2xl font-semibold tracking-tight lowercase">heading 4 (24px)</h4>
<h5 className="font-mono text-xl font-medium lowercase">heading 5 (20px)</h5>
<h6 className="font-mono text-lg font-medium lowercase">heading 6 (18px)</h6>
</div>
</SubSection>
</FieldsetSection>
);
}

View file

@ -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';

View file

@ -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 (
<fieldset id={id} className="border border-dashed border-border p-8 mb-16 scroll-mt-24">
<legend className="font-mono text-sm text-foreground-secondary px-3 lowercase">
{title}
</legend>
{children}
</fieldset>
);
}
/**
* Sub-section with title
*/
export function SubSection({
title,
children,
}: {
title: string;
children: React.ReactNode;
}): React.ReactElement {
return (
<div className="mb-10 last:mb-0">
<h3 className="font-mono text-base font-medium mb-6 text-foreground lowercase">{title}</h3>
{children}
</div>
);
}
/**
* 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 (
<div className={`${color} p-4 border border-dashed border-border`}>
<div className={`font-mono text-sm font-medium ${textLight ? 'text-white' : 'text-foreground'}`}>
{name}
</div>
<div className={`font-mono text-xs ${textLight ? 'text-white/80' : 'text-foreground-secondary'}`}>
{hex}
</div>
<div className={`font-mono text-xs mt-2 ${textLight ? 'text-white/60' : 'text-foreground-tertiary'}`}>
{desc}
</div>
</div>
);
}
/**
* 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 (
<div className={`border-2 ${isDo ? 'border-success' : 'border-error'} p-4`}>
<div className={`flex items-center gap-2 mb-3 ${isDo ? 'text-success' : 'text-error'}`}>
<Icon icon={isDo ? 'check' : 'x'} size="sm" />
<span className="font-mono text-sm font-medium uppercase">{isDo ? 'do' : "don't"}</span>
</div>
<p className="font-mono text-xs text-foreground-secondary mb-3">{title}</p>
<div className="bg-surface p-3 border border-dashed border-border">
{children}
</div>
</div>
);
}
/**
* Principle card with icon
*/
export function PrincipleCard({
icon,
title,
description,
}: {
icon: string;
title: string;
description: string;
}): React.ReactElement {
return (
<div className="border border-dashed border-border p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-accent/10 flex items-center justify-center">
<Icon icon={icon as Parameters<typeof Icon>[0]['icon']} size="md" className="text-accent" />
</div>
<h4 className="font-mono text-base font-medium text-foreground">{title}</h4>
</div>
<p className="font-sans text-sm text-foreground-secondary leading-relaxed">{description}</p>
</div>
);
}
/**
* Token display row
*/
export function TokenRow({
name,
value,
preview,
}: {
name: string;
value: string;
preview?: React.ReactNode;
}): React.ReactElement {
return (
<div className="flex items-center gap-4 py-3 border-b border-dashed border-border last:border-0">
<code className="font-mono text-xs text-accent w-48">{name}</code>
<span className="font-mono text-xs text-foreground-secondary flex-1">{value}</span>
{preview && <div className="w-24">{preview}</div>}
</div>
);
}
/**
* Navigation item for table of contents
*/
export function NavItem({
href,
children,
indent = false,
}: {
href: string;
children: React.ReactNode;
indent?: boolean;
}): React.ReactElement {
return (
<a
href={href}
className={`block font-mono text-sm text-foreground-secondary hover:text-accent transition-colors ${indent ? 'pl-4' : ''}`}
>
{children}
</a>
);
}

View file

@ -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;

View file

@ -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

View file

@ -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": [

View file

@ -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<AccordionContextValue | null>(null);
// Accordion item context
const AccordionItemContext = createContext<AccordionItemContextValue | null>(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 (
* <Accordion type="single" defaultValue="item-1">
* <AccordionItem value="item-1">
* <AccordionTrigger>Section 1</AccordionTrigger>
* <AccordionContent>Content for section 1</AccordionContent>
* </AccordionItem>
* <AccordionItem value="item-2">
* <AccordionTrigger>Section 2</AccordionTrigger>
* <AccordionContent>Content for section 2</AccordionContent>
* </AccordionItem>
* </Accordion>
* );
* }
* ```
*/
export const Accordion = forwardRef<HTMLDivElement, AccordionProps>(
(
{
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<string[]>(
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 (
<AccordionContext.Provider value={contextValue}>
<div
ref={ref}
className={cn(accordionVariants({ variant }), className)}
{...props}
>
{children}
</div>
</AccordionContext.Provider>
);
}
);
Accordion.displayName = 'Accordion';
/**
* AccordionItem component
*/
export const AccordionItem = forwardRef<HTMLDivElement, AccordionItemProps>(
({ 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 (
<AccordionItemContext.Provider value={itemContextValue}>
<div
ref={ref}
data-state={isExpanded ? 'open' : 'closed'}
className={cn(accordionItemVariants({ variant: context.variant }), className)}
{...props}
>
{children}
</div>
</AccordionItemContext.Provider>
);
}
);
AccordionItem.displayName = 'AccordionItem';
/**
* AccordionTrigger component
*/
export const AccordionTrigger = forwardRef<HTMLButtonElement, AccordionTriggerProps>(
({ 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 (
<button
ref={ref}
type="button"
id={triggerId}
aria-expanded={isExpanded}
aria-controls={contentId}
disabled={disabled}
className={cn(accordionTriggerVariants({ disabled: disabled ? 'true' : 'false' }), className)}
onClick={handleClick}
onKeyDown={handleKeyDown}
{...props}
>
<span className="flex-1">{children}</span>
<span className={accordionTriggerIconVariants({ expanded: isExpanded ? 'true' : 'false' })}>
{icon ?? <Icon icon="chevronDown" size="sm" />}
</span>
</button>
);
}
);
AccordionTrigger.displayName = 'AccordionTrigger';
/**
* AccordionContent component
*/
export const AccordionContent = forwardRef<HTMLDivElement, AccordionContentProps>(
({ children, className, ...props }, ref) => {
const itemContext = useContext(AccordionItemContext);
if (!itemContext) {
throw new Error('AccordionContent must be used within an AccordionItem');
}
const { isExpanded } = itemContext;
return (
<div
ref={ref}
role="region"
aria-hidden={!isExpanded}
className={cn(accordionContentVariants({ expanded: isExpanded ? 'true' : 'false' }), className)}
{...props}
>
<div className={accordionContentInnerVariants({})}>
{children}
</div>
</div>
);
}
);
AccordionContent.displayName = 'AccordionContent';

View file

@ -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<HTMLDivElement> {
/**
* 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<HTMLDivElement> {
/**
* 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<HTMLButtonElement> {
/**
* Icon to display (defaults to chevron)
*/
icon?: ReactNode;
/**
* Trigger content
*/
children: ReactNode;
}
/**
* AccordionContent component props
*/
export interface AccordionContentProps extends HTMLAttributes<HTMLDivElement> {
/**
* 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;
}

View file

@ -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: {},
});

View file

@ -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: {

View file

@ -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 (
* <Breadcrumbs>
* <BreadcrumbItem href="/">Home</BreadcrumbItem>
* <BreadcrumbItem href="/tools">Tools</BreadcrumbItem>
* <BreadcrumbItem current>Current Tool</BreadcrumbItem>
* </Breadcrumbs>
* );
* }
* ```
*/
export const Breadcrumbs = forwardRef<HTMLElement, BreadcrumbsProps>(
(
{
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 (
<BreadcrumbSeparatorComponent key={key}>
<Icon icon={iconName} size="xs" />
</BreadcrumbSeparatorComponent>
);
}
return (
<BreadcrumbSeparatorComponent key={key}>
{separator}
</BreadcrumbSeparatorComponent>
);
};
return (
<nav
ref={ref}
aria-label="Breadcrumb"
className={cn(breadcrumbsVariants({}), className)}
{...props}
>
<ol className={breadcrumbsListVariants({})}>
{visibleItems.map((item, index) => {
if (item === 'ellipsis') {
return (
<li key="ellipsis" className={breadcrumbItemVariants({})}>
{index > 0 && renderSeparator(`sep-before-ellipsis`)}
<BreadcrumbEllipsis onClick={() => setExpanded(true)} />
</li>
);
}
return (
<li
key={index}
className={breadcrumbItemVariants({})}
>
{index > 0 && renderSeparator(`sep-${index}`)}
{item}
</li>
);
})}
</ol>
</nav>
);
}
);
Breadcrumbs.displayName = 'Breadcrumbs';
/**
* BreadcrumbItem component
*/
export const BreadcrumbItem = forwardRef<HTMLSpanElement, BreadcrumbItemProps>(
({ current = false, href, icon, children, className, ...props }, ref) => {
const content = (
<>
{icon && <span className={breadcrumbIconVariants({})}>{icon}</span>}
{children}
</>
);
if (current) {
return (
<span
ref={ref}
aria-current="page"
className={cn(breadcrumbLinkVariants({ current: 'true' }), className)}
{...props}
>
{content}
</span>
);
}
if (href) {
return (
<a
href={href}
className={cn(breadcrumbLinkVariants({ current: 'false' }), className)}
{...(props as any)}
>
{content}
</a>
);
}
return (
<span
ref={ref}
className={cn(breadcrumbLinkVariants({ current: 'false' }), className)}
{...props}
>
{content}
</span>
);
}
);
BreadcrumbItem.displayName = 'BreadcrumbItem';
/**
* BreadcrumbLink component
*/
export const BreadcrumbLink = forwardRef<HTMLAnchorElement, BreadcrumbLinkProps>(
({ href, children, className, ...props }, ref) => (
<a
ref={ref}
href={href}
className={cn(breadcrumbLinkVariants({ current: 'false' }), className)}
{...props}
>
{children}
</a>
)
);
BreadcrumbLink.displayName = 'BreadcrumbLink';
/**
* BreadcrumbSeparator component (internal)
*/
const BreadcrumbSeparatorComponent = forwardRef<HTMLSpanElement, BreadcrumbSeparatorProps>(
({ children, className, ...props }, ref) => (
<span
ref={ref}
role="presentation"
aria-hidden="true"
className={cn(breadcrumbSeparatorVariants({}), className)}
{...props}
>
{children ?? '/'}
</span>
)
);
BreadcrumbSeparatorComponent.displayName = 'BreadcrumbSeparator';
// Export as BreadcrumbSeparator
export { BreadcrumbSeparatorComponent as BreadcrumbSeparator };
/**
* BreadcrumbEllipsis component
*/
export const BreadcrumbEllipsis = forwardRef<HTMLSpanElement, BreadcrumbEllipsisProps>(
({ className, ...props }, ref) => (
<span
ref={ref}
role="button"
aria-label="Show more breadcrumbs"
className={cn(breadcrumbEllipsisVariants({}), className)}
{...props}
>
<Icon icon="moreHorizontal" size="sm" />
</span>
)
);
BreadcrumbEllipsis.displayName = 'BreadcrumbEllipsis';
/**
* BreadcrumbPage component
*
* Represents the current page in breadcrumbs (non-link, just text)
*/
export const BreadcrumbPage = forwardRef<HTMLSpanElement, Omit<BreadcrumbItemProps, 'href' | 'current'>>(
({ children, className, ...props }, ref) => (
<span
ref={ref}
aria-current="page"
className={cn(breadcrumbLinkVariants({ current: 'true' }), className)}
{...props}
>
{children}
</span>
)
);
BreadcrumbPage.displayName = 'BreadcrumbPage';

View file

@ -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<HTMLElement> {
/**
* 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<HTMLLIElement> {
/**
* 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<HTMLAnchorElement> {
/**
* Link href
*/
href: string;
/**
* Link content
*/
children: ReactNode;
}
/**
* BreadcrumbSeparator component props
*/
export interface BreadcrumbSeparatorProps extends HTMLAttributes<HTMLSpanElement> {
/**
* Custom separator content
*/
children?: ReactNode;
}
/**
* BreadcrumbEllipsis component props
*/
export interface BreadcrumbEllipsisProps extends HTMLAttributes<HTMLSpanElement> {}
/**
* Breadcrumbs ref type
*/
export type BreadcrumbsRef = HTMLElement;

View file

@ -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: {},
});

View file

@ -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(' '),

View file

@ -8,7 +8,7 @@ export interface CardProps extends HTMLAttributes<HTMLDivElement> {
* 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

View file

@ -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',

View file

@ -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)

View file

@ -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 (
* <>
* <Button onClick={() => setOpen(true)}>Open Drawer</Button>
* <Drawer
* open={open}
* onClose={() => setOpen(false)}
* title="Settings"
* side="right"
* footer={
* <>
* <Button variant="outline" onClick={() => setOpen(false)}>
* Cancel
* </Button>
* <Button onClick={() => setOpen(false)}>
* Save
* </Button>
* </>
* }
* >
* <p>Drawer content goes here.</p>
* </Drawer>
* </>
* );
* }
* ```
*/
export const Drawer = forwardRef<HTMLDivElement, DrawerProps>(
(
{
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<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(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<HTMLElement>(
'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 */}
<div
className={drawerBackdropVariants({ state: 'entered' })}
aria-hidden="true"
onClick={handleBackdropClick}
/>
{/* Container */}
<div
className={drawerContainerVariants({})}
onKeyDown={handleKeyDown}
>
{/* Panel */}
<div
ref={(node) => {
// Handle both refs
(panelRef as React.MutableRefObject<HTMLDivElement | null>).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) && (
<div className={drawerHeaderVariants({})}>
{title && (
<h2 id={titleId} className={drawerTitleVariants({})}>
{title}
</h2>
)}
{showCloseButton && (
<button
type="button"
onClick={onClose}
className={drawerCloseButtonVariants({})}
aria-label="Close drawer"
>
<Icon icon="x" size="sm" />
</button>
)}
</div>
)}
{/* Hidden description for screen readers */}
{description && (
<p id={descriptionId} className="sr-only">
{description}
</p>
)}
{/* Body */}
<div className={drawerBodyVariants({})}>
{children}
</div>
{/* Footer */}
{footer && (
<div className={drawerFooterVariants({})}>
{footer}
</div>
)}
</div>
</div>
</>
);
return createPortal(drawerContent, document.body);
}
);
Drawer.displayName = 'Drawer';
/**
* DrawerHeader component for custom headers
*/
export const DrawerHeader = forwardRef<HTMLDivElement, DrawerHeaderProps>(
({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn(drawerHeaderVariants({}), className)}
{...props}
>
{children}
</div>
)
);
DrawerHeader.displayName = 'DrawerHeader';
/**
* DrawerBody component for custom body content
*/
export const DrawerBody = forwardRef<HTMLDivElement, DrawerBodyProps>(
({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn(drawerBodyVariants({}), className)}
{...props}
>
{children}
</div>
)
);
DrawerBody.displayName = 'DrawerBody';
/**
* DrawerFooter component for custom footers
*/
export const DrawerFooter = forwardRef<HTMLDivElement, DrawerFooterProps>(
({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn(drawerFooterVariants({}), className)}
{...props}
>
{children}
</div>
)
);
DrawerFooter.displayName = 'DrawerFooter';

View file

@ -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<HTMLAttributes<HTMLDivElement>, '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<HTMLDivElement> {
children: ReactNode;
}
/**
* DrawerBody component props
*/
export interface DrawerBodyProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
/**
* DrawerFooter component props
*/
export interface DrawerFooterProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
/**
* Drawer ref type
*/
export type DrawerRef = HTMLDivElement;

View file

@ -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: {},
});

View file

@ -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<DropdownMenuContextValue | null>(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 (
* <DropdownMenu trigger={<Button>Open Menu</Button>}>
* <DropdownMenuItem onSelect={() => console.log('Edit')}>
* Edit
* </DropdownMenuItem>
* <DropdownMenuItem onSelect={() => console.log('Duplicate')}>
* Duplicate
* </DropdownMenuItem>
* <DropdownMenuSeparator />
* <DropdownMenuItem destructive onSelect={() => console.log('Delete')}>
* Delete
* </DropdownMenuItem>
* </DropdownMenu>
* );
* }
* ```
*/
export const DropdownMenu = forwardRef<HTMLDivElement, DropdownMenuProps>(
(
{
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<HTMLElement>(null);
const contentRef = useRef<HTMLDivElement>(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<any>, {
ref: triggerRef,
onClick: (e: React.MouseEvent) => {
(trigger as React.ReactElement<any>).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 (
<DropdownMenuContext.Provider value={contextValue}>
{triggerElement}
{canRenderPortal &&
isOpen &&
createPortal(
<div
ref={(node) => {
(contentRef as React.MutableRefObject<HTMLDivElement | null>).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}
</div>,
document.body
)}
</DropdownMenuContext.Provider>
);
}
);
DropdownMenu.displayName = 'DropdownMenu';
/**
* DropdownMenuItem component
*/
export const DropdownMenuItem = forwardRef<HTMLButtonElement, DropdownMenuItemProps>(
(
{
disabled = false,
destructive = false,
icon,
shortcut,
onSelect,
children,
className,
...props
},
ref
) => {
const context = useContext(DropdownMenuContext);
const indexRef = useRef<number>(-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 (
<button
ref={ref}
type="button"
role="menuitem"
disabled={disabled}
tabIndex={isActive ? 0 : -1}
className={cn(
dropdownMenuItemVariants({
disabled: disabled ? 'true' : 'false',
destructive: destructive ? 'true' : 'false',
active: isActive ? 'true' : 'false',
}),
className
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
onMouseEnter={() => context?.setActiveIndex(indexRef.current)}
{...props}
>
{icon && (
<span className={dropdownMenuItemIconVariants({ destructive: destructive ? 'true' : 'false' })}>
{icon}
</span>
)}
<span className="flex-1">{children}</span>
{shortcut && (
<span className={dropdownMenuItemShortcutVariants({})}>
{shortcut}
</span>
)}
</button>
);
}
);
DropdownMenuItem.displayName = 'DropdownMenuItem';
/**
* DropdownMenuSeparator component
*/
export const DropdownMenuSeparator = forwardRef<HTMLDivElement, DropdownMenuSeparatorProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
role="separator"
className={cn(dropdownMenuSeparatorVariants({}), className)}
{...props}
/>
)
);
DropdownMenuSeparator.displayName = 'DropdownMenuSeparator';
/**
* DropdownMenuLabel component
*/
export const DropdownMenuLabel = forwardRef<HTMLDivElement, DropdownMenuLabelProps>(
({ children, className, ...props }, ref) => (
<div
ref={ref}
className={cn(dropdownMenuLabelVariants({}), className)}
{...props}
>
{children}
</div>
)
);
DropdownMenuLabel.displayName = 'DropdownMenuLabel';
/**
* DropdownMenuGroup component
*/
export const DropdownMenuGroup = forwardRef<HTMLDivElement, DropdownMenuGroupProps>(
({ label, children, className, ...props }, ref) => (
<div ref={ref} role="group" className={className} {...props}>
{label && <DropdownMenuLabel>{label}</DropdownMenuLabel>}
{children}
</div>
)
);
DropdownMenuGroup.displayName = 'DropdownMenuGroup';

View file

@ -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<HTMLDivElement> {
/**
* 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<HTMLButtonElement> {
/**
* 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<HTMLDivElement> {}
/**
* DropdownMenuLabel component props
*/
export interface DropdownMenuLabelProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
/**
* DropdownMenuGroup component props
*/
export interface DropdownMenuGroupProps extends HTMLAttributes<HTMLDivElement> {
/**
* Group label
*/
label?: string;
/**
* Group items
*/
children: ReactNode;
}
/**
* DropdownMenu ref type
*/
export type DropdownMenuRef = HTMLDivElement;

View file

@ -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: {},
});

View file

@ -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;

View file

@ -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

View file

@ -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 (
* <InstallSnippet
* packageName="@tpmjs/core"
* version="^1.0.0"
* defaultManager="pnpm"
* />
* );
* }
* ```
*/
export const InstallSnippet = forwardRef<HTMLDivElement, InstallSnippetProps>(
(
{
packageName,
version,
defaultManager = 'npm',
showTabs = true,
installType = 'dependencies',
copyable = true,
variant = 'default',
className,
...props
},
ref
) => {
const [activeManager, setActiveManager] = useState<PackageManager>(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 (
<div
ref={ref}
className={cn(installSnippetVariants({ variant }), className)}
{...props}
>
{/* Tabs */}
{showTabs && (
<div className={installSnippetTabsVariants({ variant })}>
{MANAGER_ORDER.map((manager) => (
<button
key={manager}
type="button"
onClick={() => setActiveManager(manager)}
className={installSnippetTabVariants({
variant,
active: activeManager === manager ? 'true' : 'false',
})}
>
{PACKAGE_MANAGERS[manager].label}
</button>
))}
</div>
)}
{/* Code */}
<div className={installSnippetCodeVariants({ variant })}>
<code className="flex-1 whitespace-nowrap">{currentCommand}</code>
{copyable && (
<button
type="button"
onClick={handleCopy}
className={installSnippetCopyButtonVariants({ variant, copied: copied ? 'true' : 'false' })}
aria-label={copied ? 'Copied!' : 'Copy to clipboard'}
>
<Icon icon={copied ? 'check' : 'copy'} size="sm" />
</button>
)}
</div>
</div>
);
}
);
InstallSnippet.displayName = 'InstallSnippet';

View file

@ -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<HTMLDivElement> {
/**
* 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;

View file

@ -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' },
],
});

View file

@ -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 (
* <>
* <Button onClick={() => setOpen(true)}>Open Modal</Button>
* <Modal
* open={open}
* onClose={() => setOpen(false)}
* title="Confirm Action"
* footer={
* <>
* <Button variant="outline" onClick={() => setOpen(false)}>
* Cancel
* </Button>
* <Button onClick={() => setOpen(false)}>
* Confirm
* </Button>
* </>
* }
* >
* <p>Are you sure you want to proceed?</p>
* </Modal>
* </>
* );
* }
* ```
*/
export const Modal = forwardRef<HTMLDivElement, ModalProps>(
(
{
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<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(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<HTMLElement>(
'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 */}
<div
className={modalBackdropVariants({ state: 'entered' })}
aria-hidden="true"
/>
{/* Container */}
<div
className={modalContainerVariants({})}
onClick={handleBackdropClick}
onKeyDown={handleKeyDown}
>
{/* Panel */}
<div
ref={(node) => {
// Handle both refs
(panelRef as React.MutableRefObject<HTMLDivElement | null>).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) && (
<div className={modalHeaderVariants({})}>
{title && (
<h2 id={titleId} className={modalTitleVariants({})}>
{title}
</h2>
)}
{showCloseButton && (
<button
type="button"
onClick={onClose}
className={modalCloseButtonVariants({})}
aria-label="Close modal"
>
<Icon icon="x" size="sm" />
</button>
)}
</div>
)}
{/* Hidden description for screen readers */}
{description && (
<p id={descriptionId} className="sr-only">
{description}
</p>
)}
{/* Body */}
<div className={modalBodyVariants({})}>
{children}
</div>
{/* Footer */}
{footer && (
<div className={modalFooterVariants({})}>
{footer}
</div>
)}
</div>
</div>
</>
);
return createPortal(modalContent, document.body);
}
);
Modal.displayName = 'Modal';
/**
* ModalHeader component for custom headers
*/
export const ModalHeader = forwardRef<HTMLDivElement, ModalHeaderProps>(
({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn(modalHeaderVariants({}), className)}
{...props}
>
{children}
</div>
)
);
ModalHeader.displayName = 'ModalHeader';
/**
* ModalBody component for custom body content
*/
export const ModalBody = forwardRef<HTMLDivElement, ModalBodyProps>(
({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn(modalBodyVariants({}), className)}
{...props}
>
{children}
</div>
)
);
ModalBody.displayName = 'ModalBody';
/**
* ModalFooter component for custom footers
*/
export const ModalFooter = forwardRef<HTMLDivElement, ModalFooterProps>(
({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn(modalFooterVariants({}), className)}
{...props}
>
{children}
</div>
)
);
ModalFooter.displayName = 'ModalFooter';

View file

@ -0,0 +1,86 @@
import type { HTMLAttributes, ReactNode } from 'react';
/**
* Modal component props
*/
export interface ModalProps extends Omit<HTMLAttributes<HTMLDivElement>, '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<HTMLDivElement> {
children: ReactNode;
}
/**
* ModalBody component props
*/
export interface ModalBodyProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
/**
* ModalFooter component props
*/
export interface ModalFooterProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
/**
* Modal ref type
*/
export type ModalRef = HTMLDivElement;

View file

@ -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: {},
});

View file

@ -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 (
* <Pagination
* page={page}
* totalPages={10}
* onPageChange={setPage}
* />
* );
* }
* ```
*/
export const Pagination = forwardRef<HTMLElement, PaginationProps>(
(
{
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 (
<nav
ref={ref}
role="navigation"
aria-label="Pagination"
className={cn(paginationVariants({ size }), className)}
{...props}
>
<button
type="button"
disabled={disabled || isFirstPage}
onClick={() => handlePageChange(page - 1)}
className={paginationNavButtonVariants({ size, disabled: (disabled || isFirstPage) ? 'true' : 'false' })}
aria-label="Go to previous page"
>
<Icon icon="chevronLeft" size={size === 'sm' ? 'xs' : 'sm'} />
{previousLabel}
</button>
<span className={paginationInfoVariants({ size })}>
{page} / {totalPages}
</span>
<button
type="button"
disabled={disabled || isLastPage}
onClick={() => handlePageChange(page + 1)}
className={paginationNavButtonVariants({ size, disabled: (disabled || isLastPage) ? 'true' : 'false' })}
aria-label="Go to next page"
>
{nextLabel}
<Icon icon="chevronRight" size={size === 'sm' ? 'xs' : 'sm'} />
</button>
</nav>
);
}
// Minimal variant: just prev/next icons
if (variant === 'minimal') {
return (
<nav
ref={ref}
role="navigation"
aria-label="Pagination"
className={cn(paginationVariants({ size }), className)}
{...props}
>
<button
type="button"
disabled={disabled || isFirstPage}
onClick={() => handlePageChange(page - 1)}
className={paginationItemVariants({ size, disabled: (disabled || isFirstPage) ? 'true' : 'false' })}
aria-label="Go to previous page"
>
<Icon icon="chevronLeft" size={size === 'sm' ? 'xs' : 'sm'} />
</button>
<span className={paginationInfoVariants({ size })}>
{page} / {totalPages}
</span>
<button
type="button"
disabled={disabled || isLastPage}
onClick={() => handlePageChange(page + 1)}
className={paginationItemVariants({ size, disabled: (disabled || isLastPage) ? 'true' : 'false' })}
aria-label="Go to next page"
>
<Icon icon="chevronRight" size={size === 'sm' ? 'xs' : 'sm'} />
</button>
</nav>
);
}
// Default variant: full pagination with page numbers
return (
<nav
ref={ref}
role="navigation"
aria-label="Pagination"
className={cn(paginationVariants({ size }), className)}
{...props}
>
{/* First page button */}
{showFirstLast && (
<button
type="button"
disabled={disabled || isFirstPage}
onClick={() => handlePageChange(1)}
className={paginationItemVariants({ size, disabled: (disabled || isFirstPage) ? 'true' : 'false' })}
aria-label="Go to first page"
>
<Icon icon="chevronsLeft" size={size === 'sm' ? 'xs' : 'sm'} />
</button>
)}
{/* Previous button */}
{showPrevNext && (
<button
type="button"
disabled={disabled || isFirstPage}
onClick={() => handlePageChange(page - 1)}
className={paginationItemVariants({ size, disabled: (disabled || isFirstPage) ? 'true' : 'false' })}
aria-label="Go to previous page"
>
<Icon icon="chevronLeft" size={size === 'sm' ? 'xs' : 'sm'} />
</button>
)}
{/* Page numbers */}
{paginationRange.map((item, index) => {
if (item === 'ellipsis') {
return (
<PaginationEllipsis
key={`ellipsis-${index}`}
size={size}
/>
);
}
return (
<button
key={item}
type="button"
disabled={disabled}
onClick={() => 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}
</button>
);
})}
{/* Next button */}
{showPrevNext && (
<button
type="button"
disabled={disabled || isLastPage}
onClick={() => handlePageChange(page + 1)}
className={paginationItemVariants({ size, disabled: (disabled || isLastPage) ? 'true' : 'false' })}
aria-label="Go to next page"
>
<Icon icon="chevronRight" size={size === 'sm' ? 'xs' : 'sm'} />
</button>
)}
{/* Last page button */}
{showFirstLast && (
<button
type="button"
disabled={disabled || isLastPage}
onClick={() => handlePageChange(totalPages)}
className={paginationItemVariants({ size, disabled: (disabled || isLastPage) ? 'true' : 'false' })}
aria-label="Go to last page"
>
<Icon icon="chevronsRight" size={size === 'sm' ? 'xs' : 'sm'} />
</button>
)}
</nav>
);
}
);
Pagination.displayName = 'Pagination';
/**
* PaginationItem component (for custom usage)
*/
export const PaginationItem = forwardRef<HTMLButtonElement, PaginationItemProps>(
({ active = false, disabled = false, size = 'md', children, className, ...props }, ref) => (
<button
ref={ref}
type="button"
disabled={disabled}
className={cn(paginationItemVariants({ size, active: active ? 'true' : 'false', disabled: disabled ? 'true' : 'false' }), className)}
{...props}
>
{children}
</button>
)
);
PaginationItem.displayName = 'PaginationItem';
/**
* PaginationEllipsis component
*/
export const PaginationEllipsis = forwardRef<HTMLSpanElement, PaginationEllipsisProps>(
({ size = 'md', className, ...props }, ref) => (
<span
ref={ref}
aria-hidden="true"
className={cn(paginationEllipsisVariants({ size }), className)}
{...props}
>
<Icon icon="moreHorizontal" size={size === 'sm' ? 'xs' : 'sm'} />
</span>
)
);
PaginationEllipsis.displayName = 'PaginationEllipsis';

View file

@ -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<HTMLElement> {
/**
* 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<HTMLButtonElement> {
/**
* 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<HTMLSpanElement> {
/**
* Size variant
*/
size?: PaginationSize;
}
/**
* Pagination ref type
*/
export type PaginationRef = HTMLElement;

View file

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

View file

@ -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 (
* <Popover
* content={<p>This is popover content</p>}
* placement="bottom"
* >
* <Button>Click me</Button>
* </Popover>
* );
* }
* ```
*/
export const Popover = forwardRef<HTMLDivElement, PopoverProps>(
(
{
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<HTMLElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const showTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<any>, {
ref: triggerRef,
...(trigger === 'click' && {
onClick: (e: React.MouseEvent) => {
(children as React.ReactElement<any>).props.onClick?.(e);
handleToggle();
},
}),
...(trigger === 'hover' && {
onMouseEnter: (e: React.MouseEvent) => {
(children as React.ReactElement<any>).props.onMouseEnter?.(e);
handleOpen();
},
onMouseLeave: (e: React.MouseEvent) => {
(children as React.ReactElement<any>).props.onMouseLeave?.(e);
handleClose();
},
}),
...(trigger === 'focus' && {
onFocus: (e: React.FocusEvent) => {
(children as React.ReactElement<any>).props.onFocus?.(e);
handleOpen();
},
onBlur: (e: React.FocusEvent) => {
(children as React.ReactElement<any>).props.onBlur?.(e);
handleClose();
},
}),
})
: children;
// Only render portal in browser
const canRenderPortal = typeof window !== 'undefined';
return (
<>
{triggerElement}
{canRenderPortal &&
isOpen &&
createPortal(
<div
ref={(node) => {
(contentRef as React.MutableRefObject<HTMLDivElement | null>).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 && (
<div className={popoverArrowVariants({ placement })} />
)}
<div className={popoverBodyVariants({})}>
{content}
</div>
</div>,
document.body
)}
</>
);
}
);
Popover.displayName = 'Popover';

View file

@ -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<HTMLAttributes<HTMLDivElement>, '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<HTMLDivElement> {
children: ReactNode;
}
/**
* PopoverTrigger component props
*/
export interface PopoverTriggerProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
asChild?: boolean;
}
/**
* Popover ref type
*/
export type PopoverRef = HTMLDivElement;
/**
* Internal popover state
*/
export interface PopoverState {
isOpen: boolean;
triggerRef: RefObject<HTMLElement | null>;
contentRef: RefObject<HTMLDivElement | null>;
}

View file

@ -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: {},
});

View file

@ -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: {

View file

@ -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<keyof QualityScoreBreakdown, string> = {
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 (
* <QualityScore
* score={85}
* variant="badge"
* showTier
* />
* );
* }
* ```
*/
export const QualityScore = forwardRef<HTMLDivElement, QualityScoreProps>(
(
{
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 (
<div
ref={ref}
className={cn(qualityScoreVariants({ variant, size }), className)}
{...props}
>
<div
className={qualityScoreCircleVariants({ size, tier })}
style={{ width: 'auto', height: 'auto', padding: '0.25rem 0.5rem', borderRadius: 0 }}
>
{showScore && normalizedScore}
</div>
{showTier && (
<span className={qualityScoreTierVariants({ size, tier })}>
{tierLabel}
</span>
)}
</div>
);
}
// Inline variant
if (variant === 'inline') {
return (
<div
ref={ref}
className={cn(qualityScoreVariants({ variant, size }), className)}
{...props}
>
{showScore && (
<span className={qualityScoreTierVariants({ size, tier })}>
{normalizedScore}
</span>
)}
{showTier && (
<span className="text-foreground-muted">
({tierLabel})
</span>
)}
</div>
);
}
// Detailed variant
if (variant === 'detailed') {
return (
<div
ref={ref}
className={cn(qualityScoreVariants({ variant, size }), className)}
{...props}
>
{/* Main score display */}
<div className="flex items-center gap-2">
<div className={qualityScoreCircleVariants({ size, tier })}>
{showScore && normalizedScore}
</div>
<div className="flex flex-col">
{showTier && (
<span className={qualityScoreTierVariants({ size, tier })}>
{tierLabel}
</span>
)}
{showScore && (
<span className="text-foreground-muted text-xs">
out of 100
</span>
)}
</div>
</div>
{/* Breakdown */}
{breakdown && (
<div className={qualityScoreBreakdownVariants({})}>
{(Object.keys(breakdown) as Array<keyof QualityScoreBreakdown>).map(
(key) => {
const value = breakdown[key];
if (value === undefined) return null;
const percentage = Math.round(value * 100);
const breakdownTier = getTierFromScore(percentage);
return (
<div
key={key}
className={qualityScoreBreakdownRowVariants({ size })}
>
<span className="w-20 text-right">
{BREAKDOWN_LABELS[key]}
</span>
<div className={qualityScoreBarVariants({ size })}>
<div
className={qualityScoreBarFillVariants({ tier: breakdownTier })}
style={{ width: `${percentage}%` }}
/>
</div>
<span className="w-8 text-right">{percentage}</span>
</div>
);
}
)}
</div>
)}
</div>
);
}
// Default variant
return (
<div
ref={ref}
className={cn(qualityScoreVariants({ variant, size }), className)}
{...props}
>
<div className={qualityScoreCircleVariants({ size, tier })}>
{showScore && normalizedScore}
</div>
{showTier && (
<div className="flex flex-col">
<span className={qualityScoreTierVariants({ size, tier })}>
{tierLabel}
</span>
<div className={qualityScoreBarVariants({ size })}>
<div
className={qualityScoreBarFillVariants({ tier })}
style={{ width: `${normalizedScore}%` }}
/>
</div>
</div>
)}
</div>
);
}
);
QualityScore.displayName = 'QualityScore';

View file

@ -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<HTMLDivElement> {
/**
* 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;

View file

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

View file

@ -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 (
* <div>
* <Skeleton variant="text" width="60%" />
* <Skeleton variant="text" width="80%" />
* <Skeleton variant="circular" width={40} height={40} />
* <Skeleton variant="rectangular" width="100%" height={200} />
* </div>
* );
* }
* ```
*/
export const Skeleton = forwardRef<HTMLDivElement, SkeletonProps>(
(
{
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 (
<div
ref={ref}
className={cn(skeletonTextContainerVariants({}), className)}
style={{ gap: gapValue, ...style }}
{...props}
>
{Array.from({ length: lines }).map((_, index) => {
const isLast = index === lines - 1;
const lineWidth = isLast && lastLineShort ? '60%' : width || '100%';
return (
<div
key={index}
className={skeletonVariants({ variant, animation })}
style={{
width: normalizeDimension(lineWidth),
height: normalizeDimension(height),
}}
/>
);
})}
</div>
);
}
return (
<div
ref={ref}
className={cn(skeletonVariants({ variant, animation }), className)}
style={{
width: normalizeDimension(width),
height: normalizeDimension(height),
...style,
}}
{...props}
/>
);
}
);
Skeleton.displayName = 'Skeleton';
/**
* SkeletonText component
*
* A preset for text loading placeholders.
*
* @example
* ```tsx
* import { SkeletonText } from '@tpmjs/ui/Skeleton/Skeleton';
*
* function MyComponent() {
* return <SkeletonText lines={3} />;
* }
* ```
*/
export const SkeletonText = forwardRef<HTMLDivElement, SkeletonTextProps>(
(
{
lines = 3,
gap = '0.5rem',
width,
animation = 'pulse',
className,
style,
...props
},
ref
) => {
const gapValue = normalizeDimension(gap);
const widths = Array.isArray(width) ? width : undefined;
return (
<div
ref={ref}
className={cn(skeletonTextContainerVariants({}), className)}
style={{ gap: gapValue, ...style }}
{...props}
>
{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 (
<div
key={index}
className={skeletonVariants({ variant: 'text', animation })}
style={{ width: normalizeDimension(lineWidth) }}
/>
);
})}
</div>
);
}
);
SkeletonText.displayName = 'SkeletonText';
/**
* SkeletonAvatar component
*
* A preset for avatar loading placeholders.
*
* @example
* ```tsx
* import { SkeletonAvatar } from '@tpmjs/ui/Skeleton/Skeleton';
*
* function MyComponent() {
* return <SkeletonAvatar size="lg" />;
* }
* ```
*/
export const SkeletonAvatar = forwardRef<HTMLDivElement, SkeletonAvatarProps>(
({ size = 'md', animation = 'pulse', className, ...props }, ref) => (
<div
ref={ref}
className={cn(skeletonAvatarVariants({ size, animation }), className)}
{...props}
/>
)
);
SkeletonAvatar.displayName = 'SkeletonAvatar';
/**
* SkeletonCard component
*
* A preset for card loading placeholders.
*
* @example
* ```tsx
* import { SkeletonCard } from '@tpmjs/ui/Skeleton/Skeleton';
*
* function MyComponent() {
* return <SkeletonCard showImage lines={3} />;
* }
* ```
*/
export const SkeletonCard = forwardRef<HTMLDivElement, SkeletonCardProps>(
(
{
showImage = true,
lines = 3,
animation = 'pulse',
className,
...props
},
ref
) => (
<div
ref={ref}
className={cn(skeletonCardVariants({}), className)}
{...props}
>
{showImage && (
<div className={skeletonCardImageVariants({ animation })} />
)}
<div className="space-y-2">
{/* Title skeleton */}
<div
className={skeletonVariants({ variant: 'text', animation })}
style={{ width: '70%', height: '1.25rem' }}
/>
{/* Content skeletons */}
{Array.from({ length: lines }).map((_, index) => (
<div
key={index}
className={skeletonVariants({ variant: 'text', animation })}
style={{
width: index === lines - 1 ? '50%' : '100%',
}}
/>
))}
</div>
</div>
)
);
SkeletonCard.displayName = 'SkeletonCard';
/**
* SkeletonTable component
*
* A preset for table loading placeholders.
*
* @example
* ```tsx
* import { SkeletonTable } from '@tpmjs/ui/Skeleton/Skeleton';
*
* function MyComponent() {
* return <SkeletonTable rows={5} columns={4} />;
* }
* ```
*/
export interface SkeletonTableProps extends React.HTMLAttributes<HTMLDivElement> {
rows?: number;
columns?: number;
animation?: 'pulse' | 'wave' | 'none';
}
export const SkeletonTable = forwardRef<HTMLDivElement, SkeletonTableProps>(
({ rows = 5, columns = 4, animation = 'pulse', className, ...props }, ref) => (
<div
ref={ref}
className={cn('w-full', className)}
{...props}
>
{/* Header */}
<div className="flex gap-4 py-3 border-b border-border">
{Array.from({ length: columns }).map((_, colIndex) => (
<div
key={`header-${colIndex}`}
className={cn(
skeletonVariants({ variant: 'text', animation }),
'flex-1'
)}
style={{ height: '1rem' }}
/>
))}
</div>
{/* Rows */}
{Array.from({ length: rows }).map((_, rowIndex) => (
<div
key={`row-${rowIndex}`}
className="flex gap-4 py-3 border-b border-border last:border-b-0"
>
{Array.from({ length: columns }).map((_, colIndex) => (
<div
key={`cell-${rowIndex}-${colIndex}`}
className={cn(
skeletonVariants({ variant: 'text', animation }),
'flex-1'
)}
style={{ height: '1rem' }}
/>
))}
</div>
))}
</div>
)
);
SkeletonTable.displayName = 'SkeletonTable';

View file

@ -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<HTMLDivElement> {
/**
* 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<HTMLDivElement> {
/**
* 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<HTMLDivElement> {
/**
* 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<HTMLDivElement> {
/**
* 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;

View file

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

View file

@ -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: {

View file

@ -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

View file

@ -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 (
* <Toast
* open={open}
* onClose={() => setOpen(false)}
* title="Success"
* description="Your changes have been saved."
* variant="success"
* />
* );
* }
* ```
*/
export const Toast = forwardRef<HTMLDivElement, ToastProps>(
(
{
open,
onClose,
title,
description,
variant = 'default',
action,
duration = 5000,
showCloseButton = true,
icon,
className,
...props
},
ref
) => {
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div
ref={ref}
role="alert"
aria-live="polite"
className={cn(toastVariants({ variant, state: 'entered' }), className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
{/* Icon */}
<div className={toastIconVariants({ variant })}>
{icon ?? <Icon icon={defaultIcon} size="sm" />}
</div>
{/* Content */}
<div className={toastContentVariants()}>
{title && <div className={toastTitleVariants()}>{title}</div>}
{description && (
<div className={toastDescriptionVariants()}>{description}</div>
)}
{action && <div className={toastActionVariants()}>{action}</div>}
</div>
{/* Close button */}
{showCloseButton && (
<button
type="button"
onClick={onClose}
className={toastCloseButtonVariants()}
aria-label="Dismiss notification"
>
<Icon icon="x" size="sm" />
</button>
)}
</div>
);
}
);
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 (
* <ToastContainer position="top-right">
* <Toast open={true} onClose={() => {}} title="Hello" />
* </ToastContainer>
* );
* }
* ```
*/
export const ToastContainer = forwardRef<HTMLDivElement, ToastContainerProps>(
({ position = 'bottom-right', children, className, ...props }, ref) => {
// Only render in browser (for SSR compatibility)
if (typeof window === 'undefined') return null;
return createPortal(
<div
ref={ref}
className={cn(toastContainerVariants({ position }), className)}
{...props}
>
{children}
</div>,
document.body
);
}
);
ToastContainer.displayName = 'ToastContainer';
// Toast context for programmatic usage
const ToastContext = createContext<ToastContextValue | null>(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 (
* <ToastProvider position="bottom-right">
* <MyComponent />
* </ToastProvider>
* );
* }
*
* function MyComponent() {
* const { toast, dismiss } = useToast();
*
* return (
* <button onClick={() => toast({ title: 'Hello', variant: 'success' })}>
* Show Toast
* </button>
* );
* }
* ```
*/
export function ToastProvider({
children,
position = 'bottom-right',
maxToasts = 5,
}: ToastProviderProps) {
const [toasts, setToasts] = useState<ToastState[]>([]);
const toast = useCallback(
(props: Omit<ToastProps, 'open' | 'onClose'>): 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 (
<ToastContext.Provider value={contextValue}>
{children}
<ToastContainer position={position}>
{toasts.map((t) => (
<Toast
key={t.id}
open={t.open}
onClose={() => dismiss(t.id)}
title={t.title}
description={t.description}
variant={t.variant}
action={t.action}
duration={t.duration}
showCloseButton={t.showCloseButton}
icon={t.icon}
/>
))}
</ToastContainer>
</ToastContext.Provider>
);
}
/**
* 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;
}

View file

@ -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<HTMLAttributes<HTMLDivElement>, '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<HTMLDivElement> {
/**
* 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<ToastProps, 'open' | 'onClose'>) => string;
dismiss: (id: string) => void;
dismissAll: () => void;
}
/**
* Internal toast state
*/
export interface ToastState extends Omit<ToastProps, 'open' | 'onClose'> {
id: string;
open: boolean;
}
/**
* Toast ref type
*/
export type ToastRef = HTMLDivElement;

View file

@ -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: {},
});

View file

@ -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 (
* <ToolCard
* name="@tpmjs/core"
* displayName="TPMJS Core"
* version="1.0.0"
* description="Core utilities for TPMJS tools"
* tier="rich"
* qualityScore={85}
* downloads={50000}
* isOfficial
* href="/tool/tpmjs-core"
* />
* );
* }
* ```
*/
export const ToolCard = forwardRef<HTMLDivElement, ToolCardProps>(
(
{
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 */}
<div className={toolCardHeaderVariants({ variant })}>
{/* Icon */}
{icon && (
<div className={toolCardIconVariants({ variant })}>
{icon}
</div>
)}
{/* Title area */}
<div className="flex-1 min-w-0">
<div className="flex items-center flex-wrap gap-2">
<h3 className={toolCardTitleVariants({ variant })}>
{displayTitle}
</h3>
{version && (
<span className={toolCardVersionVariants({})}>
v{version}
</span>
)}
</div>
{/* Badges */}
<div className="flex items-center gap-2 mt-1">
{isOfficial && (
<span className={toolCardOfficialBadgeVariants({})}>
<Icon icon="badgeCheck" size="xs" />
official
</span>
)}
{tier && (
<span className={toolCardTierBadgeVariants({ tier })}>
{tier}
</span>
)}
{category && (
<span className="font-mono text-[10px] text-foreground-muted">
{category}
</span>
)}
</div>
</div>
{/* Quality score (compact display) */}
{qualityScore !== undefined && variant !== 'compact' && (
<div className="flex-shrink-0 text-right">
<div className="font-mono text-lg font-semibold text-primary">
{Math.round(qualityScore)}
</div>
<div className="font-mono text-[10px] text-foreground-muted uppercase">
score
</div>
</div>
)}
</div>
{/* Description */}
{description && (
<p className={toolCardDescriptionVariants({ variant })}>
{description}
</p>
)}
{/* Meta info */}
<div className={toolCardMetaVariants({ variant })}>
{formattedDownloads && (
<div className={toolCardMetaItemVariants({})}>
<Icon icon="download" size="xs" />
{formattedDownloads}
</div>
)}
{stars !== undefined && (
<div className={toolCardMetaItemVariants({})}>
<Icon icon="star" size="xs" />
{stars}
</div>
)}
{author && (
<div className={toolCardMetaItemVariants({})}>
<Icon icon="user" size="xs" />
{author}
</div>
)}
{formattedTime && (
<div className={toolCardMetaItemVariants({})}>
<Icon icon="clock" size="xs" />
{formattedTime}
</div>
)}
{/* Quality score for compact variant */}
{qualityScore !== undefined && variant === 'compact' && (
<div className={cn(toolCardMetaItemVariants({}), 'ml-auto')}>
<span className="text-primary font-semibold">
{Math.round(qualityScore)}
</span>
</div>
)}
</div>
{/* Action slot */}
{action && (
<div className={toolCardActionVariants({ variant })}>
{action}
</div>
)}
</>
);
if (isClickable) {
return (
<a
ref={ref as React.Ref<HTMLAnchorElement>}
href={href}
className={cardClassName}
{...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)}
>
{cardContent}
</a>
);
}
return (
<div
ref={ref}
className={cardClassName}
{...props}
>
{cardContent}
</div>
);
}
);
ToolCard.displayName = 'ToolCard';

View file

@ -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<HTMLDivElement> {
/**
* 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;

View file

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

View file

@ -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 (
* <Tooltip content="This is a helpful tip">
* <Button>Hover me</Button>
* </Tooltip>
* );
* }
* ```
*/
export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
(
{
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<HTMLElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const showTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<any>, {
ref: triggerRef,
onMouseEnter: (e: React.MouseEvent) => {
(children as React.ReactElement<any>).props.onMouseEnter?.(e);
handleOpen();
},
onMouseLeave: (e: React.MouseEvent) => {
(children as React.ReactElement<any>).props.onMouseLeave?.(e);
handleClose();
},
onFocus: (e: React.FocusEvent) => {
(children as React.ReactElement<any>).props.onFocus?.(e);
handleOpen();
},
onBlur: (e: React.FocusEvent) => {
(children as React.ReactElement<any>).props.onBlur?.(e);
handleClose();
},
})
: children;
// Only render portal in browser
const canRenderPortal = typeof window !== 'undefined';
return (
<>
{triggerElement}
{canRenderPortal &&
isOpen &&
createPortal(
<div
ref={(node) => {
(contentRef as React.MutableRefObject<HTMLDivElement | null>).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 && (
<div className={tooltipArrowVariants({ placement })} />
)}
{content}
</div>,
document.body
)}
</>
);
}
);
Tooltip.displayName = 'Tooltip';

View file

@ -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<HTMLAttributes<HTMLDivElement>, '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;

View file

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

View file

@ -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