feat: implement revolutionary dithered landing page with Bayer matrix animation
Core Dithering System: - Add Bayer matrix 8×8/4×4 ordered dithering algorithm - Implement DitherEngine with canvas-based text rendering - Create reveal (one-shot) and pulse (continuous) animation modes - Add SSR safety checks for Next.js server-side rendering New UI Components: - DitherCanvas: Base component with RAF-based animation - DitherHeadline: Multi-line staggered reveal for hero text - DitherSectionHeader: Pulsing section headers - FlowDiagram: Animated SVG showing agent→registry→tools flow - ActivityStream: Live ticker with mock tool activity Accessibility Features: - useReducedMotion hook for prefers-reduced-motion support - Screen reader compatibility with aria-labels - Fallback to static rendering when animations disabled Homepage Redesign: - Replace generic sections with narrative storytelling - Add ProblemSection: Fragmented chaos design (before tpmjs) - Add VisionSection: Dynamic ecosystem visualization (after tpmjs) - Redesign EcosystemStats with dithered counters and live activity - Add DeveloperStories: Code-first testimonials - Update HeroSection with dithered headline and blueprint scanline Visual Design: - Minimalist + Blueprint technical aesthetic - Animated scanline background effect - Grid patterns with subtle opacity - Brutalist typography and hard edges Technical Implementation: - Export new components in UI package.json - Add components to tsup.config.ts entry points - SSR-safe canvas operations with environment checks - Performance optimizations: frame caching, mobile detection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a6dfde3383
commit
5e92ae49a3
23 changed files with 1636 additions and 97 deletions
|
|
@ -449,6 +449,47 @@
|
|||
animation-delay: 400ms;
|
||||
}
|
||||
|
||||
/* Blueprint Scanline Animation */
|
||||
@keyframes scanline {
|
||||
0% {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(100vh);
|
||||
}
|
||||
}
|
||||
|
||||
.blueprint-scanline {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
hsl(var(--brutalist-accent) / 0.3),
|
||||
transparent
|
||||
);
|
||||
animation: scanline 30s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Slide Down Animation (for Activity Stream) */
|
||||
@keyframes slide-down {
|
||||
from {
|
||||
transform: translateY(-10px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-down {
|
||||
animation: slide-down 300ms ease-out forwards;
|
||||
}
|
||||
|
||||
/* Motion Preferences - Accessibility */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Card, CardContent } from '@tpmjs/ui/Card/Card';
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import { Header } from '@tpmjs/ui/Header/Header';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { StatCard } from '@tpmjs/ui/StatCard/StatCard';
|
||||
import Link from 'next/link';
|
||||
import { ThemeToggle } from '../components/ThemeToggle';
|
||||
import { CategoryGrid } from '../components/home/CategoryGrid';
|
||||
import { DeveloperStories } from '../components/home/DeveloperStories';
|
||||
import { EcosystemStats } from '../components/home/EcosystemStats';
|
||||
import { HeroSection } from '../components/home/HeroSection';
|
||||
import { categories, featuredTools, statistics } from '../data/homePageData';
|
||||
import { ProblemSection } from '../components/home/ProblemSection';
|
||||
import { VisionSection } from '../components/home/VisionSection';
|
||||
|
||||
export default function HomePage(): React.ReactElement {
|
||||
return (
|
||||
|
|
@ -55,93 +53,20 @@ export default function HomePage(): React.ReactElement {
|
|||
/>
|
||||
|
||||
<main className="flex-1">
|
||||
{/* Hero Section - Brutalist Design */}
|
||||
{/* Hero Section - Dithered Design */}
|
||||
<HeroSection />
|
||||
|
||||
{/* 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-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, 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-brutalist-accent" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<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-sm font-mono font-bold text-foreground">
|
||||
{tool.weeklyUsage}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
{/* Problem Section - The fragmented world before tpmjs */}
|
||||
<ProblemSection />
|
||||
|
||||
{/* Categories Section - Brutalist Masonry Grid */}
|
||||
<section className="py-16 md:py-24 bg-surface">
|
||||
<Container size="xl" padding="lg">
|
||||
<h2 className="brutalist-subheading mb-12">Browse by Category</h2>
|
||||
<CategoryGrid categories={categories} />
|
||||
</Container>
|
||||
</section>
|
||||
{/* Vision Section - The dynamic ecosystem with tpmjs */}
|
||||
<VisionSection />
|
||||
|
||||
{/* 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" />
|
||||
{/* Ecosystem Stats - Live metrics with dithered counters */}
|
||||
<EcosystemStats />
|
||||
|
||||
<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, 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>
|
||||
{/* Developer Stories - Code-first testimonials */}
|
||||
<DeveloperStories />
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
|
|
|
|||
72
apps/web/src/components/home/DeveloperStories.tsx
Normal file
72
apps/web/src/components/home/DeveloperStories.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* DeveloperStories Component
|
||||
*
|
||||
* Code-first testimonials showing what builders have created with tpmjs.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import { DitherSectionHeader } from '@tpmjs/ui/DitherText/DitherSectionHeader';
|
||||
import { developerStories } from '../../data/homePageData';
|
||||
|
||||
export function DeveloperStories(): React.ReactElement {
|
||||
return (
|
||||
<section className="py-16 md:py-24 bg-background relative">
|
||||
{/* Blueprint grid */}
|
||||
<div className="absolute inset-0 opacity-[0.02]">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, currentColor 1px, transparent 1px),
|
||||
linear-gradient(to bottom, currentColor 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '24px 24px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Container size="xl" padding="lg" className="relative z-10">
|
||||
<DitherSectionHeader className="mb-12 text-center">BUILT WITH TPMJS</DitherSectionHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{developerStories.map((story, index) => {
|
||||
return (
|
||||
<div
|
||||
key={story.author}
|
||||
className={`opacity-0 animate-brutalist-entrance stagger-${(index % 5) + 1}`}
|
||||
>
|
||||
<div className="border-l-4 border-brutalist-accent pl-6">
|
||||
{/* Code snippet */}
|
||||
<CodeBlock
|
||||
code={story.code}
|
||||
language="typescript"
|
||||
size="md"
|
||||
showCopy={false}
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
{/* Impact quote */}
|
||||
<blockquote className="mb-4">
|
||||
<p className="text-lg font-medium text-foreground leading-relaxed mb-2">
|
||||
“{story.quote}”
|
||||
</p>
|
||||
<footer className="text-sm text-foreground-secondary">
|
||||
<cite className="not-italic">
|
||||
<span className="font-bold">{story.author}</span>
|
||||
{', '}
|
||||
<span className="text-brutalist-accent">{story.company}</span>
|
||||
</cite>
|
||||
</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
58
apps/web/src/components/home/EcosystemStats.tsx
Normal file
58
apps/web/src/components/home/EcosystemStats.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* EcosystemStats Component
|
||||
*
|
||||
* Redesigned statistics section with dithered numbers and live activity stream.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { ActivityStream } from '@tpmjs/ui/ActivityStream/ActivityStream';
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import { DitherSectionHeader } from '@tpmjs/ui/DitherText/DitherSectionHeader';
|
||||
import { StatCard } from '@tpmjs/ui/StatCard/StatCard';
|
||||
import { statistics } from '../../data/homePageData';
|
||||
|
||||
export function EcosystemStats(): React.ReactElement {
|
||||
return (
|
||||
<section className="py-16 md:py-24 bg-surface 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">
|
||||
<DitherSectionHeader className="mb-12 text-center">LIVE ECOSYSTEM</DitherSectionHeader>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
||||
{statistics.map((stat, index) => {
|
||||
// Extract number from value string
|
||||
const numValue = Number.parseInt(stat.value.replace(/[^0-9]/g, ''), 10) || 0;
|
||||
const suffix = stat.value.replace(/[0-9,]/g, '');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stat.label}
|
||||
className={`opacity-0 animate-brutalist-entrance stagger-${index + 1}`}
|
||||
>
|
||||
<StatCard
|
||||
value={numValue}
|
||||
label={stat.label}
|
||||
subtext={stat.subtext}
|
||||
suffix={suffix}
|
||||
variant="brutalist"
|
||||
size="md"
|
||||
showBar={true}
|
||||
barProgress={60 + index * 10}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Activity Stream */}
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<ActivityStream updateInterval={6000} maxItems={5} />
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { AnimatedCounter } from '@tpmjs/ui/AnimatedCounter/AnimatedCounter';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { DitherHeadline } from '@tpmjs/ui/DitherText/DitherHeadline';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import { useParallax } from '@tpmjs/ui/system/hooks/useParallax';
|
||||
import { useState } from 'react';
|
||||
|
|
@ -12,7 +13,7 @@ export function HeroSection(): React.ReactElement {
|
|||
|
||||
return (
|
||||
<section className="relative min-h-[90vh] overflow-hidden bg-background">
|
||||
{/* Brutalist Grid Background - Static, Not Parallax */}
|
||||
{/* Blueprint Grid Background - Static with Scanline */}
|
||||
<div className="absolute inset-0 opacity-[0.03]">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
|
|
@ -26,6 +27,9 @@ export function HeroSection(): React.ReactElement {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Animated Scanline */}
|
||||
<div className="blueprint-scanline" />
|
||||
|
||||
{/* Content Container with Parallax */}
|
||||
<div
|
||||
style={parallaxStyle}
|
||||
|
|
@ -49,15 +53,18 @@ export function HeroSection(): React.ReactElement {
|
|||
}}
|
||||
/>
|
||||
|
||||
{/* Main Heading - Brutalist Typography */}
|
||||
{/* Main Heading - Dithered 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>
|
||||
<DitherHeadline
|
||||
delay={500}
|
||||
stagger={150}
|
||||
speed={1500}
|
||||
fontSize="clamp(60px, 12vw, 120px)"
|
||||
fontWeight="800"
|
||||
className="mb-8"
|
||||
>
|
||||
TOOL REGISTRY FOR AI AGENTS
|
||||
</DitherHeadline>
|
||||
|
||||
{/* 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-base md:text-lg font-bold uppercase tracking-wider">
|
||||
|
|
|
|||
72
apps/web/src/components/home/ProblemSection.tsx
Normal file
72
apps/web/src/components/home/ProblemSection.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* ProblemSection Component
|
||||
*
|
||||
* Shows the fragmented, static world before tpmjs.
|
||||
* Cards are rotated randomly to create visual chaos.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@tpmjs/ui/Card/Card';
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import { DitherSectionHeader } from '@tpmjs/ui/DitherText/DitherSectionHeader';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { problemPoints } from '../../data/homePageData';
|
||||
|
||||
export function ProblemSection(): React.ReactElement {
|
||||
return (
|
||||
<section className="py-16 md:py-24 bg-surface relative">
|
||||
{/* Subtle grid background */}
|
||||
<div className="absolute inset-0 opacity-[0.02] grid-background" />
|
||||
|
||||
<Container size="xl" padding="lg" className="relative z-10">
|
||||
<DitherSectionHeader className="mb-12 text-center">THE PROBLEM</DitherSectionHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{problemPoints.map((problem, index) => {
|
||||
// Random rotation for chaos
|
||||
const rotation = (index % 2 === 0 ? 1 : -1) * (Math.random() * 5 + 2);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={problem.title}
|
||||
className={`opacity-0 animate-brutalist-entrance stagger-${(index % 5) + 1}`}
|
||||
style={{
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transition: 'transform 200ms ease-out',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
variant="brutalist"
|
||||
className="h-full bg-surface border-2 border-foreground/30 hover:border-red-500"
|
||||
style={{ borderRadius: 0 }}
|
||||
>
|
||||
<CardContent className="p-6 space-y-4 h-full flex flex-col">
|
||||
<Icon icon={problem.icon} size="lg" className="text-red-500" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-bold uppercase tracking-tight mb-3 text-foreground">
|
||||
{problem.title}
|
||||
</h3>
|
||||
<p className="text-sm text-foreground-secondary leading-relaxed">
|
||||
{problem.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dashed line to show disconnection */}
|
||||
{index < problemPoints.length - 1 && (
|
||||
<div className="hidden lg:block absolute -right-4 top-1/2 w-8 h-0.5 border-t-2 border-dashed border-foreground/20" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center font-mono text-sm text-foreground-secondary uppercase tracking-wider">
|
||||
Agents stuck with static, manually-configured toolsets
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
71
apps/web/src/components/home/VisionSection.tsx
Normal file
71
apps/web/src/components/home/VisionSection.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* VisionSection Component
|
||||
*
|
||||
* Shows the dynamic, connected ecosystem enabled by tpmjs.
|
||||
* Features animated flow diagram showing semantic discovery.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import { DitherSectionHeader } from '@tpmjs/ui/DitherText/DitherSectionHeader';
|
||||
import { FlowDiagram } from '@tpmjs/ui/FlowDiagram/FlowDiagram';
|
||||
|
||||
export function VisionSection(): React.ReactElement {
|
||||
return (
|
||||
<section className="py-16 md:py-24 bg-background relative">
|
||||
{/* Blueprint grid with subtle pulse */}
|
||||
<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: '24px 24px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Container size="xl" padding="lg" className="relative z-10">
|
||||
<DitherSectionHeader className="mb-12 text-center">THE VISION</DitherSectionHeader>
|
||||
|
||||
<div className="opacity-0 animate-brutalist-entrance stagger-1 max-w-4xl mx-auto">
|
||||
<FlowDiagram className="mb-12" />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mt-16">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold font-mono text-brutalist-accent mb-2">Dynamic</div>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Agents discover tools on-demand through semantic search
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold font-mono text-brutalist-accent mb-2">
|
||||
Scalable
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Thousands of tools available without manual integration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold font-mono text-brutalist-accent mb-2">
|
||||
Evolving
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
New capabilities emerge as the community publishes tools
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center font-mono text-sm text-brutalist-accent uppercase tracking-wider">
|
||||
The registry that gives agents superpowers
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -220,3 +220,71 @@ export const statistics: Statistic[] = [
|
|||
subtext: '95th percentile latency',
|
||||
},
|
||||
];
|
||||
|
||||
export interface ProblemPoint {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: IconName;
|
||||
}
|
||||
|
||||
export const problemPoints: ProblemPoint[] = [
|
||||
{
|
||||
title: 'Manual Configuration',
|
||||
description:
|
||||
'Agents require hard-coded tool imports and manual config files for every capability',
|
||||
icon: 'copy',
|
||||
},
|
||||
{
|
||||
title: 'No Discovery',
|
||||
description: 'Tools must be known ahead of time—no way to find new capabilities on-demand',
|
||||
icon: 'check',
|
||||
},
|
||||
{
|
||||
title: 'Version Hell',
|
||||
description: 'Breaking changes force manual updates across every agent using a tool',
|
||||
icon: 'chevronDown',
|
||||
},
|
||||
{
|
||||
title: 'Limited Capabilities',
|
||||
description: 'Static toolsets restrict agents to predetermined functions—no room to grow',
|
||||
icon: 'externalLink',
|
||||
},
|
||||
];
|
||||
|
||||
export interface DeveloperStory {
|
||||
code: string;
|
||||
quote: string;
|
||||
author: string;
|
||||
company: string;
|
||||
}
|
||||
|
||||
export const developerStories: DeveloperStory[] = [
|
||||
{
|
||||
code: `const agent = new Agent({
|
||||
tools: await tpmjs.search({
|
||||
query: "email, slack, calendar"
|
||||
})
|
||||
})`,
|
||||
quote: 'Reduced config from 500 lines to 3',
|
||||
author: 'Sarah Chen',
|
||||
company: 'Support.ai',
|
||||
},
|
||||
{
|
||||
code: `// Agents find tools semantically
|
||||
const tools = await tpmjs.search({
|
||||
query: "parse PDF invoices",
|
||||
limit: 5
|
||||
})`,
|
||||
quote: 'Our agents adapt to new doc formats instantly',
|
||||
author: 'Marcus Rodriguez',
|
||||
company: 'DocFlow',
|
||||
},
|
||||
{
|
||||
code: `// No more version conflicts
|
||||
agent.loadTool("sql-query@latest")
|
||||
// Always get the newest, tested version`,
|
||||
quote: 'Eliminated 80% of our integration bugs',
|
||||
author: 'Priya Patel',
|
||||
company: 'DataSync Pro',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -106,6 +106,34 @@
|
|||
"./system/hooks/useParallax": {
|
||||
"types": "./dist/system/hooks/useParallax.d.ts",
|
||||
"default": "./dist/system/hooks/useParallax.js"
|
||||
},
|
||||
"./system/hooks/useReducedMotion": {
|
||||
"types": "./dist/system/hooks/useReducedMotion.d.ts",
|
||||
"default": "./dist/system/hooks/useReducedMotion.js"
|
||||
},
|
||||
"./system/hooks/useDitherAnimation": {
|
||||
"types": "./dist/system/hooks/useDitherAnimation.d.ts",
|
||||
"default": "./dist/system/hooks/useDitherAnimation.js"
|
||||
},
|
||||
"./DitherCanvas/DitherCanvas": {
|
||||
"types": "./dist/DitherCanvas/DitherCanvas.d.ts",
|
||||
"default": "./dist/DitherCanvas/DitherCanvas.js"
|
||||
},
|
||||
"./DitherText/DitherHeadline": {
|
||||
"types": "./dist/DitherText/DitherHeadline.d.ts",
|
||||
"default": "./dist/DitherText/DitherHeadline.js"
|
||||
},
|
||||
"./DitherText/DitherSectionHeader": {
|
||||
"types": "./dist/DitherText/DitherSectionHeader.d.ts",
|
||||
"default": "./dist/DitherText/DitherSectionHeader.js"
|
||||
},
|
||||
"./FlowDiagram/FlowDiagram": {
|
||||
"types": "./dist/FlowDiagram/FlowDiagram.d.ts",
|
||||
"default": "./dist/FlowDiagram/FlowDiagram.js"
|
||||
},
|
||||
"./ActivityStream/ActivityStream": {
|
||||
"types": "./dist/ActivityStream/ActivityStream.d.ts",
|
||||
"default": "./dist/ActivityStream/ActivityStream.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
|
|
|
|||
126
packages/ui/src/ActivityStream/ActivityStream.tsx
Normal file
126
packages/ui/src/ActivityStream/ActivityStream.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* ActivityStream Component
|
||||
*
|
||||
* Live ticker showing recent tool activity (invocations, publications, updates).
|
||||
* Uses mock data to simulate a live ecosystem.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ActivityItem, ActivityStreamProps } from './types';
|
||||
|
||||
// Mock data generator
|
||||
const mockTools = [
|
||||
'web-scraper',
|
||||
'sql-query',
|
||||
'email-sender',
|
||||
'github-manager',
|
||||
'image-processor',
|
||||
'pdf-generator',
|
||||
'slack-bot',
|
||||
'calendar-sync',
|
||||
'data-validator',
|
||||
'file-converter',
|
||||
];
|
||||
|
||||
const activityTypes: ActivityItem['type'][] = ['invoked', 'published', 'updated'];
|
||||
|
||||
function generateMockActivity(): ActivityItem {
|
||||
const type = activityTypes[Math.floor(Math.random() * activityTypes.length)] || 'invoked';
|
||||
const tool = mockTools[Math.floor(Math.random() * mockTools.length)] || 'unknown-tool';
|
||||
const seconds = Math.floor(Math.random() * 30) + 1;
|
||||
|
||||
return {
|
||||
type,
|
||||
tool,
|
||||
time: `${seconds}s ago`,
|
||||
};
|
||||
}
|
||||
|
||||
function getActivityIcon(type: ActivityItem['type']): string {
|
||||
switch (type) {
|
||||
case 'invoked':
|
||||
return '▸';
|
||||
case 'published':
|
||||
return '+';
|
||||
case 'updated':
|
||||
return '↻';
|
||||
default:
|
||||
return '•';
|
||||
}
|
||||
}
|
||||
|
||||
function getActivityColor(type: ActivityItem['type']): string {
|
||||
switch (type) {
|
||||
case 'invoked':
|
||||
return 'text-brutalist-accent';
|
||||
case 'published':
|
||||
return 'text-green-500';
|
||||
case 'updated':
|
||||
return 'text-yellow-500';
|
||||
default:
|
||||
return 'text-foreground-secondary';
|
||||
}
|
||||
}
|
||||
|
||||
export function ActivityStream({
|
||||
activities: initialActivities,
|
||||
updateInterval = 6000,
|
||||
maxItems = 5,
|
||||
className = '',
|
||||
}: ActivityStreamProps): React.ReactElement {
|
||||
// Initialize with provided activities or generate mock data
|
||||
const [activities, setActivities] = useState<ActivityItem[]>(
|
||||
initialActivities || Array.from({ length: maxItems }, generateMockActivity)
|
||||
);
|
||||
|
||||
// Auto-update with new activities
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const newActivity = generateMockActivity();
|
||||
|
||||
setActivities((prev) => {
|
||||
const updated = [newActivity, ...prev];
|
||||
return updated.slice(0, maxItems);
|
||||
});
|
||||
}, updateInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [updateInterval, maxItems]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-surface border-2 border-foreground p-6 ${className}`}
|
||||
style={{ borderRadius: 0 }}
|
||||
>
|
||||
<h3 className="text-sm font-mono font-bold uppercase tracking-wider text-foreground-secondary mb-4">
|
||||
Recent Activity
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2 font-mono text-sm">
|
||||
{activities.map((activity, index) => (
|
||||
<div
|
||||
key={`${activity.tool}-${activity.time}-${index}`}
|
||||
className="flex items-center gap-3 animate-slide-down"
|
||||
style={{
|
||||
animationDuration: '300ms',
|
||||
animationFillMode: 'backwards',
|
||||
}}
|
||||
>
|
||||
<span className={`${getActivityColor(activity.type)} font-bold w-4`}>
|
||||
{getActivityIcon(activity.type)}
|
||||
</span>
|
||||
|
||||
<span className="text-foreground flex-1">
|
||||
<span className="font-bold">{activity.tool}</span>
|
||||
<span className="text-foreground-secondary"> {activity.type}</span>
|
||||
</span>
|
||||
|
||||
<span className="text-foreground-tertiary text-xs">{activity.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
packages/ui/src/ActivityStream/types.ts
Normal file
28
packages/ui/src/ActivityStream/types.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Type definitions for ActivityStream component
|
||||
*/
|
||||
|
||||
export interface ActivityItem {
|
||||
/** Activity type */
|
||||
type: 'invoked' | 'published' | 'updated';
|
||||
|
||||
/** Tool name */
|
||||
tool: string;
|
||||
|
||||
/** Time ago string (e.g., "2s ago") */
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface ActivityStreamProps {
|
||||
/** Initial activity items */
|
||||
activities?: ActivityItem[];
|
||||
|
||||
/** Update interval in milliseconds (default: 6000) */
|
||||
updateInterval?: number;
|
||||
|
||||
/** Maximum number of items to display (default: 5) */
|
||||
maxItems?: number;
|
||||
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
119
packages/ui/src/DitherCanvas/DitherCanvas.tsx
Normal file
119
packages/ui/src/DitherCanvas/DitherCanvas.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* DitherCanvas Component
|
||||
*
|
||||
* Renders text with Bayer dithering effect using HTML5 Canvas.
|
||||
* Supports reveal, pulse, and static modes with full accessibility.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { getDitherEngine } from '../system/canvas/DitherEngine';
|
||||
import { useDitherAnimation } from '../system/hooks/useDitherAnimation';
|
||||
import { useReducedMotion } from '../system/hooks/useReducedMotion';
|
||||
import type { DitherCanvasProps } from './types';
|
||||
|
||||
export function DitherCanvas({
|
||||
text,
|
||||
mode,
|
||||
speed = 2000,
|
||||
threshold = 128,
|
||||
font = 'bold 120px Space Grotesk',
|
||||
color = 'currentColor',
|
||||
className = '',
|
||||
delay = 0,
|
||||
onComplete,
|
||||
lowDetail = false,
|
||||
}: DitherCanvasProps): React.ReactElement {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
|
||||
// Adjust mode for accessibility
|
||||
const effectiveMode = prefersReducedMotion ? 'static' : mode;
|
||||
|
||||
// Get dither engine
|
||||
const engine = useMemo(() => getDitherEngine(lowDetail), [lowDetail]);
|
||||
|
||||
// Get canvas scale for retina displays
|
||||
const scale = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
|
||||
|
||||
// Generate animation frames
|
||||
const frames = useMemo(() => {
|
||||
// Skip frame generation during SSR
|
||||
if (typeof window === 'undefined') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const options = { font, threshold, color, scale, lowDetail };
|
||||
|
||||
if (effectiveMode === 'reveal') {
|
||||
return engine.generateRevealFrames(text, options, 60);
|
||||
}
|
||||
|
||||
if (effectiveMode === 'pulse') {
|
||||
return engine.generatePulseFrames(text, options, 30);
|
||||
}
|
||||
|
||||
// Static mode - single frame
|
||||
return [engine.ditherText(text, options)];
|
||||
} catch (error) {
|
||||
console.error('Failed to generate dither frames:', error);
|
||||
return [];
|
||||
}
|
||||
}, [text, font, threshold, color, effectiveMode, engine, scale, lowDetail]);
|
||||
|
||||
// Animate frames
|
||||
const { currentFrame } = useDitherAnimation({
|
||||
frames,
|
||||
mode: effectiveMode,
|
||||
speed,
|
||||
delay,
|
||||
onComplete,
|
||||
});
|
||||
|
||||
// Render current frame to canvas
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !currentFrame) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas dimensions
|
||||
canvas.width = currentFrame.width;
|
||||
canvas.height = currentFrame.height;
|
||||
|
||||
// Render frame
|
||||
ctx.putImageData(currentFrame, 0, 0);
|
||||
}, [currentFrame]);
|
||||
|
||||
// Get canvas dimensions for styling
|
||||
const dimensions = useMemo(() => {
|
||||
if (frames.length === 0 || !frames[0]) return { width: 0, height: 0 };
|
||||
return {
|
||||
width: frames[0].width / scale,
|
||||
height: frames[0].height / scale,
|
||||
};
|
||||
}, [frames, scale]);
|
||||
|
||||
return (
|
||||
<div className={`relative inline-block ${className}`}>
|
||||
{/* Canvas element */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label={text}
|
||||
style={{
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
display: 'block',
|
||||
}}
|
||||
className="max-w-full h-auto"
|
||||
/>
|
||||
|
||||
{/* Hidden text for screen readers */}
|
||||
<span className="sr-only">{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
packages/ui/src/DitherCanvas/types.ts
Normal file
35
packages/ui/src/DitherCanvas/types.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Type definitions for DitherCanvas components
|
||||
*/
|
||||
|
||||
export interface DitherCanvasProps {
|
||||
/** Text to render and dither */
|
||||
text: string;
|
||||
|
||||
/** Animation mode */
|
||||
mode: 'reveal' | 'pulse' | 'static';
|
||||
|
||||
/** Animation speed in milliseconds (default: 2000) */
|
||||
speed?: number;
|
||||
|
||||
/** Dithering threshold 0-255 (default: 128) */
|
||||
threshold?: number;
|
||||
|
||||
/** Font string (e.g., "bold 120px Space Grotesk") */
|
||||
font?: string;
|
||||
|
||||
/** Text color (default: currentColor) */
|
||||
color?: string;
|
||||
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
|
||||
/** Delay before animation starts (ms, default: 0) */
|
||||
delay?: number;
|
||||
|
||||
/** Callback when animation completes (reveal mode only) */
|
||||
onComplete?: () => void;
|
||||
|
||||
/** Use low-detail dithering for performance */
|
||||
lowDetail?: boolean;
|
||||
}
|
||||
48
packages/ui/src/DitherText/DitherHeadline.tsx
Normal file
48
packages/ui/src/DitherText/DitherHeadline.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* DitherHeadline Component
|
||||
*
|
||||
* Large hero headline with dramatic dithered reveal animation.
|
||||
* Supports multi-line text with staggered line reveals.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { DitherCanvas } from '../DitherCanvas/DitherCanvas';
|
||||
import type { DitherHeadlineProps } from './types';
|
||||
|
||||
export function DitherHeadline({
|
||||
children,
|
||||
delay = 500,
|
||||
stagger = 150,
|
||||
speed = 1500,
|
||||
fontSize = '120px',
|
||||
fontWeight = '800',
|
||||
className = '',
|
||||
onComplete,
|
||||
}: DitherHeadlineProps): React.ReactElement {
|
||||
// Split text into lines
|
||||
const lines = children.split(/\n|<br\s*\/?>/);
|
||||
|
||||
// Track completion
|
||||
const handleLineComplete = () => {
|
||||
onComplete?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-4 ${className}`}>
|
||||
{lines.map((line, index) => (
|
||||
<DitherCanvas
|
||||
key={`${line}-${index}`}
|
||||
text={line}
|
||||
mode="reveal"
|
||||
speed={speed}
|
||||
delay={delay + index * stagger}
|
||||
font={`${fontWeight} ${fontSize} Space Grotesk, sans-serif`}
|
||||
threshold={128}
|
||||
onComplete={index === lines.length - 1 ? handleLineComplete : undefined}
|
||||
className="leading-none"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
packages/ui/src/DitherText/DitherSectionHeader.tsx
Normal file
32
packages/ui/src/DitherText/DitherSectionHeader.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* DitherSectionHeader Component
|
||||
*
|
||||
* Section header with subtle pulsing dither effect.
|
||||
* Continuously animates for a "living" feel without being distracting.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { DitherCanvas } from '../DitherCanvas/DitherCanvas';
|
||||
import type { DitherSectionHeaderProps } from './types';
|
||||
|
||||
export function DitherSectionHeader({
|
||||
children,
|
||||
pulseSpeed = 3000,
|
||||
fontSize = '56px',
|
||||
fontWeight = '700',
|
||||
className = '',
|
||||
}: DitherSectionHeaderProps): React.ReactElement {
|
||||
return (
|
||||
<div className={className}>
|
||||
<DitherCanvas
|
||||
text={children}
|
||||
mode="pulse"
|
||||
speed={pulseSpeed}
|
||||
font={`${fontWeight} ${fontSize} Space Grotesk, sans-serif`}
|
||||
threshold={128}
|
||||
className="leading-tight"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
packages/ui/src/DitherText/types.ts
Normal file
46
packages/ui/src/DitherText/types.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Type definitions for DitherText components
|
||||
*/
|
||||
|
||||
export interface DitherHeadlineProps {
|
||||
/** Text content (can include line breaks) */
|
||||
children: string;
|
||||
|
||||
/** Delay before animation starts (ms) */
|
||||
delay?: number;
|
||||
|
||||
/** Delay between each line stagger (ms) */
|
||||
stagger?: number;
|
||||
|
||||
/** Animation speed (ms) */
|
||||
speed?: number;
|
||||
|
||||
/** Font size (CSS value, e.g., "120px") */
|
||||
fontSize?: string;
|
||||
|
||||
/** Font weight (CSS value, e.g., "800") */
|
||||
fontWeight?: string;
|
||||
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
|
||||
/** Callback when all lines complete */
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export interface DitherSectionHeaderProps {
|
||||
/** Header text */
|
||||
children: string;
|
||||
|
||||
/** Pulse speed (ms for full cycle) */
|
||||
pulseSpeed?: number;
|
||||
|
||||
/** Font size (CSS value, e.g., "56px") */
|
||||
fontSize?: string;
|
||||
|
||||
/** Font weight (CSS value, e.g., "700") */
|
||||
fontWeight?: string;
|
||||
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
318
packages/ui/src/FlowDiagram/FlowDiagram.tsx
Normal file
318
packages/ui/src/FlowDiagram/FlowDiagram.tsx
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
/**
|
||||
* FlowDiagram Component
|
||||
*
|
||||
* Animated SVG diagram showing the flow from AI Agent → Semantic Query → Registry → Tools.
|
||||
* Uses stroke-dashoffset animation for path reveals and expanding circles for radiating effect.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import type { FlowDiagramProps } from './types';
|
||||
|
||||
export function FlowDiagram({
|
||||
className = '',
|
||||
speed = 5000,
|
||||
autoPlay = true,
|
||||
}: FlowDiagramProps): React.ReactElement {
|
||||
const animationDuration = `${speed / 1000}s`;
|
||||
|
||||
return (
|
||||
<div className={`w-full max-w-2xl mx-auto ${className}`}>
|
||||
<svg
|
||||
viewBox="0 0 400 500"
|
||||
className="w-full h-auto"
|
||||
role="img"
|
||||
aria-label="Flow diagram showing AI Agent discovering tools through TPMJS Registry"
|
||||
>
|
||||
{/* Definitions */}
|
||||
<defs>
|
||||
{/* Glow filter for accent elements */}
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
{/* Arrow marker */}
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
||||
<polygon points="0 0, 10 3, 0 6" fill="hsl(var(--brutalist-accent))" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* AI Agent (Top) */}
|
||||
<g id="agent">
|
||||
<rect
|
||||
x="150"
|
||||
y="20"
|
||||
width="100"
|
||||
height="60"
|
||||
className="fill-surface stroke-foreground"
|
||||
strokeWidth="2"
|
||||
rx="0"
|
||||
/>
|
||||
<text
|
||||
x="200"
|
||||
y="45"
|
||||
className="text-sm font-mono font-bold fill-foreground"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
AI Agent
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* Arrow 1: Agent → Query */}
|
||||
<path
|
||||
id="path1"
|
||||
d="M 200 80 L 200 130"
|
||||
className="stroke-brutalist-accent"
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
markerEnd="url(#arrowhead)"
|
||||
strokeDasharray="50"
|
||||
strokeDashoffset={autoPlay ? '-50' : '0'}
|
||||
style={{
|
||||
animation: autoPlay ? `flowPath ${animationDuration} infinite` : 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Semantic Query */}
|
||||
<g id="query">
|
||||
<rect
|
||||
x="120"
|
||||
y="130"
|
||||
width="160"
|
||||
height="50"
|
||||
className="fill-brutalist-accent/10 stroke-brutalist-accent"
|
||||
strokeWidth="2"
|
||||
rx="0"
|
||||
/>
|
||||
<text
|
||||
x="200"
|
||||
y="152"
|
||||
className="text-xs font-mono font-bold fill-foreground"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
Semantic Query
|
||||
</text>
|
||||
<text
|
||||
x="200"
|
||||
y="168"
|
||||
className="text-xs font-mono fill-foreground-secondary"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
"email, slack..."
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* Arrow 2: Query → Registry */}
|
||||
<path
|
||||
id="path2"
|
||||
d="M 200 180 L 200 240"
|
||||
className="stroke-brutalist-accent"
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
markerEnd="url(#arrowhead)"
|
||||
strokeDasharray="60"
|
||||
strokeDashoffset={autoPlay ? '-60' : '0'}
|
||||
style={{
|
||||
animation: autoPlay ? `flowPath ${animationDuration} 0.5s infinite` : 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Registry (Center - with shimmer) */}
|
||||
<g id="registry">
|
||||
<rect
|
||||
x="100"
|
||||
y="240"
|
||||
width="200"
|
||||
height="80"
|
||||
className="fill-surface stroke-foreground"
|
||||
strokeWidth="3"
|
||||
rx="0"
|
||||
/>
|
||||
<text
|
||||
x="200"
|
||||
y="265"
|
||||
className="text-base font-bold fill-foreground"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
TPMJS REGISTRY
|
||||
</text>
|
||||
<text
|
||||
x="200"
|
||||
y="285"
|
||||
className="text-xs font-mono fill-foreground-secondary"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
Dynamic Discovery
|
||||
</text>
|
||||
<text
|
||||
x="200"
|
||||
y="305"
|
||||
className="text-xs font-mono fill-brutalist-accent"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
2,847 tools
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* Radiating circles from registry (expanding pulse) */}
|
||||
<circle
|
||||
cx="200"
|
||||
cy="280"
|
||||
r="5"
|
||||
className="fill-none stroke-brutalist-accent"
|
||||
strokeWidth="2"
|
||||
opacity="0"
|
||||
style={{
|
||||
animation: autoPlay ? `radiate ${animationDuration} 1s infinite` : 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Arrows 3a, 3b, 3c: Registry → Tools */}
|
||||
<path
|
||||
id="path3a"
|
||||
d="M 150 320 L 80 380"
|
||||
className="stroke-brutalist-accent"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
markerEnd="url(#arrowhead)"
|
||||
strokeDasharray="80"
|
||||
strokeDashoffset={autoPlay ? '-80' : '0'}
|
||||
style={{
|
||||
animation: autoPlay ? `flowPath ${animationDuration} 1.5s infinite` : 'none',
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
id="path3b"
|
||||
d="M 200 320 L 200 380"
|
||||
className="stroke-brutalist-accent"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
markerEnd="url(#arrowhead)"
|
||||
strokeDasharray="60"
|
||||
strokeDashoffset={autoPlay ? '-60' : '0'}
|
||||
style={{
|
||||
animation: autoPlay ? `flowPath ${animationDuration} 1.5s infinite` : 'none',
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
id="path3c"
|
||||
d="M 250 320 L 320 380"
|
||||
className="stroke-brutalist-accent"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
markerEnd="url(#arrowhead)"
|
||||
strokeDasharray="80"
|
||||
strokeDashoffset={autoPlay ? '-80' : '0'}
|
||||
style={{
|
||||
animation: autoPlay ? `flowPath ${animationDuration} 1.5s infinite` : 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Tool Cards (Bottom) */}
|
||||
<g id="tools">
|
||||
{/* Tool A */}
|
||||
<rect
|
||||
x="20"
|
||||
y="380"
|
||||
width="100"
|
||||
height="80"
|
||||
className="fill-surface stroke-foreground"
|
||||
strokeWidth="2"
|
||||
rx="0"
|
||||
/>
|
||||
<text x="70" y="405" className="text-sm font-bold fill-foreground" textAnchor="middle">
|
||||
Email
|
||||
</text>
|
||||
<text
|
||||
x="70"
|
||||
y="425"
|
||||
className="text-xs font-mono fill-foreground-secondary"
|
||||
textAnchor="middle"
|
||||
>
|
||||
Tool
|
||||
</text>
|
||||
<text x="70" y="445" className="text-xs fill-brutalist-accent" textAnchor="middle">
|
||||
12K calls
|
||||
</text>
|
||||
|
||||
{/* Tool B */}
|
||||
<rect
|
||||
x="150"
|
||||
y="380"
|
||||
width="100"
|
||||
height="80"
|
||||
className="fill-surface stroke-foreground"
|
||||
strokeWidth="2"
|
||||
rx="0"
|
||||
/>
|
||||
<text x="200" y="405" className="text-sm font-bold fill-foreground" textAnchor="middle">
|
||||
Slack
|
||||
</text>
|
||||
<text
|
||||
x="200"
|
||||
y="425"
|
||||
className="text-xs font-mono fill-foreground-secondary"
|
||||
textAnchor="middle"
|
||||
>
|
||||
Tool
|
||||
</text>
|
||||
<text x="200" y="445" className="text-xs fill-brutalist-accent" textAnchor="middle">
|
||||
8K calls
|
||||
</text>
|
||||
|
||||
{/* Tool C */}
|
||||
<rect
|
||||
x="280"
|
||||
y="380"
|
||||
width="100"
|
||||
height="80"
|
||||
className="fill-surface stroke-foreground"
|
||||
strokeWidth="2"
|
||||
rx="0"
|
||||
/>
|
||||
<text x="330" y="405" className="text-sm font-bold fill-foreground" textAnchor="middle">
|
||||
Calendar
|
||||
</text>
|
||||
<text
|
||||
x="330"
|
||||
y="425"
|
||||
className="text-xs font-mono fill-foreground-secondary"
|
||||
textAnchor="middle"
|
||||
>
|
||||
Tool
|
||||
</text>
|
||||
<text x="330" y="445" className="text-xs fill-brutalist-accent" textAnchor="middle">
|
||||
5K calls
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* CSS animations inline for self-containment */}
|
||||
<style>
|
||||
{`
|
||||
@keyframes flowPath {
|
||||
0% { stroke-dashoffset: 100; }
|
||||
40% { stroke-dashoffset: 0; }
|
||||
100% { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
@keyframes radiate {
|
||||
0% { r: 5; opacity: 0; }
|
||||
20% { opacity: 0.6; }
|
||||
100% { r: 100; opacity: 0; }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
packages/ui/src/FlowDiagram/types.ts
Normal file
14
packages/ui/src/FlowDiagram/types.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Type definitions for FlowDiagram component
|
||||
*/
|
||||
|
||||
export interface FlowDiagramProps {
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
|
||||
/** Animation speed in milliseconds (default: 5000) */
|
||||
speed?: number;
|
||||
|
||||
/** Whether to start animation immediately (default: true) */
|
||||
autoPlay?: boolean;
|
||||
}
|
||||
192
packages/ui/src/system/canvas/DitherEngine.ts
Normal file
192
packages/ui/src/system/canvas/DitherEngine.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
/**
|
||||
* DitherEngine - Core Bayer Dithering Algorithm
|
||||
*
|
||||
* Provides high-performance canvas-based text dithering using
|
||||
* ordered Bayer matrix dithering. Supports pre-computed animation frames
|
||||
* for reveal and pulse effects.
|
||||
*/
|
||||
|
||||
import { getBayerMatrix, getBayerMatrixSize } from './bayerMatrix';
|
||||
|
||||
export interface DitherOptions {
|
||||
/** Font string (e.g., "bold 120px Space Grotesk") */
|
||||
font: string;
|
||||
/** Dithering threshold (0-255, lower = more black pixels) */
|
||||
threshold: number;
|
||||
/** Text color (default: currentColor) */
|
||||
color?: string;
|
||||
/** Canvas scaling for high-DPI displays */
|
||||
scale?: number;
|
||||
/** Use low-detail matrix for performance */
|
||||
lowDetail?: boolean;
|
||||
}
|
||||
|
||||
export class DitherEngine {
|
||||
private bayerMatrix: number[][];
|
||||
private matrixSize: number;
|
||||
|
||||
constructor(lowDetail = false) {
|
||||
this.bayerMatrix = getBayerMatrix(lowDetail);
|
||||
this.matrixSize = getBayerMatrixSize(this.bayerMatrix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate dithered text as ImageData
|
||||
*
|
||||
* @param text - Text to dither
|
||||
* @param options - Dithering options
|
||||
* @param width - Canvas width (optional, auto-calculated if not provided)
|
||||
* @param height - Canvas height (optional, auto-calculated if not provided)
|
||||
* @returns ImageData with dithered text
|
||||
*/
|
||||
ditherText(text: string, options: DitherOptions, width?: number, height?: number): ImageData {
|
||||
// SSR safety: return empty ImageData if not in browser
|
||||
if (typeof document === 'undefined') {
|
||||
// Return 1x1 transparent ImageData for SSR
|
||||
return new ImageData(1, 1);
|
||||
}
|
||||
|
||||
const { font, threshold, color = '#000000', scale = 1 } = options;
|
||||
|
||||
// Create temporary canvas to measure text
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!tempCtx) throw new Error('Failed to get 2D context');
|
||||
|
||||
tempCtx.font = font;
|
||||
const metrics = tempCtx.measureText(text);
|
||||
|
||||
// Calculate dimensions
|
||||
const w =
|
||||
width ||
|
||||
Math.ceil(metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight + 20) * scale;
|
||||
const h =
|
||||
height ||
|
||||
Math.ceil(metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent + 20) * scale;
|
||||
|
||||
// Set canvas size
|
||||
tempCanvas.width = w;
|
||||
tempCanvas.height = h;
|
||||
|
||||
// Draw text
|
||||
tempCtx.scale(scale, scale);
|
||||
tempCtx.font = font;
|
||||
tempCtx.fillStyle = color;
|
||||
tempCtx.textBaseline = 'top';
|
||||
tempCtx.fillText(text, 10, 10);
|
||||
|
||||
// Get image data
|
||||
const imageData = tempCtx.getImageData(0, 0, w, h);
|
||||
const data = imageData.data;
|
||||
|
||||
// Apply Bayer dithering
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = (y * w + x) * 4;
|
||||
|
||||
// Convert to grayscale
|
||||
const r = data[i] ?? 0;
|
||||
const g = data[i + 1] ?? 0;
|
||||
const b = data[i + 2] ?? 0;
|
||||
const a = data[i + 3] ?? 0;
|
||||
|
||||
// Skip fully transparent pixels
|
||||
if (a === 0) continue;
|
||||
|
||||
const grayscale = r * 0.299 + g * 0.587 + b * 0.114;
|
||||
|
||||
// Get Bayer matrix value
|
||||
const bayerValue = this.bayerMatrix[y % this.matrixSize]?.[x % this.matrixSize] ?? 0;
|
||||
|
||||
// Apply threshold with Bayer value
|
||||
const output = grayscale > threshold + bayerValue ? 255 : 0;
|
||||
|
||||
data[i] = output;
|
||||
data[i + 1] = output;
|
||||
data[i + 2] = output;
|
||||
data[i + 3] = output === 255 ? 0 : 255; // Invert alpha for black pixels
|
||||
}
|
||||
}
|
||||
|
||||
return imageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate reveal animation frames
|
||||
*
|
||||
* Animates threshold from 255 (hidden) to 0 (fully visible)
|
||||
*
|
||||
* @param text - Text to animate
|
||||
* @param options - Dithering options (threshold will be overridden)
|
||||
* @param steps - Number of frames (default: 60 = 1s at 60fps)
|
||||
* @returns Array of ImageData frames
|
||||
*/
|
||||
generateRevealFrames(text: string, options: DitherOptions, steps = 60): ImageData[] {
|
||||
const frames: ImageData[] = [];
|
||||
|
||||
for (let i = 0; i < steps; i++) {
|
||||
// Ease-out curve for smooth reveal
|
||||
const progress = this.easeOutCubic(i / (steps - 1));
|
||||
const threshold = Math.floor(255 - progress * 255);
|
||||
|
||||
const frameData = this.ditherText(text, {
|
||||
...options,
|
||||
threshold,
|
||||
});
|
||||
|
||||
frames.push(frameData);
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate pulse animation frames
|
||||
*
|
||||
* Oscillates threshold for a breathing/pulsing effect
|
||||
*
|
||||
* @param text - Text to animate
|
||||
* @param options - Dithering options
|
||||
* @param phases - Number of frames in full cycle (default: 30)
|
||||
* @returns Array of ImageData frames
|
||||
*/
|
||||
generatePulseFrames(text: string, options: DitherOptions, phases = 30): ImageData[] {
|
||||
const frames: ImageData[] = [];
|
||||
const baseThreshold = options.threshold || 128;
|
||||
const amplitude = 40; // Pulse intensity
|
||||
|
||||
for (let i = 0; i < phases; i++) {
|
||||
// Sine wave oscillation
|
||||
const progress = Math.sin((i / phases) * Math.PI * 2);
|
||||
const threshold = Math.floor(baseThreshold + progress * amplitude);
|
||||
|
||||
const frameData = this.ditherText(text, {
|
||||
...options,
|
||||
threshold: Math.max(0, Math.min(255, threshold)),
|
||||
});
|
||||
|
||||
frames.push(frameData);
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ease-out cubic curve for smooth animations
|
||||
*/
|
||||
private easeOutCubic(t: number): number {
|
||||
return 1 - (1 - t) ** 3;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance for reuse across components
|
||||
*/
|
||||
let ditherEngineInstance: DitherEngine | null = null;
|
||||
|
||||
export function getDitherEngine(lowDetail = false): DitherEngine {
|
||||
if (!ditherEngineInstance) {
|
||||
ditherEngineInstance = new DitherEngine(lowDetail);
|
||||
}
|
||||
return ditherEngineInstance;
|
||||
}
|
||||
61
packages/ui/src/system/canvas/bayerMatrix.ts
Normal file
61
packages/ui/src/system/canvas/bayerMatrix.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* Bayer Matrix for Ordered Dithering
|
||||
*
|
||||
* 8×8 Bayer matrix used for ordered dithering algorithms.
|
||||
* Values are normalized to 0-255 range for easy threshold comparison.
|
||||
*
|
||||
* This creates the characteristic halftone/dot-matrix pattern
|
||||
* while avoiding the randomness of error-diffusion dithering.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Standard 8×8 Bayer matrix (normalized to 0-255)
|
||||
*
|
||||
* Formula: bayerMatrix[y][x] = (originalValue / 64) * 255
|
||||
* where original values range from 0-63
|
||||
*/
|
||||
export const BAYER_MATRIX_8X8: number[][] = [
|
||||
[0, 32, 8, 40, 2, 34, 10, 42],
|
||||
[48, 16, 56, 24, 50, 18, 58, 26],
|
||||
[12, 44, 4, 36, 14, 46, 6, 38],
|
||||
[60, 28, 52, 20, 62, 30, 54, 22],
|
||||
[3, 35, 11, 43, 1, 33, 9, 41],
|
||||
[51, 19, 59, 27, 49, 17, 57, 25],
|
||||
[15, 47, 7, 39, 13, 45, 5, 37],
|
||||
[63, 31, 55, 23, 61, 29, 53, 21],
|
||||
].map((row) => row.map((val) => Math.floor((val / 64) * 255)));
|
||||
|
||||
/**
|
||||
* 4×4 Bayer matrix for mobile/performance optimization
|
||||
*
|
||||
* Smaller matrix = faster computation, less detailed dithering
|
||||
* Use this on mobile devices or for subtle effects
|
||||
*/
|
||||
export const BAYER_MATRIX_4X4: number[][] = [
|
||||
[0, 8, 2, 10],
|
||||
[12, 4, 14, 6],
|
||||
[3, 11, 1, 9],
|
||||
[15, 7, 13, 5],
|
||||
].map((row) => row.map((val) => Math.floor((val / 16) * 255)));
|
||||
|
||||
/**
|
||||
* Get the appropriate Bayer matrix based on device capabilities
|
||||
*
|
||||
* @param preferLowDetail - Force 4×4 matrix for performance
|
||||
* @returns Bayer matrix (either 8×8 or 4×4)
|
||||
*/
|
||||
export function getBayerMatrix(preferLowDetail = false): number[][] {
|
||||
// Use smaller matrix on mobile or when explicitly requested
|
||||
if (preferLowDetail || (typeof window !== 'undefined' && window.innerWidth < 640)) {
|
||||
return BAYER_MATRIX_4X4;
|
||||
}
|
||||
|
||||
return BAYER_MATRIX_8X8;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get matrix size (4 or 8)
|
||||
*/
|
||||
export function getBayerMatrixSize(matrix: number[][]): number {
|
||||
return matrix.length;
|
||||
}
|
||||
126
packages/ui/src/system/hooks/useDitherAnimation.ts
Normal file
126
packages/ui/src/system/hooks/useDitherAnimation.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* useDitherAnimation Hook
|
||||
*
|
||||
* Manages RAF-based frame playback for dither animations.
|
||||
* Handles reveal (one-shot) and pulse (looping) animation modes.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface DitherAnimationOptions {
|
||||
/** Pre-computed animation frames */
|
||||
frames: ImageData[];
|
||||
/** Animation mode */
|
||||
mode: 'reveal' | 'pulse' | 'static';
|
||||
/** Animation duration in milliseconds */
|
||||
speed?: number;
|
||||
/** Delay before starting animation (ms) */
|
||||
delay?: number;
|
||||
/** Called when animation completes (reveal mode only) */
|
||||
onComplete?: () => void;
|
||||
/** Pause animation (for intersection observer) */
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
export interface DitherAnimationState {
|
||||
/** Current frame ImageData */
|
||||
currentFrame: ImageData | null;
|
||||
/** Whether animation is complete */
|
||||
isComplete: boolean;
|
||||
/** Current frame index */
|
||||
frameIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing dither animation playback
|
||||
*/
|
||||
export function useDitherAnimation(options: DitherAnimationOptions): DitherAnimationState {
|
||||
const { frames, mode, speed = 2000, delay = 0, onComplete, paused = false } = options;
|
||||
|
||||
const [currentFrame, setCurrentFrame] = useState<ImageData | null>(
|
||||
mode === 'static' ? frames[frames.length - 1] || null : null
|
||||
);
|
||||
const [isComplete, setIsComplete] = useState(mode === 'static');
|
||||
const [frameIndex, setFrameIndex] = useState(0);
|
||||
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Don't animate if static mode or paused
|
||||
if (mode === 'static' || paused || frames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let animationStartTime: number | null = null;
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
// Initialize start time
|
||||
if (animationStartTime === null) {
|
||||
animationStartTime = timestamp + delay;
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for delay
|
||||
if (timestamp < animationStartTime) {
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate progress
|
||||
const elapsed = timestamp - animationStartTime;
|
||||
const progress = Math.min(elapsed / speed, 1);
|
||||
|
||||
if (mode === 'reveal') {
|
||||
// Reveal: play once from start to end
|
||||
const index = Math.floor(progress * (frames.length - 1));
|
||||
const frame = frames[index];
|
||||
if (frame) {
|
||||
setCurrentFrame(frame);
|
||||
setFrameIndex(index);
|
||||
}
|
||||
|
||||
if (progress >= 1) {
|
||||
// Animation complete
|
||||
const lastFrame = frames[frames.length - 1];
|
||||
if (lastFrame) {
|
||||
setCurrentFrame(lastFrame);
|
||||
}
|
||||
setFrameIndex(frames.length - 1);
|
||||
setIsComplete(true);
|
||||
onComplete?.();
|
||||
return;
|
||||
}
|
||||
} else if (mode === 'pulse') {
|
||||
// Pulse: loop continuously
|
||||
const loopProgress = (elapsed % speed) / speed;
|
||||
const index = Math.floor(loopProgress * frames.length) % frames.length;
|
||||
const frame = frames[index];
|
||||
if (frame) {
|
||||
setCurrentFrame(frame);
|
||||
setFrameIndex(index);
|
||||
}
|
||||
}
|
||||
|
||||
// Continue animation
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
// Start animation
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [frames, mode, speed, delay, onComplete, paused]);
|
||||
|
||||
return {
|
||||
currentFrame,
|
||||
isComplete,
|
||||
frameIndex,
|
||||
};
|
||||
}
|
||||
46
packages/ui/src/system/hooks/useReducedMotion.ts
Normal file
46
packages/ui/src/system/hooks/useReducedMotion.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* useReducedMotion Hook
|
||||
*
|
||||
* Detects if the user has requested reduced motion via
|
||||
* the `prefers-reduced-motion` media query.
|
||||
*
|
||||
* Used to make animations accessible by skipping or simplifying
|
||||
* them for users who are sensitive to motion.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Check if user prefers reduced motion
|
||||
*
|
||||
* @returns true if user prefers reduced motion
|
||||
*/
|
||||
export function useReducedMotion(): boolean {
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if we're in a browser environment
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Create media query
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
// Set initial value
|
||||
setPrefersReducedMotion(mediaQuery.matches);
|
||||
|
||||
// Update on change
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setPrefersReducedMotion(event.matches);
|
||||
};
|
||||
|
||||
// Listen for changes
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return prefersReducedMotion;
|
||||
}
|
||||
|
|
@ -33,10 +33,16 @@ 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 DitherText components
|
||||
entries.push('src/DitherText/DitherHeadline.tsx');
|
||||
entries.push('src/DitherText/DitherSectionHeader.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');
|
||||
entries.push('src/system/hooks/useReducedMotion.ts');
|
||||
entries.push('src/system/hooks/useDitherAnimation.ts');
|
||||
|
||||
export default defineConfig({
|
||||
entry: entries,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue