diff --git a/.claude/DEMOS_DASHBOARD.md b/.claude/DEMOS_DASHBOARD.md
new file mode 100644
index 0000000..cc4e87b
--- /dev/null
+++ b/.claude/DEMOS_DASHBOARD.md
@@ -0,0 +1,276 @@
+# Flow Funding Demos Dashboard
+
+**Created**: 2025-11-23
+**Location**: `/app/demos/page.tsx`
+**Live URL**: `http://localhost:3000/demos`
+
+---
+
+## Overview
+
+A beautiful, comprehensive dashboard showcasing all Flow Funding interactive demonstrations. Inspired by the infinite-agents demo gallery design pattern.
+
+## Features
+
+### Visual Design
+- **Gradient Background**: Purple-to-blue gradient matching the project's aesthetic
+- **Card Layout**: Modern card-based design with hover effects
+- **Status Badges**: Color-coded status indicators (Complete, Beta, Prototype)
+- **Stats Dashboard**: Quick overview of total demos, completion status
+- **Responsive**: Mobile-first design with grid layouts
+
+### Functionality
+- **Search**: Real-time search across demo names, descriptions, types, and features
+- **Category Filters**: Quick filtering by demo category
+- **Feature Tags**: Quick-glance feature highlights for each demo
+- **Direct Links**: One-click access to each demo
+
+### Categories
+
+#### 1. **Threshold-Based Flow Funding (TBFF)** π―
+- **TBFF Interactive** (Milestones 1-3 Complete)
+ - Static visualization with color-coding
+ - Interactive allocation creation
+ - Initial distribution algorithm
+ - Multiple sample networks
+
+- **TBFF Flow Simulation** (Beta)
+ - Continuous flow mechanics
+ - Progressive outflow
+
+#### 2. **Flow Dynamics V2** π
+- **Continuous Flow Dynamics** (Complete)
+ - Per-second simulation engine
+ - Progressive outflow formula (fixed)
+ - Network overflow node
+ - 60 FPS rendering
+ - Animated flow particles
+
+#### 3. **Interactive Canvas** π¨
+- **Italism** (Complete)
+ - Live arrow propagators
+ - Shape drawing and editing
+ - Expression-based connections
+ - Undo/redo functionality
+ - FolkJS-inspired architecture
+
+#### 4. **Prototypes** π¬
+- **Flow Funding (Original)** (Prototype)
+ - Basic flow mechanics
+ - Early concept exploration
+
+## Integration Points
+
+### Main Landing Page
+- Updated hero section with "View Interactive Demos" button
+- Primary CTA now links to `/demos`
+
+### Demo Pages
+- Each demo page can link back to dashboard
+- Consistent navigation experience
+
+## Technical Details
+
+### Component Structure
+```tsx
+DemosPage (Client Component)
+βββ Header with title and description
+βββ Statistics cards (Total, Complete, Beta, Categories)
+βββ Search and filter controls
+βββ Category sections
+ βββ Demo cards with:
+ βββ Status badge
+ βββ Title and description
+ βββ Feature tags
+ βββ Type label
+ βββ Launch link
+```
+
+### Data Model
+```typescript
+interface Demo {
+ number: number
+ title: string
+ description: string
+ path: string
+ type: string
+ status: 'complete' | 'beta' | 'prototype'
+ features: string[]
+ milestone?: string
+}
+```
+
+### State Management
+- Local React state for search and filters
+- No external dependencies
+- Client-side filtering for performance
+
+## Design Patterns
+
+### Inspired by infinite-agents
+- Category-based organization
+- Stats bar at the top
+- Search and filter controls
+- Card-based demo display
+- Hover effects and transitions
+- Status badges
+
+### Improvements Over Original
+- React/Next.js instead of vanilla JS
+- Type-safe with TypeScript
+- Responsive Tailwind CSS
+- Status badges (Complete/Beta/Prototype)
+- Feature tags for each demo
+- Milestone tracking
+
+## Future Enhancements
+
+### Short-term
+- [ ] Add screenshots for each demo
+- [ ] Implement screenshot preview on hover
+- [ ] Add "New" badge for recently added demos
+- [ ] Add demo tags (e.g., "Interactive", "Simulation", "Educational")
+
+### Medium-term
+- [ ] Add demo ratings/feedback
+- [ ] Implement demo bookmarking
+- [ ] Add video previews/tours
+- [ ] Create guided learning paths
+- [ ] Add "What's New" section
+
+### Long-term
+- [ ] User accounts and personalization
+- [ ] Demo creation wizard
+- [ ] Community contributions
+- [ ] Analytics and usage tracking
+- [ ] A/B testing for different presentations
+
+## Usage
+
+### Accessing the Dashboard
+1. Navigate to `http://localhost:3000/demos`
+2. Or click "View Interactive Demos" from the home page
+
+### Searching Demos
+- Type in the search box to filter by:
+ - Demo name
+ - Description text
+ - Type
+ - Feature names
+
+### Filtering by Category
+- Click any category button to show only that category
+- Click "All Demos" to reset
+
+### Launching a Demo
+- Click anywhere on a demo card
+- Or click the "Launch Demo β" link
+
+## Maintenance
+
+### Adding New Demos
+Edit `/app/demos/page.tsx`:
+
+```typescript
+const demos: Record = {
+ categoryName: [
+ {
+ number: X,
+ title: 'Demo Title',
+ description: 'Demo description...',
+ path: '/demo-route',
+ type: 'Demo Type',
+ status: 'complete' | 'beta' | 'prototype',
+ features: ['Feature 1', 'Feature 2', ...],
+ milestone: 'Optional milestone note'
+ }
+ ]
+}
+```
+
+### Updating Categories
+Add to the `categories` array:
+
+```typescript
+{
+ id: 'categoryKey',
+ label: 'Display Name',
+ count: demos.categoryKey.length,
+ icon: 'π¬'
+}
+```
+
+## Performance Considerations
+
+### Current Performance
+- Minimal bundle size (no heavy dependencies)
+- Client-side rendering with static demo data
+- Fast search (no API calls)
+- Instant category filtering
+
+### Optimization Opportunities
+- Lazy load demo screenshots
+- Virtual scrolling for many demos
+- Server-side rendering for initial load
+- Static generation at build time
+
+## Accessibility
+
+### Current Features
+- Semantic HTML structure
+- Keyboard navigation support
+- Focus states on interactive elements
+- High contrast color schemes
+
+### Future Improvements
+- ARIA labels for all interactive elements
+- Screen reader optimizations
+- Keyboard shortcuts
+- Focus management
+
+## Design Philosophy
+
+### Post-Appitalism Alignment
+- **Transparent**: All demos visible and accessible
+- **Exploratory**: Encourages browsing and discovery
+- **Educational**: Descriptions explain what each demo teaches
+- **Beautiful**: Aesthetically compelling design
+- **Functional**: No unnecessary complexity
+
+### User Experience Goals
+1. **Immediate Value**: See all demos at a glance
+2. **Easy Discovery**: Search and filter make finding demos trivial
+3. **Clear Status**: Know which demos are production-ready
+4. **Feature Visibility**: Understand what each demo offers
+5. **Quick Access**: One click to launch any demo
+
+---
+
+## Metrics for Success
+
+### User Engagement
+- Time spent on dashboard
+- Demos launched from dashboard
+- Search usage patterns
+- Filter usage patterns
+
+### Content Quality
+- Complete demos ratio
+- Feature completeness
+- Description clarity
+- User feedback scores
+
+### Technical Performance
+- Page load time < 2s
+- Search response < 100ms
+- Filter transition < 300ms
+- Mobile responsiveness scores
+
+---
+
+**Status**: β
Complete and deployed to development
+**Next Steps**: Add screenshots, test with users, gather feedback
+
+---
+
+*"Make the abstract concrete. Make the complex simple. Make it beautiful."*
diff --git a/app/demos/page.tsx b/app/demos/page.tsx
new file mode 100644
index 0000000..4d4fcc6
--- /dev/null
+++ b/app/demos/page.tsx
@@ -0,0 +1,365 @@
+'use client'
+
+import { useState } from 'react'
+
+interface Demo {
+ number: number
+ title: string
+ description: string
+ path: string
+ type: string
+ status: 'complete' | 'beta' | 'prototype'
+ features: string[]
+ milestone?: string
+ screenshot?: string
+}
+
+const demos: Record = {
+ tbff: [
+ {
+ number: 1,
+ title: 'Threshold-Based Flow Funding (Interactive)',
+ description: 'Complete interactive demo with Milestones 1-3: Static visualization, interactive allocations, and initial distribution algorithm. Create accounts, draw allocation arrows, add funding, and watch resources flow.',
+ path: '/tbff',
+ type: 'Interactive Simulation',
+ status: 'complete',
+ milestone: 'Milestones 1-3 Complete',
+ screenshot: '/screenshots/tbff.png',
+ features: [
+ 'Visual threshold-based coloring',
+ 'Interactive allocation creation',
+ 'Automatic normalization',
+ 'Initial distribution algorithm',
+ 'Multiple sample networks',
+ 'Real-time balance updates'
+ ]
+ },
+ {
+ number: 2,
+ title: 'TBFF Flow Simulation',
+ description: 'Alternative implementation exploring continuous flow dynamics with progressive outflow ratios.',
+ path: '/tbff-flow',
+ type: 'Flow Simulation',
+ status: 'beta',
+ screenshot: '/screenshots/tbff-flow.png',
+ features: [
+ 'Continuous flow mechanics',
+ 'Progressive outflow',
+ 'Network equilibrium',
+ 'Visual flow indicators'
+ ]
+ }
+ ],
+ flowV2: [
+ {
+ number: 3,
+ title: 'Flow Funding V2: Continuous Flow Dynamics',
+ description: 'Redesigned as continuous per-second flow simulation with per-month UI. Features progressive outflow formula ensuring monotonic increase in sharing as accounts approach "enough".',
+ path: '/flow-v2',
+ type: 'Continuous Flow',
+ status: 'complete',
+ screenshot: '/screenshots/flow-v2.png',
+ features: [
+ 'Per-second simulation engine',
+ 'Progressive outflow formula (fixed)',
+ 'Network overflow node',
+ 'Smooth 60 FPS rendering',
+ 'Animated flow particles',
+ 'Time-scale architecture'
+ ]
+ }
+ ],
+ canvas: [
+ {
+ number: 4,
+ title: 'Italism: Interactive Canvas with Propagators',
+ description: 'Original canvas demo with live propagators. Draw shapes, connect them with arrows, and watch data flow through the network. Foundation for malleable software vision.',
+ path: '/italism',
+ type: 'Live Programming Canvas',
+ status: 'complete',
+ screenshot: '/screenshots/italism.png',
+ features: [
+ 'Live arrow propagators',
+ 'Shape drawing and editing',
+ 'Expression-based connections',
+ 'Undo/redo functionality',
+ 'Real-time data flow',
+ 'FolkJS-inspired architecture'
+ ]
+ }
+ ],
+ prototypes: [
+ {
+ number: 5,
+ title: 'Flow Funding (Original)',
+ description: 'Earlier prototype exploring initial flow funding concepts.',
+ path: '/flowfunding',
+ type: 'Prototype',
+ status: 'prototype',
+ screenshot: '/screenshots/flowfunding.png',
+ features: [
+ 'Basic flow mechanics',
+ 'Threshold visualization',
+ 'Network simulation'
+ ]
+ }
+ ]
+}
+
+export default function DemosPage() {
+ const [searchTerm, setSearchTerm] = useState('')
+ const [activeFilter, setActiveFilter] = useState('all')
+
+ const allDemos = Object.values(demos).flat()
+ const totalDemos = allDemos.length
+ const completeDemos = allDemos.filter(d => d.status === 'complete').length
+ const betaDemos = allDemos.filter(d => d.status === 'beta').length
+
+ const categories = [
+ { id: 'all', label: 'All Demos', count: totalDemos },
+ { id: 'tbff', label: 'TBFF Interactive', count: demos.tbff.length, icon: 'π―' },
+ { id: 'flowV2', label: 'Flow Dynamics V2', count: demos.flowV2.length, icon: 'π' },
+ { id: 'canvas', label: 'Interactive Canvas', count: demos.canvas.length, icon: 'π¨' },
+ { id: 'prototypes', label: 'Prototypes', count: demos.prototypes.length, icon: 'π¬' }
+ ]
+
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case 'complete': return 'bg-green-500'
+ case 'beta': return 'bg-yellow-500'
+ case 'prototype': return 'bg-gray-500'
+ default: return 'bg-gray-400'
+ }
+ }
+
+ const getStatusLabel = (status: string) => {
+ switch (status) {
+ case 'complete': return 'Complete'
+ case 'beta': return 'Beta'
+ case 'prototype': return 'Prototype'
+ default: return status
+ }
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+ {/* Stats */}
+
+
+
+ {totalDemos}
+
+
Total Demos
+
+
+
+ {completeDemos}
+
+
Complete
+
+
+
+ {betaDemos}
+
+
Beta
+
+
+
+
+ {/* Search & Filter */}
+
+
+ setSearchTerm(e.target.value)}
+ />
+
+
+ {categories.map(cat => (
+
+ ))}
+
+
+
+ {/* Demo Categories */}
+ {Object.entries(demos).map(([categoryKey, categoryDemos]) => {
+ if (activeFilter !== 'all' && activeFilter !== categoryKey) return null
+
+ const categoryInfo = {
+ tbff: { title: 'Threshold-Based Flow Funding', icon: 'π―', desc: 'Interactive demos with allocation creation and distribution algorithms' },
+ flowV2: { title: 'Flow Dynamics V2', icon: 'π', desc: 'Continuous per-second flow simulation with progressive outflow' },
+ canvas: { title: 'Interactive Canvas', icon: 'π¨', desc: 'Live programming environment with propagator networks' },
+ prototypes: { title: 'Early Prototypes', icon: 'π¬', desc: 'Initial explorations and concept validation' }
+ }[categoryKey] || { title: categoryKey, icon: 'π', desc: '' }
+
+ return (
+
+
+
+
+ {categoryInfo.icon}
+
+
+
{categoryInfo.title}
+
{categoryInfo.desc}
+
+
+ {categoryDemos.length} {categoryDemos.length === 1 ? 'demo' : 'demos'}
+
+
+
+
+
+
+ )
+ })}
+
+ {/* Footer */}
+
+
+
+ )
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index d2d3792..e6063f5 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -11,6 +11,14 @@ export const metadata: Metadata = {
title: "Project Interlay | Post-Appitalism",
description: "Weaving a post-appitalist future. Decomposing the data silos of capitalist business models.",
generator: "v0.app",
+ icons: {
+ icon: [
+ {
+ url: "data:image/svg+xml,",
+ type: "image/svg+xml",
+ },
+ ],
+ },
}
export default function RootLayout({
diff --git a/app/tbff-flow/page.tsx b/app/tbff-flow/page.tsx
new file mode 100644
index 0000000..665af20
--- /dev/null
+++ b/app/tbff-flow/page.tsx
@@ -0,0 +1,671 @@
+"use client"
+
+import { useEffect, useRef, useState } from "react"
+import Link from "next/link"
+import type { FlowNetwork, FlowAllocation, FlowParticle } from "@/lib/tbff-flow/types"
+import { renderFlowNetwork } from "@/lib/tbff-flow/rendering"
+import {
+ flowSampleNetworks,
+ flowNetworkOptions,
+ getFlowSampleNetwork,
+} from "@/lib/tbff-flow/sample-networks"
+import {
+ formatFlow,
+ getFlowStatusColorClass,
+ normalizeFlowAllocations,
+ calculateFlowNetworkTotals,
+ updateFlowNodeProperties,
+} from "@/lib/tbff-flow/utils"
+import { propagateFlow, updateFlowParticles } from "@/lib/tbff-flow/algorithms"
+
+type Tool = 'select' | 'create-allocation'
+
+export default function TBFFFlowPage() {
+ const canvasRef = useRef(null)
+ const animationFrameRef = useRef(null)
+
+ const [network, setNetwork] = useState(flowSampleNetworks.linear)
+ const [particles, setParticles] = useState([])
+ const [selectedNodeId, setSelectedNodeId] = useState(null)
+ const [selectedAllocationId, setSelectedAllocationId] = useState(null)
+ const [selectedNetworkKey, setSelectedNetworkKey] = useState('linear')
+ const [tool, setTool] = useState('select')
+ const [allocationSourceId, setAllocationSourceId] = useState(null)
+ const [isAnimating, setIsAnimating] = useState(true)
+
+ // Animation loop
+ useEffect(() => {
+ if (!isAnimating) return
+
+ const animate = () => {
+ // Update particles
+ setParticles(prev => updateFlowParticles(prev))
+
+ animationFrameRef.current = requestAnimationFrame(animate)
+ }
+
+ animationFrameRef.current = requestAnimationFrame(animate)
+
+ return () => {
+ if (animationFrameRef.current) {
+ cancelAnimationFrame(animationFrameRef.current)
+ }
+ }
+ }, [isAnimating])
+
+ // Render canvas
+ useEffect(() => {
+ const canvas = canvasRef.current
+ if (!canvas) return
+
+ const ctx = canvas.getContext("2d")
+ if (!ctx) return
+
+ // Set canvas size
+ canvas.width = canvas.offsetWidth
+ canvas.height = canvas.offsetHeight
+
+ // Render the network
+ renderFlowNetwork(
+ ctx,
+ network,
+ canvas.width,
+ canvas.height,
+ particles,
+ selectedNodeId,
+ selectedAllocationId
+ )
+ }, [network, particles, selectedNodeId, selectedAllocationId])
+
+ // Propagate flow when network changes
+ const handlePropagateFlow = () => {
+ const result = propagateFlow(network)
+ setNetwork(result.network)
+ setParticles(result.particles)
+ }
+
+ // Set external flow for selected node
+ const handleSetNodeFlow = (nodeId: string, flow: number) => {
+ const updatedNodes = network.nodes.map(node =>
+ node.id === nodeId
+ ? { ...node, externalFlow: Math.max(0, flow) }
+ : node
+ )
+
+ const updatedNetwork = {
+ ...network,
+ nodes: updatedNodes,
+ }
+
+ setNetwork(updatedNetwork)
+
+ // Auto-propagate
+ setTimeout(() => {
+ const result = propagateFlow(updatedNetwork)
+ setNetwork(result.network)
+ setParticles(result.particles)
+ }, 100)
+ }
+
+ // Handle canvas click
+ const handleCanvasClick = (e: React.MouseEvent) => {
+ const canvas = canvasRef.current
+ if (!canvas) return
+
+ const rect = canvas.getBoundingClientRect()
+ const x = e.clientX - rect.left
+ const y = e.clientY - rect.top
+
+ // Find clicked node
+ const clickedNode = network.nodes.find(
+ node =>
+ x >= node.x &&
+ x <= node.x + node.width &&
+ y >= node.y &&
+ y <= node.y + node.height
+ )
+
+ if (tool === 'select') {
+ if (clickedNode) {
+ setSelectedNodeId(clickedNode.id)
+ setSelectedAllocationId(null)
+ } else {
+ // Check if clicked on allocation
+ const clickedAllocation = findAllocationAtPoint(x, y)
+ if (clickedAllocation) {
+ setSelectedAllocationId(clickedAllocation.id)
+ setSelectedNodeId(null)
+ } else {
+ // Deselect all
+ setSelectedNodeId(null)
+ setSelectedAllocationId(null)
+ }
+ }
+ } else if (tool === 'create-allocation') {
+ if (clickedNode) {
+ if (!allocationSourceId) {
+ // First click: set source
+ setAllocationSourceId(clickedNode.id)
+ } else {
+ // Second click: create allocation
+ if (clickedNode.id !== allocationSourceId) {
+ createAllocation(allocationSourceId, clickedNode.id)
+ }
+ setAllocationSourceId(null)
+ }
+ }
+ }
+ }
+
+ // Find allocation at point
+ const findAllocationAtPoint = (x: number, y: number): FlowAllocation | null => {
+ const tolerance = 15
+
+ for (const allocation of network.allocations) {
+ const sourceNode = network.nodes.find(n => n.id === allocation.sourceNodeId)
+ const targetNode = network.nodes.find(n => n.id === allocation.targetNodeId)
+ if (!sourceNode || !targetNode) continue
+
+ const sourceCenter = {
+ x: sourceNode.x + sourceNode.width / 2,
+ y: sourceNode.y + sourceNode.height / 2,
+ }
+ const targetCenter = {
+ x: targetNode.x + targetNode.width / 2,
+ y: targetNode.y + targetNode.height / 2,
+ }
+
+ const distance = pointToLineDistance(
+ x, y,
+ sourceCenter.x, sourceCenter.y,
+ targetCenter.x, targetCenter.y
+ )
+
+ if (distance < tolerance) {
+ return allocation
+ }
+ }
+
+ return null
+ }
+
+ // Point to line distance
+ const pointToLineDistance = (
+ px: number, py: number,
+ x1: number, y1: number,
+ x2: number, y2: number
+ ): number => {
+ const dot = (px - x1) * (x2 - x1) + (py - y1) * (y2 - y1)
+ const lenSq = (x2 - x1) ** 2 + (y2 - y1) ** 2
+ const param = lenSq !== 0 ? dot / lenSq : -1
+
+ let xx, yy
+ if (param < 0) {
+ [xx, yy] = [x1, y1]
+ } else if (param > 1) {
+ [xx, yy] = [x2, y2]
+ } else {
+ xx = x1 + param * (x2 - x1)
+ yy = y1 + param * (y2 - y1)
+ }
+
+ return Math.sqrt((px - xx) ** 2 + (py - yy) ** 2)
+ }
+
+ // Create allocation
+ const createAllocation = (sourceId: string, targetId: string) => {
+ const newAllocation: FlowAllocation = {
+ id: `alloc_${Date.now()}`,
+ sourceNodeId: sourceId,
+ targetNodeId: targetId,
+ percentage: 0.5,
+ }
+
+ const updatedAllocations = [...network.allocations, newAllocation]
+ const sourceAllocations = updatedAllocations.filter(a => a.sourceNodeId === sourceId)
+ const normalized = normalizeFlowAllocations(sourceAllocations)
+
+ const finalAllocations = updatedAllocations.map(a => {
+ const normalizedVersion = normalized.find(n => n.id === a.id)
+ return normalizedVersion || a
+ })
+
+ const updatedNetwork = {
+ ...network,
+ allocations: finalAllocations,
+ }
+
+ setNetwork(updatedNetwork)
+ setSelectedAllocationId(newAllocation.id)
+ setTool('select')
+
+ // Re-propagate
+ setTimeout(() => handlePropagateFlow(), 100)
+ }
+
+ // Update allocation percentage
+ const updateAllocationPercentage = (allocationId: string, newPercentage: number) => {
+ const allocation = network.allocations.find(a => a.id === allocationId)
+ if (!allocation) return
+
+ const updatedAllocations = network.allocations.map(a =>
+ a.id === allocationId
+ ? { ...a, percentage: Math.max(0, Math.min(1, newPercentage)) }
+ : a
+ )
+
+ const sourceAllocations = updatedAllocations.filter(
+ a => a.sourceNodeId === allocation.sourceNodeId
+ )
+ const normalized = normalizeFlowAllocations(sourceAllocations)
+
+ const finalAllocations = updatedAllocations.map(a => {
+ const normalizedVersion = normalized.find(n => n.id === a.id)
+ return normalizedVersion || a
+ })
+
+ const updatedNetwork = {
+ ...network,
+ allocations: finalAllocations,
+ }
+
+ setNetwork(updatedNetwork)
+
+ // Re-propagate
+ setTimeout(() => {
+ const result = propagateFlow(updatedNetwork)
+ setNetwork(result.network)
+ setParticles(result.particles)
+ }, 100)
+ }
+
+ // Delete allocation
+ const deleteAllocation = (allocationId: string) => {
+ const allocation = network.allocations.find(a => a.id === allocationId)
+ if (!allocation) return
+
+ const updatedAllocations = network.allocations.filter(a => a.id !== allocationId)
+
+ const sourceAllocations = updatedAllocations.filter(
+ a => a.sourceNodeId === allocation.sourceNodeId
+ )
+ const normalized = normalizeFlowAllocations(sourceAllocations)
+
+ const finalAllocations = updatedAllocations.map(a => {
+ const normalizedVersion = normalized.find(n => n.id === a.id)
+ return normalizedVersion || a
+ })
+
+ const updatedNetwork = {
+ ...network,
+ allocations: finalAllocations,
+ }
+
+ setNetwork(updatedNetwork)
+ setSelectedAllocationId(null)
+
+ // Re-propagate
+ setTimeout(() => handlePropagateFlow(), 100)
+ }
+
+ // Load network
+ const handleLoadNetwork = (key: string) => {
+ setSelectedNetworkKey(key)
+ const newNetwork = getFlowSampleNetwork(key as keyof typeof flowSampleNetworks)
+ setNetwork(newNetwork)
+ setSelectedNodeId(null)
+ setSelectedAllocationId(null)
+ setAllocationSourceId(null)
+ setTool('select')
+
+ // Propagate initial flows
+ setTimeout(() => handlePropagateFlow(), 100)
+ }
+
+ // Get selected details
+ const selectedNode = selectedNodeId
+ ? network.nodes.find(n => n.id === selectedNodeId)
+ : null
+
+ const selectedAllocation = selectedAllocationId
+ ? network.allocations.find(a => a.id === selectedAllocationId)
+ : null
+
+ const outgoingAllocations = selectedNode
+ ? network.allocations.filter(a => a.sourceNodeId === selectedNode.id)
+ : []
+
+ const selectedAllocationSiblings = selectedAllocation
+ ? network.allocations.filter(a => a.sourceNodeId === selectedAllocation.sourceNodeId)
+ : []
+
+ // Keyboard shortcuts
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ setTool('select')
+ setAllocationSourceId(null)
+ setSelectedNodeId(null)
+ setSelectedAllocationId(null)
+ } else if (e.key === 'Delete' && selectedAllocationId) {
+ deleteAllocation(selectedAllocationId)
+ } else if (e.key === ' ') {
+ e.preventDefault()
+ setIsAnimating(prev => !prev)
+ }
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [selectedAllocationId])
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Main Content */}
+
+ {/* Canvas */}
+
+
+
+ {/* Tool indicator */}
+ {allocationSourceId && (
+
+ Click target node to create allocation
+
+ )}
+
+ {/* Animation status */}
+
+ {isAnimating ? 'βΆοΈ Animating' : 'βΈοΈ Paused'} (Space to toggle)
+
+
+
+ {/* Sidebar */}
+
+ {/* Tools */}
+
+
Tools
+
+
+
+
+
+
+ {/* Network Selector */}
+
+
Select Network
+
+
+
+ {/* Network Info */}
+
+
{network.name}
+
+
+ Nodes:
+ {network.nodes.filter(n => !n.isOverflowSink).length}
+
+
+ Allocations:
+ {network.allocations.length}
+
+
+ Total Inflow:
+ {formatFlow(network.totalInflow)}
+
+
+ Total Absorbed:
+ {formatFlow(network.totalAbsorbed)}
+
+
+ Total Outflow:
+ {formatFlow(network.totalOutflow)}
+
+
+
+
+ {/* Set Flow Input */}
+ {selectedNode && !selectedNode.isOverflowSink && (
+
+
π§ Set Flow Input
+
+
+
+ handleSetNodeFlow(selectedNode.id, parseFloat(e.target.value) || 0)}
+ className="w-full px-3 py-2 bg-slate-700 rounded text-sm"
+ min="0"
+ step="10"
+ />
+
+
+ Current inflow: {formatFlow(selectedNode.inflow)}
+
+ Absorbed: {formatFlow(selectedNode.absorbed)}
+
+ Outflow: {formatFlow(selectedNode.outflow)}
+
+
+
+ )}
+
+ {/* Selected Allocation Editor */}
+ {selectedAllocation && (
+
+
Edit Allocation
+
+
+ From:
+
+ {network.nodes.find(n => n.id === selectedAllocation.sourceNodeId)?.name}
+
+
+
+ To:
+
+ {network.nodes.find(n => n.id === selectedAllocation.targetNodeId)?.name}
+
+
+
+
+ {selectedAllocationSiblings.length === 1 ? (
+
+ Single allocation must be 100%.
+
+ ) : (
+
+ updateAllocationPercentage(
+ selectedAllocation.id,
+ parseFloat(e.target.value) / 100
+ )
+ }
+ className="w-full"
+ />
+ )}
+
+
+
+
+ )}
+
+ {/* Selected Node Details */}
+ {selectedNode && (
+
+
Node Details
+
+
+ Name:
+ {selectedNode.name}
+
+
+ Status:
+
+ {selectedNode.status.toUpperCase()}
+
+
+
+
+ Inflow:
+ {formatFlow(selectedNode.inflow)}
+
+
+ Absorbed:
+ {formatFlow(selectedNode.absorbed)}
+
+
+ Outflow:
+ {formatFlow(selectedNode.outflow)}
+
+
+ Min Absorption:
+ {formatFlow(selectedNode.minAbsorption)}
+
+
+ Max Absorption:
+ {formatFlow(selectedNode.maxAbsorption)}
+
+
+
+ {/* Outgoing Allocations */}
+ {outgoingAllocations.length > 0 && (
+
+
Outgoing Allocations:
+ {outgoingAllocations.map((alloc) => {
+ const target = network.nodes.find(n => n.id === alloc.targetNodeId)
+ return (
+
setSelectedAllocationId(alloc.id)}
+ >
+ β {target?.name}
+
+ {Math.round(alloc.percentage * 100)}%
+
+
+ )
+ })}
+
+ )}
+
+
+ )}
+
+ {/* Legend */}
+
+
Legend
+
+
+
+
Starved - Below minimum absorption
+
+
+
+
Minimum - At minimum absorption
+
+
+
+
Healthy - Between min and max
+
+
+
+
Saturated - At maximum capacity
+
+
+
+
Particle - Flow animation
+
+
+
+
+ {/* Instructions */}
+
+
+ Flow-Based Model
+
+
+ - Click node to select and set flow
+ - Use Create Arrow to draw allocations
+ - Watch flows propagate in real-time
+ - Press Space to pause/play animation
+ - Overflow sink appears automatically if needed
+
+
+
+
+
+ )
+}
diff --git a/components/hero-section.tsx b/components/hero-section.tsx
index b2c6c91..81e9845 100644
--- a/components/hero-section.tsx
+++ b/components/hero-section.tsx
@@ -36,12 +36,16 @@ export function HeroSection() {
diff --git a/lib/tbff-flow/README.md b/lib/tbff-flow/README.md
new file mode 100644
index 0000000..e57ce7f
--- /dev/null
+++ b/lib/tbff-flow/README.md
@@ -0,0 +1,404 @@
+# Flow-Based Flow Funding Module
+
+**Status**: Initial Implementation Complete β
+**Route**: `/tbff-flow`
+**Last Updated**: 2025-11-09
+
+---
+
+## Overview
+
+This module implements a **flow-based** visualization of Flow Funding, focusing on resource circulation rather than accumulation. Unlike the stock-based model (`/tbff`), this visualizes how resources move through networks in real-time.
+
+## Core Concepts
+
+### Flow vs Stock
+
+**Stock Model** (`/tbff`):
+- Accounts have balances (accumulated resources)
+- Distribution adds to balances
+- Thresholds define states (deficit, healthy, overflow)
+- Overflow triggers redistribution
+
+**Flow Model** (`/tbff-flow`):
+- Nodes have flow rates (resources in motion)
+- Flow continuously circulates
+- Thresholds define absorption capacity
+- Real-time animation shows circulation
+
+### 1. Flow Node
+
+Each node receives flow, absorbs what it needs, and passes excess onward.
+
+**Properties**:
+- `minAbsorption`: Minimum flow needed to function (survival level)
+- `maxAbsorption`: Maximum flow that can be absorbed (capacity)
+- `externalFlow`: Flow injected by user (source)
+- `inflow`: Total flow entering (external + from allocations)
+- `absorbed`: Amount kept (between min and max)
+- `outflow`: Excess flow leaving (to allocations)
+
+**Status**:
+- π΄ **Starved**: absorbed < minAbsorption
+- π‘ **Minimum**: absorbed β minAbsorption
+- π΅ **Healthy**: minAbsorption < absorbed < maxAbsorption
+- π’ **Saturated**: absorbed β₯ maxAbsorption
+
+### 2. Flow Propagation
+
+**Algorithm**:
+1. Start with external flows (user-set inputs)
+2. For each iteration:
+ - Calculate absorption: `min(inflow, maxAbsorption)`
+ - Calculate outflow: `max(0, inflow - absorbed)`
+ - Distribute outflow via allocations
+ - Update inflows for next iteration
+3. Repeat until convergence (flows stabilize)
+4. Create overflow sink if needed
+
+**Convergence**: Flows change by less than 0.01 between iterations
+
+### 3. Overflow Sink
+
+**Auto-created** when network has unallocated outflow.
+
+**Purpose**: Capture excess flow that has nowhere to go
+
+**Behavior**:
+- Appears automatically when needed
+- Disappears when no longer needed
+- Infinite absorption capacity
+- Visualized with "SINK" label
+
+### 4. Flow Particles
+
+**Animation**: Particles move along allocation arrows
+
+**Properties**:
+- Position along arrow (0.0 to 1.0)
+- Amount (affects size and color)
+- Speed (faster for higher flow)
+- Continuous loop (resets at end)
+
+**Purpose**: Visual feedback of resource circulation
+
+---
+
+## Module Structure
+
+```
+lib/tbff-flow/
+βββ types.ts # Flow-based types (FlowNode, FlowNetwork, etc.)
+βββ utils.ts # Utility functions (absorption, status, etc.)
+βββ algorithms.ts # Flow propagation algorithm
+βββ rendering.ts # Canvas rendering with particles
+βββ sample-networks.ts # Demo networks (linear, split, circular)
+βββ README.md # This file
+
+app/tbff-flow/
+βββ page.tsx # Main page with real-time animation
+```
+
+---
+
+## Features
+
+### β
Implemented
+
+1. **Flow Propagation Algorithm**
+ - Iterative flow distribution
+ - Convergence detection
+ - Comprehensive console logging
+ - Automatic overflow node creation
+
+2. **Real-Time Animation**
+ - Continuous particle movement
+ - Pause/play with spacebar
+ - Smooth 60fps rendering
+ - Visual flow indicators
+
+3. **Interactive Controls**
+ - Click node to set external flow
+ - Create allocations with arrow tool
+ - Edit allocation percentages
+ - Delete allocations
+
+4. **Sample Networks**
+ - Linear Flow (A β B β C)
+ - Split Flow (Projects + Commons)
+ - Circular Flow (A β B β C)
+ - Empty Network (build your own)
+
+5. **Visual Design**
+ - Flow bars show inflow/absorption/outflow
+ - Status colors (red/yellow/blue/green)
+ - Arrow thickness = flow amount
+ - Particle density = flow volume
+ - External flow indicators (green dots)
+
+---
+
+## Usage
+
+### Setting Flow
+
+1. Select a node (click on it)
+2. Enter external flow value in sidebar
+3. Watch flow propagate automatically
+4. See particles animate along arrows
+
+### Creating Allocations
+
+1. Click "Create Arrow" tool
+2. Click source node
+3. Click target node
+4. Allocation created with 50% default
+5. Flow re-propagates automatically
+
+### Observing Flow
+
+- **Inflow bars** (blue): Total flow entering
+- **Absorption bars** (status color): Amount kept
+- **Outflow bars** (green): Excess leaving
+- **Particles**: Moving resources
+- **Arrow thickness**: Flow amount
+
+### Keyboard Shortcuts
+
+- **Space**: Pause/play animation
+- **Escape**: Cancel creation, deselect
+- **Delete**: Remove selected allocation
+
+---
+
+## Sample Networks
+
+### 1. Linear Flow
+
+**Structure**: A β B β C
+
+**Setup**: Alice receives 100 flow, passes excess to Bob, who passes to Carol
+
+**Demonstrates**: Sequential absorption and propagation
+
+### 2. Split Flow
+
+**Structure**: Source β Projects (A, B) β Commons
+
+**Setup**: Source splits 60/40 between projects, which merge at Commons
+
+**Demonstrates**: Branching and merging flows
+
+### 3. Circular Flow
+
+**Structure**: A β B β C β A
+
+**Setup**: Alice injects 50 flow, which circulates continuously
+
+**Demonstrates**: Circular resource circulation
+
+### 4. Empty Network
+
+**Structure**: 3 unconnected nodes
+
+**Purpose**: Build custom flow patterns from scratch
+
+---
+
+## Algorithm Details
+
+### Flow Propagation Example
+
+```
+Initial State:
+ Alice: externalFlow = 100, maxAbsorption = 50
+ Bob: externalFlow = 0, maxAbsorption = 30
+ Alice β Bob (100%)
+
+Iteration 1:
+ Alice: inflow = 100, absorbed = 50, outflow = 50
+ Bob: inflow = 0 (not yet received)
+
+Iteration 2:
+ Alice: inflow = 100, absorbed = 50, outflow = 50
+ Bob: inflow = 50, absorbed = 30, outflow = 20
+
+Iteration 3 onwards:
+ (Converged - no change)
+
+Result:
+ Alice absorbs 50, passes 50 to Bob
+ Bob absorbs 30, has 20 outflow (needs overflow node)
+```
+
+### Convergence
+
+**Condition**: `max(|inflow[i] - inflow[i-1]|) < 0.01` for all nodes
+
+**Max Iterations**: 100
+
+**Typical**: Converges in 10-20 iterations for most networks
+
+---
+
+## Technical Implementation
+
+### State Management
+
+```typescript
+const [network, setNetwork] = useState(...)
+const [particles, setParticles] = useState([])
+const [isAnimating, setIsAnimating] = useState(true)
+```
+
+### Animation Loop
+
+```typescript
+useEffect(() => {
+ const animate = () => {
+ setParticles(prev => updateFlowParticles(prev))
+ requestAnimationFrame(animate)
+ }
+ requestAnimationFrame(animate)
+}, [isAnimating])
+```
+
+### Canvas Rendering
+
+```typescript
+useEffect(() => {
+ renderFlowNetwork(ctx, network, width, height, particles, ...)
+}, [network, particles, selectedNodeId, selectedAllocationId])
+```
+
+### Flow Propagation Trigger
+
+- On network load
+- On external flow change
+- On allocation create/update/delete
+- Automatic 100ms after change
+
+---
+
+## Design Decisions
+
+### 1. Why Separate from Stock Model?
+
+**Decision**: Create `/tbff-flow` as separate route
+
+**Reasoning**:
+- Different mental models (flow vs stock)
+- Different visualizations (particles vs bars)
+- Different algorithms (propagation vs distribution)
+- Users can compare both approaches
+
+### 2. Why Real-Time Continuous Animation?
+
+**Decision**: Particles move continuously at 60fps
+
+**Reasoning**:
+- Emphasizes circulation over states
+- More engaging and dynamic
+- Matches "flow" concept intuitively
+- Educational - see resources in motion
+
+**Trade-off**: More CPU usage vs better UX
+
+### 3. Why Auto-Create Overflow Node?
+
+**Decision**: Automatically create/remove overflow sink
+
+**Reasoning**:
+- Unallocated outflow needs destination
+- Prevents "leaking" flow
+- Conserves resources (total inflow = absorbed + overflow)
+- User shouldn't have to manually manage
+
+### 4. Why Absorption Thresholds?
+
+**Decision**: Min/max thresholds define absorption capacity
+
+**Reasoning**:
+- Maps to real resource needs (minimum to function, maximum to benefit)
+- Similar to stock model (easy to understand)
+- Allows partial absorption (not all-or-nothing)
+- Generates meaningful outflow for circulation
+
+---
+
+## Comparison with Stock Model
+
+| Aspect | Stock Model (`/tbff`) | Flow Model (`/tbff-flow`) |
+|--------|----------------------|---------------------------|
+| **Core Metric** | Balance (accumulated) | Flow rate (per time) |
+| **Visualization** | Fill height | Flow bars + particles |
+| **Input** | Add funding (one-time) | Set flow (continuous) |
+| **Algorithm** | Initial distribution | Flow propagation |
+| **Animation** | Static (for now) | Real-time particles |
+| **Overflow** | Triggers redistribution | Continuous outflow |
+| **Use Case** | Budget allocation | Resource circulation |
+
+---
+
+## Future Enhancements
+
+### Phase 2
+
+- [ ] Variable particle colors (by source)
+- [ ] Flow rate history graphs
+- [ ] Equilibrium detection indicator
+- [ ] Save/load custom networks
+- [ ] Export flow data (CSV, JSON)
+
+### Phase 3
+
+- [ ] Multiple simultaneous external flows
+- [ ] Time-varying flows (pulses, waves)
+- [ ] Flow constraints (min/max on arrows)
+- [ ] Network analysis (bottlenecks, unutilized capacity)
+
+### Phase 4
+
+- [ ] Combine stock and flow models
+- [ ] Hybrid visualization
+- [ ] Round-based simulation mode
+- [ ] Multi-network comparison
+
+---
+
+## Resources
+
+- **Stock Model**: `/lib/tbff/README.md`
+- **Design Session**: `/.claude/journal/FLOW_FUNDING_DESIGN_SESSION.md`
+- **Academic Paper**: `../../../threshold-based-flow-funding.md`
+
+---
+
+## Testing Checklist
+
+- [x] Load default network (Linear Flow)
+- [x] Switch between sample networks
+- [x] Set external flow on node
+- [x] Watch flow propagate
+- [x] See particles animate
+- [x] Create allocation with arrow tool
+- [x] Edit allocation percentage
+- [x] Delete allocation
+- [x] Pause/play animation with Space
+- [x] See overflow node appear when needed
+- [x] See overflow node disappear when not needed
+- [x] Check console for propagation logs
+- [x] Verify convergence
+- [x] Test circular flow network
+
+---
+
+**Built with**: TypeScript, React, Next.js, Canvas API, requestAnimationFrame
+
+**Module Owner**: TBFF Flow Team
+
+**Philosophy**: "Resources are meant to circulate, not accumulate."
+
+---
+
+*Flow where needed, absorb what's needed, pass on the rest.*
diff --git a/lib/tbff-flow/algorithms.ts b/lib/tbff-flow/algorithms.ts
new file mode 100644
index 0000000..4a19c6e
--- /dev/null
+++ b/lib/tbff-flow/algorithms.ts
@@ -0,0 +1,267 @@
+/**
+ * Flow propagation algorithms
+ *
+ * Models how flow circulates through the network:
+ * 1. Flow enters nodes (external + from allocations)
+ * 2. Nodes absorb what they can (up to max threshold)
+ * 3. Excess flows out via allocations
+ * 4. Repeat until steady state
+ */
+
+import type { FlowNetwork, FlowNode, FlowParticle, FlowPropagationResult } from './types'
+import { updateFlowNodeProperties, calculateFlowNetworkTotals, createOverflowNode, needsOverflowNode } from './utils'
+
+const MAX_ITERATIONS = 100
+const CONVERGENCE_THRESHOLD = 0.01
+
+/**
+ * Propagate flow through the network
+ *
+ * Algorithm:
+ * 1. Reset all inflows to external flow only
+ * 2. For each iteration:
+ * a. Calculate absorption and outflow for each node
+ * b. Distribute outflow via allocations
+ * c. Update inflows for next iteration
+ * 3. Repeat until flows stabilize or max iterations
+ * 4. Create overflow node if needed
+ * 5. Generate flow particles for animation
+ *
+ * @param network - Current network state
+ * @returns Updated network with flow propagation results
+ */
+export function propagateFlow(network: FlowNetwork): FlowPropagationResult {
+ console.log('\nπ Flow Propagation Started')
+ console.log('β'.repeat(50))
+
+ let currentNetwork = { ...network }
+ let iterations = 0
+ let converged = false
+
+ // Initialize: set inflow to external flow
+ let nodes = currentNetwork.nodes.map(node => ({
+ ...node,
+ inflow: node.externalFlow,
+ }))
+
+ console.log('Initial external flows:')
+ nodes.forEach(node => {
+ if (node.externalFlow > 0) {
+ console.log(` ${node.name}: ${node.externalFlow.toFixed(1)}`)
+ }
+ })
+
+ // Iterate until convergence
+ while (iterations < MAX_ITERATIONS && !converged) {
+ iterations++
+
+ // Step 1: Calculate absorption and outflow for each node
+ nodes = nodes.map(updateFlowNodeProperties)
+
+ // Step 2: Calculate new inflows from allocations
+ const newInflows = new Map()
+
+ // Initialize with external flows
+ nodes.forEach(node => {
+ newInflows.set(node.id, node.externalFlow)
+ })
+
+ // Add flow from allocations
+ nodes.forEach(sourceNode => {
+ if (sourceNode.outflow <= 0) return
+
+ // Get allocations from this node
+ const allocations = currentNetwork.allocations.filter(
+ a => a.sourceNodeId === sourceNode.id
+ )
+
+ if (allocations.length === 0) {
+ // No allocations - this flow will need overflow node
+ return
+ }
+
+ // Distribute outflow via allocations
+ allocations.forEach(allocation => {
+ const flowToTarget = sourceNode.outflow * allocation.percentage
+ const currentInflow = newInflows.get(allocation.targetNodeId) || 0
+ newInflows.set(allocation.targetNodeId, currentInflow + flowToTarget)
+ })
+ })
+
+ // Step 3: Check for convergence
+ let maxChange = 0
+ nodes.forEach(node => {
+ const newInflow = newInflows.get(node.id) || node.externalFlow
+ const change = Math.abs(newInflow - node.inflow)
+ maxChange = Math.max(maxChange, change)
+ })
+
+ converged = maxChange < CONVERGENCE_THRESHOLD
+
+ // Step 4: Update inflows for next iteration
+ nodes = nodes.map(node => ({
+ ...node,
+ inflow: newInflows.get(node.id) || node.externalFlow,
+ }))
+
+ if (iterations % 10 === 0 || converged) {
+ console.log(`Iteration ${iterations}: max change = ${maxChange.toFixed(3)}`)
+ }
+ }
+
+ console.log(`\n${converged ? 'β' : 'β οΈ'} ${converged ? 'Converged' : 'Max iterations reached'} after ${iterations} iterations`)
+
+ // Final property update
+ nodes = nodes.map(updateFlowNodeProperties)
+
+ // Check if we need an overflow node
+ let finalNodes = nodes
+ let overflowNodeId: string | null = currentNetwork.overflowNodeId
+
+ const needsOverflow = needsOverflowNode({ ...currentNetwork, nodes })
+
+ if (needsOverflow && !overflowNodeId) {
+ // Create overflow node
+ const overflowNode = createOverflowNode(600, 300)
+ finalNodes = [...nodes, overflowNode]
+ overflowNodeId = overflowNode.id
+
+ console.log('\nπ§ Created overflow sink node')
+
+ // Calculate total unallocated outflow
+ let totalUnallocated = 0
+ nodes.forEach(node => {
+ const hasAllocations = currentNetwork.allocations.some(a => a.sourceNodeId === node.id)
+ if (!hasAllocations && node.outflow > 0) {
+ totalUnallocated += node.outflow
+ }
+ })
+
+ // Update overflow node
+ const overflowNode2 = finalNodes.find(n => n.id === overflowNodeId)!
+ const updatedOverflowNode = updateFlowNodeProperties({
+ ...overflowNode2,
+ inflow: totalUnallocated,
+ })
+
+ finalNodes = finalNodes.map(n => n.id === overflowNodeId ? updatedOverflowNode : n)
+
+ console.log(` Receiving ${totalUnallocated.toFixed(1)} unallocated flow`)
+ } else if (!needsOverflow && overflowNodeId) {
+ // Remove overflow node
+ finalNodes = nodes.filter(n => !n.isOverflowSink)
+ overflowNodeId = null
+ console.log('\nποΈ Removed overflow sink node (no longer needed)')
+ }
+
+ // Generate flow particles for animation
+ const particles = generateFlowParticles(currentNetwork, finalNodes)
+
+ // Build final network
+ const finalNetwork = calculateFlowNetworkTotals({
+ ...currentNetwork,
+ nodes: finalNodes,
+ overflowNodeId,
+ })
+
+ console.log('\nπ Final State:')
+ console.log(` Total inflow: ${finalNetwork.totalInflow.toFixed(1)}`)
+ console.log(` Total absorbed: ${finalNetwork.totalAbsorbed.toFixed(1)}`)
+ console.log(` Total outflow: ${finalNetwork.totalOutflow.toFixed(1)}`)
+
+ console.log('\nπ― Node States:')
+ finalNodes.forEach(node => {
+ if (node.inflow > 0 || node.absorbed > 0 || node.outflow > 0) {
+ console.log(
+ ` ${node.name.padEnd(15)} ` +
+ `in: ${node.inflow.toFixed(1).padStart(6)} ` +
+ `abs: ${node.absorbed.toFixed(1).padStart(6)} ` +
+ `out: ${node.outflow.toFixed(1).padStart(6)} ` +
+ `[${node.status}]`
+ )
+ }
+ })
+
+ return {
+ network: finalNetwork,
+ iterations,
+ converged,
+ particles,
+ }
+}
+
+/**
+ * Generate flow particles for animation
+ * Creates particles traveling along allocations based on flow amount
+ */
+function generateFlowParticles(network: FlowNetwork, nodes: FlowNode[]): FlowParticle[] {
+ const particles: FlowParticle[] = []
+ let particleId = 0
+
+ // Create particles for each allocation based on flow amount
+ network.allocations.forEach(allocation => {
+ const sourceNode = nodes.find(n => n.id === allocation.sourceNodeId)
+ if (!sourceNode || sourceNode.outflow <= 0) return
+
+ const flowAmount = sourceNode.outflow * allocation.percentage
+
+ // Create particles proportional to flow amount
+ // More flow = more particles
+ const particleCount = Math.min(10, Math.max(1, Math.floor(flowAmount / 10)))
+
+ for (let i = 0; i < particleCount; i++) {
+ particles.push({
+ id: `particle_${particleId++}`,
+ allocationId: allocation.id,
+ progress: i / particleCount, // Spread along arrow
+ amount: flowAmount / particleCount,
+ speed: 0.01 + (flowAmount / 1000), // Faster for more flow
+ })
+ }
+ })
+
+ // Create particles for overflow node flows
+ nodes.forEach(node => {
+ if (node.outflow > 0) {
+ const hasAllocations = network.allocations.some(a => a.sourceNodeId === node.id)
+ if (!hasAllocations && network.overflowNodeId) {
+ // Create virtual allocation to overflow node
+ const particleCount = Math.min(5, Math.max(1, Math.floor(node.outflow / 20)))
+ for (let i = 0; i < particleCount; i++) {
+ particles.push({
+ id: `particle_overflow_${particleId++}`,
+ allocationId: `virtual_${node.id}_overflow`,
+ progress: i / particleCount,
+ amount: node.outflow / particleCount,
+ speed: 0.01,
+ })
+ }
+ }
+ }
+ })
+
+ return particles
+}
+
+/**
+ * Update particles animation
+ * Moves particles along their paths
+ */
+export function updateFlowParticles(particles: FlowParticle[]): FlowParticle[] {
+ return particles.map(particle => {
+ const newProgress = particle.progress + particle.speed
+
+ // If particle reached end, reset to beginning
+ if (newProgress >= 1.0) {
+ return {
+ ...particle,
+ progress: 0,
+ }
+ }
+
+ return {
+ ...particle,
+ progress: newProgress,
+ }
+ })
+}
diff --git a/lib/tbff-flow/rendering.ts b/lib/tbff-flow/rendering.ts
new file mode 100644
index 0000000..dbd32df
--- /dev/null
+++ b/lib/tbff-flow/rendering.ts
@@ -0,0 +1,319 @@
+/**
+ * Canvas rendering for flow-based visualization
+ */
+
+import type { FlowNetwork, FlowNode, FlowAllocation, FlowParticle } from './types'
+import { getFlowNodeCenter, getFlowStatusColor } from './utils'
+
+/**
+ * Render a flow node
+ * Shows inflow, absorption, and outflow rates
+ */
+export function renderFlowNode(
+ ctx: CanvasRenderingContext2D,
+ node: FlowNode,
+ isSelected: boolean = false
+): void {
+ const { x, y, width, height } = node
+
+ // Background
+ ctx.fillStyle = isSelected ? '#1e293b' : '#0f172a'
+ ctx.fillRect(x, y, width, height)
+
+ // Border (thicker if selected)
+ ctx.strokeStyle = isSelected ? '#06b6d4' : '#334155'
+ ctx.lineWidth = isSelected ? 3 : 1
+ ctx.strokeRect(x, y, width, height)
+
+ // Flow visualization bars
+ const barWidth = width - 20
+ const barHeight = 8
+ const barX = x + 10
+ let barY = y + 25
+
+ // Inflow bar (blue)
+ if (node.inflow > 0) {
+ const inflowPercent = Math.min(1, node.inflow / node.maxAbsorption)
+ ctx.fillStyle = 'rgba(59, 130, 246, 0.7)'
+ ctx.fillRect(barX, barY, barWidth * inflowPercent, barHeight)
+ ctx.strokeStyle = 'rgba(59, 130, 246, 0.5)'
+ ctx.strokeRect(barX, barY, barWidth, barHeight)
+ }
+
+ barY += barHeight + 4
+
+ // Absorption bar (status color)
+ if (node.absorbed > 0) {
+ const absorbedPercent = Math.min(1, node.absorbed / node.maxAbsorption)
+ ctx.fillStyle = getFlowStatusColor(node.status, 0.7)
+ ctx.fillRect(barX, barY, barWidth * absorbedPercent, barHeight)
+ ctx.strokeStyle = getFlowStatusColor(node.status, 0.5)
+ ctx.strokeRect(barX, barY, barWidth, barHeight)
+ }
+
+ barY += barHeight + 4
+
+ // Outflow bar (green)
+ if (node.outflow > 0) {
+ const outflowPercent = Math.min(1, node.outflow / node.maxAbsorption)
+ ctx.fillStyle = 'rgba(16, 185, 129, 0.7)'
+ ctx.fillRect(barX, barY, barWidth * outflowPercent, barHeight)
+ ctx.strokeStyle = 'rgba(16, 185, 129, 0.5)'
+ ctx.strokeRect(barX, barY, barWidth, barHeight)
+ }
+
+ // Node name
+ ctx.fillStyle = '#f1f5f9'
+ ctx.font = 'bold 14px sans-serif'
+ ctx.textAlign = 'center'
+ ctx.fillText(node.name, x + width / 2, y + 16)
+
+ // Flow rates
+ ctx.font = '10px monospace'
+ ctx.fillStyle = '#94a3b8'
+ const textX = x + width / 2
+ let textY = y + height - 30
+
+ if (node.inflow > 0) {
+ ctx.fillText(`β ${node.inflow.toFixed(1)}`, textX, textY)
+ textY += 12
+ }
+ if (node.absorbed > 0) {
+ ctx.fillText(`β ${node.absorbed.toFixed(1)}`, textX, textY)
+ textY += 12
+ }
+ if (node.outflow > 0) {
+ ctx.fillText(`β ${node.outflow.toFixed(1)}`, textX, textY)
+ }
+
+ // External flow indicator
+ if (node.externalFlow > 0 && !node.isOverflowSink) {
+ ctx.fillStyle = '#10b981'
+ ctx.beginPath()
+ ctx.arc(x + width - 10, y + 10, 5, 0, 2 * Math.PI)
+ ctx.fill()
+ }
+
+ // Overflow sink indicator
+ if (node.isOverflowSink) {
+ ctx.fillStyle = '#64748b'
+ ctx.font = 'bold 12px sans-serif'
+ ctx.textAlign = 'center'
+ ctx.fillText('SINK', x + width / 2, y + height / 2)
+ }
+
+ // Center dot for connections
+ const centerX = x + width / 2
+ const centerY = y + height / 2
+ ctx.fillStyle = isSelected ? '#06b6d4' : '#475569'
+ ctx.beginPath()
+ ctx.arc(centerX, centerY, 4, 0, 2 * Math.PI)
+ ctx.fill()
+}
+
+/**
+ * Render a flow allocation arrow
+ * Thickness represents flow amount
+ */
+export function renderFlowAllocation(
+ ctx: CanvasRenderingContext2D,
+ allocation: FlowAllocation,
+ sourceNode: FlowNode,
+ targetNode: FlowNode,
+ isSelected: boolean = false
+): void {
+ const source = getFlowNodeCenter(sourceNode)
+ const target = getFlowNodeCenter(targetNode)
+
+ // Calculate arrow properties
+ const dx = target.x - source.x
+ const dy = target.y - source.y
+ const angle = Math.atan2(dy, dx)
+ const length = Math.sqrt(dx * dx + dy * dy)
+
+ // Shorten arrow to not overlap nodes
+ const shortenStart = 60
+ const shortenEnd = 60
+ const startX = source.x + (shortenStart / length) * dx
+ const startY = source.y + (shortenStart / length) * dy
+ const endX = target.x - (shortenEnd / length) * dx
+ const endY = target.y - (shortenEnd / length) * dy
+
+ // Arrow thickness based on flow amount
+ const flowAmount = sourceNode.outflow * allocation.percentage
+ const thickness = Math.max(2, Math.min(12, 2 + flowAmount / 10))
+
+ // Color based on selection and flow amount
+ const hasFlow = flowAmount > 0.1
+ const baseColor = isSelected ? '#06b6d4' : hasFlow ? '#10b981' : '#475569'
+ const alpha = hasFlow ? 0.8 : 0.3
+
+ // Draw arrow line
+ ctx.strokeStyle = baseColor
+ ctx.globalAlpha = alpha
+ ctx.lineWidth = thickness
+ ctx.lineCap = 'round'
+
+ ctx.beginPath()
+ ctx.moveTo(startX, startY)
+ ctx.lineTo(endX, endY)
+ ctx.stroke()
+
+ // Draw arrowhead
+ const headSize = 10 + thickness
+ ctx.fillStyle = baseColor
+ ctx.beginPath()
+ ctx.moveTo(endX, endY)
+ ctx.lineTo(
+ endX - headSize * Math.cos(angle - Math.PI / 6),
+ endY - headSize * Math.sin(angle - Math.PI / 6)
+ )
+ ctx.lineTo(
+ endX - headSize * Math.cos(angle + Math.PI / 6),
+ endY - headSize * Math.sin(angle + Math.PI / 6)
+ )
+ ctx.closePath()
+ ctx.fill()
+
+ ctx.globalAlpha = 1.0
+
+ // Label with flow amount
+ if (hasFlow || isSelected) {
+ const midX = (startX + endX) / 2
+ const midY = (startY + endY) / 2
+
+ // Background for text
+ ctx.fillStyle = '#0f172a'
+ ctx.fillRect(midX - 20, midY - 8, 40, 16)
+
+ // Text
+ ctx.fillStyle = baseColor
+ ctx.font = '11px monospace'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(flowAmount.toFixed(1), midX, midY)
+ }
+}
+
+/**
+ * Render flow particles moving along allocations
+ */
+export function renderFlowParticles(
+ ctx: CanvasRenderingContext2D,
+ particles: FlowParticle[],
+ network: FlowNetwork
+): void {
+ particles.forEach(particle => {
+ // Find the allocation
+ const allocation = network.allocations.find(a => a.id === particle.allocationId)
+ if (!allocation) {
+ // Handle virtual overflow allocations
+ if (particle.allocationId.startsWith('virtual_')) {
+ const sourceNodeId = particle.allocationId.split('_')[1]
+ const sourceNode = network.nodes.find(n => n.id === sourceNodeId)
+ const overflowNode = network.nodes.find(n => n.isOverflowSink)
+ if (sourceNode && overflowNode) {
+ renderParticle(ctx, particle, sourceNode, overflowNode)
+ }
+ }
+ return
+ }
+
+ const sourceNode = network.nodes.find(n => n.id === allocation.sourceNodeId)
+ const targetNode = network.nodes.find(n => n.id === allocation.targetNodeId)
+
+ if (!sourceNode || !targetNode) return
+
+ renderParticle(ctx, particle, sourceNode, targetNode)
+ })
+}
+
+/**
+ * Render a single particle
+ */
+function renderParticle(
+ ctx: CanvasRenderingContext2D,
+ particle: FlowParticle,
+ sourceNode: FlowNode,
+ targetNode: FlowNode
+): void {
+ const source = getFlowNodeCenter(sourceNode)
+ const target = getFlowNodeCenter(targetNode)
+
+ // Interpolate position
+ const x = source.x + (target.x - source.x) * particle.progress
+ const y = source.y + (target.y - source.y) * particle.progress
+
+ // Particle size based on amount
+ const size = Math.max(3, Math.min(8, particle.amount / 10))
+
+ // Draw particle
+ ctx.fillStyle = '#10b981'
+ ctx.globalAlpha = 0.8
+ ctx.beginPath()
+ ctx.arc(x, y, size, 0, 2 * Math.PI)
+ ctx.fill()
+ ctx.globalAlpha = 1.0
+
+ // Glow effect
+ const gradient = ctx.createRadialGradient(x, y, 0, x, y, size * 2)
+ gradient.addColorStop(0, 'rgba(16, 185, 129, 0.3)')
+ gradient.addColorStop(1, 'rgba(16, 185, 129, 0)')
+ ctx.fillStyle = gradient
+ ctx.beginPath()
+ ctx.arc(x, y, size * 2, 0, 2 * Math.PI)
+ ctx.fill()
+}
+
+/**
+ * Render entire flow network
+ */
+export function renderFlowNetwork(
+ ctx: CanvasRenderingContext2D,
+ network: FlowNetwork,
+ canvasWidth: number,
+ canvasHeight: number,
+ particles: FlowParticle[],
+ selectedNodeId: string | null = null,
+ selectedAllocationId: string | null = null
+): void {
+ // Clear canvas
+ ctx.fillStyle = '#0f172a'
+ ctx.fillRect(0, 0, canvasWidth, canvasHeight)
+
+ // Draw allocations (arrows) first
+ network.allocations.forEach(allocation => {
+ const sourceNode = network.nodes.find(n => n.id === allocation.sourceNodeId)
+ const targetNode = network.nodes.find(n => n.id === allocation.targetNodeId)
+ if (sourceNode && targetNode) {
+ renderFlowAllocation(
+ ctx,
+ allocation,
+ sourceNode,
+ targetNode,
+ allocation.id === selectedAllocationId
+ )
+ }
+ })
+
+ // Draw particles
+ renderFlowParticles(ctx, particles, network)
+
+ // Draw nodes on top
+ network.nodes.forEach(node => {
+ renderFlowNode(ctx, node, node.id === selectedNodeId)
+ })
+
+ // Draw network stats in corner
+ ctx.fillStyle = '#f1f5f9'
+ ctx.font = '12px monospace'
+ ctx.textAlign = 'left'
+ const statsX = 10
+ let statsY = 20
+
+ ctx.fillText(`Inflow: ${network.totalInflow.toFixed(1)}`, statsX, statsY)
+ statsY += 16
+ ctx.fillText(`Absorbed: ${network.totalAbsorbed.toFixed(1)}`, statsX, statsY)
+ statsY += 16
+ ctx.fillText(`Outflow: ${network.totalOutflow.toFixed(1)}`, statsX, statsY)
+}
diff --git a/lib/tbff-flow/sample-networks.ts b/lib/tbff-flow/sample-networks.ts
new file mode 100644
index 0000000..f462563
--- /dev/null
+++ b/lib/tbff-flow/sample-networks.ts
@@ -0,0 +1,329 @@
+/**
+ * Sample flow networks for demonstration
+ */
+
+import type { FlowNetwork, FlowNode } from './types'
+import { updateFlowNodeProperties, calculateFlowNetworkTotals } from './utils'
+
+/**
+ * Create a simple linear flow: A β B β C
+ * Flow enters A, passes through to C
+ */
+export function createLinearFlowNetwork(): FlowNetwork {
+ const nodes: FlowNode[] = [
+ {
+ id: 'alice',
+ name: 'Alice',
+ x: 100,
+ y: 200,
+ width: 120,
+ height: 100,
+ minAbsorption: 10,
+ maxAbsorption: 50,
+ inflow: 100, // Start with 100 flow
+ absorbed: 0,
+ outflow: 0,
+ status: 'healthy',
+ externalFlow: 100,
+ isOverflowSink: false,
+ },
+ {
+ id: 'bob',
+ name: 'Bob',
+ x: 300,
+ y: 200,
+ width: 120,
+ height: 100,
+ minAbsorption: 10,
+ maxAbsorption: 30,
+ inflow: 0,
+ absorbed: 0,
+ outflow: 0,
+ status: 'starved',
+ externalFlow: 0,
+ isOverflowSink: false,
+ },
+ {
+ id: 'carol',
+ name: 'Carol',
+ x: 500,
+ y: 200,
+ width: 120,
+ height: 100,
+ minAbsorption: 10,
+ maxAbsorption: 40,
+ inflow: 0,
+ absorbed: 0,
+ outflow: 0,
+ status: 'starved',
+ externalFlow: 0,
+ isOverflowSink: false,
+ },
+ ]
+
+ const allocations = [
+ { id: 'alloc_1', sourceNodeId: 'alice', targetNodeId: 'bob', percentage: 1.0 },
+ { id: 'alloc_2', sourceNodeId: 'bob', targetNodeId: 'carol', percentage: 1.0 },
+ ]
+
+ return calculateFlowNetworkTotals({
+ name: 'Linear Flow (A β B β C)',
+ nodes: nodes.map(updateFlowNodeProperties),
+ allocations,
+ totalInflow: 0,
+ totalAbsorbed: 0,
+ totalOutflow: 0,
+ overflowNodeId: null,
+ })
+}
+
+/**
+ * Create a split flow: A β B and C
+ * Flow enters A, splits between B and C
+ */
+export function createSplitFlowNetwork(): FlowNetwork {
+ const nodes: FlowNode[] = [
+ {
+ id: 'source',
+ name: 'Source',
+ x: 100,
+ y: 200,
+ width: 120,
+ height: 100,
+ minAbsorption: 5,
+ maxAbsorption: 20,
+ inflow: 100,
+ absorbed: 0,
+ outflow: 0,
+ status: 'healthy',
+ externalFlow: 100,
+ isOverflowSink: false,
+ },
+ {
+ id: 'project_a',
+ name: 'Project A',
+ x: 300,
+ y: 100,
+ width: 120,
+ height: 100,
+ minAbsorption: 15,
+ maxAbsorption: 40,
+ inflow: 0,
+ absorbed: 0,
+ outflow: 0,
+ status: 'starved',
+ externalFlow: 0,
+ isOverflowSink: false,
+ },
+ {
+ id: 'project_b',
+ name: 'Project B',
+ x: 300,
+ y: 300,
+ width: 120,
+ height: 100,
+ minAbsorption: 15,
+ maxAbsorption: 40,
+ inflow: 0,
+ absorbed: 0,
+ outflow: 0,
+ status: 'starved',
+ externalFlow: 0,
+ isOverflowSink: false,
+ },
+ {
+ id: 'commons',
+ name: 'Commons',
+ x: 500,
+ y: 200,
+ width: 120,
+ height: 100,
+ minAbsorption: 10,
+ maxAbsorption: 30,
+ inflow: 0,
+ absorbed: 0,
+ outflow: 0,
+ status: 'starved',
+ externalFlow: 0,
+ isOverflowSink: false,
+ },
+ ]
+
+ const allocations = [
+ { id: 'alloc_1', sourceNodeId: 'source', targetNodeId: 'project_a', percentage: 0.6 },
+ { id: 'alloc_2', sourceNodeId: 'source', targetNodeId: 'project_b', percentage: 0.4 },
+ { id: 'alloc_3', sourceNodeId: 'project_a', targetNodeId: 'commons', percentage: 1.0 },
+ { id: 'alloc_4', sourceNodeId: 'project_b', targetNodeId: 'commons', percentage: 1.0 },
+ ]
+
+ return calculateFlowNetworkTotals({
+ name: 'Split Flow (Source β Projects β Commons)',
+ nodes: nodes.map(updateFlowNodeProperties),
+ allocations,
+ totalInflow: 0,
+ totalAbsorbed: 0,
+ totalOutflow: 0,
+ overflowNodeId: null,
+ })
+}
+
+/**
+ * Create a circular flow: A β B β C β A
+ * Flow circulates through the network
+ */
+export function createCircularFlowNetwork(): FlowNetwork {
+ const centerX = 350
+ const centerY = 250
+ const radius = 150
+
+ const nodes: FlowNode[] = [
+ {
+ id: 'alice',
+ name: 'Alice',
+ x: centerX + radius * Math.cos(0) - 60,
+ y: centerY + radius * Math.sin(0) - 50,
+ width: 120,
+ height: 100,
+ minAbsorption: 10,
+ maxAbsorption: 30,
+ inflow: 50,
+ absorbed: 0,
+ outflow: 0,
+ status: 'healthy',
+ externalFlow: 50,
+ isOverflowSink: false,
+ },
+ {
+ id: 'bob',
+ name: 'Bob',
+ x: centerX + radius * Math.cos((2 * Math.PI) / 3) - 60,
+ y: centerY + radius * Math.sin((2 * Math.PI) / 3) - 50,
+ width: 120,
+ height: 100,
+ minAbsorption: 10,
+ maxAbsorption: 30,
+ inflow: 0,
+ absorbed: 0,
+ outflow: 0,
+ status: 'starved',
+ externalFlow: 0,
+ isOverflowSink: false,
+ },
+ {
+ id: 'carol',
+ name: 'Carol',
+ x: centerX + radius * Math.cos((4 * Math.PI) / 3) - 60,
+ y: centerY + radius * Math.sin((4 * Math.PI) / 3) - 50,
+ width: 120,
+ height: 100,
+ minAbsorption: 10,
+ maxAbsorption: 30,
+ inflow: 0,
+ absorbed: 0,
+ outflow: 0,
+ status: 'starved',
+ externalFlow: 0,
+ isOverflowSink: false,
+ },
+ ]
+
+ const allocations = [
+ { id: 'alloc_1', sourceNodeId: 'alice', targetNodeId: 'bob', percentage: 1.0 },
+ { id: 'alloc_2', sourceNodeId: 'bob', targetNodeId: 'carol', percentage: 1.0 },
+ { id: 'alloc_3', sourceNodeId: 'carol', targetNodeId: 'alice', percentage: 1.0 },
+ ]
+
+ return calculateFlowNetworkTotals({
+ name: 'Circular Flow (A β B β C β A)',
+ nodes: nodes.map(updateFlowNodeProperties),
+ allocations,
+ totalInflow: 0,
+ totalAbsorbed: 0,
+ totalOutflow: 0,
+ overflowNodeId: null,
+ })
+}
+
+/**
+ * Create an empty network for user to build
+ */
+export function createEmptyFlowNetwork(): FlowNetwork {
+ const nodes: FlowNode[] = [
+ {
+ id: 'node1',
+ name: 'Node 1',
+ x: 150,
+ y: 150,
+ width: 120,
+ height: 100,
+ minAbsorption: 10,
+ maxAbsorption: 50,
+ inflow: 0,
+ absorbed: 0,
+ outflow: 0,
+ status: 'starved',
+ externalFlow: 0,
+ isOverflowSink: false,
+ },
+ {
+ id: 'node2',
+ name: 'Node 2',
+ x: 350,
+ y: 150,
+ width: 120,
+ height: 100,
+ minAbsorption: 10,
+ maxAbsorption: 50,
+ inflow: 0,
+ absorbed: 0,
+ outflow: 0,
+ status: 'starved',
+ externalFlow: 0,
+ isOverflowSink: false,
+ },
+ {
+ id: 'node3',
+ name: 'Node 3',
+ x: 250,
+ y: 300,
+ width: 120,
+ height: 100,
+ minAbsorption: 10,
+ maxAbsorption: 50,
+ inflow: 0,
+ absorbed: 0,
+ outflow: 0,
+ status: 'starved',
+ externalFlow: 0,
+ isOverflowSink: false,
+ },
+ ]
+
+ return calculateFlowNetworkTotals({
+ name: 'Empty Network (Set flows to begin)',
+ nodes: nodes.map(updateFlowNodeProperties),
+ allocations: [],
+ totalInflow: 0,
+ totalAbsorbed: 0,
+ totalOutflow: 0,
+ overflowNodeId: null,
+ })
+}
+
+export const flowSampleNetworks = {
+ linear: createLinearFlowNetwork(),
+ split: createSplitFlowNetwork(),
+ circular: createCircularFlowNetwork(),
+ empty: createEmptyFlowNetwork(),
+}
+
+export const flowNetworkOptions = [
+ { value: 'linear', label: 'Linear Flow (A β B β C)' },
+ { value: 'split', label: 'Split Flow (Projects + Commons)' },
+ { value: 'circular', label: 'Circular Flow (A β B β C)' },
+ { value: 'empty', label: 'Empty Network' },
+]
+
+export function getFlowSampleNetwork(key: keyof typeof flowSampleNetworks): FlowNetwork {
+ return flowSampleNetworks[key]
+}
diff --git a/lib/tbff-flow/types.ts b/lib/tbff-flow/types.ts
new file mode 100644
index 0000000..529430a
--- /dev/null
+++ b/lib/tbff-flow/types.ts
@@ -0,0 +1,90 @@
+/**
+ * Flow-based Flow Funding Types
+ *
+ * This model focuses on resource circulation rather than accumulation.
+ * Nodes receive flow, absorb what they need, and pass excess to others.
+ */
+
+export type FlowNodeStatus = 'starved' | 'minimum' | 'healthy' | 'saturated'
+
+/**
+ * A node in the flow network
+ * Flow enters, gets partially absorbed, and excess flows out
+ */
+export interface FlowNode {
+ id: string
+ name: string
+
+ // Position for rendering
+ x: number
+ y: number
+ width: number
+ height: number
+
+ // Flow thresholds (per time unit)
+ minAbsorption: number // Minimum flow needed to function
+ maxAbsorption: number // Maximum flow that can be absorbed
+
+ // Current flow state (computed)
+ inflow: number // Total flow entering this node
+ absorbed: number // Amount absorbed (between min and max)
+ outflow: number // Excess flow leaving this node
+ status: FlowNodeStatus // Derived from absorbed vs thresholds
+
+ // External flow input (set by user)
+ externalFlow: number // Flow injected into this node
+
+ // Special node type
+ isOverflowSink: boolean // True for the auto-created overflow node
+}
+
+/**
+ * Allocation defines how outflow is distributed
+ * Same as stock model but applied to outflow instead of overflow
+ */
+export interface FlowAllocation {
+ id: string
+ sourceNodeId: string
+ targetNodeId: string
+ percentage: number // 0.0 to 1.0
+}
+
+/**
+ * The complete flow network
+ */
+export interface FlowNetwork {
+ name: string
+ nodes: FlowNode[]
+ allocations: FlowAllocation[]
+
+ // Network-level computed properties
+ totalInflow: number // Sum of all external flows
+ totalAbsorbed: number // Sum of all absorbed flow
+ totalOutflow: number // Sum of all outflow
+
+ // Overflow sink
+ overflowNodeId: string | null // ID of auto-created overflow node
+}
+
+/**
+ * Flow particle for animation
+ * Moves along allocation arrows
+ */
+export interface FlowParticle {
+ id: string
+ allocationId: string // Which arrow it's traveling along
+ progress: number // 0.0 to 1.0 (position along arrow)
+ amount: number // Size/color intensity
+ speed: number // How fast it moves
+}
+
+/**
+ * Flow propagation result
+ * Shows how flow moved through the network
+ */
+export interface FlowPropagationResult {
+ network: FlowNetwork
+ iterations: number // How many steps to converge
+ converged: boolean // Did it reach steady state?
+ particles: FlowParticle[] // Active particles for animation
+}
diff --git a/lib/tbff-flow/utils.ts b/lib/tbff-flow/utils.ts
new file mode 100644
index 0000000..a9b93b9
--- /dev/null
+++ b/lib/tbff-flow/utils.ts
@@ -0,0 +1,176 @@
+/**
+ * Utility functions for flow-based calculations
+ */
+
+import type { FlowNode, FlowNodeStatus, FlowNetwork, FlowAllocation } from './types'
+
+/**
+ * Calculate node status based on absorbed flow vs thresholds
+ */
+export function getFlowNodeStatus(node: FlowNode): FlowNodeStatus {
+ if (node.absorbed < node.minAbsorption) return 'starved'
+ if (node.absorbed >= node.maxAbsorption) return 'saturated'
+ if (Math.abs(node.absorbed - node.minAbsorption) < 0.01) return 'minimum'
+ return 'healthy'
+}
+
+/**
+ * Calculate how much flow a node absorbs given inflow
+ * Absorbs between min and max thresholds
+ */
+export function calculateAbsorption(inflow: number, minAbsorption: number, maxAbsorption: number): number {
+ // Absorb as much as possible, up to max
+ return Math.min(inflow, maxAbsorption)
+}
+
+/**
+ * Calculate outflow (excess that couldn't be absorbed)
+ */
+export function calculateOutflow(inflow: number, absorbed: number): number {
+ return Math.max(0, inflow - absorbed)
+}
+
+/**
+ * Update all computed properties on a node
+ */
+export function updateFlowNodeProperties(node: FlowNode): FlowNode {
+ const absorbed = calculateAbsorption(node.inflow, node.minAbsorption, node.maxAbsorption)
+ const outflow = calculateOutflow(node.inflow, absorbed)
+ const status = getFlowNodeStatus({ ...node, absorbed })
+
+ return {
+ ...node,
+ absorbed,
+ outflow,
+ status,
+ }
+}
+
+/**
+ * Calculate network-level totals
+ */
+export function calculateFlowNetworkTotals(network: FlowNetwork): FlowNetwork {
+ const totalInflow = network.nodes.reduce((sum, node) => sum + node.externalFlow, 0)
+ const totalAbsorbed = network.nodes.reduce((sum, node) => sum + node.absorbed, 0)
+ const totalOutflow = network.nodes.reduce((sum, node) => sum + node.outflow, 0)
+
+ return {
+ ...network,
+ totalInflow,
+ totalAbsorbed,
+ totalOutflow,
+ }
+}
+
+/**
+ * Normalize allocations so they sum to 1.0
+ * Same as stock model
+ */
+export function normalizeFlowAllocations(allocations: FlowAllocation[]): FlowAllocation[] {
+ if (allocations.length === 1) {
+ return allocations.map(a => ({ ...a, percentage: 1.0 }))
+ }
+
+ const total = allocations.reduce((sum, a) => sum + a.percentage, 0)
+
+ if (total === 0) {
+ const equalShare = 1.0 / allocations.length
+ return allocations.map((a) => ({ ...a, percentage: equalShare }))
+ }
+
+ if (Math.abs(total - 1.0) < 0.0001) {
+ return allocations
+ }
+
+ return allocations.map((a) => ({
+ ...a,
+ percentage: a.percentage / total,
+ }))
+}
+
+/**
+ * Get center point of a node (for arrow endpoints)
+ */
+export function getFlowNodeCenter(node: FlowNode): { x: number; y: number } {
+ return {
+ x: node.x + node.width / 2,
+ y: node.y + node.height / 2,
+ }
+}
+
+/**
+ * Get status color for rendering
+ */
+export function getFlowStatusColor(status: FlowNodeStatus, alpha: number = 1): string {
+ const colors = {
+ starved: `rgba(239, 68, 68, ${alpha})`, // Red
+ minimum: `rgba(251, 191, 36, ${alpha})`, // Yellow
+ healthy: `rgba(99, 102, 241, ${alpha})`, // Blue
+ saturated: `rgba(16, 185, 129, ${alpha})`, // Green
+ }
+ return colors[status]
+}
+
+/**
+ * Get status color as Tailwind class
+ */
+export function getFlowStatusColorClass(status: FlowNodeStatus): string {
+ const classes = {
+ starved: 'text-red-400',
+ minimum: 'text-yellow-400',
+ healthy: 'text-blue-400',
+ saturated: 'text-green-400',
+ }
+ return classes[status]
+}
+
+/**
+ * Format flow rate for display
+ */
+export function formatFlow(rate: number): string {
+ return rate.toFixed(1)
+}
+
+/**
+ * Format percentage for display
+ */
+export function formatPercentage(decimal: number): string {
+ return `${Math.round(decimal * 100)}%`
+}
+
+/**
+ * Create the overflow sink node
+ */
+export function createOverflowNode(x: number, y: number): FlowNode {
+ return {
+ id: 'overflow-sink',
+ name: 'Overflow',
+ x,
+ y,
+ width: 120,
+ height: 80,
+ minAbsorption: 0, // Can absorb any amount
+ maxAbsorption: Infinity, // No limit
+ inflow: 0,
+ absorbed: 0,
+ outflow: 0,
+ status: 'healthy',
+ externalFlow: 0,
+ isOverflowSink: true,
+ }
+}
+
+/**
+ * Check if network needs an overflow node
+ * Returns true if any node has outflow with no allocations
+ */
+export function needsOverflowNode(network: FlowNetwork): boolean {
+ return network.nodes.some(node => {
+ if (node.isOverflowSink) return false
+
+ const hasOutflow = node.outflow > 0.01
+ const hasAllocations = network.allocations.some(a => a.sourceNodeId === node.id)
+
+ return hasOutflow && !hasAllocations
+ })
+}
diff --git a/package.json b/package.json
index 142e10e..c187d90 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,8 @@
"build": "next build",
"dev": "next dev",
"lint": "eslint .",
- "start": "next start"
+ "start": "next start",
+ "screenshots": "node scripts/capture-screenshots.mjs"
},
"dependencies": {
"@folkjs/propagators": "link:../folkjs/packages/propagators",
@@ -67,6 +68,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"postcss": "^8.5",
+ "puppeteer": "^24.31.0",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"typescript": "^5"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3dbf16c..934a8e7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -177,6 +177,9 @@ importers:
postcss:
specifier: ^8.5
version: 8.5.0
+ puppeteer:
+ specifier: ^24.31.0
+ version: 24.31.0(typescript@5.0.2)
tailwindcss:
specifier: ^4.1.9
version: 4.1.9
@@ -197,6 +200,14 @@ packages:
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
+ '@babel/code-frame@7.27.1':
+ resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.28.5':
+ resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
+ engines: {node: '>=6.9.0'}
+
'@babel/runtime@7.28.4':
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
@@ -421,6 +432,11 @@ packages:
cpu: [x64]
os: [win32]
+ '@puppeteer/browsers@2.10.13':
+ resolution: {integrity: sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@radix-ui/number@1.1.0':
resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==}
@@ -1168,6 +1184,9 @@ packages:
'@tailwindcss/postcss@4.1.9':
resolution: {integrity: sha512-v3DKzHibZO8ioVDmuVHCW1PR0XSM7nS40EjZFJEA1xPuvTuQPaR5flE1LyikU3hu2u1KNWBtEaSe8qsQjX3tyg==}
+ '@tootallnate/quickjs-emscripten@0.23.0':
+ resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
+
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@@ -1204,6 +1223,9 @@ packages:
'@types/react@19.0.0':
resolution: {integrity: sha512-MY3oPudxvMYyesqs/kW1Bh8y9VqSmf+tzqw3ae8a9DZW68pUe3zAdHeI1jc6iAysuRdACnVknHP8AhwD4/dxtg==}
+ '@types/yauzl@2.10.3':
+ resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
+
'@vercel/analytics@1.5.0':
resolution: {integrity: sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g==}
peerDependencies:
@@ -1230,10 +1252,29 @@ packages:
vue-router:
optional: true
+ agent-base@7.1.4:
+ resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
+ engines: {node: '>= 14'}
+
+ ansi-regex@5.0.1:
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+ engines: {node: '>=8'}
+
+ ansi-styles@4.3.0:
+ resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+ engines: {node: '>=8'}
+
+ argparse@2.0.1:
+ resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
+ ast-types@0.13.4:
+ resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==}
+ engines: {node: '>=4'}
+
autoprefixer@10.4.20:
resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==}
engines: {node: ^10 || ^12 || >=14}
@@ -1241,15 +1282,72 @@ packages:
peerDependencies:
postcss: ^8.1.0
+ b4a@1.7.3:
+ resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==}
+ peerDependencies:
+ react-native-b4a: '*'
+ peerDependenciesMeta:
+ react-native-b4a:
+ optional: true
+
+ bare-events@2.8.2:
+ resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
+ peerDependencies:
+ bare-abort-controller: '*'
+ peerDependenciesMeta:
+ bare-abort-controller:
+ optional: true
+
+ bare-fs@4.5.1:
+ resolution: {integrity: sha512-zGUCsm3yv/ePt2PHNbVxjjn0nNB1MkIaR4wOCxJ2ig5pCf5cCVAYJXVhQg/3OhhJV6DB1ts7Hv0oUaElc2TPQg==}
+ engines: {bare: '>=1.16.0'}
+ peerDependencies:
+ bare-buffer: '*'
+ peerDependenciesMeta:
+ bare-buffer:
+ optional: true
+
+ bare-os@3.6.2:
+ resolution: {integrity: sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==}
+ engines: {bare: '>=1.14.0'}
+
+ bare-path@3.0.0:
+ resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==}
+
+ bare-stream@2.7.0:
+ resolution: {integrity: sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==}
+ peerDependencies:
+ bare-buffer: '*'
+ bare-events: '*'
+ peerDependenciesMeta:
+ bare-buffer:
+ optional: true
+ bare-events:
+ optional: true
+
+ bare-url@2.3.2:
+ resolution: {integrity: sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==}
+
baseline-browser-mapping@2.8.23:
resolution: {integrity: sha512-616V5YX4bepJFzNyOfce5Fa8fDJMfoxzOIzDCZwaGL8MKVpFrXqfNUoIpRn9YMI5pXf/VKgzjB4htFMsFKKdiQ==}
hasBin: true
+ basic-ftp@5.0.5:
+ resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==}
+ engines: {node: '>=10.0.0'}
+
browserslist@4.27.0:
resolution: {integrity: sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
+ buffer-crc32@0.2.13:
+ resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
+
+ callsites@3.1.0:
+ resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
+ engines: {node: '>=6'}
+
caniuse-lite@1.0.30001752:
resolution: {integrity: sha512-vKUk7beoukxE47P5gcVNKkDRzXdVofotshHwfR9vmpeFKxmI5PBpgOMC18LUJUA/DvJ70Y7RveasIBraqsyO/g==}
@@ -1257,12 +1355,21 @@ packages:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
+ chromium-bidi@11.0.0:
+ resolution: {integrity: sha512-cM3DI+OOb89T3wO8cpPSro80Q9eKYJ7hGVXoGS3GkDPxnYSqiv+6xwpIf6XERyJ9Tdsl09hmNmY94BkgZdVekw==}
+ peerDependencies:
+ devtools-protocol: '*'
+
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
+ cliui@8.0.1:
+ resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
+ engines: {node: '>=12'}
+
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -1273,6 +1380,22 @@ packages:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
+ color-convert@2.0.1:
+ resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+ engines: {node: '>=7.0.0'}
+
+ color-name@1.1.4:
+ resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+ cosmiconfig@9.0.0:
+ resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==}
+ engines: {node: '>=14'}
+ peerDependencies:
+ typescript: '>=4.9.5'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@@ -1320,15 +1443,32 @@ packages:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
+ data-uri-to-buffer@6.0.2:
+ resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
+ engines: {node: '>= 14'}
+
date-fns-jalali@4.1.0-0:
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
+ degenerator@5.0.1:
+ resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==}
+ engines: {node: '>= 14'}
+
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -1336,6 +1476,9 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+ devtools-protocol@0.0.1521046:
+ resolution: {integrity: sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==}
+
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
@@ -1355,31 +1498,100 @@ packages:
embla-carousel@8.5.1:
resolution: {integrity: sha512-JUb5+FOHobSiWQ2EJNaueCNT/cQU9L6XWBbWmorWPQT9bkbk+fhsuLr8wWrzXKagO3oWszBO7MSx+GfaRk4E6A==}
+ emoji-regex@8.0.0:
+ resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+
+ end-of-stream@1.4.5:
+ resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
+
enhanced-resolve@5.18.3:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
+ env-paths@2.2.1:
+ resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
+ engines: {node: '>=6'}
+
+ error-ex@1.3.4:
+ resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
+
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
+ escodegen@2.1.0:
+ resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==}
+ engines: {node: '>=6.0'}
+ hasBin: true
+
+ esprima@4.0.1:
+ resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
+ engines: {node: '>=4'}
+ hasBin: true
+
+ estraverse@5.3.0:
+ resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
+ engines: {node: '>=4.0'}
+
+ esutils@2.0.3:
+ resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+ engines: {node: '>=0.10.0'}
+
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
+ events-universal@1.0.1:
+ resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
+
+ extract-zip@2.0.1:
+ resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
+ engines: {node: '>= 10.17.0'}
+ hasBin: true
+
fast-equals@5.3.2:
resolution: {integrity: sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==}
engines: {node: '>=6.0.0'}
+ fast-fifo@1.3.2:
+ resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
+
+ fd-slicer@1.1.0:
+ resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
+
fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
+ get-caller-file@2.0.5:
+ resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
+ engines: {node: 6.* || 8.* || >= 10.*}
+
get-nonce@1.0.1:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
+ get-stream@5.2.0:
+ resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
+ engines: {node: '>=8'}
+
+ get-uri@6.0.5:
+ resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==}
+ engines: {node: '>= 14'}
+
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+ http-proxy-agent@7.0.2:
+ resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
+ engines: {node: '>= 14'}
+
+ https-proxy-agent@7.0.6:
+ resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
+ engines: {node: '>= 14'}
+
+ import-fresh@3.3.1:
+ resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
+ engines: {node: '>=6'}
+
input-otp@1.4.1:
resolution: {integrity: sha512-+yvpmKYKHi9jIGngxagY9oWiiblPB7+nEO75F2l2o4vs+6vpPZZmUl4tBNYuTCvQjhvEIbdNeJu70bhfYP2nbw==}
peerDependencies:
@@ -1390,6 +1602,17 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
+ ip-address@10.1.0:
+ resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
+ engines: {node: '>= 12'}
+
+ is-arrayish@0.2.1:
+ resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
+
+ is-fullwidth-code-point@3.0.0:
+ resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+ engines: {node: '>=8'}
+
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -1397,6 +1620,13 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+ js-yaml@4.1.1:
+ resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
+ hasBin: true
+
+ json-parse-even-better-errors@2.3.1:
+ resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
+
lightningcss-darwin-arm64@1.30.1:
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
engines: {node: '>= 12.0.0'}
@@ -1461,6 +1691,9 @@ packages:
resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
engines: {node: '>= 12.0.0'}
+ lines-and-columns@1.2.4:
+ resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@@ -1468,6 +1701,10 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
+ lru-cache@7.18.3:
+ resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
+ engines: {node: '>=12'}
+
lucide-react@0.454.0:
resolution: {integrity: sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==}
peerDependencies:
@@ -1484,11 +1721,21 @@ packages:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
+ mitt@3.0.1:
+ resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ netmask@2.0.2:
+ resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==}
+ engines: {node: '>= 0.4.0'}
+
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
@@ -1527,6 +1774,28 @@ packages:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
+ once@1.4.0:
+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
+ pac-proxy-agent@7.2.0:
+ resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==}
+ engines: {node: '>= 14'}
+
+ pac-resolver@7.0.1:
+ resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==}
+ engines: {node: '>= 14'}
+
+ parent-module@1.0.1:
+ resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
+ engines: {node: '>=6'}
+
+ parse-json@5.2.0:
+ resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
+ engines: {node: '>=8'}
+
+ pend@1.2.0:
+ resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1541,9 +1810,32 @@ packages:
resolution: {integrity: sha512-27VKOqrYfPncKA2NrFOVhP5MGAfHKLYn/Q0mz9cNQyRAKYi3VNHwYU2qKKqPCqgBmeeJ0uAFB56NumXZ5ZReXg==}
engines: {node: ^10 || ^12 || >=14}
+ progress@2.0.3:
+ resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
+ engines: {node: '>=0.4.0'}
+
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+ proxy-agent@6.5.0:
+ resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==}
+ engines: {node: '>= 14'}
+
+ proxy-from-env@1.1.0:
+ resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+
+ pump@3.0.3:
+ resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
+
+ puppeteer-core@24.31.0:
+ resolution: {integrity: sha512-pnAohhSZipWQoFpXuGV7xCZfaGhqcBR9C4pVrU0QSrcMi7tQMH9J9lDBqBvyMAHQqe8HCARuREqFuVKRQOgTvg==}
+ engines: {node: '>=18'}
+
+ puppeteer@24.31.0:
+ resolution: {integrity: sha512-q8y5yLxLD8xdZdzNWqdOL43NbfvUOp60SYhaLZQwHC9CdKldxQKXOyJAciOr7oUJfyAH/KgB2wKvqT2sFKoVXA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
react-day-picker@9.8.0:
resolution: {integrity: sha512-E0yhhg7R+pdgbl/2toTb0xBhsEAtmAx1l7qjIWYfcxOy8w4rTSVfbtBoSzVVhPwKP/5E9iL38LivzoE3AQDhCQ==}
engines: {node: '>=18'}
@@ -1629,6 +1921,14 @@ packages:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ require-directory@2.1.1:
+ resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
+ engines: {node: '>=0.10.0'}
+
+ resolve-from@4.0.0:
+ resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+ engines: {node: '>=4'}
+
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -1641,6 +1941,18 @@ packages:
resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ smart-buffer@4.2.0:
+ resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
+ engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
+
+ socks-proxy-agent@8.0.5:
+ resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==}
+ engines: {node: '>= 14'}
+
+ socks@2.8.7:
+ resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==}
+ engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
+
sonner@1.7.4:
resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==}
peerDependencies:
@@ -1651,6 +1963,21 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ source-map@0.6.1:
+ resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
+ engines: {node: '>=0.10.0'}
+
+ streamx@2.23.0:
+ resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
+
+ string-width@4.2.3:
+ resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
+ engines: {node: '>=8'}
+
+ strip-ansi@6.0.1:
+ resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+ engines: {node: '>=8'}
+
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@@ -1679,10 +2006,19 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
+ tar-fs@3.1.1:
+ resolution: {integrity: sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==}
+
+ tar-stream@3.1.7:
+ resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
+
tar@7.5.2:
resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==}
engines: {node: '>=18'}
+ text-decoder@1.2.3:
+ resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==}
+
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -1692,6 +2028,9 @@ packages:
tw-animate-css@1.3.3:
resolution: {integrity: sha512-tXE2TRWrskc4TU3RDd7T8n8Np/wCfoeH9gz22c7PzYqNPQ9FBGFbWWzwL0JyHcFp+jHozmF76tbHfPAx22ua2Q==}
+ typed-query-selector@2.12.0:
+ resolution: {integrity: sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==}
+
typescript@5.0.2:
resolution: {integrity: sha512-wVORMBGO/FAs/++blGNeAVdbNKtIh1rbBL2EyQ1+J9lClJ93KiiKe8PmFIVdXhHcyv44SL9oglmfeSsndo0jRw==}
engines: {node: '>=12.20'}
@@ -1740,10 +2079,47 @@ packages:
victory-vendor@36.9.2:
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
+ webdriver-bidi-protocol@0.3.9:
+ resolution: {integrity: sha512-uIYvlRQ0PwtZR1EzHlTMol1G0lAlmOe6wPykF9a77AK3bkpvZHzIVxRE2ThOx5vjy2zISe0zhwf5rzuUfbo1PQ==}
+
+ wrap-ansi@7.0.0:
+ resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
+ engines: {node: '>=10'}
+
+ wrappy@1.0.2:
+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
+ ws@8.18.3:
+ resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
+ y18n@5.0.8:
+ resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
+ engines: {node: '>=10'}
+
yallist@5.0.0:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
+ yargs-parser@21.1.1:
+ resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
+ engines: {node: '>=12'}
+
+ yargs@17.7.2:
+ resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
+ engines: {node: '>=12'}
+
+ yauzl@2.10.0:
+ resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
+
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
@@ -1756,6 +2132,14 @@ snapshots:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
+ '@babel/code-frame@7.27.1':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.28.5
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/helper-validator-identifier@7.28.5': {}
+
'@babel/runtime@7.28.4': {}
'@date-fns/tz@1.2.0': {}
@@ -1919,6 +2303,21 @@ snapshots:
'@next/swc-win32-x64-msvc@16.0.0':
optional: true
+ '@puppeteer/browsers@2.10.13':
+ dependencies:
+ debug: 4.4.3
+ extract-zip: 2.0.1
+ progress: 2.0.3
+ proxy-agent: 6.5.0
+ semver: 7.7.3
+ tar-fs: 3.1.1
+ yargs: 17.7.2
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - react-native-b4a
+ - supports-color
+
'@radix-ui/number@1.1.0': {}
'@radix-ui/primitive@1.1.1': {}
@@ -2687,6 +3086,8 @@ snapshots:
postcss: 8.5.0
tailwindcss: 4.1.9
+ '@tootallnate/quickjs-emscripten@0.23.0': {}
+
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
@@ -2723,15 +3124,34 @@ snapshots:
dependencies:
csstype: 3.1.3
+ '@types/yauzl@2.10.3':
+ dependencies:
+ '@types/node': 22.0.0
+ optional: true
+
'@vercel/analytics@1.5.0(next@16.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)':
optionalDependencies:
next: 16.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
+ agent-base@7.1.4: {}
+
+ ansi-regex@5.0.1: {}
+
+ ansi-styles@4.3.0:
+ dependencies:
+ color-convert: 2.0.1
+
+ argparse@2.0.1: {}
+
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
+ ast-types@0.13.4:
+ dependencies:
+ tslib: 2.8.1
+
autoprefixer@10.4.20(postcss@8.5.0):
dependencies:
browserslist: 4.27.0
@@ -2742,8 +3162,49 @@ snapshots:
postcss: 8.5.0
postcss-value-parser: 4.2.0
+ b4a@1.7.3: {}
+
+ bare-events@2.8.2: {}
+
+ bare-fs@4.5.1:
+ dependencies:
+ bare-events: 2.8.2
+ bare-path: 3.0.0
+ bare-stream: 2.7.0(bare-events@2.8.2)
+ bare-url: 2.3.2
+ fast-fifo: 1.3.2
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+ optional: true
+
+ bare-os@3.6.2:
+ optional: true
+
+ bare-path@3.0.0:
+ dependencies:
+ bare-os: 3.6.2
+ optional: true
+
+ bare-stream@2.7.0(bare-events@2.8.2):
+ dependencies:
+ streamx: 2.23.0
+ optionalDependencies:
+ bare-events: 2.8.2
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+ optional: true
+
+ bare-url@2.3.2:
+ dependencies:
+ bare-path: 3.0.0
+ optional: true
+
baseline-browser-mapping@2.8.23: {}
+ basic-ftp@5.0.5: {}
+
browserslist@4.27.0:
dependencies:
baseline-browser-mapping: 2.8.23
@@ -2752,16 +3213,32 @@ snapshots:
node-releases: 2.0.27
update-browserslist-db: 1.1.4(browserslist@4.27.0)
+ buffer-crc32@0.2.13: {}
+
+ callsites@3.1.0: {}
+
caniuse-lite@1.0.30001752: {}
chownr@3.0.0: {}
+ chromium-bidi@11.0.0(devtools-protocol@0.0.1521046):
+ dependencies:
+ devtools-protocol: 0.0.1521046
+ mitt: 3.0.1
+ zod: 3.25.76
+
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
client-only@0.0.1: {}
+ cliui@8.0.1:
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 7.0.0
+
clsx@2.1.1: {}
cmdk@1.0.4(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
@@ -2776,6 +3253,21 @@ snapshots:
- '@types/react'
- '@types/react-dom'
+ color-convert@2.0.1:
+ dependencies:
+ color-name: 1.1.4
+
+ color-name@1.1.4: {}
+
+ cosmiconfig@9.0.0(typescript@5.0.2):
+ dependencies:
+ env-paths: 2.2.1
+ import-fresh: 3.3.1
+ js-yaml: 4.1.1
+ parse-json: 5.2.0
+ optionalDependencies:
+ typescript: 5.0.2
+
csstype@3.1.3: {}
d3-array@3.2.4:
@@ -2816,16 +3308,30 @@ snapshots:
d3-timer@3.0.1: {}
+ data-uri-to-buffer@6.0.2: {}
+
date-fns-jalali@4.1.0-0: {}
date-fns@4.1.0: {}
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
decimal.js-light@2.5.1: {}
+ degenerator@5.0.1:
+ dependencies:
+ ast-types: 0.13.4
+ escodegen: 2.1.0
+ esprima: 4.0.1
+
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
+ devtools-protocol@0.0.1521046: {}
+
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.28.4
@@ -2845,23 +3351,104 @@ snapshots:
embla-carousel@8.5.1: {}
+ emoji-regex@8.0.0: {}
+
+ end-of-stream@1.4.5:
+ dependencies:
+ once: 1.4.0
+
enhanced-resolve@5.18.3:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.0
+ env-paths@2.2.1: {}
+
+ error-ex@1.3.4:
+ dependencies:
+ is-arrayish: 0.2.1
+
escalade@3.2.0: {}
+ escodegen@2.1.0:
+ dependencies:
+ esprima: 4.0.1
+ estraverse: 5.3.0
+ esutils: 2.0.3
+ optionalDependencies:
+ source-map: 0.6.1
+
+ esprima@4.0.1: {}
+
+ estraverse@5.3.0: {}
+
+ esutils@2.0.3: {}
+
eventemitter3@4.0.7: {}
+ events-universal@1.0.1:
+ dependencies:
+ bare-events: 2.8.2
+ transitivePeerDependencies:
+ - bare-abort-controller
+
+ extract-zip@2.0.1:
+ dependencies:
+ debug: 4.4.3
+ get-stream: 5.2.0
+ yauzl: 2.10.0
+ optionalDependencies:
+ '@types/yauzl': 2.10.3
+ transitivePeerDependencies:
+ - supports-color
+
fast-equals@5.3.2: {}
+ fast-fifo@1.3.2: {}
+
+ fd-slicer@1.1.0:
+ dependencies:
+ pend: 1.2.0
+
fraction.js@4.3.7: {}
+ get-caller-file@2.0.5: {}
+
get-nonce@1.0.1: {}
+ get-stream@5.2.0:
+ dependencies:
+ pump: 3.0.3
+
+ get-uri@6.0.5:
+ dependencies:
+ basic-ftp: 5.0.5
+ data-uri-to-buffer: 6.0.2
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
graceful-fs@4.2.11: {}
+ http-proxy-agent@7.0.2:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ https-proxy-agent@7.0.6:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ import-fresh@3.3.1:
+ dependencies:
+ parent-module: 1.0.1
+ resolve-from: 4.0.0
+
input-otp@1.4.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
react: 19.2.0
@@ -2869,10 +3456,22 @@ snapshots:
internmap@2.0.3: {}
+ ip-address@10.1.0: {}
+
+ is-arrayish@0.2.1: {}
+
+ is-fullwidth-code-point@3.0.0: {}
+
jiti@2.6.1: {}
js-tokens@4.0.0: {}
+ js-yaml@4.1.1:
+ dependencies:
+ argparse: 2.0.1
+
+ json-parse-even-better-errors@2.3.1: {}
+
lightningcss-darwin-arm64@1.30.1:
optional: true
@@ -2918,12 +3517,16 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.1
lightningcss-win32-x64-msvc: 1.30.1
+ lines-and-columns@1.2.4: {}
+
lodash@4.17.21: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
+ lru-cache@7.18.3: {}
+
lucide-react@0.454.0(react@19.2.0):
dependencies:
react: 19.2.0
@@ -2938,8 +3541,14 @@ snapshots:
dependencies:
minipass: 7.1.2
+ mitt@3.0.1: {}
+
+ ms@2.1.3: {}
+
nanoid@3.3.11: {}
+ netmask@2.0.2: {}
+
next-themes@0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
react: 19.2.0
@@ -2974,6 +3583,41 @@ snapshots:
object-assign@4.1.1: {}
+ once@1.4.0:
+ dependencies:
+ wrappy: 1.0.2
+
+ pac-proxy-agent@7.2.0:
+ dependencies:
+ '@tootallnate/quickjs-emscripten': 0.23.0
+ agent-base: 7.1.4
+ debug: 4.4.3
+ get-uri: 6.0.5
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ pac-resolver: 7.0.1
+ socks-proxy-agent: 8.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ pac-resolver@7.0.1:
+ dependencies:
+ degenerator: 5.0.1
+ netmask: 2.0.2
+
+ parent-module@1.0.1:
+ dependencies:
+ callsites: 3.1.0
+
+ parse-json@5.2.0:
+ dependencies:
+ '@babel/code-frame': 7.27.1
+ error-ex: 1.3.4
+ json-parse-even-better-errors: 2.3.1
+ lines-and-columns: 1.2.4
+
+ pend@1.2.0: {}
+
picocolors@1.1.1: {}
postcss-value-parser@4.2.0: {}
@@ -2990,12 +3634,68 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ progress@2.0.3: {}
+
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
+ proxy-agent@6.5.0:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ lru-cache: 7.18.3
+ pac-proxy-agent: 7.2.0
+ proxy-from-env: 1.1.0
+ socks-proxy-agent: 8.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ proxy-from-env@1.1.0: {}
+
+ pump@3.0.3:
+ dependencies:
+ end-of-stream: 1.4.5
+ once: 1.4.0
+
+ puppeteer-core@24.31.0:
+ dependencies:
+ '@puppeteer/browsers': 2.10.13
+ chromium-bidi: 11.0.0(devtools-protocol@0.0.1521046)
+ debug: 4.4.3
+ devtools-protocol: 0.0.1521046
+ typed-query-selector: 2.12.0
+ webdriver-bidi-protocol: 0.3.9
+ ws: 8.18.3
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - bufferutil
+ - react-native-b4a
+ - supports-color
+ - utf-8-validate
+
+ puppeteer@24.31.0(typescript@5.0.2):
+ dependencies:
+ '@puppeteer/browsers': 2.10.13
+ chromium-bidi: 11.0.0(devtools-protocol@0.0.1521046)
+ cosmiconfig: 9.0.0(typescript@5.0.2)
+ devtools-protocol: 0.0.1521046
+ puppeteer-core: 24.31.0
+ typed-query-selector: 2.12.0
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - bufferutil
+ - react-native-b4a
+ - supports-color
+ - typescript
+ - utf-8-validate
+
react-day-picker@9.8.0(react@19.2.0):
dependencies:
'@date-fns/tz': 1.2.0
@@ -3084,10 +3784,13 @@ snapshots:
tiny-invariant: 1.3.3
victory-vendor: 36.9.2
+ require-directory@2.1.1: {}
+
+ resolve-from@4.0.0: {}
+
scheduler@0.27.0: {}
- semver@7.7.3:
- optional: true
+ semver@7.7.3: {}
sharp@0.34.4:
dependencies:
@@ -3119,6 +3822,21 @@ snapshots:
'@img/sharp-win32-x64': 0.34.4
optional: true
+ smart-buffer@4.2.0: {}
+
+ socks-proxy-agent@8.0.5:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ socks: 2.8.7
+ transitivePeerDependencies:
+ - supports-color
+
+ socks@2.8.7:
+ dependencies:
+ ip-address: 10.1.0
+ smart-buffer: 4.2.0
+
sonner@1.7.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
react: 19.2.0
@@ -3126,6 +3844,28 @@ snapshots:
source-map-js@1.2.1: {}
+ source-map@0.6.1:
+ optional: true
+
+ streamx@2.23.0:
+ dependencies:
+ events-universal: 1.0.1
+ fast-fifo: 1.3.2
+ text-decoder: 1.2.3
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+
+ string-width@4.2.3:
+ dependencies:
+ emoji-regex: 8.0.0
+ is-fullwidth-code-point: 3.0.0
+ strip-ansi: 6.0.1
+
+ strip-ansi@6.0.1:
+ dependencies:
+ ansi-regex: 5.0.1
+
styled-jsx@5.1.6(react@19.2.0):
dependencies:
client-only: 0.0.1
@@ -3141,6 +3881,27 @@ snapshots:
tapable@2.3.0: {}
+ tar-fs@3.1.1:
+ dependencies:
+ pump: 3.0.3
+ tar-stream: 3.1.7
+ optionalDependencies:
+ bare-fs: 4.5.1
+ bare-path: 3.0.0
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - react-native-b4a
+
+ tar-stream@3.1.7:
+ dependencies:
+ b4a: 1.7.3
+ fast-fifo: 1.3.2
+ streamx: 2.23.0
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+
tar@7.5.2:
dependencies:
'@isaacs/fs-minipass': 4.0.1
@@ -3149,12 +3910,20 @@ snapshots:
minizlib: 3.1.0
yallist: 5.0.0
+ text-decoder@1.2.3:
+ dependencies:
+ b4a: 1.7.3
+ transitivePeerDependencies:
+ - react-native-b4a
+
tiny-invariant@1.3.3: {}
tslib@2.8.1: {}
tw-animate-css@1.3.3: {}
+ typed-query-selector@2.12.0: {}
+
typescript@5.0.2: {}
undici-types@6.11.1: {}
@@ -3210,6 +3979,37 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
+ webdriver-bidi-protocol@0.3.9: {}
+
+ wrap-ansi@7.0.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+
+ wrappy@1.0.2: {}
+
+ ws@8.18.3: {}
+
+ y18n@5.0.8: {}
+
yallist@5.0.0: {}
+ yargs-parser@21.1.1: {}
+
+ yargs@17.7.2:
+ dependencies:
+ cliui: 8.0.1
+ escalade: 3.2.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ string-width: 4.2.3
+ y18n: 5.0.8
+ yargs-parser: 21.1.1
+
+ yauzl@2.10.0:
+ dependencies:
+ buffer-crc32: 0.2.13
+ fd-slicer: 1.1.0
+
zod@3.25.76: {}
diff --git a/public/screenshots/flow-v2.png b/public/screenshots/flow-v2.png
new file mode 100644
index 0000000..0741642
Binary files /dev/null and b/public/screenshots/flow-v2.png differ
diff --git a/public/screenshots/flowfunding.png b/public/screenshots/flowfunding.png
new file mode 100644
index 0000000..0f097ab
Binary files /dev/null and b/public/screenshots/flowfunding.png differ
diff --git a/public/screenshots/italism.png b/public/screenshots/italism.png
new file mode 100644
index 0000000..213ca93
Binary files /dev/null and b/public/screenshots/italism.png differ
diff --git a/public/screenshots/tbff-flow.png b/public/screenshots/tbff-flow.png
new file mode 100644
index 0000000..32954ad
Binary files /dev/null and b/public/screenshots/tbff-flow.png differ
diff --git a/public/screenshots/tbff.png b/public/screenshots/tbff.png
new file mode 100644
index 0000000..838d750
Binary files /dev/null and b/public/screenshots/tbff.png differ
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 0000000..a366599
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,138 @@
+# Screenshot Management
+
+This directory contains scripts for managing demo screenshots.
+
+## Capturing Screenshots
+
+### Prerequisites
+- Development server must be running (`pnpm dev`)
+- All demo pages should be accessible at their routes
+
+### Running the Screenshot Script
+
+```bash
+# Make sure dev server is running first
+pnpm dev
+
+# In another terminal, capture screenshots
+pnpm screenshots
+```
+
+This will:
+1. Launch a headless Chrome browser
+2. Navigate to each demo page
+3. Wait for content to load
+4. Capture a 1280x800 screenshot
+5. Save to `public/screenshots/`
+
+### Output
+
+Screenshots are saved as:
+- `public/screenshots/tbff.png`
+- `public/screenshots/tbff-flow.png`
+- `public/screenshots/flow-v2.png`
+- `public/screenshots/italism.png`
+- `public/screenshots/flowfunding.png`
+
+### Adding New Demos
+
+To capture screenshots for new demos, edit `capture-screenshots.mjs`:
+
+```javascript
+const demos = [
+ { path: '/your-new-demo', name: 'your-new-demo' },
+ // ... existing demos
+];
+```
+
+Then update `app/demos/page.tsx` to include the screenshot path:
+
+```typescript
+{
+ title: 'Your New Demo',
+ path: '/your-new-demo',
+ screenshot: '/screenshots/your-new-demo.png',
+ // ... other properties
+}
+```
+
+## Screenshot Specifications
+
+- **Viewport**: 1280x800 pixels
+- **Format**: PNG
+- **Wait Time**: 2 seconds after page load (for animations to settle)
+- **Network**: Waits for networkidle2 (most network activity finished)
+
+## Customization
+
+### Changing Viewport Size
+
+Edit the viewport in `capture-screenshots.mjs`:
+
+```javascript
+await page.setViewport({
+ width: 1920, // Change width
+ height: 1080 // Change height
+});
+```
+
+### Changing Wait Time
+
+Adjust the timeout if demos need more time to render:
+
+```javascript
+await new Promise(resolve => setTimeout(resolve, 3000)); // 3 seconds
+```
+
+### Capturing Specific Section
+
+To capture only part of a page:
+
+```javascript
+const element = await page.$('.demo-container');
+await element.screenshot({ path: screenshotPath });
+```
+
+## Troubleshooting
+
+### Screenshots are blank
+- Increase wait time
+- Check if content loads in actual browser
+- Ensure dev server is running
+
+### Browser launch fails
+- Check if puppeteer installed: `pnpm list puppeteer`
+- Reinstall: `pnpm add -D puppeteer`
+- Check system dependencies for Chrome
+
+### Timeout errors
+- Increase timeout in script:
+ ```javascript
+ timeout: 60000 // 60 seconds
+ ```
+
+## Manual Screenshot Workflow
+
+If automated screenshots don't work:
+
+1. Open demo in browser
+2. Set window to 1280x800
+3. Use browser screenshot tool (F12 β Device Toolbar β Screenshot)
+4. Save to `public/screenshots/[demo-name].png`
+5. Update demo card with screenshot path
+
+## Performance Tips
+
+- Screenshots are cached by browser
+- Total size: ~560KB for 5 demos
+- Consider optimizing PNGs with tools like `pngquant` or `imagemin`
+- WebP format could reduce size further
+
+## Future Enhancements
+
+- [ ] Generate thumbnails in addition to full screenshots
+- [ ] Add WebP format support
+- [ ] Capture at multiple viewport sizes
+- [ ] Add screenshot comparison for regression testing
+- [ ] Automate screenshot capture on build
+- [ ] Add screenshot update on demo changes (CI/CD)
diff --git a/scripts/capture-screenshots.mjs b/scripts/capture-screenshots.mjs
new file mode 100755
index 0000000..cbbbd84
--- /dev/null
+++ b/scripts/capture-screenshots.mjs
@@ -0,0 +1,78 @@
+#!/usr/bin/env node
+
+/**
+ * Screenshot Capture Script for Flow Funding Demos
+ *
+ * This script captures screenshots of all demo pages using Puppeteer
+ */
+
+import puppeteer from 'puppeteer';
+import { fileURLToPath } from 'url';
+import { dirname, join } from 'path';
+import { existsSync, mkdirSync } from 'fs';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const demos = [
+ { path: '/tbff', name: 'tbff' },
+ { path: '/tbff-flow', name: 'tbff-flow' },
+ { path: '/flow-v2', name: 'flow-v2' },
+ { path: '/italism', name: 'italism' },
+ { path: '/flowfunding', name: 'flowfunding' },
+];
+
+const baseUrl = 'http://localhost:3000';
+const screenshotsDir = join(__dirname, '../public/screenshots');
+
+// Ensure screenshots directory exists
+if (!existsSync(screenshotsDir)) {
+ mkdirSync(screenshotsDir, { recursive: true });
+}
+
+async function captureScreenshots() {
+ console.log('π Starting screenshot capture...\n');
+
+ const browser = await puppeteer.launch({
+ headless: true,
+ args: ['--no-sandbox', '--disable-setuid-sandbox']
+ });
+
+ try {
+ for (const demo of demos) {
+ const url = `${baseUrl}${demo.path}`;
+ console.log(`πΈ Capturing ${demo.name}...`);
+
+ const page = await browser.newPage();
+ await page.setViewport({ width: 1280, height: 800 });
+
+ try {
+ await page.goto(url, {
+ waitUntil: 'networkidle2',
+ timeout: 30000
+ });
+
+ // Wait a bit for animations to settle
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ const screenshotPath = join(screenshotsDir, `${demo.name}.png`);
+ await page.screenshot({
+ path: screenshotPath,
+ type: 'png'
+ });
+
+ console.log(` β
Saved to public/screenshots/${demo.name}.png`);
+ } catch (error) {
+ console.error(` β Failed to capture ${demo.name}:`, error.message);
+ } finally {
+ await page.close();
+ }
+ }
+ } finally {
+ await browser.close();
+ }
+
+ console.log('\n⨠Screenshot capture complete!');
+}
+
+captureScreenshots().catch(console.error);