diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index eda64d3..d9b4a01 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -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) { *, diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 7c238de..3360a1e 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -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 { />
- {/* Hero Section - Brutalist Design */} + {/* Hero Section - Dithered Design */} - {/* Featured Tools Section - Brutalist Cards */} -
- -
-

Featured Tools

- -
-
- {featuredTools.map((tool, index) => ( - - -
- -
-

{tool.name}

-

- {tool.description} -

-
- - {tool.category} - - - {tool.weeklyUsage} - -
-
-
-
-
- ))} -
-
-
+ {/* Problem Section - The fragmented world before tpmjs */} + - {/* Categories Section - Brutalist Masonry Grid */} -
- -

Browse by Category

- -
-
+ {/* Vision Section - The dynamic ecosystem with tpmjs */} + - {/* Statistics Section - Brutalist Stat Cards */} -
- {/* Subtle grid background */} -
+ {/* Ecosystem Stats - Live metrics with dithered counters */} + - -

Platform Statistics

-
- {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 ( - - ); - })} -
-
-
+ {/* Developer Stories - Code-first testimonials */} +
{/* Footer */} diff --git a/apps/web/src/components/home/DeveloperStories.tsx b/apps/web/src/components/home/DeveloperStories.tsx new file mode 100644 index 0000000..f8ff5a3 --- /dev/null +++ b/apps/web/src/components/home/DeveloperStories.tsx @@ -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 ( +
+ {/* Blueprint grid */} +
+
+
+ + + BUILT WITH TPMJS + +
+ {developerStories.map((story, index) => { + return ( +
+
+ {/* Code snippet */} + + + {/* Impact quote */} +
+

+ “{story.quote}” +

+ +
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/web/src/components/home/EcosystemStats.tsx b/apps/web/src/components/home/EcosystemStats.tsx new file mode 100644 index 0000000..76e11d6 --- /dev/null +++ b/apps/web/src/components/home/EcosystemStats.tsx @@ -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 ( +
+ {/* Subtle grid background */} +
+ + + LIVE ECOSYSTEM + + {/* Stats Grid */} +
+ {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 ( +
+ +
+ ); + })} +
+ + {/* Activity Stream */} +
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/home/HeroSection.tsx b/apps/web/src/components/home/HeroSection.tsx index f152eaf..4f1971a 100644 --- a/apps/web/src/components/home/HeroSection.tsx +++ b/apps/web/src/components/home/HeroSection.tsx @@ -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 (
- {/* Brutalist Grid Background - Static, Not Parallax */} + {/* Blueprint Grid Background - Static with Scanline */}
+ {/* Animated Scanline */} +
+ {/* Content Container with Parallax */}
- {/* Main Heading - Brutalist Typography */} + {/* Main Heading - Dithered Typography */}
-

- TOOL -
- REGISTRY -
- FOR AI AGENTS -

+ + TOOL REGISTRY FOR AI AGENTS + {/* Live Metrics Strip */}
diff --git a/apps/web/src/components/home/ProblemSection.tsx b/apps/web/src/components/home/ProblemSection.tsx new file mode 100644 index 0000000..a15fff2 --- /dev/null +++ b/apps/web/src/components/home/ProblemSection.tsx @@ -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 ( +
+ {/* Subtle grid background */} +
+ + + THE PROBLEM + +
+ {problemPoints.map((problem, index) => { + // Random rotation for chaos + const rotation = (index % 2 === 0 ? 1 : -1) * (Math.random() * 5 + 2); + + return ( +
+ + + +
+

+ {problem.title} +

+

+ {problem.description} +

+
+ + {/* Dashed line to show disconnection */} + {index < problemPoints.length - 1 && ( +
+ )} + + +
+ ); + })} +
+ +
+ Agents stuck with static, manually-configured toolsets +
+ +
+ ); +} diff --git a/apps/web/src/components/home/VisionSection.tsx b/apps/web/src/components/home/VisionSection.tsx new file mode 100644 index 0000000..3f91f7b --- /dev/null +++ b/apps/web/src/components/home/VisionSection.tsx @@ -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 ( +
+ {/* Blueprint grid with subtle pulse */} +
+
+
+ + + THE VISION + +
+ + +
+
+
Dynamic
+

+ Agents discover tools on-demand through semantic search +

+
+ +
+
+ Scalable +
+

+ Thousands of tools available without manual integration +

+
+ +
+
+ Evolving +
+

+ New capabilities emerge as the community publishes tools +

+
+
+
+ +
+ The registry that gives agents superpowers +
+
+
+ ); +} diff --git a/apps/web/src/data/homePageData.ts b/apps/web/src/data/homePageData.ts index 2ddf6bf..b741ced 100644 --- a/apps/web/src/data/homePageData.ts +++ b/apps/web/src/data/homePageData.ts @@ -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', + }, +]; diff --git a/packages/ui/package.json b/packages/ui/package.json index 9c963c9..abec288 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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"], diff --git a/packages/ui/src/ActivityStream/ActivityStream.tsx b/packages/ui/src/ActivityStream/ActivityStream.tsx new file mode 100644 index 0000000..4773d13 --- /dev/null +++ b/packages/ui/src/ActivityStream/ActivityStream.tsx @@ -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( + 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 ( +
+

+ Recent Activity +

+ +
+ {activities.map((activity, index) => ( +
+ + {getActivityIcon(activity.type)} + + + + {activity.tool} + {activity.type} + + + {activity.time} +
+ ))} +
+
+ ); +} diff --git a/packages/ui/src/ActivityStream/types.ts b/packages/ui/src/ActivityStream/types.ts new file mode 100644 index 0000000..ae7d647 --- /dev/null +++ b/packages/ui/src/ActivityStream/types.ts @@ -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; +} diff --git a/packages/ui/src/DitherCanvas/DitherCanvas.tsx b/packages/ui/src/DitherCanvas/DitherCanvas.tsx new file mode 100644 index 0000000..2716f73 --- /dev/null +++ b/packages/ui/src/DitherCanvas/DitherCanvas.tsx @@ -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(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 ( +
+ {/* Canvas element */} + + + {/* Hidden text for screen readers */} + {text} +
+ ); +} diff --git a/packages/ui/src/DitherCanvas/types.ts b/packages/ui/src/DitherCanvas/types.ts new file mode 100644 index 0000000..d68429a --- /dev/null +++ b/packages/ui/src/DitherCanvas/types.ts @@ -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; +} diff --git a/packages/ui/src/DitherText/DitherHeadline.tsx b/packages/ui/src/DitherText/DitherHeadline.tsx new file mode 100644 index 0000000..23d74ac --- /dev/null +++ b/packages/ui/src/DitherText/DitherHeadline.tsx @@ -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|/); + + // Track completion + const handleLineComplete = () => { + onComplete?.(); + }; + + return ( +
+ {lines.map((line, index) => ( + + ))} +
+ ); +} diff --git a/packages/ui/src/DitherText/DitherSectionHeader.tsx b/packages/ui/src/DitherText/DitherSectionHeader.tsx new file mode 100644 index 0000000..83519da --- /dev/null +++ b/packages/ui/src/DitherText/DitherSectionHeader.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/packages/ui/src/DitherText/types.ts b/packages/ui/src/DitherText/types.ts new file mode 100644 index 0000000..d778200 --- /dev/null +++ b/packages/ui/src/DitherText/types.ts @@ -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; +} diff --git a/packages/ui/src/FlowDiagram/FlowDiagram.tsx b/packages/ui/src/FlowDiagram/FlowDiagram.tsx new file mode 100644 index 0000000..8f48ed5 --- /dev/null +++ b/packages/ui/src/FlowDiagram/FlowDiagram.tsx @@ -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 ( +
+ + {/* Definitions */} + + {/* Glow filter for accent elements */} + + + + + + + + + {/* Arrow marker */} + + + + + + {/* AI Agent (Top) */} + + + + AI Agent + + + + {/* Arrow 1: Agent → Query */} + + + {/* Semantic Query */} + + + + Semantic Query + + + "email, slack..." + + + + {/* Arrow 2: Query → Registry */} + + + {/* Registry (Center - with shimmer) */} + + + + TPMJS REGISTRY + + + Dynamic Discovery + + + 2,847 tools + + + + {/* Radiating circles from registry (expanding pulse) */} + + + {/* Arrows 3a, 3b, 3c: Registry → Tools */} + + + + + {/* Tool Cards (Bottom) */} + + {/* Tool A */} + + + Email + + + Tool + + + 12K calls + + + {/* Tool B */} + + + Slack + + + Tool + + + 8K calls + + + {/* Tool C */} + + + Calendar + + + Tool + + + 5K calls + + + + {/* CSS animations inline for self-containment */} + + +
+ ); +} diff --git a/packages/ui/src/FlowDiagram/types.ts b/packages/ui/src/FlowDiagram/types.ts new file mode 100644 index 0000000..5bc4f85 --- /dev/null +++ b/packages/ui/src/FlowDiagram/types.ts @@ -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; +} diff --git a/packages/ui/src/system/canvas/DitherEngine.ts b/packages/ui/src/system/canvas/DitherEngine.ts new file mode 100644 index 0000000..979adb8 --- /dev/null +++ b/packages/ui/src/system/canvas/DitherEngine.ts @@ -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; +} diff --git a/packages/ui/src/system/canvas/bayerMatrix.ts b/packages/ui/src/system/canvas/bayerMatrix.ts new file mode 100644 index 0000000..f319d60 --- /dev/null +++ b/packages/ui/src/system/canvas/bayerMatrix.ts @@ -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; +} diff --git a/packages/ui/src/system/hooks/useDitherAnimation.ts b/packages/ui/src/system/hooks/useDitherAnimation.ts new file mode 100644 index 0000000..ccf16c8 --- /dev/null +++ b/packages/ui/src/system/hooks/useDitherAnimation.ts @@ -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( + mode === 'static' ? frames[frames.length - 1] || null : null + ); + const [isComplete, setIsComplete] = useState(mode === 'static'); + const [frameIndex, setFrameIndex] = useState(0); + + const rafRef = useRef(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, + }; +} diff --git a/packages/ui/src/system/hooks/useReducedMotion.ts b/packages/ui/src/system/hooks/useReducedMotion.ts new file mode 100644 index 0000000..0166477 --- /dev/null +++ b/packages/ui/src/system/hooks/useReducedMotion.ts @@ -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; +} diff --git a/packages/ui/tsup.config.ts b/packages/ui/tsup.config.ts index bf94e2c..2d31e5a 100644 --- a/packages/ui/tsup.config.ts +++ b/packages/ui/tsup.config.ts @@ -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,