Initial commit: FlowFi website - flowfi.network

7-section narrative journey with interactive Sankey diagrams,
flow sandbox (@xyflow/react), and animated pipe visualizations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-09 19:00:49 -07:00
commit 6836108065
34 changed files with 2827 additions and 0 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
node_modules
.next
out
.git
.gitignore
*.md
.env*
.DS_Store

31
.gitignore vendored Normal file
View File

@ -0,0 +1,31 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files
.env*.local
.env
# typescript
*.tsbuildinfo
next-env.d.ts

11
Dockerfile Normal file
View File

@ -0,0 +1,11 @@
FROM node:20-alpine AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:alpine
COPY --from=builder /app/out /usr/share/nginx/html
EXPOSE 80

172
app/globals.css Normal file
View File

@ -0,0 +1,172 @@
@import "tailwindcss";
@theme {
--color-void: #0a0a0a;
--color-nothing: #1a1a2e;
--color-less: #16213e;
--color-anti-green: #e94560;
--color-flow-green: #2dd4bf;
--color-zen: #f5f5dc;
--color-sloth: #c3b091;
--font-family-mono: "Courier New", monospace;
--font-family-marker: var(--font-marker), "Permanent Marker", cursive;
--font-family-caveat: var(--font-caveat), "Caveat", cursive;
}
/* Pipe flow animation — core effect */
@keyframes pipe-flow {
0% { stroke-dashoffset: 24; }
100% { stroke-dashoffset: 0; }
}
@keyframes pipe-flow-fast {
0% { stroke-dashoffset: 24; }
100% { stroke-dashoffset: 0; }
}
@keyframes pipe-flow-reverse {
0% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: 24; }
}
.animate-pipe-flow {
stroke-dasharray: 8 4;
animation: pipe-flow 1.5s linear infinite;
}
.animate-pipe-flow-fast {
stroke-dasharray: 8 4;
animation: pipe-flow 0.8s linear infinite;
}
.animate-pipe-flow-slow {
stroke-dasharray: 8 4;
animation: pipe-flow 2.5s linear infinite;
}
/* Particle travel along path */
@keyframes travel-path {
0% { offset-distance: 0%; opacity: 0; }
5% { opacity: 1; }
95% { opacity: 1; }
100% { offset-distance: 100%; opacity: 0; }
}
.animate-particle {
offset-rotate: 0deg;
animation: travel-path var(--duration, 3s) linear infinite;
animation-delay: var(--delay, 0s);
}
/* Water distortion */
@keyframes turbulence {
0% { }
50% { }
100% { }
}
/* Scroll reveal */
@keyframes reveal-up {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
/* Breathing / pulsing */
@keyframes breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.02); }
}
@keyframes slow-pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.6; }
}
@keyframes drift {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(20px); }
}
@keyframes flow-gradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes float-up {
0% { opacity: 0.6; transform: translateY(0) rotate(0deg); }
100% { opacity: 0; transform: translateY(-200px) rotate(10deg); }
}
.animate-breathe { animation: breathe 6s ease-in-out infinite; }
.animate-slow-pulse { animation: slow-pulse 8s ease-in-out infinite; }
.animate-drift { animation: drift 12s ease-in-out infinite; }
.animate-flow-gradient {
background-size: 200% 200%;
animation: flow-gradient 8s ease-in-out infinite;
}
/* Wobble rotations */
.wobble-1 { transform: rotate(-1.5deg); }
.wobble-2 { transform: rotate(0.8deg); }
.wobble-3 { transform: rotate(-0.5deg); }
.wobble-4 { transform: rotate(1.2deg); }
/* Scrawl underlines */
.scrawl-underline {
position: relative;
display: inline-block;
}
.scrawl-underline::after {
content: '';
position: absolute;
left: -4%;
bottom: -4px;
width: 108%;
height: 4px;
background: var(--color-flow-green);
transform: rotate(-0.5deg) scaleX(0.95);
border-radius: 2px;
}
/* Sandbox node styles */
.flow-node {
border: 2px solid rgba(245, 245, 220, 0.2);
border-radius: 8px;
padding: 12px;
background: rgba(26, 26, 46, 0.9);
backdrop-filter: blur(4px);
min-width: 120px;
}
.flow-node:hover {
border-color: var(--color-flow-green);
}
/* Disable feTurbulence on mobile for performance */
@media (max-width: 768px) {
.water-distortion {
filter: none !important;
}
}
/* React Flow overrides */
.react-flow__background {
background-color: var(--color-void) !important;
}
.react-flow__controls button {
background-color: var(--color-nothing) !important;
border-color: rgba(245, 245, 220, 0.1) !important;
color: var(--color-zen) !important;
fill: var(--color-zen) !important;
}
.react-flow__controls button:hover {
background-color: var(--color-less) !important;
}
.react-flow__minimap {
background-color: var(--color-void) !important;
}

36
app/layout.tsx Normal file
View File

@ -0,0 +1,36 @@
import type { Metadata } from 'next'
import { Permanent_Marker, Caveat } from 'next/font/google'
import './globals.css'
const marker = Permanent_Marker({
weight: '400',
subsets: ['latin'],
variable: '--font-marker',
})
const caveat = Caveat({
subsets: ['latin'],
variable: '--font-caveat',
})
export const metadata: Metadata = {
title: 'FlowFi — Everything Flows',
description: 'Finance as metabolism, not mechanism. Pre-distributive. Re-distributive. Naturally.',
openGraph: {
title: 'FlowFi — Everything Flows',
description: 'Finance as metabolism, not mechanism. Pre-distributive. Re-distributive. Naturally.',
url: 'https://flowfi.network',
siteName: 'FlowFi',
type: 'website',
},
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={`bg-void text-zen font-mono antialiased ${marker.variable} ${caveat.variable}`}>
{children}
</body>
</html>
)
}

