diff --git a/src/css/custom.css b/src/css/custom.css index f7125e926..3fe33133e 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -596,4 +596,19 @@ div.testimonials a:hover .wm-name { video:fullscreen { object-fit: contain !important; +} + +/* Improve readability of BenchmarkVisualization chart text */ +.benchmark-chart-container canvas { + filter: brightness(1.2) contrast(1.1); +} + +.benchmark-chart-container[data-theme="dark"] { + --chart-text-color: #ffffff; + --chart-axis-color: #e5e7eb; +} + +.benchmark-chart-container[data-theme="light"] { + --chart-text-color: #1f2937; + --chart-axis-color: #4b5563; } \ No newline at end of file diff --git a/src/landing/AppAnimation.tsx b/src/landing/AppAnimation.tsx index e6df026ae..832569612 100644 --- a/src/landing/AppAnimation.tsx +++ b/src/landing/AppAnimation.tsx @@ -131,7 +131,7 @@ export default function AppAnimation({ active, only }) { variant5: { top: 360, left: 300, - text: 'Build complex apps with our 50+ atomic components, and add code only when needed.', + //text: 'Build complex apps with our 50+ atomic components, and add code only when needed.', displayArrow: false } }; diff --git a/src/landing/CallToAction.jsx b/src/landing/CallToAction.jsx index 3ce4ed5c2..19c85078e 100644 --- a/src/landing/CallToAction.jsx +++ b/src/landing/CallToAction.jsx @@ -1,85 +1,36 @@ import React, { useState } from 'react'; -import { useColorMode } from '@docusaurus/theme-common'; +import { ArrowRight } from 'lucide-react'; import BookDemoModal from '../components/BookDemoModal'; -export default function CallToAction({ color }) { - const { colorMode } = useColorMode(); - const [bookDemoOpen, setBookDemoOpen] = useState(false); +export default function CallToAction() { + const [bookDemoOpen, setBookDemoOpen] = useState(false); - const colorMap = { - blue: { - light: '#bfdbfe', - dark: '#3b82f6' - }, - orange: { - light: '#fb923c', - dark: '#7c2d12' - }, - green: { - light: '#bbf7d0', - dark: '#10b981' - } - }; - - const c = colorMap[color ?? 'blue'][colorMode]; - - return ( -
-
-
-

- Build endpoints, workflows & ETLs, UIs with code only where it matters -

-

- Get started building your internal tool in under 10 minutes -

-
- window.plausible('try-cloud')} - data-analytics='"try-cloud"' - className="rounded-md bg-white px-3.5 py-1.5 text-base hover:text-blue-500 font-semibold leading-7 text-gray-900 shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white !no-underline" - rel="nofollow" - > - Get started for free - - -
- -
-
- -
- ); -} + return ( +
+
+

+ Start building with Windmill +

+
+ window.plausible('try-cloud')} + data-analytics='"try-cloud"' + className="rounded-lg bg-blue-600 hover:bg-blue-700 px-6 py-3 text-base font-semibold text-white shadow-sm transition-colors !no-underline" + rel="nofollow" + > + Get started for free + + +
+
+ +
+ ); +} \ No newline at end of file diff --git a/src/landing/CombinedAnimation.tsx b/src/landing/CombinedAnimation.tsx new file mode 100644 index 000000000..21f37585b --- /dev/null +++ b/src/landing/CombinedAnimation.tsx @@ -0,0 +1,133 @@ +import React, { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; + +export default function CombinedAnimation() { + const [currentIndex, setCurrentIndex] = useState(0); + + const images = [ + { + id: 'script', + src: '/illustrations/animscripts.png', + alt: 'VS Code editor with test panel and execution results', + label: 'Scripts', + subtitle: 'Write scripts in 20+ languages (Python, TS, Go, Bash...)' + }, + { + id: 'flow', + src: '/illustrations/animflows.png', + alt: 'Flow diagram with execution logs and performance metrics', + label: 'Flows', + subtitle: 'Orchestrate scripts into scalable workflows' + }, + { + id: 'app', + src: '/illustrations/animapps.png', + alt: 'App builder with button, table, and code editor', + label: 'Apps', + subtitle: 'Generate production-ready frontends with AI' + } + ]; + + // Auto-switch every 4 seconds + useEffect(() => { + const interval = setInterval(() => { + setDirection(1); // Always forward for auto-advance + setCurrentIndex((prev) => (prev + 1) % images.length); + }, 4000); + + return () => clearInterval(interval); + }, [images.length]); + + const slideVariants = { + enter: (direction: number) => ({ + x: direction > 0 ? '100%' : '-100%', + opacity: 0 + }), + center: { + x: 0, + opacity: 1 + }, + exit: (direction: number) => ({ + x: direction > 0 ? '-100%' : '100%', + opacity: 0 + }) + }; + + const [direction, setDirection] = useState(0); + + const goToSlide = (index: number) => { + const newDirection = index > currentIndex ? 1 : -1; + setDirection(newDirection); + setCurrentIndex(index); + }; + + return ( +
+ {/* Title and Subtitle - Hidden on mobile, visible on desktop */} +
+ + +
+ {images[currentIndex].label} +
+
+ {images[currentIndex].subtitle} +
+
+
+
+ {/* Image container */} +
+ + + {images[currentIndex].alt} { + console.error('Failed to load image:', images[currentIndex].src); + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + + +
+ + {/* Navigation dots */} +
+ {images.map((_, index) => ( +
+
+ ); +} + diff --git a/src/landing/CorePrinciple.tsx b/src/landing/CorePrinciple.tsx new file mode 100644 index 000000000..edc412718 --- /dev/null +++ b/src/landing/CorePrinciple.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import LandingSection from './LandingSection'; +import { ArrowRight } from 'lucide-react'; +import BrowserOnly from '@docusaurus/BrowserOnly'; +// @ts-ignore +import polyGlott from '/illustrations/polyglot.json'; +// @ts-ignore +import secrets from '/illustrations/secrets.json'; +// @ts-ignore +import thirdparty from '/illustrations/thirdparty.json'; + +// Client-only component that renders a static frame of a Lottie animation +function StaticLottie({ animationData }: { animationData: unknown }) { + const { useLottie } = require('lottie-react'); + const { View, goToAndStop, getDuration } = useLottie({ + animationData, + loop: false, + autoplay: false + }); + + React.useEffect(() => { + // Go to 90% of the animation + const totalFrames = getDuration(true); + if (totalFrames > 0) { + goToAndStop(Math.floor(totalFrames * 0.90), true); + } + }, [goToAndStop, getDuration]); + + return View; +} + +interface FeatureCardProps { + title: string; + description: string; + actionLink: string; + actionUrl?: string; + lottieData?: unknown; +} + +function FeatureCard({ title, description, actionLink, actionUrl, lottieData }: FeatureCardProps) { + return ( +
+

{title}

+

+ {description} +

+ + {actionLink} + + + {lottieData && ( +
+
+ }> + {() => } + +
+
+ )} +
+ ); +} + +export default function CorePrinciple() { + return ( + +
+
+

+ Our core principles +

+ + The foundational beliefs that guide how we build Windmill. + +
+ + {/* 3 cards in a row */} +
+ + + +
+
+
+ ); +} diff --git a/src/landing/DeveloperExperienceSection.tsx b/src/landing/DeveloperExperienceSection.tsx new file mode 100644 index 000000000..c8a35fad6 --- /dev/null +++ b/src/landing/DeveloperExperienceSection.tsx @@ -0,0 +1,242 @@ +import React, { useRef, useEffect, useState } from 'react'; +import { ArrowRight } from 'lucide-react'; +import LandingSection from './LandingSection'; +import { Lottie } from './LightFeatureCard'; +import BrowserOnly from '@docusaurus/BrowserOnly'; +import { BenchmarkVisualization } from '../components/BenchmarkVisualization'; +import { useColorMode } from '@docusaurus/theme-common'; +import classNames from 'classnames'; +import { Switch } from '@headlessui/react'; + +interface FeatureCardProps { + title: string; + description: string; + href: string; + actionText?: string; + image?: string; + imageAlt?: string; + video?: string; + lottieData?: unknown; +} + +function FeatureCard({ + title, + description, + href, + actionText = 'Learn more', + image, + imageAlt, + video, + lottieData +}: FeatureCardProps) { + const containerRef = useRef(null); + const videoRef = useRef(null); + const [hasBeenVisible, setHasBeenVisible] = useState(false); + + // Start video when 80% visible + useEffect(() => { + if (!video) return; + const container = containerRef.current; + if (!container) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !hasBeenVisible) { + setHasBeenVisible(true); + videoRef.current?.play(); + } + }, + { threshold: 0.8 } + ); + + observer.observe(container); + return () => observer.disconnect(); + }, [hasBeenVisible, video]); + + return ( +
+ +
{title}
+
{description}
+
+ {actionText} + +
+
+
+ {lottieData ? ( + + ) : video ? ( + + ) : image ? ( +
+ {imageAlt +
+ ) : null} +
+
+ ); +} + +function BenchmarkCard() { + const { colorMode } = useColorMode(); + const [chart, setChart] = useState<'short' | 'long' | undefined>(undefined); + + // Initialize chart after mount to trigger animation + React.useEffect(() => { + setChart('short'); + }, []); + + return ( +
+
+

+ Run at any scale with best performance +

+

+ We engineered Windmill to be the fastest orchestrator in the industry, ensuring your most + demanding workloads never bottleneck. From a single-node VPS to 1,000-node K8s clusters, + auto-scale on demand or isolate critical tasks with dedicated worker groups on Kubernetes + and Docker. +

+ + See benchmarks + + +
+
+ + 10 long tasks + + setChart(chart === 'long' ? 'short' : 'long')} + className={`${ + chart === 'short' ? 'bg-blue-500 dark:bg-blue-900' : 'bg-gray-200 dark:bg-gray-800' + } relative inline-flex h-[24px] w-[48px] shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75`} + > + + + 40 lightweight tasks + +
+
+ }> + {() => ( +
+ {chart === 'short' ? ( +
+ +
+ ) : chart === 'long' ? ( +
+ +
+ ) : ( +
+ )} +
+ )} + +
+
+ ); +} + +export default function DeveloperExperienceSection() { + return ( + +
+
+

+ Run in production +

+ + Review, deploy and run on the most scalable and reliable infra with workers managed by + Windmill, and all observability, alerting and error handling built-in (+export to OTEL) + +
+
+ + + +
+
+
+ ); +} diff --git a/src/landing/ExplainerSection.tsx b/src/landing/ExplainerSection.tsx new file mode 100644 index 000000000..d6d67fef8 --- /dev/null +++ b/src/landing/ExplainerSection.tsx @@ -0,0 +1,875 @@ +import React from 'react'; +import { + GitCompareArrows, + ArrowRight, + ChevronRight, + ChevronLeft, + RotateCcw, + Play, + Pause +} from 'lucide-react'; +import ProductionTabs, { defaultTabs, defaultSubtitles } from './components/ProductionTabs'; +import { Lottie } from './LightFeatureCard'; +// @ts-ignore +import devfriendly from '/illustrations/devfriendly.json'; +import CombinedAnimation from './CombinedAnimation'; +// @ts-ignore +import thirdparty from '/illustrations/thirdparty.json'; +import { BenchmarkVisualization } from '../components/BenchmarkVisualization'; +import { ArrowLongDownIcon } from '@heroicons/react/20/solid'; +import { useColorMode } from '@docusaurus/theme-common'; +import classNames from 'classnames'; +import { Switch } from '@headlessui/react'; +import ScriptAnimation from './ScriptAnimation'; +import FlowAnimation from './FlowAnimation'; +import AppAnimation from './AppAnimation'; +import ProgressBars from './animations/ProgressBars'; +import AnimationCarousel from './animations/AnimationCarousel'; +import ScrollContext from './animations/ScrollContext'; +import { useMotionValue } from 'framer-motion'; +import { useRef, useEffect, useState } from 'react'; +import { scriptScrollCount, flowScrollCount, appScrollCount } from './animations/useAnimateScroll'; + +export default function TutorialSection({ subIndex, children }) { + const [chart, setChart] = React.useState<'short' | 'long'>(undefined); + const [step, setStep] = React.useState(subIndex || 0); + const containerRef = React.useRef(null); + + const oneStep = subIndex !== undefined; + const maxHeight = oneStep ? 5000 : 15000; + + const items = [ + { + key: 'scripts', + content: ( +
+ +
+ ) + }, + { + key: 'flows', + content: ( +
+ +
+ ) + }, + { + key: 'apps', + content: ( +
+ +
+ ) + } + ]; + + const [px, setPx] = React.useState(0); + + useEffect(() => { + setPx((maxHeight - window.innerHeight) / 3); + }, []); + + function nextStep() { + const top = containerRef.current.getBoundingClientRect().y * -1; + + const steps = { + scripts: { total: px, steps: [20, 40, 50, 60, 70, 80] }, + flows: { total: px * 2, steps: [10, 20, 30, 40, 50, 60, 75, 80, 90] }, + apps: { total: px * 3, steps: [10, 15, 30, 45, 60, 72, 90, 99] } + }; + + if (subIndex !== undefined) { + const stepKey = subIndex === 0 ? 'scripts' : subIndex === 1 ? 'flows' : 'apps'; + const section = steps[stepKey]; + + if (!section) { + return; + } + + smoothScrollToNextStep(top, { + total: px, + steps: section.steps + }); + } else { + for (const section of Object.values(steps)) { + if (top < section.total) { + const res = smoothScrollToNextStep(top, section); + if (res === 'next') { + window.scrollBy({ + top: section.total - top + 50, + behavior: 'smooth' + }); + } + break; + } + } + } + } + + function smoothScrollToNextStep( + top: number, + { steps, total }: { total: number; steps: number[] } + ) { + const percentage = (100 * (top % px)) / px + 1; + const nextStepPercentage = steps.find((step: number) => step > percentage); + + if (nextStepPercentage === undefined) { + return 'next'; + } + + const scrollAmount = (nextStepPercentage * px) / 100 + (total - px); + + window.scrollBy({ + top: scrollAmount - top, + behavior: 'smooth' + }); + + return 'done'; + } + + function findHighestUnderX(arr: number[], x: number) { + return arr.reduce((prev, curr) => (curr > prev && curr < x ? curr : prev), 0); + } + + function smoothScrollToPreviousStep( + top: number, + { steps, total }: { total: number; steps: number[] } + ) { + const percentage = (100 * (top % px)) / px - 1; + const nextStepPercentage = findHighestUnderX(steps, percentage); + + if (nextStepPercentage === 0) { + return 'previous'; + } + + const scrollAmount = (nextStepPercentage * px) / 100 + (total - px); + + window.scrollBy({ + top: scrollAmount - top, + behavior: 'smooth' + }); + + return 'done'; + } + + function prevStep() { + const top = containerRef.current.getBoundingClientRect().y * -1; + let foundSection = false; + + const steps = { + scripts: { total: px, steps: [20, 40, 50, 60, 70, 80] }, + flows: { total: px * 2, steps: [10, 20, 30, 40, 50, 60, 75, 80, 90] }, + apps: { total: px * 3, steps: [10, 15, 30, 45, 60, 72, 90, 99] } + }; + + if (subIndex !== undefined) { + const stepKey = subIndex === 0 ? 'scripts' : subIndex === 1 ? 'flows' : 'apps'; + const section = steps[stepKey]; + + if (!section) { + return; + } + + smoothScrollToPreviousStep(top, { + total: px, + steps: section.steps + }); + } else { + for (const [sectionName, section] of Object.entries(steps)) { + if (top <= section.total) { + const res = smoothScrollToPreviousStep(top, section); + + if (res === 'previous' && sectionName !== 'scripts') { + const previousSectionName = + Object.keys(steps)[Object.keys(steps).indexOf(sectionName) - 1]; + const previousSection = steps[previousSectionName]; + + if (!previousSection) { + return; + } + + const lastStepPercentage = previousSection.steps[previousSection.steps.length - 1]; + const scrollAmount = (lastStepPercentage * px) / 100 + (previousSection.total - px); + + window.scrollBy({ + top: scrollAmount - top, + behavior: 'smooth' + }); + } + foundSection = true; + break; + } + } + } + + if (!foundSection) { + // If we're at the very bottom, go to the last step of the last section + const lastSectionName = Object.keys(steps)[Object.keys(steps).length - 1]; + const lastSection = steps[lastSectionName]; + const lastStepPercentage = lastSection.steps[lastSection.steps.length - 1]; + const scrollAmount = (lastStepPercentage * px) / 100 + (lastSection.total - px); + + window.scrollBy({ + top: scrollAmount - top, + behavior: 'smooth' + }); + } + } + + useEffect(() => { + setPx((maxHeight - window.innerHeight) / 3); + + const handleKeyDown = (event) => { + if (event.key === 'ArrowRight') { + nextStep(); + } else if (event.key === 'ArrowLeft') { + prevStep(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [step, px]); + + useEffect(() => { + setChart('short'); + }, []); + + const features = [ + { + title: 'Build for production', + description: + 'Build mission-critical internal tools and data pipelines that integrates directly with your existing stack and resources.', + icon: GitCompareArrows, + href: '/docs/intro', + lottieData: devfriendly + }, + { + title: 'Full local dev experience', + description: + 'Use Windmill locally via our CLI and VS Code extension. Leverage AI-assisted rules for Cursor and Claude, and deploy through automated Git-sync pipelines across staging and production.', + href: '/docs/advanced/local_development', + actionText: 'Set up local dev', + lottieData: devfriendly, + youtubeUrl: 'https://www.youtube.com/watch?v=sxNW_6J4RG8' + } + ]; + + const ArrowSeparator = () => ( +
+ +
+ ); + + const FeatureCard = ({ feature, index, totalFeatures }) => { + const { colorMode } = useColorMode(); + const ContentWrapper = feature.href ? 'a' : 'div'; + const isProductionUse = feature.title === 'Build for production'; + + const wrapperProps = feature.href + ? { + href: feature.href, + target: '_blank', + className: isProductionUse + ? 'group text-black dark:text-white !no-underline hover:text-black hover:dark:text-white cursor-pointer' + : 'col-span-2 group text-black dark:text-white !no-underline hover:text-black hover:dark:text-white cursor-pointer flex flex-col justify-center' + } + : { + className: isProductionUse + ? 'group text-black dark:text-white cursor-pointer' + : 'col-span-2 group text-black dark:text-white cursor-pointer flex flex-col justify-center' + }; + + return ( + <> +
+ {!isProductionUse && ( + +
+ {feature.title} +
+
+ {feature.description} +
+
+ {feature.actionText || 'Learn more'} + +
+
+ )} +
+ {feature.useBenchmark ? ( +
+
+ + 10 long running tasks + + { + setChart(chart === 'long' ? 'short' : 'long'); + }} + className={`${ + chart === 'short' + ? 'bg-blue-500 dark:bg-blue-900' + : 'bg-gray-200 dark:bg-gray-800' + } + relative inline-flex h-[24px] w-[48px] shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75`} + > + + + 40 lightweight tasks + +
+
+
+ {chart === 'short' ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+
+ ) : feature.title === 'Build for production' ? ( + + ) : feature.lottieData ? ( +
+ + {feature.youtubeUrl && ( + + )} +
+ ) : feature.video ? ( + + ) : ( +
+ {feature.imageAlt +
+ )} +
+
+ + ); + }; + + // Animation section component - using the working wrapper from CorePrinciple + const AnimationSection = () => { + const scrollValue = useMotionValue(0); + const previousValueRef = useRef(0); + const intervalRef = useRef(null); + const [step, setStep] = useState(0); + const [isPlaying, setIsPlaying] = useState(true); + + // Section-specific speeds + const sectionSpeeds = { + scripts: 0.5, + flows: 0.3, + apps: 0.5 + }; + + const handleClick = (newStep: number) => { + if (newStep === 0) { + previousValueRef.current = scrollValue.get(); + scrollValue.set(0); + setStep(0); + } + }; + + useEffect(() => { + if (!isPlaying) return; + + // Auto-advance the animation through Script, Flow, and App sections + // Speed multiplier varies by section + let progress = scrollValue.get(); + const totalScroll = scriptScrollCount + flowScrollCount + appScrollCount; + const baseIncrement = 50; // Base increment per 100ms + + intervalRef.current = setInterval(() => { + // Determine current section and apply appropriate speed + let currentSpeed; + if (progress < scriptScrollCount) { + currentSpeed = sectionSpeeds.scripts; + } else if (progress < scriptScrollCount + flowScrollCount) { + currentSpeed = sectionSpeeds.flows; + } else { + currentSpeed = sectionSpeeds.apps; + } + + const increment = baseIncrement * currentSpeed; + progress += increment; + if (progress > totalScroll) { + progress = 0; // Reset to loop + } + previousValueRef.current = scrollValue.get(); + scrollValue.set(progress); + }, 100); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [scrollValue, isPlaying]); + + // Create a mock MotionValue with onChange method and getPrevious + const mockMotionValue = { + get: () => scrollValue.get(), + getPrevious: () => previousValueRef.current, + set: (value: number) => { + previousValueRef.current = scrollValue.get(); + scrollValue.set(value); + }, + onChange: (callback: (latest: number) => void) => { + return scrollValue.on('change', callback); + } + } as any; + + const totalScroll = scriptScrollCount + flowScrollCount + appScrollCount; + const scriptSteps = [0, 20, 40, 50, 60, 70, 80]; + const flowSteps = [0, 5, 30, 50, 60, 70, 90]; + const appSteps = [0, 10, 15, 30, 45, 60, 72, 90, 99]; + const [currentProgress, setCurrentProgress] = useState(0); + const [activeSection, setActiveSection] = useState<'scripts' | 'flows' | 'apps'>('scripts'); + const [currentStepIndex, setCurrentStepIndex] = useState(0); + + const jumpToStep = (stepPercent: number, section: 'scripts' | 'flows' | 'apps') => { + let targetProgress; + if (section === 'scripts') { + targetProgress = (stepPercent * scriptScrollCount) / 100; + } else if (section === 'flows') { + // Flow steps are relative to flowScrollCount, but we need to add scriptScrollCount offset + targetProgress = scriptScrollCount + (stepPercent * flowScrollCount) / 100; + } else { + // App steps are relative to appScrollCount, but we need to add scriptScrollCount + flowScrollCount offset + targetProgress = scriptScrollCount + flowScrollCount + (stepPercent * appScrollCount) / 100; + } + previousValueRef.current = scrollValue.get(); + scrollValue.set(targetProgress); + setIsPlaying(false); // Pause when manually jumping + }; + + useEffect(() => { + const unsubscribe = scrollValue.on('change', (latest) => { + setCurrentProgress((latest / totalScroll) * 100); + // Determine which section is active and current step + if (latest < scriptScrollCount) { + setActiveSection('scripts'); + // Find closest step index + const progressPercent = (latest / scriptScrollCount) * 100; + const closestIndex = scriptSteps.reduce((prev, curr, index) => { + return Math.abs(curr - progressPercent) < Math.abs(scriptSteps[prev] - progressPercent) + ? index + : prev; + }, 0); + setCurrentStepIndex(closestIndex); + } else if (latest < scriptScrollCount + flowScrollCount) { + setActiveSection('flows'); + const flowProgress = latest - scriptScrollCount; + const progressPercent = (flowProgress / flowScrollCount) * 100; + const closestIndex = flowSteps.reduce((prev, curr, index) => { + return Math.abs(curr - progressPercent) < Math.abs(flowSteps[prev] - progressPercent) + ? index + : prev; + }, 0); + setCurrentStepIndex(closestIndex); + } else { + setActiveSection('apps'); + const appProgress = latest - scriptScrollCount - flowScrollCount; + const progressPercent = (appProgress / appScrollCount) * 100; + const closestIndex = appSteps.reduce((prev, curr, index) => { + return Math.abs(curr - progressPercent) < Math.abs(appSteps[prev] - progressPercent) + ? index + : prev; + }, 0); + setCurrentStepIndex(closestIndex); + } + }); + return () => unsubscribe(); + }, [scrollValue, totalScroll]); + + // Keyboard navigation + useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + e.preventDefault(); + // Go to previous step + if (activeSection === 'scripts') { + if (currentStepIndex > 0) { + const prevStep = scriptSteps[currentStepIndex - 1]; + jumpToStep(prevStep, 'scripts'); + } + } else if (activeSection === 'flows') { + if (currentStepIndex > 0) { + const prevStep = flowSteps[currentStepIndex - 1]; + jumpToStep(prevStep, 'flows'); + } else { + // Go to last step of scripts + const lastScriptStep = scriptSteps[scriptSteps.length - 1]; + jumpToStep(lastScriptStep, 'scripts'); + } + } else { + // apps + if (currentStepIndex > 0) { + const prevStep = appSteps[currentStepIndex - 1]; + jumpToStep(prevStep, 'apps'); + } else { + // Go to last step of flows + const lastFlowStep = flowSteps[flowSteps.length - 1]; + jumpToStep(lastFlowStep, 'flows'); + } + } + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + // Go to next step + if (activeSection === 'scripts') { + if (currentStepIndex < scriptSteps.length - 1) { + const nextStep = scriptSteps[currentStepIndex + 1]; + jumpToStep(nextStep, 'scripts'); + } else { + // Go to first step of flows + const firstFlowStep = flowSteps[0]; + jumpToStep(firstFlowStep, 'flows'); + } + } else if (activeSection === 'flows') { + if (currentStepIndex < flowSteps.length - 1) { + const nextStep = flowSteps[currentStepIndex + 1]; + jumpToStep(nextStep, 'flows'); + } else { + // Go to first step of apps + const firstAppStep = appSteps[0]; + jumpToStep(firstAppStep, 'apps'); + } + } else { + // apps + if (currentStepIndex < appSteps.length - 1) { + const nextStep = appSteps[currentStepIndex + 1]; + jumpToStep(nextStep, 'apps'); + } + } + } + }; + + window.addEventListener('keydown', handleKeyPress); + return () => { + window.removeEventListener('keydown', handleKeyPress); + }; + }, [activeSection, currentStepIndex, scriptSteps, flowSteps, appSteps]); + + const scriptProgressPercent = (scriptScrollCount / totalScroll) * 100; + const scriptCurrentProgress = + activeSection === 'scripts' + ? (scrollValue.get() / scriptScrollCount) * 100 + : activeSection === 'flows' || activeSection === 'apps' + ? 100 + : 0; + const flowCurrentProgress = + activeSection === 'flows' + ? ((scrollValue.get() - scriptScrollCount) / flowScrollCount) * 100 + : activeSection === 'apps' + ? 100 + : 0; + const appCurrentProgress = + activeSection === 'apps' + ? ((scrollValue.get() - scriptScrollCount - flowScrollCount) / appScrollCount) * 100 + : 0; + + return ( + +
+
+
+ Develop and iterate with instant feedback +
+
+ + + + +
+
+
+
+ {/* Scripts, Flows, and Apps Progress Bars */} +
+ {/* Scripts Progress Bar */} +
+
+ Scripts +
+
+
+ {scriptSteps.map((percent) => ( +
+
+ {/* Flows Progress Bar */} +
+
Flows
+
+
+ {flowSteps.map((percent) => ( +
+
+ {/* Apps Progress Bar */} +
+
Apps
+
+
+ {appSteps.map((percent) => ( +
+
+
+
+
+
+ {activeSection === 'scripts' ? ( + + ) : activeSection === 'flows' ? ( + + ) : ( + + )} +
+
+
+
+ + ); + }; + + return ( +
+ {/* FeatureCard Section */} +
+ {/* Section Header */} +
+

+ Build +

+ + Build mission-critical internal tools and data pipelines that integrate directly with + your existing stack and resources using code with a powerful WebIDE or locally using our + CLI and your favorite editor and AI agent. + +
+ {features.map((feature, index) => { + // Replace the second feature (index 1) with the animation section + // if (index === 1) { + // return ( + // + // + // {index < features.length - 1 && } + // + // ); + // } + + return ( + + + + ); + })} +
+
+ ); +} diff --git a/src/landing/LandingHeader.jsx b/src/landing/LandingHeader.jsx index 78224e777..fe853b13d 100644 --- a/src/landing/LandingHeader.jsx +++ b/src/landing/LandingHeader.jsx @@ -100,6 +100,7 @@ export default function LandingHeader() { + {/* Products dropdown - temporarily hidden while improving sections {({ open }) => ( <> @@ -154,6 +155,7 @@ export default function LandingHeader() { )} + */} window.plausible?.('read-docs')} @@ -343,7 +345,7 @@ export default function LandingHeader() { ))}
- {/* Show Products dropdown items on mobile */} + {/* Show Products dropdown items on mobile - temporarily hidden while improving sections
Products @@ -360,6 +362,7 @@ export default function LandingHeader() { ))}
+ */} {/* Show social icons only on xl screens (xl:hidden) */}
diff --git a/src/landing/LightFeatureCard.tsx b/src/landing/LightFeatureCard.tsx index 29afffd02..b1f96929b 100644 --- a/src/landing/LightFeatureCard.tsx +++ b/src/landing/LightFeatureCard.tsx @@ -1,10 +1,34 @@ -import React from 'react'; -import { useLottie } from 'lottie-react'; +import React, { useRef, useEffect, useState } from 'react'; +import BrowserOnly from '@docusaurus/BrowserOnly'; import { ArrowRight, CircleIcon, LucideIcon } from 'lucide-react'; import { twMerge } from 'tailwind-merge'; import FlowChart from './FlowChart'; import SyntaxHighlighter from 'react-syntax-highlighter'; import { light } from 'react-syntax-highlighter/dist/esm/styles/hljs'; + +// Client-only Lottie component +function LottiePlayer({ animationData, loop, autoplay, onPlay }: { + animationData: unknown; + loop: boolean; + autoplay: boolean; + onPlay?: (playFn: () => void) => void; +}) { + const { useLottie } = require('lottie-react'); + const { View, play } = useLottie({ + animationData, + loop, + autoplay + }); + + useEffect(() => { + if (onPlay) { + onPlay(play); + } + }, [play, onPlay]); + + return View; +} + type FeatureCardProps = { feature: { title: string; description: string; images: string[] }; animationDelay: number; @@ -38,14 +62,9 @@ export default function LightFeatureCard({ vertical = false, code = undefined }: FeatureCardProps) { - const options = { - animationData: lottieData, - loop: loop, - autoplay: autoplay - }; const span = !vertical ? 'col-span-2' : 'col-span-2 md:col-span-1'; + const playRef = useRef<(() => void) | null>(null); - const { View, play } = useLottie(options); return ( { - play(); + playRef.current?.(); }} href={url} target="_blank" @@ -101,7 +120,16 @@ export default function LightFeatureCard({ : 'bg-blue-200 dark:bg-blue-800' )} > - {View} + }> + {() => ( + { playRef.current = play; }} + /> + )} +
) : defaultImage ? (
@@ -125,22 +153,51 @@ export default function LightFeatureCard({ ); } -export function Lottie({ lottieData, autoplay = true, loop = false }) { - const options = { +// Client-only Lottie with visibility detection +function LottieWithVisibility({ lottieData, autoplay, loop }: { lottieData: unknown; autoplay: boolean; loop: boolean }) { + const { useLottie } = require('lottie-react'); + const containerRef = useRef(null); + const [hasBeenVisible, setHasBeenVisible] = useState(false); + + const { View, play } = useLottie({ animationData: lottieData, - loop: loop, - autoplay: autoplay - }; + loop, + autoplay: false + }); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !hasBeenVisible) { + setHasBeenVisible(true); + play(); + } + }, + { threshold: 0.8 } + ); + + observer.observe(container); + return () => observer.disconnect(); + }, [hasBeenVisible, play]); - const { View, play } = useLottie(options); return (
{ - play(); - }} + onMouseOver={() => play()} > {View}
); } + +export function Lottie({ lottieData, autoplay = true, loop = false }) { + return ( + }> + {() => } + + ); +} diff --git a/src/landing/LogoClouds.tsx b/src/landing/LogoClouds.tsx index 5d3a82a5e..06df9a432 100644 --- a/src/landing/LogoClouds.tsx +++ b/src/landing/LogoClouds.tsx @@ -51,7 +51,7 @@ export default function LogoClouds() { ]; return ( -
+

Trusted by 3,000+ organizations, including: diff --git a/src/landing/MobileTutorialSection.tsx b/src/landing/MobileTutorialSection.tsx index 94e54cc58..9691f5a8a 100644 --- a/src/landing/MobileTutorialSection.tsx +++ b/src/landing/MobileTutorialSection.tsx @@ -1,194 +1,73 @@ -import React, { useEffect } from 'react'; -import AnimationCarousel from './animations/AnimationCarousel'; - -import { Activity, GitCompareArrows, Server, ArrowRight } from 'lucide-react'; - +import React from 'react'; +import { ArrowRight } from 'lucide-react'; +import ProductionTabs, { defaultTabs } from './components/ProductionTabs'; import { Lottie } from './LightFeatureCard'; // @ts-ignore -import deployAtScale from '/illustrations/deploy_at_scale.json'; -import { ArrowLongDownIcon } from '@heroicons/react/20/solid'; -import Window from './animations/Window'; -import { twMerge } from 'tailwind-merge'; - -export default function TutorialSection() { - const containerRef = React.useRef(null); - - const [currentIndex, setCurrentIndex] = React.useState(0); +import devfriendly from '/illustrations/devfriendly.json'; +export default function MobileTutorialSection() { return ( - <> -
-
-
-
- {'Develop and iterate with instant feedback'} -
-
-
setCurrentIndex(0)} - className={twMerge( - 'cursor-pointer px-2 py-1 border-b-2 hover:bg-opacity-50 ', - currentIndex === 0 ? ' border-blue-600' : 'border-blue-800' - )} - > - Scripts -
-
setCurrentIndex(1)} - className={twMerge( - 'cursor-pointer px-2 py-1 border-b-2 hover:bg-opacity-50 ', - currentIndex === 1 ? ' border-emerald-600' : 'border-emerald-800' - )} - > - Flows -
-
setCurrentIndex(2)} - className={twMerge( - 'cursor-pointer px-2 py-1 border-b-2 hover:bg-opacity-50 ', - currentIndex === 2 ? ' border-orange-600' : 'border-orange-800' - )} - > - Apps -
-
- - {currentIndex === 0 && ( -
-
- - Scripts - -
-
- )} - {currentIndex === 1 && ( -
-
- - Flows - -
-
- )} - {currentIndex === 2 && ( -
-
- - Apps - -
-
- )} - -
Animation available on desktop
-
-
-
- -
-
+
-
- -
- - Review -
-
- {'Use the built-in diff viewer, GitHub PRs or GitLab MRs to review changes.'} -
-
- Learn more - -
-
-
-
- {'Review'} -
-
+ {/* Section Header */} +
+

+ Build +

+ + Build mission-critical internal tools and data pipelines that integrate directly with + your existing stack and resources using code with a powerful WebIDE or locally using our + CLI and your favorite editor and AI agent. +
-
- + + {/* Build for production card */} +
+
+ +
-
+ {/* Develop locally card */} +
-
- - Deploy at scale +
+ Full local dev experience
-
); } diff --git a/src/landing/TestimonialsSection.tsx b/src/landing/TestimonialsSection.tsx index 16e7ba59c..e2d00f7cd 100644 --- a/src/landing/TestimonialsSection.tsx +++ b/src/landing/TestimonialsSection.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import LandingSection from './LandingSection'; import { useColorMode } from '@docusaurus/theme-common'; +import { ArrowRight } from 'lucide-react'; const clientTestimonials = [ { @@ -15,6 +16,7 @@ const clientTestimonials = [ }, company_url: 'https://photoroom.com', linkedIn: 'https://www.linkedin.com/in/eliotandres/', + caseStudyHref: 'https://www.linkedin.com/in/eliotandres/', text: `Windmill quickly became crucial at Photoroom. We self-hosted Windmill Enterprise Edition to run a large number of internal scripts and business-critical automations. Windmill made chatops and iterations over scripts incredibly easy. It proved very reliable for running and monitoring workloads at scale. On top of that, their support is incredibly fast.` }, { @@ -29,6 +31,7 @@ const clientTestimonials = [ }, company_url: 'https://www.pave.com/', linkedIn: 'https://www.linkedin.com/in/lewisjellis/', + caseStudyHref: '', text: `At Pave, we self-host Windmill Enterprise Edition to run 100+ scripts and 15+ crons. Our Windmill deployment interacts with half a dozen data stores to power all kinds of business-critical tasks and automations across several teams. It enables our engineering org to move quickly while keeping things secure and avoiding infrastructure sprawl. ` }, { @@ -43,7 +46,8 @@ const clientTestimonials = [ }, text: `Bloom Credit uses Windmill to automate back office and support tasks, and orchestrate their ELT process. It is rapidly becoming a foundational technology in our SaaS control plane. The Windmill team have been great partners; they are responsive to support inquiries and new feature requests and are truly invested in our success with the platform.`, company_url: 'https://bloomcredit.io', - linkedIn: 'https://www.linkedin.com/in/mikeesler/' + linkedIn: 'https://www.linkedin.com/in/mikeesler/', + caseStudyHref: '' }, { author: { @@ -57,6 +61,7 @@ const clientTestimonials = [ }, company_url: 'https://www.investing.com', linkedIn: 'https://www.linkedin.com/in/yonatan-adest/', + caseStudyHref: '', text: `At Investing.com, we use Windmill to orchestrate our AI workflows. The quick setup through Docker Compose and intuitive UI allowed us to get started immediately. We leverage Windmill for various automation tasks including content processing pipelines, automated stock analysis report generation, and ETL processes.` }, { @@ -71,6 +76,7 @@ const clientTestimonials = [ }, company_url: 'https://www.qovery.com', linkedIn: '/blog/qovery-case-study', + caseStudyHref: '/blog/qovery-case-study', text: `Windmill has been able to cover all of our needs in terms of ETL & workflow orchestration and observability. We use Windmill to manage entirely our playground and complex billing engine. The platform offers a clear DX for code editing, permission management and error handling.` }, { @@ -85,9 +91,10 @@ const clientTestimonials = [ }, company_url: 'https://motimateapp.com', linkedIn: '/blog/kahoot-case-study', + caseStudyHref: '/blog/kahoot-case-study', text: `Currently, we employ 9 apps, 20 flows, and 63 scripts in our daily operations. They all serve as the foundation for essential tasks, allowing users to independently manage their activities according to their specific needs.` }, - { + /*{ author: { name: 'Ben Packer', company: 'United Auto Workers', @@ -99,9 +106,10 @@ const clientTestimonials = [ }, company_url: 'https://uaw.org/', linkedIn: 'https://www.linkedin.com/in/benpaulryanpacker/', + caseStudyHref: '', text: `I've used Retool, Argo, Airflow, etc., and nothing comes close to Windmill. It's coherent and expertly designed for developers to interface with non-technical staff. It lets our small team move super fast and cover a huge surface area in a way that's maintainable, observable, and debuggable.` }, - { + /*{ author: { name: 'Rudo Kemper', company: 'Conservation Metrics', @@ -112,15 +120,16 @@ const clientTestimonials = [ }, company_url: 'https://conservationmetrics.com/', linkedIn: '/blog/conservation-metrics-case-study', + caseStudyHref: '/blog/conservation-metrics-case-study', text: `Windmill is invaluable for our end users, the indigenous communities. As simple as it is to deploy and scale, it saves them hours of work and provides near-instantaneous data access that previously took months of manual work.` - } + }*/ ]; export default function Example() { const { colorMode } = useColorMode(); return ( - +

@@ -132,18 +141,29 @@ export default function Example() {

-
+
{clientTestimonials.map((testimonial, index) => (
-
-
-
+
+
+

+ {testimonial.caseStudyHref && testimonial.caseStudyHref !== '' && ( + + )}
{testimonial.author.profile_picture ? ( @@ -163,9 +183,7 @@ export default function Example() { {testimonial.author.name}

- {[testimonial.author.position, testimonial.author.company] - .filter(Boolean) - .join(' @ ')} + {testimonial.author.position}
) : ( diff --git a/src/landing/TutorialSection.tsx b/src/landing/TutorialSection.tsx index 1eda1c8b4..d6d67fef8 100644 --- a/src/landing/TutorialSection.tsx +++ b/src/landing/TutorialSection.tsx @@ -1,36 +1,39 @@ -import React, { useEffect } from 'react'; -import ScriptAnimation from './ScriptAnimation'; -import AnimationCarousel from './animations/AnimationCarousel'; -import FlowAnimation from './FlowAnimation'; -import AppAnimation from './AppAnimation'; +import React from 'react'; import { - Activity, GitCompareArrows, - Server, ArrowRight, ChevronRight, ChevronLeft, - SkipForward, RotateCcw, - Play + Play, + Pause } from 'lucide-react'; -import SmoothScroll from './animations/SmoothScroll'; -import ProgressBars from './animations/ProgressBars'; +import ProductionTabs, { defaultTabs, defaultSubtitles } from './components/ProductionTabs'; import { Lottie } from './LightFeatureCard'; // @ts-ignore -import deployAtScale from '/illustrations/deploy_at_scale.json'; -import { ArrowLongDownIcon } from '@heroicons/react/20/solid'; -import { twMerge } from 'tailwind-merge'; +import devfriendly from '/illustrations/devfriendly.json'; +import CombinedAnimation from './CombinedAnimation'; // @ts-ignore -import BrowserOnly from '@docusaurus/BrowserOnly'; +import thirdparty from '/illustrations/thirdparty.json'; +import { BenchmarkVisualization } from '../components/BenchmarkVisualization'; +import { ArrowLongDownIcon } from '@heroicons/react/20/solid'; +import { useColorMode } from '@docusaurus/theme-common'; +import classNames from 'classnames'; +import { Switch } from '@headlessui/react'; +import ScriptAnimation from './ScriptAnimation'; +import FlowAnimation from './FlowAnimation'; +import AppAnimation from './AppAnimation'; +import ProgressBars from './animations/ProgressBars'; +import AnimationCarousel from './animations/AnimationCarousel'; +import ScrollContext from './animations/ScrollContext'; +import { useMotionValue } from 'framer-motion'; +import { useRef, useEffect, useState } from 'react'; +import { scriptScrollCount, flowScrollCount, appScrollCount } from './animations/useAnimateScroll'; export default function TutorialSection({ subIndex, children }) { + const [chart, setChart] = React.useState<'short' | 'long'>(undefined); const [step, setStep] = React.useState(subIndex || 0); const containerRef = React.useRef(null); - const [animationEnabled, setAnimationEnabled] = React.useState( - // enabled on desktop, disabled on mobile - typeof window !== 'undefined' ? window.innerWidth > 768 : false - ); const oneStep = subIndex !== undefined; const maxHeight = oneStep ? 5000 : 15000; @@ -40,7 +43,7 @@ export default function TutorialSection({ subIndex, children }) { key: 'scripts', content: (
- +
) }, @@ -48,7 +51,7 @@ export default function TutorialSection({ subIndex, children }) { key: 'flows', content: (
- +
) }, @@ -56,7 +59,7 @@ export default function TutorialSection({ subIndex, children }) { key: 'apps', content: (
- +
) } @@ -169,8 +172,6 @@ export default function TutorialSection({ subIndex, children }) { return; } - console.log(px, section); - smoothScrollToPreviousStep(top, { total: px, steps: section.steps @@ -235,221 +236,639 @@ export default function TutorialSection({ subIndex, children }) { }; }, [step, px]); - return ( -
- - {() => ( - { - setAnimationEnabled(false); - }} - animationEnabled={animationEnabled} - count={oneStep ? 1 : 3} - > -
-
{children}
-
-
-
- {subIndex === undefined ? 'Develop and iterate with instant feedback' : ''} -
-
- {animationEnabled ? ( - <> - - - - ) : ( - - )} -
-
+ useEffect(() => { + setChart('short'); + }, []); -
- {animationEnabled === false && ( -
-
- Animation disabled - -
-
- )} -
- { - containerRef.current.scrollIntoView({ behavior: 'instant' }); - - window.scrollBy({ - top: i * px + 50, - behavior: 'instant' - }); - }} - subIndex={subIndex} - /> + const features = [ + { + title: 'Build for production', + description: + 'Build mission-critical internal tools and data pipelines that integrates directly with your existing stack and resources.', + icon: GitCompareArrows, + href: '/docs/intro', + lottieData: devfriendly + }, + { + title: 'Full local dev experience', + description: + 'Use Windmill locally via our CLI and VS Code extension. Leverage AI-assisted rules for Cursor and Claude, and deploy through automated Git-sync pipelines across staging and production.', + href: '/docs/advanced/local_development', + actionText: 'Set up local dev', + lottieData: devfriendly, + youtubeUrl: 'https://www.youtube.com/watch?v=sxNW_6J4RG8' + } + ]; - + const ArrowSeparator = () => ( +
+ +
+ ); -
-
Scroll or use the arrow keys to navigate
-
- - + const FeatureCard = ({ feature, index, totalFeatures }) => { + const { colorMode } = useColorMode(); + const ContentWrapper = feature.href ? 'a' : 'div'; + const isProductionUse = feature.title === 'Build for production'; + + const wrapperProps = feature.href + ? { + href: feature.href, + target: '_blank', + className: isProductionUse + ? 'group text-black dark:text-white !no-underline hover:text-black hover:dark:text-white cursor-pointer' + : 'col-span-2 group text-black dark:text-white !no-underline hover:text-black hover:dark:text-white cursor-pointer flex flex-col justify-center' + } + : { + className: isProductionUse + ? 'group text-black dark:text-white cursor-pointer' + : 'col-span-2 group text-black dark:text-white cursor-pointer flex flex-col justify-center' + }; + + return ( + <> +
+ {!isProductionUse && ( + +
+ {feature.title} +
+
+ {feature.description} +
+
+ {feature.actionText || 'Learn more'} + +
+
+ )} +
+ {feature.useBenchmark ? ( +
+
+ + 10 long running tasks + + { + setChart(chart === 'long' ? 'short' : 'long'); + }} + className={`${ + chart === 'short' + ? 'bg-blue-500 dark:bg-blue-900' + : 'bg-gray-200 dark:bg-gray-800' + } + relative inline-flex h-[24px] w-[48px] shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75`} + > + + + 40 lightweight tasks + +
+
+
+ {chart === 'short' ? ( +
+
-
+ ) : ( +
+ +
+ )}
-
- + ) : feature.title === 'Build for production' ? ( + + ) : feature.lottieData ? ( +
+ + {feature.youtubeUrl && ( + + )}
-
- - )} - - -
- -
- -
+ + ); + }; + + // Animation section component - using the working wrapper from CorePrinciple + const AnimationSection = () => { + const scrollValue = useMotionValue(0); + const previousValueRef = useRef(0); + const intervalRef = useRef(null); + const [step, setStep] = useState(0); + const [isPlaying, setIsPlaying] = useState(true); + + // Section-specific speeds + const sectionSpeeds = { + scripts: 0.5, + flows: 0.3, + apps: 0.5 + }; -
- -
- - Deploy at scale -
-
- { - 'Deploy with ease on our infrastructure or your own infrastructure, on bare VMs with docker-compose, ecs, or large Kubernetes clusters with up to 1000 workers and even remote agents.' - } + const handleClick = (newStep: number) => { + if (newStep === 0) { + previousValueRef.current = scrollValue.get(); + scrollValue.set(0); + setStep(0); + } + }; + + useEffect(() => { + if (!isPlaying) return; + + // Auto-advance the animation through Script, Flow, and App sections + // Speed multiplier varies by section + let progress = scrollValue.get(); + const totalScroll = scriptScrollCount + flowScrollCount + appScrollCount; + const baseIncrement = 50; // Base increment per 100ms + + intervalRef.current = setInterval(() => { + // Determine current section and apply appropriate speed + let currentSpeed; + if (progress < scriptScrollCount) { + currentSpeed = sectionSpeeds.scripts; + } else if (progress < scriptScrollCount + flowScrollCount) { + currentSpeed = sectionSpeeds.flows; + } else { + currentSpeed = sectionSpeeds.apps; + } + + const increment = baseIncrement * currentSpeed; + progress += increment; + if (progress > totalScroll) { + progress = 0; // Reset to loop + } + previousValueRef.current = scrollValue.get(); + scrollValue.set(progress); + }, 100); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [scrollValue, isPlaying]); + + // Create a mock MotionValue with onChange method and getPrevious + const mockMotionValue = { + get: () => scrollValue.get(), + getPrevious: () => previousValueRef.current, + set: (value: number) => { + previousValueRef.current = scrollValue.get(); + scrollValue.set(value); + }, + onChange: (callback: (latest: number) => void) => { + return scrollValue.on('change', callback); + } + } as any; + + const totalScroll = scriptScrollCount + flowScrollCount + appScrollCount; + const scriptSteps = [0, 20, 40, 50, 60, 70, 80]; + const flowSteps = [0, 5, 30, 50, 60, 70, 90]; + const appSteps = [0, 10, 15, 30, 45, 60, 72, 90, 99]; + const [currentProgress, setCurrentProgress] = useState(0); + const [activeSection, setActiveSection] = useState<'scripts' | 'flows' | 'apps'>('scripts'); + const [currentStepIndex, setCurrentStepIndex] = useState(0); + + const jumpToStep = (stepPercent: number, section: 'scripts' | 'flows' | 'apps') => { + let targetProgress; + if (section === 'scripts') { + targetProgress = (stepPercent * scriptScrollCount) / 100; + } else if (section === 'flows') { + // Flow steps are relative to flowScrollCount, but we need to add scriptScrollCount offset + targetProgress = scriptScrollCount + (stepPercent * flowScrollCount) / 100; + } else { + // App steps are relative to appScrollCount, but we need to add scriptScrollCount + flowScrollCount offset + targetProgress = scriptScrollCount + flowScrollCount + (stepPercent * appScrollCount) / 100; + } + previousValueRef.current = scrollValue.get(); + scrollValue.set(targetProgress); + setIsPlaying(false); // Pause when manually jumping + }; + + useEffect(() => { + const unsubscribe = scrollValue.on('change', (latest) => { + setCurrentProgress((latest / totalScroll) * 100); + // Determine which section is active and current step + if (latest < scriptScrollCount) { + setActiveSection('scripts'); + // Find closest step index + const progressPercent = (latest / scriptScrollCount) * 100; + const closestIndex = scriptSteps.reduce((prev, curr, index) => { + return Math.abs(curr - progressPercent) < Math.abs(scriptSteps[prev] - progressPercent) + ? index + : prev; + }, 0); + setCurrentStepIndex(closestIndex); + } else if (latest < scriptScrollCount + flowScrollCount) { + setActiveSection('flows'); + const flowProgress = latest - scriptScrollCount; + const progressPercent = (flowProgress / flowScrollCount) * 100; + const closestIndex = flowSteps.reduce((prev, curr, index) => { + return Math.abs(curr - progressPercent) < Math.abs(flowSteps[prev] - progressPercent) + ? index + : prev; + }, 0); + setCurrentStepIndex(closestIndex); + } else { + setActiveSection('apps'); + const appProgress = latest - scriptScrollCount - flowScrollCount; + const progressPercent = (appProgress / appScrollCount) * 100; + const closestIndex = appSteps.reduce((prev, curr, index) => { + return Math.abs(curr - progressPercent) < Math.abs(appSteps[prev] - progressPercent) + ? index + : prev; + }, 0); + setCurrentStepIndex(closestIndex); + } + }); + return () => unsubscribe(); + }, [scrollValue, totalScroll]); + + // Keyboard navigation + useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + e.preventDefault(); + // Go to previous step + if (activeSection === 'scripts') { + if (currentStepIndex > 0) { + const prevStep = scriptSteps[currentStepIndex - 1]; + jumpToStep(prevStep, 'scripts'); + } + } else if (activeSection === 'flows') { + if (currentStepIndex > 0) { + const prevStep = flowSteps[currentStepIndex - 1]; + jumpToStep(prevStep, 'flows'); + } else { + // Go to last step of scripts + const lastScriptStep = scriptSteps[scriptSteps.length - 1]; + jumpToStep(lastScriptStep, 'scripts'); + } + } else { + // apps + if (currentStepIndex > 0) { + const prevStep = appSteps[currentStepIndex - 1]; + jumpToStep(prevStep, 'apps'); + } else { + // Go to last step of flows + const lastFlowStep = flowSteps[flowSteps.length - 1]; + jumpToStep(lastFlowStep, 'flows'); + } + } + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + // Go to next step + if (activeSection === 'scripts') { + if (currentStepIndex < scriptSteps.length - 1) { + const nextStep = scriptSteps[currentStepIndex + 1]; + jumpToStep(nextStep, 'scripts'); + } else { + // Go to first step of flows + const firstFlowStep = flowSteps[0]; + jumpToStep(firstFlowStep, 'flows'); + } + } else if (activeSection === 'flows') { + if (currentStepIndex < flowSteps.length - 1) { + const nextStep = flowSteps[currentStepIndex + 1]; + jumpToStep(nextStep, 'flows'); + } else { + // Go to first step of apps + const firstAppStep = appSteps[0]; + jumpToStep(firstAppStep, 'apps'); + } + } else { + // apps + if (currentStepIndex < appSteps.length - 1) { + const nextStep = appSteps[currentStepIndex + 1]; + jumpToStep(nextStep, 'apps'); + } + } + } + }; + + window.addEventListener('keydown', handleKeyPress); + return () => { + window.removeEventListener('keydown', handleKeyPress); + }; + }, [activeSection, currentStepIndex, scriptSteps, flowSteps, appSteps]); + + const scriptProgressPercent = (scriptScrollCount / totalScroll) * 100; + const scriptCurrentProgress = + activeSection === 'scripts' + ? (scrollValue.get() / scriptScrollCount) * 100 + : activeSection === 'flows' || activeSection === 'apps' + ? 100 + : 0; + const flowCurrentProgress = + activeSection === 'flows' + ? ((scrollValue.get() - scriptScrollCount) / flowScrollCount) * 100 + : activeSection === 'apps' + ? 100 + : 0; + const appCurrentProgress = + activeSection === 'apps' + ? ((scrollValue.get() - scriptScrollCount - flowScrollCount) / appScrollCount) * 100 + : 0; + + return ( + +
+
+
+ Develop and iterate with instant feedback
-
- Learn more - +
+ + + +
-
-
-
-
-
- -
-
- -
- - Monitor -
-
- {'Keep track of your scripts, flows, and apps with detailed logs and metrics.'} -
-
- Learn more - +
+
+ {/* Scripts, Flows, and Apps Progress Bars */} +
+ {/* Scripts Progress Bar */} +
+
+ Scripts +
+
+
+ {scriptSteps.map((percent) => ( +
+
+ {/* Flows Progress Bar */} +
+
Flows
+
+
+ {flowSteps.map((percent) => ( +
+
+ {/* Apps Progress Bar */} +
+
Apps
+
+
+ {appSteps.map((percent) => ( +
+
+
-
-
-
- {'Review'} +
+
+ {activeSection === 'scripts' ? ( + + ) : activeSection === 'flows' ? ( + + ) : ( + + )} +
+ + ); + }; + + return ( +
+ {/* FeatureCard Section */} +
+ {/* Section Header */} +
+

+ Build +

+ + Build mission-critical internal tools and data pipelines that integrate directly with + your existing stack and resources using code with a powerful WebIDE or locally using our + CLI and your favorite editor and AI agent. + +
+ {features.map((feature, index) => { + // Replace the second feature (index 1) with the animation section + // if (index === 1) { + // return ( + // + // + // {index < features.length - 1 && } + // + // ); + // } + + return ( + + + + ); + })}
); diff --git a/src/landing/cards-v2/CardSection.tsx b/src/landing/cards-v2/CardSection.tsx index 0f3a26e8e..61641955a 100644 --- a/src/landing/cards-v2/CardSection.tsx +++ b/src/landing/cards-v2/CardSection.tsx @@ -19,6 +19,7 @@ export default function CardSection({ features, colors, title, description, defa
{ + if (typeof document === 'undefined') return; const mouseMoveHandler = (e: MouseEvent) => { // @ts-ignore for (const card of document.getElementsByClassName('card')) { diff --git a/src/landing/components/ProductionTabs.tsx b/src/landing/components/ProductionTabs.tsx new file mode 100644 index 000000000..45f18a7e7 --- /dev/null +++ b/src/landing/components/ProductionTabs.tsx @@ -0,0 +1,378 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Server, Monitor, Database, Play, Pause } from 'lucide-react'; + +// Icon mapping for tab configuration +const iconMap = { + Server, + Monitor, + Database, +}; + +export interface TabConfig { + id: string; + label: string; + icon: keyof typeof iconMap; + description: string; + video: string; + youtubeUrl?: string; +} + +export interface SubtitleConfig { + time: number; + text: string; + duration: number; +} + +export interface ProductionTabsProps { + tabs: TabConfig[]; + subtitles?: Record; + enableSubtitles?: boolean; +} + +// Default tabs configuration +export const defaultTabs: TabConfig[] = [ + { id: 'scripts', label: 'Scripts', icon: 'Server', description: 'Write scripts in 20+ languages (Python, TS, Go...) with full LSP support, auto-generated UI, managed dependencies and turn them into instant endpoints or hooks for pubsub events.', video: '/videos/landingscripts-ui.webm', youtubeUrl: 'https://www.youtube.com/watch?v=QRf8C8qF7CY' }, + { id: 'flows', label: 'Flows', icon: 'Server', description: 'Orchestrate your scripts into high-performance flows with full code flexibility, AI assistance, and sub-20ms overhead.', video: '/videos/landingflows-ui.webm', youtubeUrl: 'https://www.youtube.com/watch?v=yE-eDNWTj3g' }, + { id: 'apps', label: 'Apps', icon: 'Monitor', description: 'Build powerful full-stack apps using Windmill as a backend and any framework as frontend.', video: '/videos/landingapps-ui.webm', youtubeUrl: 'https://www.youtube.com/watch?v=CNtRLDXbfOE' }, +]; + +// Default subtitles configuration +export const defaultSubtitles: Record = { + scripts: [ + { time: 0.0, text: 'Code in 20+ languages (Python, TS, Go, Rust, Java, SQL, Bash, Ruby...)', duration: 3 }, + { time: 3.5, text: 'Import any package including your private repositories', duration: 2 }, + { time: 10.5, text: 'Auto-generate UIs from your script parameters', duration: 2.5 }, + { time: 16.0, text: 'Real-time logs and instant test results', duration: 2.5 }, + { time: 30.0, text: 'Trigger via webhooks, schedule, CLI, Slack, email and more', duration: 2.5 }, + ], + flows: [ + { time: 0.0, text: 'Orchestrate scripts into high-performance workflows', duration: 2.5 }, + { time: 8.0, text: 'Link steps seamlessly using dynamic expressions', duration: 2 }, + { time: 17.0, text: 'Build complex flows faster with AI assistance', duration: 2 }, + ], + apps: [ + { time: 0.0, text: 'Build frontends with full code and any framework', duration: 2 }, + { time: 2.5, text: 'Develop by hand or generate with AI', duration: 2 }, + { time: 26.0, text: 'Iterate rapidly with instant code previews', duration: 2 }, + { time: 32.0, text: 'Bind your UI to existing scripts and flows', duration: 3 }, + ] +}; + +export default function ProductionTabs({ + tabs = defaultTabs, + subtitles = defaultSubtitles, + enableSubtitles = true, +}: ProductionTabsProps) { + const [selectedTab, setSelectedTab] = useState(tabs[0]?.id || 'scripts'); + const [progress, setProgress] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + const [duration, setDuration] = useState(0); + const videoRefs = useRef>({}); + const progressBarRef = useRef(null); + + // Subtitle state + const [currentSubtitle, setCurrentSubtitle] = useState(null); + const shownSubtitlesRef = useRef>(new Set()); + const subtitleTimeoutRef = useRef(null); + + const containerRef = useRef(null); + const [hasBeenVisible, setHasBeenVisible] = useState(false); + + const getCurrentVideo = () => videoRefs.current[selectedTab]; + + // Detect when component is visible in viewport + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !hasBeenVisible) { + setHasBeenVisible(true); + } + }, + { threshold: 0.8 } + ); + + observer.observe(container); + return () => observer.disconnect(); + }, [hasBeenVisible]); + + // Play/pause videos when tab changes + useEffect(() => { + // Pause all videos first + Object.values(videoRefs.current).forEach(video => { + if (video) video.pause(); + }); + + const video = getCurrentVideo(); + if (!video) return; + + // Reset to beginning and play + video.currentTime = 0; + + if (selectedTab === tabs[0]?.id) { + if (hasBeenVisible) { + video.play(); + } + } else { + video.play(); + } + }, [selectedTab, hasBeenVisible, tabs]); + + // Track video progress and handle subtitle detection + useEffect(() => { + const video = getCurrentVideo(); + if (!video) return; + + const handleTimeUpdate = () => { + if (video.duration > 0) { + setProgress((video.currentTime / video.duration) * 100); + setDuration(video.duration); + + // Check for subtitle triggers (only if enabled) + if (enableSubtitles) { + const tabSubtitles = subtitles[selectedTab] || []; + const currentTime = video.currentTime; + + for (const subtitle of tabSubtitles) { + const subtitleKey = `${selectedTab}-${subtitle.time}`; + // Check if we're within 0.2s of the subtitle timestamp and haven't shown it yet + if ( + Math.abs(currentTime - subtitle.time) < 0.2 && + !shownSubtitlesRef.current.has(subtitleKey) + ) { + // Mark as shown + shownSubtitlesRef.current.add(subtitleKey); + + // Pause video and show subtitle + video.pause(); + setCurrentSubtitle(subtitle.text); + + // Clear any existing timeout + if (subtitleTimeoutRef.current) { + clearTimeout(subtitleTimeoutRef.current); + } + + // Resume after duration + subtitleTimeoutRef.current = setTimeout(() => { + setCurrentSubtitle(null); + video.play(); + }, subtitle.duration * 1000); + + break; // Only trigger one subtitle at a time + } + } + } + } + }; + + const handlePlay = () => setIsPlaying(true); + const handlePause = () => setIsPlaying(false); + + const handleLoadedMetadata = () => { + setDuration(video.duration); + }; + + video.addEventListener('timeupdate', handleTimeUpdate); + video.addEventListener('play', handlePlay); + video.addEventListener('pause', handlePause); + video.addEventListener('loadedmetadata', handleLoadedMetadata); + + return () => { + video.removeEventListener('timeupdate', handleTimeUpdate); + video.removeEventListener('play', handlePlay); + video.removeEventListener('pause', handlePause); + video.removeEventListener('loadedmetadata', handleLoadedMetadata); + }; + }, [selectedTab, enableSubtitles, subtitles]); + + // Reset progress and subtitles when tab changes + useEffect(() => { + setProgress(0); + setIsPlaying(false); + setCurrentSubtitle(null); + shownSubtitlesRef.current.clear(); + if (subtitleTimeoutRef.current) { + clearTimeout(subtitleTimeoutRef.current); + } + }, [selectedTab]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (subtitleTimeoutRef.current) { + clearTimeout(subtitleTimeoutRef.current); + } + }; + }, []); + + const togglePlayPause = () => { + const video = getCurrentVideo(); + if (!video) return; + if (video.paused) { + video.play(); + } else { + video.pause(); + } + }; + + const handleProgressBarClick = (e: React.MouseEvent) => { + const video = getCurrentVideo(); + const progressBar = progressBarRef.current; + if (!video || !progressBar) return; + + const rect = progressBar.getBoundingClientRect(); + const clickPosition = (e.clientX - rect.left) / rect.width; + video.currentTime = clickPosition * video.duration; + }; + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + const currentTime = duration > 0 ? (progress / 100) * duration : 0; + + const skipSubtitle = () => { + const video = getCurrentVideo(); + if (subtitleTimeoutRef.current) { + clearTimeout(subtitleTimeoutRef.current); + } + setCurrentSubtitle(null); + if (video) { + video.play(); + } + }; + + return ( +
+ {/* Tab buttons */} +
+ {tabs.map((tab) => ( + + ))} +
+ {/* Tab content container */} +
+ {tabs.map((tab) => ( +
+ {/* Description */} +

+ {tab.description} +

+ {/* Video */} +
+ + {/* Subtitle overlay */} + {enableSubtitles && selectedTab === tab.id && currentSubtitle && ( +
+
+

{currentSubtitle}

+
+
+ )} + {/* Circular progress indicator - only show for active tab */} + {selectedTab === tab.id && ( +
+ + + + +
+ )} + {/* Control bar - appears on hover, only for active tab */} + {selectedTab === tab.id && ( +
+ +
+
+
+ + {formatTime(currentTime)} / {formatTime(duration)} + +
+ )} +
+ {/* YouTube CTA */} + {tab.youtubeUrl && ( + + )} +
+ ))} +
+
+ ); +} diff --git a/src/pages/index.js b/src/pages/index.js index 9283ed386..b246829af 100644 --- a/src/pages/index.js +++ b/src/pages/index.js @@ -11,11 +11,14 @@ import LandingHeader from '../landing/LandingHeader'; import LayoutProvider from '@theme/Layout/Provider'; import LogoClouds from '../landing/LogoClouds'; import TestimonialsSection from '../landing/TestimonialsSection'; +import CorePrinciple from '../landing/CorePrinciple'; import ScriptLightSection from '../landing/ScriptLightSection'; import AppLightSection from '../landing/AppLightSection'; import FlowLightSection from '../landing/FlowLightSection'; import TutorialSection from '../landing/TutorialSection'; + import MobileTutorialSection from '../landing/MobileTutorialSection'; +import DeveloperExperienceSection from '../landing/DeveloperExperienceSection'; function HomepageHeader() { return ( @@ -23,20 +26,18 @@ function HomepageHeader() { -
- - - - - - + + + + {/* */} + {/* */} diff --git a/static/illustrations/animapps.png b/static/illustrations/animapps.png new file mode 100644 index 000000000..026597b6e Binary files /dev/null and b/static/illustrations/animapps.png differ diff --git a/static/illustrations/animflows.png b/static/illustrations/animflows.png new file mode 100644 index 000000000..3478323c0 Binary files /dev/null and b/static/illustrations/animflows.png differ diff --git a/static/illustrations/animscripts.png b/static/illustrations/animscripts.png new file mode 100644 index 000000000..6c59c7b7b Binary files /dev/null and b/static/illustrations/animscripts.png differ diff --git a/static/videos/landingapps-ui.webm b/static/videos/landingapps-ui.webm new file mode 100644 index 000000000..cf27ec69a Binary files /dev/null and b/static/videos/landingapps-ui.webm differ diff --git a/static/videos/landingdata-ui.webm b/static/videos/landingdata-ui.webm new file mode 100644 index 000000000..412ef0927 Binary files /dev/null and b/static/videos/landingdata-ui.webm differ diff --git a/static/videos/landingflows-ui.webm b/static/videos/landingflows-ui.webm new file mode 100644 index 000000000..259ee3fce Binary files /dev/null and b/static/videos/landingflows-ui.webm differ diff --git a/static/videos/landingscripts-ui.webm b/static/videos/landingscripts-ui.webm new file mode 100644 index 000000000..7bdb1f90f Binary files /dev/null and b/static/videos/landingscripts-ui.webm differ diff --git a/static/videos/your-observability-video.webm b/static/videos/your-observability-video.webm new file mode 100644 index 000000000..64407fb61 Binary files /dev/null and b/static/videos/your-observability-video.webm differ