fix: replace hardcoded dark mode colors with theme-aware semantic tokens in tool-search page

- Replace bg-black with bg-background
- Replace text-zinc-100 with text-foreground
- Replace text-zinc-400 with text-foreground-secondary
- Replace text-zinc-500 with text-foreground-tertiary
- Ensures page respects light/dark theme toggle

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2025-11-27 15:37:46 +10:00
parent 036b04b0bd
commit 70f87a60b2
21 changed files with 1458 additions and 79 deletions

View file

@ -92,6 +92,10 @@
--tracking-wide: 0.025em;
--tracking-wider: 0.05em;
--tracking-widest: 0.1em;
/* Brutalist Accent Colors */
--brutalist-accent: 221 83% 53%; /* #0066ff - Electric blue for light mode */
--brutalist-accent-hover: 221 83% 43%;
}
/* Dark mode (opt-in) - Vercel/Cursor/Perplexity aesthetic */
@ -148,6 +152,10 @@
/* Card */
--card: 210 10% 8%;
--card-foreground: 210 10% 90%;
/* Brutalist Accent Colors */
--brutalist-accent: 158 100% 50%; /* #00ff88 - Neon green for dark mode */
--brutalist-accent-hover: 158 100% 40%;
}
/* Base element styles */
@ -321,4 +329,134 @@
);
background-size: 1000px 100%;
}
/* Brutalist Typography */
.brutalist-heading {
font-size: clamp(48px, 8vw, 120px);
font-weight: 800;
letter-spacing: -0.05em;
line-height: 0.9;
text-transform: uppercase;
}
.brutalist-subheading {
font-size: clamp(24px, 4vw, 56px);
font-weight: 700;
letter-spacing: -0.025em;
line-height: 1.1;
text-transform: uppercase;
}
/* Brutalist Borders */
.brutalist-border {
border: 6px solid hsl(var(--foreground));
border-radius: 0;
}
.brutalist-border-thick {
border: 8px solid hsl(var(--foreground));
border-radius: 0;
}
.brutalist-border-mega {
border: 12px solid hsl(var(--foreground));
border-radius: 0;
}
/* Brutalist Shadows - Hard, not soft */
.brutalist-shadow {
box-shadow: 0 10px 0 0 hsl(var(--brutalist-accent));
}
.brutalist-shadow-hover {
box-shadow: 0 12px 0 0 hsl(var(--brutalist-accent));
}
.brutalist-shadow-sm {
box-shadow: 0 6px 0 0 hsl(var(--brutalist-accent));
}
/* Glitch Animation */
@keyframes glitch {
0%,
100% {
transform: translateX(0);
}
20% {
transform: translateX(-5px);
}
40% {
transform: translateX(5px);
}
60% {
transform: translateX(-3px);
}
80% {
transform: translateX(3px);
}
}
.animate-glitch {
animation: glitch 500ms ease-in-out;
}
/* Brutalist Entrance Animation */
@keyframes brutalist-entrance {
from {
transform: translateY(40px) scale(0.9);
opacity: 0;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
.animate-brutalist-entrance {
animation: brutalist-entrance 600ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* Bar Growth Animation */
@keyframes bar-grow {
from {
transform: scaleY(0);
transform-origin: bottom;
}
to {
transform: scaleY(1);
transform-origin: bottom;
}
}
.animate-bar-grow {
animation: bar-grow 800ms ease-out forwards;
}
/* Stagger delays for entrance animations */
.stagger-1 {
animation-delay: 80ms;
}
.stagger-2 {
animation-delay: 160ms;
}
.stagger-3 {
animation-delay: 240ms;
}
.stagger-4 {
animation-delay: 320ms;
}
.stagger-5 {
animation-delay: 400ms;
}
/* Motion Preferences - Accessibility */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
}

View file

@ -4,9 +4,11 @@ import { Card, CardContent } from '@tpmjs/ui/Card/Card';
import { Container } from '@tpmjs/ui/Container/Container';
import { Header } from '@tpmjs/ui/Header/Header';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import { Input } from '@tpmjs/ui/Input/Input';
import { StatCard } from '@tpmjs/ui/StatCard/StatCard';
import Link from 'next/link';
import { ThemeToggle } from '../components/ThemeToggle';
import { CategoryGrid } from '../components/home/CategoryGrid';
import { HeroSection } from '../components/home/HeroSection';
import { categories, featuredTools, statistics } from '../data/homePageData';
export default function HomePage(): React.ReactElement {
@ -50,49 +52,42 @@ export default function HomePage(): React.ReactElement {
/>
<main className="flex-1">
{/* Hero Section */}
<section className="relative py-24 overflow-hidden dotted-grid-background">
<div className="absolute inset-0 bg-background/95" />
<Container size="xl" padding="lg">
<div className="max-w-3xl mx-auto text-center space-y-8 relative z-10">
<h1 className="text-5xl md:text-6xl font-bold tracking-tight">
Tool Registry for AI Agents
</h1>
<p className="text-xl text-foreground-secondary">
Discover, share, and integrate tools that give your agents superpowers. The registry
for AI tools.
</p>
<div className="flex gap-3 items-center max-w-2xl mx-auto">
<Input size="lg" placeholder="Search tools..." className="flex-1" />
<Button size="lg" className="px-8">
Search
</Button>
</div>
</div>
</Container>
</section>
{/* Hero Section - Brutalist Design */}
<HeroSection />
{/* Featured Tools Section */}
<section className="py-16 bg-surface blueprint-background">
{/* Featured Tools Section - Brutalist Cards */}
<section className="py-16 md:py-24 bg-background">
<Container size="xl" padding="lg">
<div className="flex items-center justify-between mb-8">
<h2 className="text-3xl font-semibold">Featured Tools</h2>
<Button variant="ghost">View all tools →</Button>
<div className="flex items-center justify-between mb-12">
<h2 className="brutalist-subheading">Featured Tools</h2>
<Button variant="ghost" className="uppercase tracking-wider font-bold">
View all →
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{featuredTools.map((tool) => (
<Card key={tool.id} className="hover:shadow-lg transition-shadow">
{featuredTools.map((tool, index) => (
<Card
key={tool.id}
variant="brutalist"
className={`opacity-0 animate-brutalist-entrance stagger-${index % 6}`}
>
<CardContent className="p-6 space-y-4">
<div className="flex items-start gap-4">
<Icon icon={tool.icon} size="lg" className="text-primary" />
<Icon icon={tool.icon} size="lg" className="text-brutalist-accent" />
<div className="flex-1 space-y-2">
<h3 className="text-lg font-semibold">{tool.name}</h3>
<p className="text-sm text-foreground-secondary">{tool.description}</p>
<div className="flex items-center gap-2">
<Badge variant={tool.categoryVariant} size="sm">
<h3 className="text-lg font-bold uppercase tracking-tight">{tool.name}</h3>
<p className="text-sm text-foreground-secondary leading-relaxed">
{tool.description}
</p>
<div className="flex items-center gap-3 pt-2">
<Badge
variant={tool.categoryVariant}
size="sm"
className="uppercase font-mono text-xs"
>
{tool.category}
</Badge>
<span className="text-xs text-foreground-tertiary">
<span className="text-sm font-mono font-bold text-foreground">
{tool.weeklyUsage}
</span>
</div>
@ -105,46 +100,42 @@ export default function HomePage(): React.ReactElement {
</Container>
</section>
{/* Categories Section */}
<section className="py-16">
{/* Categories Section - Brutalist Masonry Grid */}
<section className="py-16 md:py-24 bg-surface">
<Container size="xl" padding="lg">
<h2 className="text-3xl font-semibold mb-8">Browse by Category</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{categories.map((category) => (
<Card
key={category.id}
className="hover:shadow-lg transition-shadow cursor-pointer"
>
<CardContent className={`p-6 ${category.colorClass}`}>
<div className="space-y-3">
<Icon icon={category.icon} size="lg" />
<h3 className="font-semibold">{category.name}</h3>
<p className="text-sm opacity-90">{category.toolCount} tools</p>
</div>
</CardContent>
</Card>
))}
</div>
<h2 className="brutalist-subheading mb-12">Browse by Category</h2>
<CategoryGrid categories={categories} />
</Container>
</section>
{/* Statistics Section */}
<section className="py-16 bg-surface grid-background">
<Container size="xl" padding="lg">
<h2 className="text-3xl font-semibold mb-8 text-center">Platform Statistics</h2>
{/* Statistics Section - Brutalist Stat Cards */}
<section className="py-16 md:py-24 bg-background relative overflow-hidden">
{/* Subtle grid background */}
<div className="absolute inset-0 opacity-[0.02] grid-background" />
<Container size="xl" padding="lg" className="relative z-10">
<h2 className="brutalist-subheading mb-12 text-center">Platform Statistics</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{statistics.map((stat) => (
<Card key={stat.label}>
<CardContent className="p-6 text-center space-y-3">
<Icon icon={stat.icon} size="lg" className="mx-auto text-primary" />
<div className="text-4xl font-bold">{stat.value}</div>
<div className="text-sm font-medium">{stat.label}</div>
{stat.subtext && (
<div className="text-xs text-foreground-tertiary">{stat.subtext}</div>
)}
</CardContent>
</Card>
))}
{statistics.map((stat, index) => {
// Extract number from value string
const numValue = Number.parseInt(stat.value.replace(/[^0-9]/g, ''), 10);
const suffix = stat.value.replace(/[0-9,]/g, '');
return (
<StatCard
key={stat.label}
value={numValue}
label={stat.label}
subtext={stat.subtext}
suffix={suffix}
variant="brutalist"
size="md"
showBar={true}
barProgress={60 + index * 10}
className={`opacity-0 animate-brutalist-entrance stagger-${index + 1}`}
/>
);
})}
</div>
</Container>
</section>

View file

@ -0,0 +1,124 @@
'use client';
import { Card, CardContent } from '@tpmjs/ui/Card/Card';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import type { IconName } from '@tpmjs/ui/Icon/Icon';
import { useScrollReveal } from '@tpmjs/ui/system/hooks/useScrollReveal';
export interface Category {
id: string;
name: string;
icon: IconName;
colorClass: string;
toolCount: number;
href: string;
}
export interface CategoryGridProps {
categories: Category[];
}
/**
* CategoryGrid Component
*
* Brutalist masonry-style grid with variable heights and random entrance animations.
* Each tile has thick borders, hover effects, and intentional chaos.
*/
export function CategoryGrid({ categories }: CategoryGridProps): React.ReactElement {
// Create random heights for masonry effect (in tailwind classes)
const heights = ['h-32', 'h-40', 'h-36', 'h-44'] as const;
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{categories.map((category, index) => {
const randomHeight = heights[index % heights.length] || 'h-36';
const randomDelay = Math.random() * 400; // 0-400ms random delay
return (
<CategoryTile
key={category.id}
category={category}
height={randomHeight}
delay={randomDelay}
/>
);
})}
</div>
);
}
function CategoryTile({
category,
height,
delay,
}: {
category: Category;
height: string;
delay: number;
}): React.ReactElement {
const { ref, isVisible } = useScrollReveal<HTMLAnchorElement>({
threshold: 0.1,
once: true,
delay,
});
// Random rotation for entrance animation
const randomRotation = (Math.random() - 0.5) * 20; // -10 to +10 degrees
return (
<a
href={category.href}
ref={ref}
className="group block"
style={{
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'rotate(0deg) scale(1)' : `rotate(${randomRotation}deg) scale(0.8)`,
transition: 'all 600ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
>
<Card
variant="brutalist"
padding="none"
className={`${height} relative overflow-hidden cursor-pointer
hover:border-[8px] hover:-translate-y-1 hover:rotate-[-2deg]
active:translate-y-0 active:rotate-0
transition-all duration-200`}
>
<CardContent
className={`${category.colorClass} h-full p-6 flex flex-col justify-between relative`}
>
{/* Background Pattern */}
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage: `
linear-gradient(45deg, currentColor 25%, transparent 25%),
linear-gradient(-45deg, currentColor 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, currentColor 75%),
linear-gradient(-45deg, transparent 75%, currentColor 75%)
`,
backgroundSize: '20px 20px',
backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px',
}}
/>
{/* Content */}
<div className="relative z-10">
<Icon icon={category.icon} size="lg" className="mb-3" />
<h3 className="font-bold text-lg uppercase tracking-tight leading-tight">
{category.name}
</h3>
</div>
{/* Tool Count with Arrow */}
<div className="relative z-10 flex items-center justify-between">
<span className="font-mono text-3xl font-bold tabular-nums">{category.toolCount}</span>
<span className="text-4xl group-hover:translate-x-1 transition-transform duration-200">
▸
</span>
</div>
</CardContent>
</Card>
</a>
);
}

View file

@ -0,0 +1,86 @@
'use client';
import { useEffect, useState } from 'react';
export interface GlitchBarsProps {
/**
* Number of bars to render
* @default 3
*/
count?: number;
/**
* Minimum interval between glitches (ms)
* @default 2000
*/
minInterval?: number;
/**
* Maximum interval between glitches (ms)
* @default 5000
*/
maxInterval?: number;
}
/**
* GlitchBars Component
*
* Renders decorative horizontal bars that randomly glitch/shift position.
* Pure visual element for brutalist aesthetic.
*/
export function GlitchBars({
count = 3,
minInterval = 2000,
maxInterval = 5000,
}: GlitchBarsProps): React.ReactElement {
const [activeBar, setActiveBar] = useState<number | null>(null);
useEffect(() => {
const triggerGlitch = (): void => {
const randomBar = Math.floor(Math.random() * count);
setActiveBar(randomBar);
// Reset after glitch animation completes
setTimeout(() => {
setActiveBar(null);
}, 500);
// Schedule next glitch
const nextInterval = minInterval + Math.random() * (maxInterval - minInterval);
setTimeout(triggerGlitch, nextInterval);
};
const initialDelay = Math.random() * 2000;
const timeout = setTimeout(triggerGlitch, initialDelay);
return () => {
clearTimeout(timeout);
};
}, [count, minInterval, maxInterval]);
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
{Array.from({ length: count }).map((_, index) => {
const top = 20 + index * 25; // Distribute vertically
const width = 100 + index * 50; // Varying widths
const isLeft = index % 2 === 0;
return (
<div
key={`glitch-bar-${index}`}
className={`absolute h-1 ${
index === 0 ? 'bg-brutalist-accent' : 'bg-foreground'
} opacity-${index === 0 ? '80' : '20'} transition-transform duration-500 ease-in-out ${
activeBar === index ? 'animate-glitch' : ''
}`}
style={{
top: `${top}%`,
width: `${width}px`,
[isLeft ? 'left' : 'right']: 0,
}}
/>
);
})}
</div>
);
}

View file

@ -0,0 +1,131 @@
'use client';
import { AnimatedCounter } from '@tpmjs/ui/AnimatedCounter/AnimatedCounter';
import { Button } from '@tpmjs/ui/Button/Button';
import { Input } from '@tpmjs/ui/Input/Input';
import { useParallax } from '@tpmjs/ui/system/hooks/useParallax';
import { useState } from 'react';
export function HeroSection(): React.ReactElement {
const [searchQuery, setSearchQuery] = useState('');
const parallaxStyle = useParallax({ speed: 0.5 });
return (
<section className="relative min-h-[90vh] overflow-hidden bg-background">
{/* Brutalist Grid Background - Static, Not Parallax */}
<div className="absolute inset-0 opacity-[0.03]">
<div
className="absolute inset-0"
style={{
backgroundImage: `
linear-gradient(to right, currentColor 1px, transparent 1px),
linear-gradient(to bottom, currentColor 1px, transparent 1px)
`,
backgroundSize: '40px 40px',
}}
/>
</div>
{/* Content Container with Parallax */}
<div
style={parallaxStyle}
className="relative z-10 flex min-h-[90vh] flex-col justify-center px-6 md:px-12 lg:px-24"
>
{/* Glitch Bars - Decorative Elements */}
<div
className="absolute left-0 top-32 h-1.5 w-64 bg-brutalist-accent opacity-80 animate-glitch"
style={{
animationDelay: '0.5s',
animationIterationCount: 'infinite',
animationDuration: '3s',
}}
/>
<div
className="absolute right-0 top-48 h-1 w-48 bg-foreground opacity-30 animate-glitch"
style={{
animationDelay: '1.5s',
animationIterationCount: 'infinite',
animationDuration: '4s',
}}
/>
{/* Main Heading - Brutalist Typography */}
<div className="max-w-7xl">
<h1 className="brutalist-heading mb-8 leading-[0.85] tracking-tighter">
TOOL
<br />
REGISTRY
<br />
<span className="text-brutalist-accent">FOR AI AGENTS</span>
</h1>
{/* Live Metrics Strip */}
<div className="mb-12 flex flex-wrap items-center gap-3 border-l-[6px] border-brutalist-accent pl-6 font-mono text-sm md:text-base font-bold uppercase tracking-wider">
<div className="flex items-center gap-2">
<AnimatedCounter value={2847} separator="," size="md" />
<span className="text-foreground-secondary">TOOLS</span>
</div>
<span className="text-foreground-tertiary">/</span>
<div className="flex items-center gap-2">
<AnimatedCounter value={12000000} suffix="+" size="md" />
<span className="text-foreground-secondary">INVOCATIONS</span>
</div>
<span className="text-foreground-tertiary">/</span>
<div className="flex items-center gap-2">
<AnimatedCounter value={47} suffix="ms" size="md" />
<span className="text-foreground-secondary">LATENCY</span>
</div>
</div>
{/* Subheading */}
<p className="mb-12 max-w-3xl text-xl md:text-2xl font-medium leading-relaxed text-foreground-secondary tracking-tight">
Discover, share, and integrate tools that give your agents superpowers.
<br />
The registry for AI tools.
</p>
{/* Brutalist Search Interface */}
<div className="max-w-3xl">
<div className="relative">
{/* Command Line Prompt */}
<div className="absolute left-0 top-0 bottom-0 flex items-center pl-6 font-mono text-brutalist-accent text-lg font-bold pointer-events-none">
$
</div>
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="search tools..."
className="brutalist-border h-16 md:h-20 pl-14 pr-4 text-lg md:text-xl font-mono placeholder:text-foreground-tertiary placeholder:uppercase focus:ring-4 focus:ring-brutalist-accent focus:ring-offset-0 bg-background"
style={{ borderRadius: 0 }}
/>
{/* Search Button */}
<Button
size="lg"
className="brutalist-border-thick absolute right-2 top-1/2 -translate-y-1/2 h-12 md:h-14 px-8 bg-brutalist-accent text-background hover:bg-brutalist-accent-hover font-bold uppercase tracking-wider"
style={{ borderRadius: 0, borderColor: 'hsl(var(--foreground))' }}
>
Search
</Button>
</div>
{/* Helper Text */}
<div className="mt-4 flex items-center gap-4 font-mono text-xs text-foreground-tertiary uppercase tracking-wider">
<span>Try: &ldquo;web-scraper&rdquo;, &ldquo;github-manager&rdquo;, &ldquo;sql-query&rdquo;</span>
</div>
</div>
</div>
{/* Scroll Indicator */}
<div className="absolute bottom-12 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 animate-pulse">
<span className="font-mono text-xs uppercase tracking-widest text-foreground-tertiary">
Scroll
</span>
<div className="h-8 w-[2px] bg-brutalist-accent" />
</div>
</div>
</section>
);
}

View file

@ -86,6 +86,26 @@
"./FormField/FormField": {
"types": "./dist/FormField/FormField.d.ts",
"default": "./dist/FormField/FormField.js"
},
"./AnimatedCounter/AnimatedCounter": {
"types": "./dist/AnimatedCounter/AnimatedCounter.d.ts",
"default": "./dist/AnimatedCounter/AnimatedCounter.js"
},
"./StatCard/StatCard": {
"types": "./dist/StatCard/StatCard.d.ts",
"default": "./dist/StatCard/StatCard.js"
},
"./system/hooks/useScrollReveal": {
"types": "./dist/system/hooks/useScrollReveal.d.ts",
"default": "./dist/system/hooks/useScrollReveal.js"
},
"./system/hooks/useCountUp": {
"types": "./dist/system/hooks/useCountUp.d.ts",
"default": "./dist/system/hooks/useCountUp.js"
},
"./system/hooks/useParallax": {
"types": "./dist/system/hooks/useParallax.d.ts",
"default": "./dist/system/hooks/useParallax.js"
}
},
"files": ["dist"],

View file

@ -0,0 +1,113 @@
'use client';
import { cn } from '@tpmjs/utils/cn';
import { forwardRef, useEffect } from 'react';
import { useCountUp } from '../system/hooks/useCountUp';
import { useScrollReveal } from '../system/hooks/useScrollReveal';
import type { AnimatedCounterProps } from './types';
import { animatedCounterVariants } from './variants';
/**
* AnimatedCounter component
*
* Displays an animated number counter with customizable formatting.
* Can start animation on mount or when scrolled into viewport.
*
* @example
* ```tsx
* import { AnimatedCounter } from '@tpmjs/ui/AnimatedCounter/AnimatedCounter';
*
* function Stats() {
* return (
* <div>
* <AnimatedCounter value={2847} suffix=" Tools" size="xl" />
* <AnimatedCounter value={12000000} suffix="+" prefix="" size="lg" />
* </div>
* );
* }
* ```
*/
export const AnimatedCounter = forwardRef<HTMLSpanElement, AnimatedCounterProps>(
(
{
className,
value,
duration = 2000,
decimals = 0,
prefix = '',
suffix = '',
separator = '',
startOn = 'viewport',
easing = 'easeOutExpo',
size = 'md',
mono = true,
...props
},
ref
) => {
const { count, start } = useCountUp({
end: value,
duration,
decimals,
easing,
autoStart: startOn === 'mount',
});
const { ref: scrollRef, isVisible } = useScrollReveal({
threshold: 0.2,
once: true,
});
// Start animation when element becomes visible (if startOn === 'viewport')
useEffect(() => {
if (startOn === 'viewport' && isVisible) {
start();
}
}, [isVisible, start, startOn]);
// Format number with separator if provided
const formatNumber = (num: number): string => {
const numStr = num.toFixed(decimals);
if (!separator) return numStr;
const parts = numStr.split('.');
const intPart = parts[0];
const decPart = parts[1];
const formattedInt = intPart ? intPart.replace(/\B(?=(\d{3})+(?!\d))/g, separator) : '';
return decPart ? `${formattedInt}.${decPart}` : formattedInt;
};
const formattedValue = formatNumber(count);
return (
<span
ref={(node) => {
// Assign to both refs
if (scrollRef) {
(scrollRef as React.MutableRefObject<HTMLSpanElement | null>).current = node;
}
if (typeof ref === 'function') {
ref(node);
} else if (ref) {
ref.current = node;
}
}}
className={cn(
animatedCounterVariants({
size,
mono: mono ? 'true' : 'false',
}),
className
)}
aria-label={`${prefix}${value}${suffix}`}
{...props}
>
{prefix}
{formattedValue}
{suffix}
</span>
);
}
);
AnimatedCounter.displayName = 'AnimatedCounter';

View file

@ -0,0 +1,70 @@
import type { HTMLAttributes } from 'react';
/**
* AnimatedCounter component props
*/
export interface AnimatedCounterProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'> {
/**
* Target number to count to
*/
value: number;
/**
* Duration of the animation in milliseconds
* @default 2000
*/
duration?: number;
/**
* Number of decimal places to display
* @default 0
*/
decimals?: number;
/**
* Prefix string (e.g., "$", "#")
* @default ''
*/
prefix?: string;
/**
* Suffix string (e.g., "K", "M", "%")
* @default ''
*/
suffix?: string;
/**
* Separator for thousands (e.g., ",")
* @default ''
*/
separator?: string;
/**
* Whether to start animation when component mounts or enters viewport
* @default 'viewport'
*/
startOn?: 'mount' | 'viewport';
/**
* Easing function
* @default 'easeOutExpo'
*/
easing?: 'linear' | 'easeOutExpo' | 'easeOutQuad';
/**
* Size variant
* @default 'md'
*/
size?: 'sm' | 'md' | 'lg' | 'xl';
/**
* Whether to use monospace font
* @default true
*/
mono?: boolean;
}
/**
* AnimatedCounter ref type
*/
export type AnimatedCounterRef = HTMLSpanElement;

View file

@ -0,0 +1,34 @@
import { createVariants } from '../system/variants';
/**
* AnimatedCounter variant definitions
*/
export const animatedCounterVariants = createVariants({
base: [
// Display
'inline-block',
// Transitions
'transition-base',
].join(' '),
variants: {
size: {
sm: 'text-sm',
md: 'text-base',
lg: 'text-2xl',
xl: 'text-4xl',
},
mono: {
true: 'font-mono tabular-nums',
false: '',
},
},
compoundVariants: [],
defaultVariants: {
size: 'md',
mono: 'true',
},
});

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';
variant?: 'default' | 'elevated' | 'outline' | 'blueprint' | 'ghost' | 'brutalist';
/**
* Padding size for the card

View file

@ -38,6 +38,17 @@ export const cardVariants = createVariants({
].join(' '),
ghost: ['bg-transparent text-foreground'].join(' '),
brutalist: [
'border-[6px] border-foreground',
'bg-background text-foreground',
'rounded-none',
'hover:shadow-[0_10px_0_0_hsl(var(--brutalist-accent))]',
'hover:-translate-y-1',
'active:shadow-[0_6px_0_0_hsl(var(--brutalist-accent))]',
'active:translate-y-0',
'transition-all duration-200',
].join(' '),
},
padding: {

View file

@ -8,13 +8,23 @@ import { RadioGroup } from './RadioGroup';
describe('Radio', () => {
describe('Rendering', () => {
it('should throw error when not used within RadioGroup', () => {
// Suppress console.error for this test
it('should render with default values when not used within RadioGroup (SSR/hydration compatibility)', () => {
// In development mode, should log a console.error after mounting
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<Radio value="test" />)).toThrow(
'Radio must be used within a RadioGroup'
);
const { container } = render(<Radio value="test" />);
// Should render successfully (for SSR/hydration compatibility)
const radio = container.querySelector('input[type="radio"]');
expect(radio).toBeInTheDocument();
expect(radio).toHaveAttribute('value', 'test');
// In development, should have logged an error after mount
if (process.env.NODE_ENV !== 'production') {
expect(spy).toHaveBeenCalledWith(
expect.stringContaining('Radio must be used within a RadioGroup')
);
}
spy.mockRestore();
});

View file

@ -1,5 +1,5 @@
import { cn } from '@tpmjs/utils/cn';
import { createContext, useContext, useEffect } from 'react';
import { createContext, useContext } from 'react';
import { useControlled } from '../system/useControlled';
import type { RadioGroupContextValue, RadioGroupProps } from './types';
import { radioGroupVariants } from './variants';

View file

@ -0,0 +1,130 @@
'use client';
import { cn } from '@tpmjs/utils/cn';
import { forwardRef } from 'react';
import { AnimatedCounter } from '../AnimatedCounter/AnimatedCounter';
import { useScrollReveal } from '../system/hooks/useScrollReveal';
import type { StatCardProps } from './types';
import {
statCardVariants,
statLabelVariants,
statSubtextVariants,
statValueVariants,
} from './variants';
/**
* StatCard component
*
* Displays a statistic with animated counter and optional bar chart.
* Perfect for brutalist dashboards and metrics display.
*
* @example
* ```tsx
* import { StatCard } from '@tpmjs/ui/StatCard/StatCard';
*
* function Dashboard() {
* return (
* <div className="grid grid-cols-4 gap-4">
* <StatCard
* value={2847}
* label="Published Tools"
* subtext="Across 24 categories"
* variant="brutalist"
* showBar
* barProgress={85}
* />
* <StatCard
* value={48000}
* suffix="+"
* label="Active Developers"
* variant="default"
* />
* </div>
* );
* }
* ```
*/
export const StatCard = forwardRef<HTMLDivElement, StatCardProps>(
(
{
className,
value,
label,
subtext,
prefix = '',
suffix = '',
separator = ',',
variant = 'default',
size = 'md',
showBar = false,
barProgress = 80,
...props
},
ref
) => {
const { ref: scrollRef, isVisible } = useScrollReveal({
threshold: 0.2,
once: true,
});
return (
<div
ref={(node) => {
// Assign to both refs
if (scrollRef) {
(scrollRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
}
if (typeof ref === 'function') {
ref(node);
} else if (ref) {
ref.current = node;
}
}}
className={cn(
statCardVariants({
variant,
size,
}),
className
)}
{...props}
>
{/* Value */}
<div className={statValueVariants({ size })}>
<AnimatedCounter
value={value}
prefix={prefix}
suffix={suffix}
separator={separator}
duration={2000}
startOn="viewport"
easing="easeOutExpo"
/>
</div>
{/* Label */}
<div className={cn(statLabelVariants({ size }), 'mt-2')}>{label}</div>
{/* Subtext */}
{subtext && <div className={cn(statSubtextVariants({ size }), 'mt-1')}>{subtext}</div>}
{/* Optional Bar Chart */}
{showBar && (
<div className="mt-4 h-2 w-full bg-muted overflow-hidden">
<div
className={cn(
'h-full bg-brutalist-accent transition-all duration-1000 ease-out',
isVisible ? 'w-full' : 'w-0'
)}
style={{
width: isVisible ? `${barProgress}%` : '0%',
}}
/>
</div>
)}
</div>
);
}
);
StatCard.displayName = 'StatCard';

View file

@ -0,0 +1,69 @@
import type { HTMLAttributes } from 'react';
/**
* StatCard component props
*/
export interface StatCardProps extends HTMLAttributes<HTMLDivElement> {
/**
* The statistic value (number)
*/
value: number;
/**
* Label describing the statistic
*/
label: string;
/**
* Optional subtext/description
*/
subtext?: string;
/**
* Value prefix (e.g., "$", "#")
* @default ''
*/
prefix?: string;
/**
* Value suffix (e.g., "K", "M", "%", "+")
* @default ''
*/
suffix?: string;
/**
* Thousands separator
* @default ','
*/
separator?: string;
/**
* Visual variant
* @default 'default'
*/
variant?: 'default' | 'brutalist' | 'minimal';
/**
* Size variant
* @default 'md'
*/
size?: 'sm' | 'md' | 'lg';
/**
* Whether to show an animated bar chart
* @default false
*/
showBar?: boolean;
/**
* Bar fill percentage (0-100)
* Only used if showBar is true
* @default 80
*/
barProgress?: number;
}
/**
* StatCard ref type
*/
export type StatCardRef = HTMLDivElement;

View file

@ -0,0 +1,108 @@
import { createVariants } from '../system/variants';
/**
* StatCard variant definitions
*/
export const statCardVariants = createVariants({
base: [
// Layout
'flex flex-col',
// Transitions
'transition-base',
].join(' '),
variants: {
variant: {
default: [
'border border-dotted border-border',
'bg-card text-card-foreground',
'rounded-lg',
'shadow-sm',
].join(' '),
brutalist: [
'border-[6px] border-foreground',
'bg-background text-foreground',
'rounded-none',
].join(' '),
minimal: ['bg-transparent text-foreground'].join(' '),
},
size: {
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
},
},
compoundVariants: [],
defaultVariants: {
variant: 'default',
size: 'md',
},
});
/**
* Stat value variants
*/
export const statValueVariants = createVariants({
base: ['font-mono font-bold tabular-nums'].join(' '),
variants: {
size: {
sm: 'text-3xl',
md: 'text-4xl md:text-5xl',
lg: 'text-5xl md:text-6xl',
},
},
compoundVariants: [],
defaultVariants: {
size: 'md',
},
});
/**
* Stat label variants
*/
export const statLabelVariants = createVariants({
base: ['font-semibold uppercase tracking-wide'].join(' '),
variants: {
size: {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
},
},
compoundVariants: [],
defaultVariants: {
size: 'md',
},
});
/**
* Stat subtext variants
*/
export const statSubtextVariants = createVariants({
base: ['text-foreground-tertiary'].join(' '),
variants: {
size: {
sm: 'text-xs',
md: 'text-xs',
lg: 'text-sm',
},
},
compoundVariants: [],
defaultVariants: {
size: 'md',
},
});

View file

@ -0,0 +1,137 @@
'use client';
import { useEffect, useState } from 'react';
export interface UseCountUpOptions {
/**
* Starting value
* @default 0
*/
start?: number;
/**
* Ending value (target)
*/
end: number;
/**
* Duration of the animation in milliseconds
* @default 2000
*/
duration?: number;
/**
* Whether to start the animation immediately
* @default false
*/
autoStart?: boolean;
/**
* Easing function for the animation
* @default 'easeOutExpo'
*/
easing?: 'linear' | 'easeOutExpo' | 'easeOutQuad';
/**
* Number of decimal places
* @default 0
*/
decimals?: number;
}
// Easing functions
const easingFunctions = {
linear: (t: number): number => t,
easeOutExpo: (t: number): number => (t === 1 ? 1 : 1 - 2 ** (-10 * t)),
easeOutQuad: (t: number): number => t * (2 - t),
};
/**
* useCountUp Hook
*
* Animates a number from start to end using requestAnimationFrame.
* Perfect for brutalist counter animations with precise control.
*
* @example
* ```tsx
* function Counter() {
* const { count, start } = useCountUp({ end: 1000, duration: 2000 });
*
* useEffect(() => {
* start();
* }, [start]);
*
* return <div className="text-6xl font-mono">{Math.floor(count)}</div>;
* }
* ```
*/
export function useCountUp(options: UseCountUpOptions): {
count: number;
start: () => void;
reset: () => void;
isAnimating: boolean;
} {
const {
start: startValue = 0,
end,
duration = 2000,
autoStart = false,
easing = 'easeOutExpo',
decimals = 0,
} = options;
const [count, setCount] = useState(startValue);
const [isAnimating, setIsAnimating] = useState(false);
const animate = (): void => {
const startTime = Date.now();
const endTime = startTime + duration;
const range = end - startValue;
const easingFn = easingFunctions[easing];
const updateCount = (): void => {
const now = Date.now();
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = easingFn(progress);
const currentCount = startValue + range * easedProgress;
setCount(Number(currentCount.toFixed(decimals)));
if (now < endTime) {
requestAnimationFrame(updateCount);
} else {
setCount(Number(end.toFixed(decimals)));
setIsAnimating(false);
}
};
setIsAnimating(true);
requestAnimationFrame(updateCount);
};
const start = (): void => {
if (!isAnimating) {
animate();
}
};
const reset = (): void => {
setCount(startValue);
setIsAnimating(false);
};
useEffect(() => {
if (autoStart) {
start();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoStart]);
return {
count,
start,
reset,
isAnimating,
};
}

View file

@ -0,0 +1,83 @@
'use client';
import { useEffect, useState } from 'react';
export interface UseParallaxOptions {
/**
* Speed multiplier for parallax effect
* - 1 = normal scroll speed
* - 0.5 = half scroll speed (slower, moves less)
* - 2 = double scroll speed (faster, moves more)
* @default 0.5
*/
speed?: number;
/**
* Whether to enable the parallax effect
* @default true
*/
enabled?: boolean;
}
/**
* useParallax Hook
*
* Creates a parallax scroll effect by tracking scroll position and applying
* a transform based on the speed multiplier. Optimized with requestAnimationFrame.
*
* @example
* ```tsx
* function HeroSection() {
* const parallaxStyle = useParallax({ speed: 0.5 });
*
* return (
* <div style={parallaxStyle} className="hero">
* Content moves slower than scroll
* </div>
* );
* }
* ```
*/
export function useParallax(options: UseParallaxOptions = {}): React.CSSProperties {
const { speed = 0.5, enabled = true } = options;
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
if (!enabled) return;
let ticking = false;
const handleScroll = (): void => {
if (!ticking) {
requestAnimationFrame(() => {
setScrollY(window.scrollY);
ticking = false;
});
ticking = true;
}
};
// Initial scroll position
setScrollY(window.scrollY);
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [enabled]);
if (!enabled) {
return {};
}
// Calculate transform based on scroll position and speed
// Negative speed values create reverse parallax
const translateY = scrollY * (1 - speed);
return {
transform: `translateY(${translateY}px)`,
willChange: 'transform',
};
}

View file

@ -0,0 +1,113 @@
'use client';
import { useEffect, useRef, useState } from 'react';
export interface UseScrollRevealOptions {
/**
* Threshold for Intersection Observer (0-1)
* @default 0.2
*/
threshold?: number;
/**
* Root margin for Intersection Observer
* @default '-100px'
*/
rootMargin?: string;
/**
* Whether to trigger animation only once
* @default true
*/
once?: boolean;
/**
* Delay before animation starts (ms)
* @default 0
*/
delay?: number;
}
/**
* useScrollReveal Hook
*
* Triggers animations when an element enters the viewport using Intersection Observer.
* Returns a ref to attach to the element and a boolean indicating visibility.
*
* @example
* ```tsx
* function MyComponent() {
* const { ref, isVisible } = useScrollReveal({ threshold: 0.3 });
*
* return (
* <div
* ref={ref}
* className={isVisible ? 'animate-brutalist-entrance' : 'opacity-0'}
* >
* Content
* </div>
* );
* }
* ```
*/
export function useScrollReveal<T extends HTMLElement>(
options: UseScrollRevealOptions = {}
): {
ref: React.RefObject<T | null>;
isVisible: boolean;
} {
const { threshold = 0.2, rootMargin = '-100px', once = true, delay = 0 } = options;
const ref = useRef<T>(null);
const [isVisible, setIsVisible] = useState(false);
const [hasAnimated, setHasAnimated] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
// If animation has already happened and once is true, don't re-observe
if (once && hasAnimated) return;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry && entry.isIntersecting) {
if (delay > 0) {
setTimeout(() => {
setIsVisible(true);
if (once) {
setHasAnimated(true);
}
}, delay);
} else {
setIsVisible(true);
if (once) {
setHasAnimated(true);
}
}
// Disconnect if once is true
if (once) {
observer.disconnect();
}
} else if (!once) {
// Reset visibility if not once
setIsVisible(false);
}
},
{
threshold,
rootMargin,
}
);
observer.observe(element);
return () => {
observer.disconnect();
};
}, [threshold, rootMargin, once, delay, hasAnimated]);
return { ref, isVisible };
}

View file

@ -64,6 +64,7 @@ export function useControlled<T>({
}
// Callback to update the value
// eslint-disable-next-line react-hooks/exhaustive-deps -- isControlled is a ref value and never changes
const setValueIfUncontrolled = useCallback((newValue: T) => {
if (!isControlled) {
setValue(newValue);

View file

@ -33,6 +33,11 @@ const entries = allFiles.filter((file) => {
// Manually add RadioGroup which doesn't match the folder/file naming convention
entries.push('src/Radio/RadioGroup.tsx');
// Manually add system hooks
entries.push('src/system/hooks/useScrollReveal.ts');
entries.push('src/system/hooks/useCountUp.ts');
entries.push('src/system/hooks/useParallax.ts');
export default defineConfig({
entry: entries,
format: ['esm'],
@ -44,12 +49,17 @@ export default defineConfig({
},
},
clean: true,
treeshake: true,
treeshake: false, // Disable treeshaking to preserve 'use client'
splitting: false,
external: ['react', 'react-dom'],
banner: {
js: '"use client";',
},
esbuildOptions(options) {
options.jsx = 'automatic';
// Try to preserve directives
options.legalComments = 'inline';
},
// Reduce bundle size by minifying in production
minify: process.env.NODE_ENV === 'production',
minify: false, // Disable minification to preserve directives
});