21
app/page.tsx Normal file
View File

@ -0,0 +1,21 @@
import HeroSection from '@/components/sections/HeroSection'
import NatureFlowsSection from '@/components/sections/NatureFlowsSection'
import ExtractivePipeSection from '@/components/sections/ExtractivePipeSection'
import RegenerativeSection from '@/components/sections/RegenerativeSection'
import TransitionSection from '@/components/sections/TransitionSection'
import SandboxSection from '@/components/sections/SandboxSection'
import CTASection from '@/components/sections/CTASection'
export default function Home() {
return (
<main>
<HeroSection />
<NatureFlowsSection />
<ExtractivePipeSection />
<RegenerativeSection />
<TransitionSection />
<SandboxSection />
<CTASection />
</main>
)
}

View File

@ -0,0 +1,124 @@
'use client'
import { useCallback, useState, useMemo } from 'react'
import {
ReactFlow,
Controls,
MiniMap,
Background,
useNodesState,
useEdgesState,
addEdge,
type Connection,
type Node,
BackgroundVariant,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import SourceNode from './nodes/SourceNode'
import PipeNode from './nodes/PipeNode'
import SinkNode from './nodes/SinkNode'
import AnimatedPipeEdge from './edges/AnimatedPipeEdge'
import FlowToolbar from './FlowToolbar'
import { presets } from './presets'
const nodeTypes = {
source: SourceNode,
pipe: PipeNode,
sink: SinkNode,
}
const edgeTypes = {
animatedPipe: AnimatedPipeEdge,
}
export default function FlowCanvas() {
const defaultPreset = typeof window !== 'undefined' && window.innerWidth < 768 ? 0 : 0
const [nodes, setNodes, onNodesChange] = useNodesState(presets[defaultPreset].nodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(presets[defaultPreset].edges)
const [isPlaying, setIsPlaying] = useState(true)
const [nodeIdCounter, setNodeIdCounter] = useState(100)
const onConnect = useCallback(
(connection: Connection) => {
setEdges((eds) =>
addEdge(
{ ...connection, type: 'animatedPipe', data: { flowRate: 3 } },
eds
)
)
},
[setEdges]
)
const handleAddNode = useCallback(
(type: 'source' | 'pipe' | 'sink') => {
const id = `n${nodeIdCounter}`
setNodeIdCounter((c) => c + 1)
const labels = { source: 'Source', pipe: 'Pipe', sink: 'Sink' }
const newNode: Node = {
id,
type,
position: { x: 200 + Math.random() * 200, y: 100 + Math.random() * 200 },
data: {
label: labels[type],
...(type === 'source' ? { flowRate: 5 } : {}),
...(type === 'sink' ? { fillLevel: 0 } : {}),
},
}
setNodes((nds) => [...nds, newNode])
},
[nodeIdCounter, setNodes]
)
const handleLoadPreset = useCallback(
(index: number) => {
const preset = presets[index]
setNodes(preset.nodes)
setEdges(preset.edges)
},
[setNodes, setEdges]
)
const handleReset = useCallback(() => {
setNodes(presets[2].nodes) // Blank preset
setEdges(presets[2].edges)
}, [setNodes, setEdges])
const showMiniMap = typeof window !== 'undefined' && window.innerWidth >= 768
return (
<div className="w-full h-[500px] md:h-[600px] rounded-lg border border-zen/10 overflow-hidden relative bg-void">
<FlowToolbar
onAddNode={handleAddNode}
onLoadPreset={handleLoadPreset}
onReset={handleReset}
isPlaying={isPlaying}
onTogglePlay={() => setIsPlaying(!isPlaying)}
/>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={{ type: 'animatedPipe' }}
fitView
className={!isPlaying ? '[&_.animate-pipe-flow]:!animation-play-state-paused' : ''}
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#f5f5dc10" />
<Controls position="bottom-right" />
{showMiniMap && (
<MiniMap
position="bottom-left"
nodeColor="#2dd4bf40"
maskColor="#0a0a0acc"
/>
)}
</ReactFlow>
</div>
)
}

View File

@ -0,0 +1,77 @@
'use client'
import { presets } from './presets'
interface FlowToolbarProps {
onAddNode: (type: 'source' | 'pipe' | 'sink') => void
onLoadPreset: (index: number) => void
onReset: () => void
isPlaying: boolean
onTogglePlay: () => void
}
export default function FlowToolbar({
onAddNode,
onLoadPreset,
onReset,
isPlaying,
onTogglePlay,
}: FlowToolbarProps) {
return (
<div className="absolute top-4 left-4 z-10 flex flex-wrap gap-2">
{/* Add nodes */}
<div className="flex gap-1 bg-nothing/80 backdrop-blur-sm rounded-lg p-1 border border-zen/10">
<button
onClick={() => onAddNode('source')}
className="px-3 py-1.5 text-xs text-flow-green/70 hover:bg-flow-green/10 rounded transition-colors"
title="Add Source"
>
+ Source
</button>
<button
onClick={() => onAddNode('pipe')}
className="px-3 py-1.5 text-xs text-zen/50 hover:bg-zen/10 rounded transition-colors"
title="Add Pipe"
>
+ Pipe
</button>
<button
onClick={() => onAddNode('sink')}
className="px-3 py-1.5 text-xs text-zen/40 hover:bg-zen/10 rounded transition-colors"
title="Add Sink"
>
+ Sink
</button>
</div>
{/* Presets */}
<div className="flex gap-1 bg-nothing/80 backdrop-blur-sm rounded-lg p-1 border border-zen/10">
{presets.map((preset, i) => (
<button
key={preset.name}
onClick={() => onLoadPreset(i)}
className="px-3 py-1.5 text-xs text-zen/50 hover:bg-zen/10 rounded transition-colors"
>
{preset.name}
</button>
))}
</div>
{/* Controls */}
<div className="flex gap-1 bg-nothing/80 backdrop-blur-sm rounded-lg p-1 border border-zen/10">
<button
onClick={onTogglePlay}
className="px-3 py-1.5 text-xs text-flow-green/70 hover:bg-flow-green/10 rounded transition-colors"
>
{isPlaying ? '⏸' : '▶'}
</button>
<button
onClick={onReset}
className="px-3 py-1.5 text-xs text-anti-green/50 hover:bg-anti-green/10 rounded transition-colors"
>
Reset
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,51 @@
'use client'
import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/react'
export default function AnimatedPipeEdge({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
style,
}: EdgeProps) {
const [edgePath] = getBezierPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
})
const flowRate = (data as any)?.flowRate ?? 3
const pipeWidth = Math.max(2, Math.min(12, flowRate * 2))
return (
<>
{/* Outer wall */}
<path
d={edgePath}
fill="none"
stroke="#2dd4bf"
strokeWidth={pipeWidth + 4}
strokeOpacity={0.15}
strokeLinecap="round"
/>
{/* Inner flow with animation */}
<path
d={edgePath}
fill="none"
stroke="#2dd4bf"
strokeWidth={pipeWidth}
strokeOpacity={0.5}
strokeLinecap="round"
className="animate-pipe-flow"
style={style}
/>
</>
)
}

View File

@ -0,0 +1,16 @@
'use client'
import { Handle, Position, type NodeProps } from '@xyflow/react'
export default function PipeNode({ data }: NodeProps) {
return (
<div className="flow-node border-zen/20">
<Handle type="target" position={Position.Left} className="!bg-zen/40 !w-3 !h-3" />
<Handle type="source" position={Position.Right} className="!bg-zen/40 !w-3 !h-3" />
<div className="text-center">
<div className="text-zen/50 text-lg mb-1"></div>
<div className="text-zen/60 text-xs font-mono">{(data as any).label || 'Pipe'}</div>
</div>
</div>
)
}

View File

@ -0,0 +1,24 @@
'use client'
import { Handle, Position, type NodeProps } from '@xyflow/react'
export default function SinkNode({ data }: NodeProps) {
const fillLevel = (data as any).fillLevel ?? 0
return (
<div className="flow-node border-flow-green/30">
<Handle type="target" position={Position.Left} className="!bg-flow-green/60 !w-3 !h-3" />
<div className="text-center">
<div className="text-zen/40 text-lg mb-1"></div>
<div className="text-zen/60 text-xs font-mono">{(data as any).label || 'Sink'}</div>
{/* Fill level indicator */}
<div className="w-full h-2 bg-void/50 rounded mt-2 overflow-hidden">
<div
className="h-full bg-flow-green/60 rounded transition-all duration-500"
style={{ width: `${Math.min(100, fillLevel)}%` }}
/>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,20 @@
'use client'
import { Handle, Position, type NodeProps } from '@xyflow/react'
export default function SourceNode({ data }: NodeProps) {
return (
<div className="flow-node border-flow-green/40">
<Handle type="source" position={Position.Right} className="!bg-flow-green !w-3 !h-3" />
<div className="text-center">
<div className="text-flow-green text-lg mb-1"></div>
<div className="text-zen/70 text-xs font-mono">{(data as any).label || 'Source'}</div>
{(data as any).flowRate !== undefined && (
<div className="text-flow-green/50 text-[10px] mt-1">
{(data as any).flowRate} units/s
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,62 @@
import type { Node, Edge } from '@xyflow/react'
export interface FlowPreset {
name: string
nodes: Node[]
edges: Edge[]
}
export const extractivePreset: FlowPreset = {
name: 'Extractive',
nodes: [
{ id: 's1', type: 'source', position: { x: 50, y: 50 }, data: { label: 'Labor', flowRate: 5 } },
{ id: 's2', type: 'source', position: { x: 50, y: 180 }, data: { label: 'Nature', flowRate: 8 } },
{ id: 's3', type: 'source', position: { x: 50, y: 310 }, data: { label: 'Creativity', flowRate: 4 } },
{ id: 'p1', type: 'pipe', position: { x: 300, y: 160 }, data: { label: 'Finance' } },
{ id: 'k1', type: 'sink', position: { x: 550, y: 80 }, data: { label: 'Shareholders', fillLevel: 85 } },
{ id: 'k2', type: 'sink', position: { x: 550, y: 230 }, data: { label: 'Executives', fillLevel: 70 } },
{ id: 'k3', type: 'sink', position: { x: 550, y: 370 }, data: { label: 'Public Good', fillLevel: 10 } },
],
edges: [
{ id: 'e1', source: 's1', target: 'p1', type: 'animatedPipe', data: { flowRate: 5 } },
{ id: 'e2', source: 's2', target: 'p1', type: 'animatedPipe', data: { flowRate: 8 } },
{ id: 'e3', source: 's3', target: 'p1', type: 'animatedPipe', data: { flowRate: 4 } },
{ id: 'e4', source: 'p1', target: 'k1', type: 'animatedPipe', data: { flowRate: 8 } },
{ id: 'e5', source: 'p1', target: 'k2', type: 'animatedPipe', data: { flowRate: 6 } },
{ id: 'e6', source: 'p1', target: 'k3', type: 'animatedPipe', data: { flowRate: 1 } },
],
}
export const regenerativePreset: FlowPreset = {
name: 'Regenerative',
nodes: [
{ id: 's1', type: 'source', position: { x: 50, y: 100 }, data: { label: 'Commons', flowRate: 6 } },
{ id: 's2', type: 'source', position: { x: 50, y: 280 }, data: { label: 'Ecology', flowRate: 5 } },
{ id: 'p1', type: 'pipe', position: { x: 280, y: 60 }, data: { label: 'Community' } },
{ id: 'p2', type: 'pipe', position: { x: 280, y: 200 }, data: { label: 'Care' } },
{ id: 'p3', type: 'pipe', position: { x: 280, y: 340 }, data: { label: 'Knowledge' } },
{ id: 'k1', type: 'sink', position: { x: 520, y: 100 }, data: { label: 'Stewardship', fillLevel: 60 } },
{ id: 'k2', type: 'sink', position: { x: 520, y: 280 }, data: { label: 'Regeneration', fillLevel: 55 } },
],
edges: [
{ id: 'e1', source: 's1', target: 'p1', type: 'animatedPipe', data: { flowRate: 3 } },
{ id: 'e2', source: 's1', target: 'p2', type: 'animatedPipe', data: { flowRate: 3 } },
{ id: 'e3', source: 's2', target: 'p2', type: 'animatedPipe', data: { flowRate: 3 } },
{ id: 'e4', source: 's2', target: 'p3', type: 'animatedPipe', data: { flowRate: 3 } },
{ id: 'e5', source: 'p1', target: 'k1', type: 'animatedPipe', data: { flowRate: 3 } },
{ id: 'e6', source: 'p2', target: 'k1', type: 'animatedPipe', data: { flowRate: 3 } },
{ id: 'e7', source: 'p2', target: 'k2', type: 'animatedPipe', data: { flowRate: 3 } },
{ id: 'e8', source: 'p3', target: 'k2', type: 'animatedPipe', data: { flowRate: 3 } },
],
}
export const blankPreset: FlowPreset = {
name: 'Blank',
nodes: [
{ id: 's1', type: 'source', position: { x: 100, y: 200 }, data: { label: 'Source', flowRate: 5 } },
{ id: 'k1', type: 'sink', position: { x: 500, y: 200 }, data: { label: 'Sink', fillLevel: 0 } },
],
edges: [],
}
export const presets = [regenerativePreset, extractivePreset, blankPreset]

View File

@ -0,0 +1,53 @@
'use client'
import ScrollReveal from '@/components/ui/ScrollReveal'
export default function CTASection() {
return (
<section className="min-h-[60vh] flex flex-col items-center justify-center relative px-4 py-20">
<div className="absolute inset-0 bg-void" />
<div className="relative z-10 text-center max-w-3xl">
<ScrollReveal>
<h2 className="font-marker text-4xl md:text-6xl text-zen/80 mb-4 wobble-4">
Ready to <span className="text-flow-green">flow</span>?
</h2>
</ScrollReveal>
<ScrollReveal delay={0.2}>
<p className="font-caveat text-2xl text-zen/40 mb-12">
You already are.
</p>
</ScrollReveal>
<ScrollReveal delay={0.4}>
<p className="text-zen/20 text-sm mb-8">
NoFi FlowFi ???
</p>
</ScrollReveal>
<ScrollReveal delay={0.5}>
<a
href="https://nofi.lol"
className="inline-block border border-zen/10 px-8 py-4 text-zen/30 hover:text-flow-green/60 hover:border-flow-green/30 transition-all duration-1000 text-sm"
>
back to NoFi
</a>
</ScrollReveal>
</div>
{/* Footer */}
<footer className="relative z-10 w-full border-t border-zen/5 mt-20 pt-6 flex justify-between items-center max-w-4xl">
<span className="text-zen/15 text-xs">
Part of the CoFi cinematic universe
</span>
<a
href="https://nofi.lol"
className="text-zen/15 text-xs hover:text-flow-green/40 transition-all duration-1000"
>
nofi.lol
</a>
</footer>
</section>
)
}

View File

@ -0,0 +1,39 @@
'use client'
import SankeyDiagram from '@/components/visualizations/SankeyDiagram'
import SectionHeader from '@/components/ui/SectionHeader'
import ScrollReveal from '@/components/ui/ScrollReveal'
import { extractiveFlows } from '@/lib/sankey-data'
export default function ExtractivePipeSection() {
return (
<section className="min-h-screen flex flex-col items-center justify-center relative px-4 py-20">
<div className="absolute inset-0 bg-gradient-to-b from-void via-less to-void opacity-80" />
{/* Red tint overlay */}
<div className="absolute inset-0 bg-anti-green/5" />
<div className="relative z-10 max-w-5xl w-full text-center">
<SectionHeader wobble={3}>The Extractive Pattern</SectionHeader>
<ScrollReveal delay={0.2}>
<div className="my-12">
<SankeyDiagram
data={extractiveFlows}
showLabels={true}
speed="fast"
showParticles={true}
showDistortion={false}
maxParticles={6}
/>
</div>
</ScrollReveal>
<ScrollReveal delay={0.4}>
<blockquote className="font-caveat text-xl md:text-2xl text-zen/50 max-w-2xl mx-auto border-l-2 border-anti-green/30 pl-6 wobble-1">
The plumbing hired a marketing team and <span className="text-anti-green/70">went public</span>.
</blockquote>
</ScrollReveal>
</div>
</section>
)
}

View File

@ -0,0 +1,81 @@
'use client'
export default function HeroSection() {
return (
<section className="min-h-screen flex flex-col items-center justify-center relative px-4 overflow-hidden">
{/* Background with water ripple SVG filter */}
<svg className="absolute inset-0 w-full h-full" style={{ zIndex: 0 }}>
<defs>
<filter id="hero-ripple">
<feTurbulence
type="fractalNoise"
baseFrequency="0.01 0.02"
numOctaves={3}
seed={5}
result="turbulence"
>
<animate
attributeName="seed"
from="0"
to="200"
dur="20s"
repeatCount="indefinite"
/>
</feTurbulence>
<feDisplacementMap
in="SourceGraphic"
in2="turbulence"
scale={8}
xChannelSelector="R"
yChannelSelector="G"
/>
</filter>
</defs>
<rect
width="100%"
height="100%"
fill="url(#hero-gradient)"
className="water-distortion"
filter="url(#hero-ripple)"
/>
<defs>
<linearGradient id="hero-gradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#0a0a0a" />
<stop offset="40%" stopColor="#1a1a2e" />
<stop offset="70%" stopColor="#16213e" />
<stop offset="100%" stopColor="#0a0a0a" />
</linearGradient>
</defs>
</svg>
<div className="absolute inset-0 bg-gradient-to-b from-void via-nothing/50 to-void opacity-90" />
<div className="relative z-10 text-center max-w-4xl">
<p className="text-sloth/50 text-sm tracking-[0.3em] uppercase mb-6 animate-slow-pulse">
everything flows
</p>
<h1 className="font-marker text-6xl md:text-8xl lg:text-9xl mb-6 wobble-1">
<span className="text-flow-green">Flow</span>
<span className="text-zen/90">Fi</span>
<span className="text-zen/20">.</span>
</h1>
<p className="font-caveat text-2xl md:text-3xl text-zen/50 mb-12">
Everything flows. The question is: <span className="text-flow-green/80">where?</span>
</p>
<a
href="https://nofi.lol"
className="inline-block border border-zen/10 px-6 py-3 text-sm text-zen/30 hover:text-flow-green/60 hover:border-flow-green/30 transition-all duration-1000"
>
back to NoFi
</a>
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-drift">
<span className="text-zen/15 text-sm">scroll to flow </span>
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,37 @@
'use client'
import SankeyDiagram from '@/components/visualizations/SankeyDiagram'
import SectionHeader from '@/components/ui/SectionHeader'
import ScrollReveal from '@/components/ui/ScrollReveal'
import { naturalFlows } from '@/lib/sankey-data'
export default function NatureFlowsSection() {
return (
<section className="min-h-screen flex flex-col items-center justify-center relative px-4 py-20">
<div className="absolute inset-0 bg-gradient-to-b from-void via-nothing to-void opacity-80" />
<div className="relative z-10 max-w-5xl w-full text-center">
<SectionHeader wobble={2}>Natural Flows</SectionHeader>
<ScrollReveal delay={0.2}>
<div className="my-12">
<SankeyDiagram
data={naturalFlows}
showLabels={false}
speed="slow"
showParticles={true}
showDistortion={true}
maxParticles={8}
/>
</div>
</ScrollReveal>
<ScrollReveal delay={0.4}>
<p className="font-caveat text-xl md:text-2xl text-zen/50 max-w-2xl mx-auto">
In living systems, value doesn&apos;t accumulate. It <span className="text-flow-green/70">circulates</span>.
</p>
</ScrollReveal>
</div>
</section>
)
}

View File

@ -0,0 +1,39 @@
'use client'
import SankeyDiagram from '@/components/visualizations/SankeyDiagram'
import SectionHeader from '@/components/ui/SectionHeader'
import ScrollReveal from '@/components/ui/ScrollReveal'
import { regenerativeFlows } from '@/lib/sankey-data'
export default function RegenerativeSection() {
return (
<section className="min-h-screen flex flex-col items-center justify-center relative px-4 py-20">
<div className="absolute inset-0 bg-gradient-to-b from-void via-nothing to-void opacity-80" />
{/* Green tint overlay */}
<div className="absolute inset-0 bg-flow-green/3" />
<div className="relative z-10 max-w-5xl w-full text-center">
<SectionHeader wobble={1}>The Regenerative Alternative</SectionHeader>
<ScrollReveal delay={0.2}>
<div className="my-12">
<SankeyDiagram
data={regenerativeFlows}
showLabels={true}
speed="normal"
showParticles={true}
showDistortion={true}
maxParticles={8}
/>
</div>
</ScrollReveal>
<ScrollReveal delay={0.4}>
<p className="font-caveat text-xl md:text-2xl text-zen/50 max-w-2xl mx-auto">
<span className="text-flow-green/70">Pre-distributive</span>. <span className="text-flow-green/70">Re-distributive</span>. Naturally.
</p>
</ScrollReveal>
</div>
</section>
)
}

View File

@ -0,0 +1,42 @@
'use client'
import dynamic from 'next/dynamic'
import SectionHeader from '@/components/ui/SectionHeader'
import ScrollReveal from '@/components/ui/ScrollReveal'
const FlowCanvas = dynamic(() => import('@/components/sandbox/FlowCanvas'), {
ssr: false,
loading: () => (
<div className="w-full h-[500px] md:h-[600px] rounded-lg border border-zen/10 bg-void flex items-center justify-center">
<span className="text-zen/20 text-sm animate-slow-pulse">loading canvas...</span>
</div>
),
})
export default function SandboxSection() {
return (
<section className="min-h-screen flex flex-col items-center justify-center relative px-4 py-20">
<div className="absolute inset-0 bg-void" />
<div className="relative z-10 max-w-6xl w-full">
<SectionHeader wobble={2}>Flow Sandbox</SectionHeader>
<ScrollReveal delay={0.1}>
<p className="font-caveat text-lg md:text-xl text-zen/40 text-center mb-8 max-w-2xl mx-auto">
Reconnect the flows. What would you build?
</p>
</ScrollReveal>
<ScrollReveal delay={0.2}>
<FlowCanvas />
</ScrollReveal>
<ScrollReveal delay={0.3}>
<p className="text-zen/20 text-xs text-center mt-4">
drag nodes connect ports load presets build your own flow system
</p>
</ScrollReveal>
</div>
</section>
)
}

View File

@ -0,0 +1,29 @@
'use client'
import ScrollReveal from '@/components/ui/ScrollReveal'
const lines = [
'NoFi was the diagnosis.',
'FlowFi is the prognosis.',
'Not no flows — better flows.',
'Not no finance — living finance.',
'Finance as metabolism, not mechanism.',
]
export default function TransitionSection() {
return (
<section className="min-h-screen flex flex-col items-center justify-center relative px-4 py-20">
<div className="absolute inset-0 bg-gradient-to-b from-void via-less to-void opacity-80" />
<div className="relative z-10 max-w-3xl w-full space-y-12 text-center">
{lines.map((line, i) => (
<ScrollReveal key={i} delay={i * 0.15}>
<p className={`font-caveat text-2xl md:text-4xl text-zen/60 wobble-${(i % 4) + 1}`}>
{line}
</p>
</ScrollReveal>
))}
</div>
</section>
)
}

View File

@ -0,0 +1,27 @@
'use client'
import { useRef } from 'react'
import { motion, useInView } from 'framer-motion'
interface ScrollRevealProps {
children: React.ReactNode
className?: string
delay?: number
}
export default function ScrollReveal({ children, className = '', delay = 0 }: ScrollRevealProps) {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, margin: '-100px' })
return (
<motion.div
ref={ref}
className={className}
initial={{ opacity: 0, y: 30 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
transition={{ duration: 0.8, delay, ease: 'easeOut' }}
>
{children}
</motion.div>
)
}

View File

@ -0,0 +1,19 @@
import ScrollReveal from './ScrollReveal'
interface SectionHeaderProps {
children: React.ReactNode
wobble?: 1 | 2 | 3 | 4
className?: string
}
export default function SectionHeader({ children, wobble = 1, className = '' }: SectionHeaderProps) {
return (
<ScrollReveal>
<h2
className={`font-marker text-3xl md:text-5xl text-zen/90 mb-8 wobble-${wobble} ${className}`}
>
<span className="scrawl-underline">{children}</span>
</h2>
</ScrollReveal>
)
}

View File

@ -0,0 +1,47 @@
'use client'
interface AnimatedPipeProps {
d: string
width?: number
color?: string
speed?: 'slow' | 'normal' | 'fast'
opacity?: number
}
export default function AnimatedPipe({
d,
width = 4,
color = '#2dd4bf',
speed = 'normal',
opacity = 0.6,
}: AnimatedPipeProps) {
const speedClass = {
slow: 'animate-pipe-flow-slow',
normal: 'animate-pipe-flow',
fast: 'animate-pipe-flow-fast',
}[speed]
return (
<g>
{/* Outer wall */}
<path
d={d}
fill="none"
stroke={color}
strokeWidth={width + 4}
strokeOpacity={opacity * 0.3}
strokeLinecap="round"
/>
{/* Inner flow */}
<path
d={d}
fill="none"
stroke={color}
strokeWidth={width}
strokeOpacity={opacity}
strokeLinecap="round"
className={speedClass}
/>
</g>
)
}

View File

@ -0,0 +1,35 @@
'use client'
interface FlowParticlesProps {
pathId: string
count?: number
color?: string
size?: number
duration?: number
}
export default function FlowParticles({
pathId,
count = 3,
color = '#2dd4bf',
size = 3,
duration = 3,
}: FlowParticlesProps) {
return (
<>
{Array.from({ length: count }).map((_, i) => (
<circle
key={i}
r={size}
fill={color}
className="animate-particle"
style={{
offsetPath: `url(#${pathId})`,
'--duration': `${duration}s`,
'--delay': `${(i / count) * duration}s`,
} as React.CSSProperties}
/>
))}
</>
)
}

View File

@ -0,0 +1,171 @@
'use client'
import { useMemo } from 'react'
import {
sankey as d3Sankey,
sankeyLinkHorizontal,
SankeyNode,
SankeyLink,
} from 'd3-sankey'
import { FlowData } from '@/lib/types'
import AnimatedPipe from './AnimatedPipe'
import FlowParticles from './FlowParticles'
import WaterDistortion from './WaterDistortion'
interface SankeyDiagramProps {
data: FlowData
width?: number
height?: number
showLabels?: boolean
speed?: 'slow' | 'normal' | 'fast'
showParticles?: boolean
showDistortion?: boolean
maxParticles?: number
className?: string
}
interface SNode {
id: string
label?: string
color?: string
}
interface SLink {
source: string
target: string
value: number
color?: string
}
export default function SankeyDiagram({
data,
width = 800,
height = 400,
showLabels = false,
speed = 'normal',
showParticles = true,
showDistortion = false,
maxParticles = 6,
className = '',
}: SankeyDiagramProps) {
const { nodes, links } = useMemo(() => {
const sankeyNodes = data.nodes.map(n => ({ ...n }))
const sankeyLinks = data.links.map(l => ({
source: l.source,
target: l.target,
value: l.value,
color: l.color,
}))
const generator = (d3Sankey as any)()
.nodeId((d: any) => d.id)
.nodeWidth(12)
.nodePadding(16)
.extent([[1, 1], [width - 1, height - 1]])
const result = generator({
nodes: sankeyNodes,
links: sankeyLinks,
})
return {
nodes: result.nodes as any[],
links: result.links as any[],
}
}, [data, width, height])
const linkPath = sankeyLinkHorizontal()
const filterId = `distortion-${Math.random().toString(36).slice(2, 7)}`
let particleCount = 0
return (
<svg
viewBox={`0 0 ${width} ${height}`}
className={`w-full h-auto ${className}`}
preserveAspectRatio="xMidYMid meet"
>
<defs>
{showDistortion && <WaterDistortion id={filterId} />}
{/* Define paths for particles */}
{links.map((link, i) => {
const d = linkPath(link as any)
return d ? (
<path key={`path-${i}`} id={`flow-path-${i}`} d={d} fill="none" />
) : null
})}
</defs>
<g
className={showDistortion ? 'water-distortion' : ''}
filter={showDistortion ? `url(#${filterId})` : undefined}
>
{/* Links */}
{links.map((link, i) => {
const d = linkPath(link as any)
if (!d) return null
const linkWidth = Math.max(2, (link as any).width || 4)
return (
<AnimatedPipe
key={`link-${i}`}
d={d}
width={linkWidth}
color={link.color || '#2dd4bf'}
speed={speed}
opacity={0.6}
/>
)
})}
{/* Particles */}
{showParticles && links.map((link, i) => {
if (particleCount >= maxParticles) return null
const pCount = Math.min(2, maxParticles - particleCount)
particleCount += pCount
return (
<FlowParticles
key={`particles-${i}`}
pathId={`flow-path-${i}`}
count={pCount}
color={link.color?.replace(/[0-9a-f]{2}$/i, 'ff') || '#2dd4bf'}
size={2}
duration={speed === 'fast' ? 2 : speed === 'slow' ? 5 : 3}
/>
)
})}
{/* Nodes */}
{nodes.map((node, i) => (
<g key={`node-${i}`}>
<rect
x={node.x0}
y={node.y0}
width={(node.x1 ?? 0) - (node.x0 ?? 0)}
height={(node.y1 ?? 0) - (node.y0 ?? 0)}
fill={node.color || '#2dd4bf'}
rx={3}
opacity={0.8}
/>
{showLabels && node.label && (
<text
x={(node.x0 ?? 0) < width / 2
? (node.x1 ?? 0) + 8
: (node.x0 ?? 0) - 8
}
y={((node.y0 ?? 0) + (node.y1 ?? 0)) / 2}
textAnchor={(node.x0 ?? 0) < width / 2 ? 'start' : 'end'}
dominantBaseline="middle"
fill="#f5f5dc"
fontSize={12}
fontFamily="'Courier New', monospace"
opacity={0.7}
>
{node.label}
</text>
)}
</g>
))}
</g>
</svg>
)
}

View File

@ -0,0 +1,38 @@
'use client'
interface WaterDistortionProps {
id?: string
intensity?: number
}
export default function WaterDistortion({
id = 'water-distortion',
intensity = 0.003,
}: WaterDistortionProps) {
return (
<filter id={id}>
<feTurbulence
type="fractalNoise"
baseFrequency={`${intensity} ${intensity * 1.5}`}
numOctaves={3}
seed={42}
result="turbulence"
>
<animate
attributeName="seed"
from="0"
to="100"
dur="10s"
repeatCount="indefinite"
/>
</feTurbulence>
<feDisplacementMap
in="SourceGraphic"
in2="turbulence"
scale={4}
xChannelSelector="R"
yChannelSelector="G"
/>
</filter>
)
}

34
docker-compose.yml Normal file
View File

@ -0,0 +1,34 @@
services:
flowfi:
build: .
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.flowfi.rule=Host(`flowfi.network`) || Host(`www.flowfi.network`)"
- "traefik.http.services.flowfi.loadbalancer.server.port=80"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:80/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
networks:
- traefik-public
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
- CHOWN
- SETGID
- SETUID
- DAC_OVERRIDE
read_only: true
tmpfs:
- /tmp
- /var/cache/nginx
networks:
traefik-public:
external: true

94
lib/sankey-data.ts Normal file
View File

@ -0,0 +1,94 @@
import { FlowData } from './types'
// S2: Natural flows — abstract organic Sankey (unlabeled, acyclic)
// Represents sunlight/rain → photosynthesis/absorption → distribution → endpoints
export const naturalFlows: FlowData = {
nodes: [
{ id: 'sun', color: '#fbbf24' },
{ id: 'rain', color: '#60a5fa' },
{ id: 'photo', color: '#34d399' },
{ id: 'soil', color: '#c3b091' },
{ id: 'canopy', color: '#34d399' },
{ id: 'roots', color: '#a78bfa' },
{ id: 'stream', color: '#60a5fa' },
{ id: 'mycelium', color: '#a78bfa' },
{ id: 'fauna', color: '#f59e0b' },
{ id: 'atmosphere', color: '#94a3b8' },
{ id: 'humus', color: '#92704f' },
],
links: [
{ source: 'sun', target: 'photo', value: 8, color: '#fbbf2480' },
{ source: 'sun', target: 'canopy', value: 4, color: '#fbbf2440' },
{ source: 'rain', target: 'stream', value: 5, color: '#60a5fa80' },
{ source: 'rain', target: 'soil', value: 6, color: '#60a5fa60' },
{ source: 'photo', target: 'canopy', value: 5, color: '#34d39980' },
{ source: 'photo', target: 'roots', value: 3, color: '#34d39960' },
{ source: 'soil', target: 'roots', value: 4, color: '#c3b09180' },
{ source: 'soil', target: 'mycelium', value: 3, color: '#c3b09160' },
{ source: 'roots', target: 'mycelium', value: 3, color: '#a78bfa80' },
{ source: 'canopy', target: 'atmosphere', value: 4, color: '#34d39960' },
{ source: 'canopy', target: 'fauna', value: 3, color: '#34d39940' },
{ source: 'mycelium', target: 'humus', value: 4, color: '#a78bfa60' },
{ source: 'stream', target: 'atmosphere', value: 3, color: '#60a5fa60' },
{ source: 'fauna', target: 'humus', value: 2, color: '#f59e0b60' },
{ source: 'roots', target: 'humus', value: 2, color: '#a78bfa40' },
],
}
// S3: Extractive pattern — finance funnel (labeled, acyclic)
export const extractiveFlows: FlowData = {
nodes: [
{ id: 'labor', label: 'Labor', color: '#e9456060' },
{ id: 'creativity', label: 'Creativity', color: '#e9456060' },
{ id: 'nature', label: 'Nature', color: '#e9456060' },
{ id: 'communities', label: 'Communities', color: '#e9456060' },
{ id: 'finance', label: 'Finance', color: '#e94560' },
{ id: 'shareholders', label: 'Shareholders', color: '#e94560cc' },
{ id: 'executives', label: 'Executives', color: '#e94560cc' },
{ id: 'tax-havens', label: 'Tax Havens', color: '#e94560aa' },
{ id: 'public-good', label: 'Public Good', color: '#e9456030' },
],
links: [
{ source: 'labor', target: 'finance', value: 10, color: '#e9456040' },
{ source: 'creativity', target: 'finance', value: 8, color: '#e9456040' },
{ source: 'nature', target: 'finance', value: 12, color: '#e9456040' },
{ source: 'communities', target: 'finance', value: 6, color: '#e9456040' },
{ source: 'finance', target: 'shareholders', value: 15, color: '#e9456080' },
{ source: 'finance', target: 'executives', value: 12, color: '#e9456080' },
{ source: 'finance', target: 'tax-havens', value: 7, color: '#e9456060' },
{ source: 'finance', target: 'public-good', value: 2, color: '#e9456020' },
],
}
// S4: Regenerative alternative — redistributive mesh (labeled, acyclic)
// Uses separate input/output nodes to represent the circular nature without actual cycles
export const regenerativeFlows: FlowData = {
nodes: [
{ id: 'commons-in', label: 'Commons', color: '#2dd4bf' },
{ id: 'labor-r', label: 'Labor', color: '#2dd4bfcc' },
{ id: 'ecology', label: 'Ecology', color: '#34d399' },
{ id: 'care', label: 'Care', color: '#a78bfa' },
{ id: 'creativity-r', label: 'Creativity', color: '#60a5fa' },
{ id: 'community', label: 'Community', color: '#fbbf24' },
{ id: 'knowledge', label: 'Knowledge', color: '#f472b6' },
{ id: 'stewardship', label: 'Stewardship', color: '#34d399cc' },
{ id: 'commons-out', label: 'Commons', color: '#2dd4bf' },
],
links: [
{ source: 'commons-in', target: 'labor-r', value: 5, color: '#2dd4bf60' },
{ source: 'commons-in', target: 'ecology', value: 6, color: '#2dd4bf60' },
{ source: 'commons-in', target: 'care', value: 5, color: '#2dd4bf60' },
{ source: 'commons-in', target: 'creativity-r', value: 4, color: '#2dd4bf60' },
{ source: 'labor-r', target: 'community', value: 4, color: '#2dd4bf40' },
{ source: 'labor-r', target: 'knowledge', value: 3, color: '#2dd4bf40' },
{ source: 'ecology', target: 'stewardship', value: 4, color: '#34d39960' },
{ source: 'ecology', target: 'community', value: 3, color: '#34d39940' },
{ source: 'care', target: 'community', value: 4, color: '#a78bfa60' },
{ source: 'care', target: 'knowledge', value: 3, color: '#a78bfa40' },
{ source: 'creativity-r', target: 'knowledge', value: 3, color: '#60a5fa60' },
{ source: 'creativity-r', target: 'community', value: 3, color: '#60a5fa40' },
{ source: 'community', target: 'commons-out', value: 6, color: '#fbbf2460' },
{ source: 'knowledge', target: 'commons-out', value: 4, color: '#f472b660' },
{ source: 'stewardship', target: 'commons-out', value: 4, color: '#34d39960' },
],
}

25
lib/types.ts Normal file
View File

@ -0,0 +1,25 @@
export interface FlowNode {
id: string
label?: string
color?: string
}
export interface FlowLink {
source: string
target: string
value: number
color?: string
}
export interface FlowData {
nodes: FlowNode[]
links: FlowLink[]
}
export interface SandboxNode {
id: string
type: 'source' | 'pipe' | 'sink'
label: string
flowRate?: number
fillLevel?: number
}

13
next.config.mjs Normal file
View File

@ -0,0 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
distDir: 'out',
typescript: {
ignoreBuildErrors: true,
},
images: {
unoptimized: true,
},
}
export default nextConfig

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "flowfi-network",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4",
"next": "^16.1.6",
"d3-sankey": "^0.12.3",
"d3-shape": "^3.2.0",
"@xyflow/react": "^12.6.0",
"framer-motion": "^12.6.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/d3-sankey": "^0.12.5",
"@types/d3-shape": "^3.1.7",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
},
"packageManager": "pnpm@10.23.0"
}

1271
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

41
tsconfig.json Normal file
View File

@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"paths": {
"@/*": [
"./*"
]
},
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}