+
{/* Error bubble positioned outside container */}
{isFailedOrDeath &&
showErrorBubble &&
- (state.type === "failed" ? (
+ (state.type === 'failed' ? (
) : (
@@ -69,7 +71,10 @@ function EffectNodeComponent({
{/* Notification bubbles - hidden when error bubbles are shown */}
{!isFailedOrDeath && notification && (
-
+
)}
@@ -77,10 +82,10 @@ function EffectNodeComponent({
style={{
width: effectMotion.nodeWidth,
height: 64,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- position: "relative",
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ position: 'relative',
}}
>
({
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
-
-
+
+
-
+
- )
+ );
}
-export const EffectNode = memo(EffectNodeComponent) as typeof EffectNodeComponent
+export const EffectNode = memo(EffectNodeComponent) as typeof EffectNodeComponent;
diff --git a/src/components/index.ts b/src/components/index.ts
index 941979b..4b706fc 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,13 +1,14 @@
// Content components
-export * from "./CodeBlock"
+export * from './CodeBlock';
// Display components
-export * from "./display"
+export * from './display';
// Effect visualization
-export * from "./effect"
+export * from './effect';
// Feedback components
-export * from "./feedback"
-export * from "./HeaderView"
+export * from './feedback';
+export * from './HeaderView';
// Renderers
-export * from "./renderers"
+export * from './renderers';
+export * from './StreamTimeline';
// UI components
-export * from "./ui"
+export * from './ui';
diff --git a/src/components/patterns/BorderPulsePattern.tsx b/src/components/patterns/BorderPulsePattern.tsx
new file mode 100644
index 0000000..40ccc61
--- /dev/null
+++ b/src/components/patterns/BorderPulsePattern.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { motion, useSpring, animate } from 'motion/react';
+import { useEffect } from 'react';
+import { timing } from '../../animations';
+
+interface BorderPulsePatternProps {
+ size?: number;
+ color?: string;
+}
+
+export function BorderPulsePattern({
+ size = 64,
+ color = 'rgba(100, 200, 255, 0.8)',
+}: BorderPulsePatternProps) {
+ const borderOpacity = useSpring(1, { stiffness: 180, damping: 25 });
+
+ useEffect(() => {
+ const anim = animate(borderOpacity, [...timing.borderPulse.values], {
+ duration: timing.borderPulse.duration,
+ ease: 'easeInOut',
+ repeat: Infinity,
+ });
+
+ return () => {
+ anim.stop();
+ };
+ }, [borderOpacity]);
+
+ return (
+
+ );
+}
diff --git a/src/components/patterns/CardFaceBack.tsx b/src/components/patterns/CardFaceBack.tsx
new file mode 100644
index 0000000..bb4bc92
--- /dev/null
+++ b/src/components/patterns/CardFaceBack.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+import { motion } from 'motion/react';
+
+// CardFaceBack: Face-down card showing mystery/locked state
+// Used for unrevealed chunks in the stack (face-down state)
+export interface CardFaceBackProps {
+ size?: { width: number; height: number };
+}
+
+export function CardFaceBack({ size = { width: 64, height: 88 } }: CardFaceBackProps) {
+ return (
+
+ {/* Diagonal lines pattern overlay */}
+
+
+ {/* Large question mark watermark */}
+
+ ?
+
+
+ {/* Lock icon in center */}
+ 🔒
+
+ );
+}
diff --git a/src/components/patterns/CardFaceFront.tsx b/src/components/patterns/CardFaceFront.tsx
new file mode 100644
index 0000000..adf877f
--- /dev/null
+++ b/src/components/patterns/CardFaceFront.tsx
@@ -0,0 +1,38 @@
+'use client';
+
+import { motion } from 'motion/react';
+
+// CardFaceFront: Playing card aspect ratio showing the chunk value
+// Used for revealed chunks in the stack (face-up state)
+export interface CardFaceFrontProps {
+ value: number;
+ size?: { width: number; height: number };
+}
+
+export function CardFaceFront({ value, size = { width: 64, height: 88 } }: CardFaceFrontProps) {
+ return (
+
+ {/* Optional small value label in top-left */}
+
+ {value}
+
+
+ {/* Main centered number */}
+ {value}
+
+ );
+}
diff --git a/src/components/patterns/CardFlip.tsx b/src/components/patterns/CardFlip.tsx
new file mode 100644
index 0000000..8eae980
--- /dev/null
+++ b/src/components/patterns/CardFlip.tsx
@@ -0,0 +1,147 @@
+'use client';
+
+import { motion } from 'motion/react';
+import type { ReactNode } from 'react';
+import { useEffect, useState } from 'react';
+
+// CardFlip: Two-sided card that flips on Y-axis
+// Handles the flip animation and visibility swap at 90deg
+export interface CardFlipProps {
+ isFlipped: boolean;
+ frontFace: ReactNode;
+ backFace: ReactNode;
+ size?: { width: number; height: number };
+ onFlipComplete?: () => void;
+ showPulsingBorder?: boolean;
+}
+
+export function CardFlip({
+ isFlipped,
+ frontFace,
+ backFace,
+ size = { width: 64, height: 88 },
+ onFlipComplete,
+ showPulsingBorder = false,
+}: CardFlipProps) {
+ const [showFront, setShowFront] = useState(!isFlipped);
+
+ useEffect(() => {
+ // At 90deg (halfway through flip), swap which face is visible
+ const timeout = setTimeout(
+ () => {
+ setShowFront(isFlipped);
+ },
+ 250, // Half of 500ms animation
+ );
+
+ return () => clearTimeout(timeout);
+ }, [isFlipped]);
+
+ return (
+
+ {
+ if (onFlipComplete) {
+ onFlipComplete();
+ }
+ }}
+ >
+ {/* Pulsing border - inside the rotating container */}
+ {showPulsingBorder && (
+
+ )}
+
+ {/* Back face (shown when not flipped) */}
+
+ {backFace}
+
+
+ {/* Front face (shown when flipped) */}
+
+ {frontFace}
+
+
+ {/* Glow burst on flip completion - inside rotating container */}
+ {isFlipped && (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/patterns/ChunkCardStack.tsx b/src/components/patterns/ChunkCardStack.tsx
new file mode 100644
index 0000000..eddbbff
--- /dev/null
+++ b/src/components/patterns/ChunkCardStack.tsx
@@ -0,0 +1,93 @@
+'use client';
+
+import { motion } from 'motion/react';
+
+interface ChunkCardStackProps {
+ items: number[];
+ pulling?: boolean;
+ pushing?: boolean;
+ label?: string;
+}
+
+export function ChunkCardStack({
+ items,
+ pulling = false,
+ pushing = false,
+ label = 'Source Queue',
+}: ChunkCardStackProps) {
+ return (
+
+
{label}
+
+ {items.length > 0 ? (
+
+ {items.map((num, idx) => {
+ const isBottom = idx === items.length - 1;
+ const isTop = idx === 0;
+ // Stack from bottom up with overlap
+ const bottomOffset = idx * 12; // 12px offset per card
+ return (
+
+
+ {num}
+ {isBottom && (
+
+ )}
+
+
+ );
+ })}
+
+ ) : (
+
+ Empty
+
+ )}
+
+
+ );
+}
diff --git a/src/components/patterns/CompletionCheckPattern.tsx b/src/components/patterns/CompletionCheckPattern.tsx
new file mode 100644
index 0000000..dbbd358
--- /dev/null
+++ b/src/components/patterns/CompletionCheckPattern.tsx
@@ -0,0 +1,36 @@
+'use client';
+
+import { StarFourIcon } from '@phosphor-icons/react';
+import { motion, useSpring, animate } from 'motion/react';
+import { useLayoutEffect } from 'react';
+import { springs } from '../../animations';
+
+interface CompletionCheckPatternProps {
+ size?: number;
+}
+
+export function CompletionCheckPattern({ size = 64 }: CompletionCheckPatternProps) {
+ const contentScale = useSpring(1, springs.default);
+
+ useLayoutEffect(() => {
+ contentScale.set(0);
+ animate(contentScale, [1.3, 1], springs.contentScale);
+ }, [contentScale]);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/patterns/DeathGlitchPattern.tsx b/src/components/patterns/DeathGlitchPattern.tsx
new file mode 100644
index 0000000..4eabdcd
--- /dev/null
+++ b/src/components/patterns/DeathGlitchPattern.tsx
@@ -0,0 +1,96 @@
+'use client';
+
+import { SkullIcon } from '@phosphor-icons/react';
+import { motion, useSpring, useMotionValue } from 'motion/react';
+import { useEffect } from 'react';
+import { timing, effects } from '../../animations';
+
+interface DeathGlitchPatternProps {
+ size?: number;
+}
+
+export function DeathGlitchPattern({ size = 64 }: DeathGlitchPatternProps) {
+ const contentScale = useSpring(1, { stiffness: 180, damping: 25 });
+ const glowIntensity = useSpring(0, { stiffness: 180, damping: 25 });
+
+ useEffect(() => {
+ let cancelled = false;
+ let timeoutId: ReturnType
| null = null;
+
+ const scheduleIdle = (cb: () => void, delay: number) => {
+ timeoutId = setTimeout(() => {
+ const win = window as Window & { requestIdleCallback?: (cb: () => void) => number };
+ if (typeof win.requestIdleCallback === 'function') {
+ win.requestIdleCallback(cb);
+ } else {
+ cb();
+ }
+ }, delay);
+ };
+
+ const glitchSequence = async () => {
+ const t = timing.glitch;
+ const e = effects.glitch;
+
+ // initial pulses
+ for (let i = 0; i < t.initialCount && !cancelled; i++) {
+ contentScale.set(1 + Math.random() * e.scaleRange);
+ glowIntensity.set(Math.random() * e.intensePulseMax);
+
+ await new Promise((resolve) => {
+ scheduleIdle(
+ resolve,
+ t.initialDelayMin + Math.random() * Math.max(0, t.initialDelayMax - t.initialDelayMin),
+ );
+ });
+
+ if (cancelled) break;
+ contentScale.set(1);
+ glowIntensity.set(e.glowMax);
+
+ await new Promise((resolve) => {
+ scheduleIdle(resolve, t.pauseMin + Math.random() * Math.max(0, t.pauseMax - t.pauseMin));
+ });
+ }
+
+ // subtle loop
+ const subtle = () => {
+ if (cancelled) return;
+ glowIntensity.set(e.glowMin + Math.random() * (e.glowMax - e.glowMin));
+ scheduleIdle(
+ subtle,
+ t.subtleDelayMin + Math.random() * Math.max(0, t.subtleDelayMax - t.subtleDelayMin),
+ );
+ };
+ subtle();
+ };
+
+ glitchSequence();
+
+ return () => {
+ cancelled = true;
+ if (timeoutId) clearTimeout(timeoutId);
+ glowIntensity.set(0);
+ };
+ }, [contentScale, glowIntensity]);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/patterns/FailureShakePattern.tsx b/src/components/patterns/FailureShakePattern.tsx
new file mode 100644
index 0000000..04b1f2a
--- /dev/null
+++ b/src/components/patterns/FailureShakePattern.tsx
@@ -0,0 +1,70 @@
+'use client';
+
+import { SkullIcon } from '@phosphor-icons/react';
+import { motion, useMotionValue, animate } from 'motion/react';
+import { useEffect } from 'react';
+import { shake } from '../../animations';
+
+interface FailureShakePatternProps {
+ size?: number;
+}
+
+export function FailureShakePattern({ size = 64 }: FailureShakePatternProps) {
+ const shakeX = useMotionValue(0);
+ const shakeY = useMotionValue(0);
+ const rotation = useMotionValue(0);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const shakeSequence = async () => {
+ const { intensity, duration, count, rotationRange, returnDuration } = shake.failure;
+
+ for (let i = 0; i < count && !cancelled; i++) {
+ const xOffset = (Math.random() - 0.5) * intensity;
+ const yOffset = (Math.random() - 0.5) * intensity;
+ const rotOffset = (Math.random() - 0.5) * rotationRange;
+
+ const anims = [
+ animate(shakeX, xOffset, { duration, ease: 'easeInOut' }),
+ animate(shakeY, yOffset, { duration, ease: 'easeInOut' }),
+ animate(rotation, rotOffset, { duration, ease: 'easeInOut' }),
+ ];
+ await Promise.all(anims.map((a) => a.finished));
+ }
+
+ if (!cancelled) {
+ await Promise.all([
+ animate(shakeX, 0, { duration: returnDuration, ease: 'easeOut' }).finished,
+ animate(shakeY, 0, { duration: returnDuration, ease: 'easeOut' }).finished,
+ animate(rotation, 0, { duration: returnDuration, ease: 'easeOut' }).finished,
+ ]);
+ }
+ };
+
+ shakeSequence();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [shakeX, shakeY, rotation]);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/patterns/FlashPattern.tsx b/src/components/patterns/FlashPattern.tsx
new file mode 100644
index 0000000..43b3342
--- /dev/null
+++ b/src/components/patterns/FlashPattern.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+import { motion, useMotionValue, animate } from 'motion/react';
+import { useEffect } from 'react';
+import { timing, colors } from '../../animations';
+
+interface FlashPatternProps {
+ size?: number;
+}
+
+export function FlashPattern({ size = 64 }: FlashPatternProps) {
+ const flashOpacity = useMotionValue(0);
+
+ useEffect(() => {
+ const up = animate(flashOpacity, 0.6, {
+ duration: 0.02,
+ ease: 'circOut',
+ });
+ up.finished.then(() => {
+ animate(flashOpacity, 0, {
+ duration: timing.flash.duration,
+ ease: timing.flash.ease,
+ });
+ });
+ }, [flashOpacity]);
+
+ return (
+
+ );
+}
diff --git a/src/components/patterns/GlowPulsePattern.tsx b/src/components/patterns/GlowPulsePattern.tsx
new file mode 100644
index 0000000..9e3455a
--- /dev/null
+++ b/src/components/patterns/GlowPulsePattern.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { motion, useSpring, animate, useTransform } from 'motion/react';
+import { useEffect } from 'react';
+import { timing, colors } from '../../animations';
+
+interface GlowPulsePatternProps {
+ size?: number;
+ color?: string;
+}
+
+export function GlowPulsePattern({
+ size = 64,
+ color = colors.glow.running,
+}: GlowPulsePatternProps) {
+ const glowIntensity = useSpring(0, { stiffness: 180, damping: 25 });
+
+ useEffect(() => {
+ const anim = animate(glowIntensity, [...timing.glowPulse.values], {
+ duration: timing.glowPulse.duration,
+ ease: 'easeInOut',
+ repeat: Infinity,
+ });
+
+ return () => {
+ anim.stop();
+ };
+ }, [glowIntensity]);
+
+ const boxShadow = useTransform([glowIntensity], ([glow = 0]: Array) => {
+ const cappedGlow = Math.min(glow, 8);
+ return cappedGlow > 0 ? `0 0 ${cappedGlow}px ${color}` : 'none';
+ });
+
+ return (
+
+ );
+}
diff --git a/src/components/patterns/IdleStatePattern.tsx b/src/components/patterns/IdleStatePattern.tsx
new file mode 100644
index 0000000..eac8486
--- /dev/null
+++ b/src/components/patterns/IdleStatePattern.tsx
@@ -0,0 +1,30 @@
+'use client';
+
+import { StarFourIcon } from '@phosphor-icons/react';
+import { motion } from 'motion/react';
+import { TASK_COLORS } from '../../constants/colors';
+
+interface IdleStatePatternProps {
+ size?: number;
+}
+
+export function IdleStatePattern({ size = 64 }: IdleStatePatternProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/patterns/JitterPattern.tsx b/src/components/patterns/JitterPattern.tsx
new file mode 100644
index 0000000..1acd37e
--- /dev/null
+++ b/src/components/patterns/JitterPattern.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+import { motion, useMotionValue, useTransform, useVelocity, animate } from 'motion/react';
+import { useEffect } from 'react';
+import { shake } from '../../animations';
+
+interface JitterPatternProps {
+ size?: number;
+}
+
+export function JitterPattern({ size = 64 }: JitterPatternProps) {
+ const rotation = useMotionValue(0);
+ const shakeX = useMotionValue(0);
+ const shakeY = useMotionValue(0);
+
+ const rotationVelocity = useVelocity(rotation);
+ const blurAmount = useTransform(rotationVelocity, [-100, 0, 100], [1, 0, 1], { clamp: true });
+
+ useEffect(() => {
+ let cancelled = false;
+ let rafId: number | null = null;
+
+ const jitter = () => {
+ if (cancelled) return;
+
+ const angle =
+ (Math.random() * shake.running.angleRange + shake.running.angleBase) *
+ (Math.random() < 0.5 ? 1 : -1);
+
+ const offset =
+ (Math.random() * shake.running.offsetRange + shake.running.offsetBase) *
+ (Math.random() < 0.5 ? -1 : 1);
+
+ const offsetY =
+ (Math.random() * shake.running.offsetYRange + shake.running.offsetYBase) *
+ (Math.random() < 0.5 ? -1 : 1);
+
+ const min = shake.running.durationMin;
+ const max = shake.running.durationMax ?? min * 2;
+ const duration = min + Math.random() * Math.max(0.001, max - min);
+
+ const rot = animate(rotation, angle, { duration, ease: 'circInOut' });
+ const x = animate(shakeX, offset, { duration, ease: 'easeInOut' });
+ const y = animate(shakeY, offsetY, { duration, ease: 'easeInOut' });
+
+ Promise.all([rot.finished, x.finished, y.finished]).then(() => {
+ if (cancelled) return;
+ rafId = requestAnimationFrame(jitter);
+ });
+ };
+
+ rafId = requestAnimationFrame(jitter);
+
+ return () => {
+ cancelled = true;
+ if (rafId !== null) cancelAnimationFrame(rafId);
+ };
+ }, [rotation, shakeX, shakeY]);
+
+ return (
+ ) => {
+ const cappedBlur = Math.min(blur, 2);
+ return `blur(${cappedBlur}px)`;
+ }),
+ }}
+ >
+ Jitter
+
+ );
+}
diff --git a/src/components/patterns/LightSweepPattern.tsx b/src/components/patterns/LightSweepPattern.tsx
new file mode 100644
index 0000000..e5520b5
--- /dev/null
+++ b/src/components/patterns/LightSweepPattern.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import { motion } from 'motion/react';
+
+interface LightSweepPatternProps {
+ size?: number;
+}
+
+export function LightSweepPattern({ size = 64 }: LightSweepPatternProps) {
+ return (
+
+ {[0, 0.2, 0.4, 0.6, 0.8, 1].map((delay, i) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/patterns/RetryTimelinePattern.tsx b/src/components/patterns/RetryTimelinePattern.tsx
new file mode 100644
index 0000000..1848dd2
--- /dev/null
+++ b/src/components/patterns/RetryTimelinePattern.tsx
@@ -0,0 +1,292 @@
+/**
+ * RetryTimelinePattern - Complete retry visualization
+ * Shows multiple retry attempts with increasing gaps (exponential backoff)
+ */
+
+import { motion } from "motion/react"
+import { useState } from "react"
+import { TimelineSegmentPattern } from "./TimelineSegmentPattern"
+import { TimelineTicksPattern } from "./TimelineTicksPattern"
+
+export interface RetryAttempt {
+ attemptDuration: number // How long the attempt ran
+ delayAfter?: number // Delay before next attempt
+ failed?: boolean
+}
+
+export interface RetryTimelinePatternProps {
+ attempts: RetryAttempt[]
+ pixelsPerSecond?: number
+ height?: number
+ showTicks?: boolean
+ className?: string
+}
+
+const DEFAULT_PIXELS_PER_SECOND = 100
+const DEFAULT_HEIGHT = 80
+const SEGMENT_Y = "50%"
+const LINE_HEIGHT = 3
+const DOT_SIZE = 12
+
+const COLORS = {
+ attempt: "var(--color-blue-500)",
+ gap: "var(--color-neutral-500)",
+ failed: "var(--color-red-500)",
+}
+
+export function RetryTimelinePattern({
+ attempts,
+ pixelsPerSecond = DEFAULT_PIXELS_PER_SECOND,
+ height = DEFAULT_HEIGHT,
+ showTicks = true,
+ className = "",
+}: RetryTimelinePatternProps) {
+ // Calculate segment positions
+ let currentX = 50 // Start offset
+ const segments: Array<{
+ type: "attempt" | "gap"
+ startX: number
+ endX: number
+ duration: number
+ failed?: boolean
+ }> = []
+ type SegmentKey = `${number}-${"attempt" | "gap"}`
+
+ for (const attempt of attempts) {
+ // Attempt segment
+ const attemptWidth = (attempt.attemptDuration / 1000) * pixelsPerSecond
+ segments.push({
+ type: "attempt",
+ startX: currentX,
+ endX: currentX + attemptWidth,
+ duration: attempt.attemptDuration,
+ failed: attempt.failed,
+ })
+ currentX += attemptWidth
+
+ // Gap segment (if there's a delay after this attempt)
+ if (attempt.delayAfter !== undefined) {
+ const gapWidth = (attempt.delayAfter / 1000) * pixelsPerSecond
+ segments.push({
+ type: "gap",
+ startX: currentX,
+ endX: currentX + gapWidth,
+ duration: attempt.delayAfter,
+ })
+ currentX += gapWidth
+ }
+ }
+
+ const totalWidth = currentX + 100 // Add padding at the end
+
+ return (
+
+
+ {/* Background ticks */}
+ {showTicks &&
}
+
+ {/* Background line */}
+
+
+ {/* Segments */}
+ {segments.map((segment, i) => {
+ const key: SegmentKey = `${i}-${segment.type}`
+ if (segment.type === "attempt") {
+ const color = segment.failed ? COLORS.failed : COLORS.attempt
+ return (
+
+ )
+ } else {
+ // Gap segment
+ return (
+
+ )
+ }
+ })}
+
+ {/* Terminator bar at the end */}
+
+
+
+ )
+}
+
+/**
+ * Interactive retry timeline demo with controls
+ */
+export interface InteractiveRetryTimelineDemoProps {
+ className?: string
+}
+
+export function InteractiveRetryTimelineDemo({
+ className = "",
+}: InteractiveRetryTimelineDemoProps) {
+ const [retryCount, setRetryCount] = useState(3)
+ const [delayType, setDelayType] = useState<"exponential" | "fixed">("exponential")
+ const [baseDelay, setBaseDelay] = useState(500)
+ const [isRunning, setIsRunning] = useState(false)
+ const [currentAttempts, setCurrentAttempts] = useState([])
+
+ const handleStart = () => {
+ setIsRunning(true)
+ const attempts: RetryAttempt[] = []
+
+ // Initial failed attempt
+ attempts.push({
+ attemptDuration: 200,
+ delayAfter: baseDelay,
+ failed: true,
+ })
+
+ // Retry attempts
+ for (let i = 0; i < retryCount; i++) {
+ const delay = delayType === "exponential" ? baseDelay * 2 ** i : baseDelay
+
+ const isLastAttempt = i === retryCount - 1
+ attempts.push({
+ attemptDuration: 200,
+ delayAfter: isLastAttempt ? undefined : delay,
+ failed: !isLastAttempt, // Last attempt succeeds
+ })
+ }
+
+ setCurrentAttempts(attempts)
+ }
+
+ const handleReset = () => {
+ setIsRunning(false)
+ setCurrentAttempts([])
+ }
+
+ return (
+
+ {/* Controls */}
+
+
+
+
+
+
+
+
+ setRetryCount(Number.parseInt(e.target.value, 10))}
+ disabled={isRunning}
+ className="w-16 px-2 py-1 bg-neutral-700 border border-neutral-600 rounded text-sm disabled:opacity-50"
+ />
+
+
+
+
+
+
+
+
+
+ setBaseDelay(Number.parseInt(e.target.value, 10))}
+ disabled={isRunning}
+ className="w-20 px-2 py-1 bg-neutral-700 border border-neutral-600 rounded text-sm disabled:opacity-50"
+ />
+
+
+
+ {/* Timeline */}
+ {currentAttempts.length > 0 && (
+
+
+
+ )}
+
+ {/* Status */}
+
+ {isRunning ? `Running ${retryCount} retries with ${delayType} backoff` : "Ready to start"}
+
+
+ )
+}
diff --git a/src/components/patterns/RunningStatePattern.tsx b/src/components/patterns/RunningStatePattern.tsx
new file mode 100644
index 0000000..21cdd1e
--- /dev/null
+++ b/src/components/patterns/RunningStatePattern.tsx
@@ -0,0 +1,168 @@
+'use client';
+
+import {
+ motion,
+ useSpring,
+ useMotionValue,
+ useTransform,
+ useVelocity,
+ animate,
+} from 'motion/react';
+import { useEffect } from 'react';
+import { timing, colors, shake } from '../../animations';
+import { TASK_COLORS } from '../../constants/colors';
+
+interface RunningStatePatternProps {
+ size?: number;
+}
+
+export function RunningStatePattern({ size = 64 }: RunningStatePatternProps) {
+ // All motion values
+ const borderOpacity = useSpring(1, { stiffness: 180, damping: 25 });
+ const glowIntensity = useSpring(0, { stiffness: 180, damping: 25 });
+ const rotation = useMotionValue(0);
+ const shakeX = useMotionValue(0);
+ const shakeY = useMotionValue(0);
+ const nodeHeight = useMotionValue(size);
+ const borderRadius = useSpring(8, { stiffness: 180, damping: 25 });
+
+ const rotationVelocity = useVelocity(rotation);
+ const blurAmount = useTransform(rotationVelocity, [-100, 0, 100], [1, 0, 1], { clamp: true });
+
+ // Shape morph
+ useEffect(() => {
+ animate(nodeHeight, size * 0.4, {
+ duration: 0.4,
+ bounce: 0.3,
+ type: 'spring',
+ });
+ borderRadius.set(15);
+ }, [nodeHeight, borderRadius, size]);
+
+ // Border + glow pulses
+ useEffect(() => {
+ const borderAnim = animate(borderOpacity, [...timing.borderPulse.values], {
+ duration: timing.borderPulse.duration,
+ ease: 'easeInOut',
+ repeat: Infinity,
+ });
+
+ const glowAnim = animate(glowIntensity, [...timing.glowPulse.values], {
+ duration: timing.glowPulse.duration,
+ ease: 'easeInOut',
+ repeat: Infinity,
+ });
+
+ return () => {
+ borderAnim.stop();
+ glowAnim.stop();
+ };
+ }, [borderOpacity, glowIntensity]);
+
+ // Jitter
+ useEffect(() => {
+ let cancelled = false;
+ let rafId: number | null = null;
+
+ const jitter = () => {
+ if (cancelled) return;
+
+ const angle =
+ (Math.random() * shake.running.angleRange + shake.running.angleBase) *
+ (Math.random() < 0.5 ? 1 : -1);
+
+ const offset =
+ (Math.random() * shake.running.offsetRange + shake.running.offsetBase) *
+ (Math.random() < 0.5 ? -1 : 1);
+
+ const offsetY =
+ (Math.random() * shake.running.offsetYRange + shake.running.offsetYBase) *
+ (Math.random() < 0.5 ? -1 : 1);
+
+ const min = shake.running.durationMin;
+ const max = shake.running.durationMax ?? min * 2;
+ const duration = min + Math.random() * Math.max(0.001, max - min);
+
+ const rot = animate(rotation, angle, { duration, ease: 'circInOut' });
+ const x = animate(shakeX, offset, { duration, ease: 'easeInOut' });
+ const y = animate(shakeY, offsetY, { duration, ease: 'easeInOut' });
+
+ Promise.all([rot.finished, x.finished, y.finished]).then(() => {
+ if (cancelled) return;
+ rafId = requestAnimationFrame(jitter);
+ });
+ };
+
+ rafId = requestAnimationFrame(jitter);
+
+ return () => {
+ cancelled = true;
+ if (rafId !== null) cancelAnimationFrame(rafId);
+ };
+ }, [rotation, shakeX, shakeY]);
+
+ const boxShadow = useTransform([glowIntensity], ([glow = 0]: Array) => {
+ const cappedGlow = Math.min(glow, 8);
+ return cappedGlow > 0 ? `0 0 ${cappedGlow}px ${colors.glow.running}` : 'none';
+ });
+
+ return (
+ ) => {
+ const cappedBlur = Math.min(blur, 2);
+ return `blur(${cappedBlur}px)`;
+ }),
+ }}
+ >
+ {/* Border overlay */}
+
+
+ {/* Light sweeps */}
+ {[0, 0.2, 0.4, 0.6, 0.8, 1].map((delay, i) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/patterns/ShapeMorphPattern.tsx b/src/components/patterns/ShapeMorphPattern.tsx
new file mode 100644
index 0000000..fa8ac05
--- /dev/null
+++ b/src/components/patterns/ShapeMorphPattern.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+import { motion } from 'motion/react';
+
+interface ShapeMorphPatternProps {
+ size?: number;
+}
+
+export function ShapeMorphPattern({ size = 64 }: ShapeMorphPatternProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/patterns/StackedCard.tsx b/src/components/patterns/StackedCard.tsx
new file mode 100644
index 0000000..2d9697f
--- /dev/null
+++ b/src/components/patterns/StackedCard.tsx
@@ -0,0 +1,79 @@
+'use client';
+
+import { motion } from 'motion/react';
+import { CardFaceBack } from './CardFaceBack';
+import { CardFaceFront } from './CardFaceFront';
+import { CardFlip } from './CardFlip';
+
+// StackedCard: Card positioned within a stack with offset and rotation
+// Combines CardFlip with stack positioning logic
+export interface StackedCardProps {
+ value: number;
+ stackIndex: number;
+ totalInStack: number;
+ isFlipped: boolean;
+ pulling?: boolean;
+ pushing?: boolean;
+ size?: { width: number; height: number };
+}
+
+export function StackedCard({
+ value,
+ stackIndex,
+ totalInStack,
+ isFlipped,
+ pulling = false,
+ pushing = false,
+ size = { width: 64, height: 88 },
+}: StackedCardProps) {
+ // Calculate offset - each card is slightly offset from the one below
+ const offsetY = stackIndex * 10;
+
+ // Small random tilt based on index for natural stack feel
+ // Use deterministic rotation based on value to keep consistent
+ const rotation = ((value * 37) % 7) - 3; // Range: -3° to +3°
+
+ // Bottom card (stackIndex === 0) gets pulsing highlight
+ const isBottom = stackIndex === 0;
+
+ // Pulling animation: slide right before flip
+ const pullingX = pulling ? 40 : 0;
+
+ // Pushing animation: slide in from above with rotation
+ const pushingY = pushing ? -80 : 0;
+ const pushingRotation = pushing ? -15 : 0;
+
+ return (
+
+
+ }
+ backFace={}
+ size={size}
+ showPulsingBorder={isBottom}
+ />
+
+ );
+}
diff --git a/src/components/patterns/TimelineDotPattern.tsx b/src/components/patterns/TimelineDotPattern.tsx
new file mode 100644
index 0000000..64f164d
--- /dev/null
+++ b/src/components/patterns/TimelineDotPattern.tsx
@@ -0,0 +1,65 @@
+/**
+ * TimelineDotPattern - Individual dot marker on timeline
+ * Can be start, middle, or end markers with different states
+ */
+
+import { motion } from "motion/react"
+
+export interface TimelineDotPatternProps {
+ x: number
+ y?: string
+ size?: number
+ color?: string
+ state?: "idle" | "active" | "complete"
+ className?: string
+}
+
+const DEFAULT_SIZE = 12
+const DEFAULT_Y = "50%"
+const DEFAULT_COLORS = {
+ idle: "var(--color-neutral-500)",
+ active: "var(--color-blue-400)",
+ complete: "var(--color-blue-500)",
+}
+
+const SPRING_CONFIG = {
+ type: "spring" as const,
+ visualDuration: 0.5,
+ bounce: 0.4,
+}
+
+export function TimelineDotPattern({
+ x,
+ y = DEFAULT_Y,
+ size = DEFAULT_SIZE,
+ color,
+ state = "idle",
+ className = "",
+}: TimelineDotPatternProps) {
+ const dotColor = color || DEFAULT_COLORS[state]
+ const scale = state === "active" ? 1.2 : 1
+
+ return (
+
+ )
+}
diff --git a/src/components/patterns/TimelineSegmentPattern.tsx b/src/components/patterns/TimelineSegmentPattern.tsx
new file mode 100644
index 0000000..fbee7eb
--- /dev/null
+++ b/src/components/patterns/TimelineSegmentPattern.tsx
@@ -0,0 +1,107 @@
+/**
+ * TimelineSegmentPattern - Single timeline segment (line with start/end dots)
+ * Shows duration label above the segment
+ */
+
+import { motion } from "motion/react"
+import { TimelineDotPattern } from "./TimelineDotPattern"
+
+export interface TimelineSegmentPatternProps {
+ startX: number
+ endX: number
+ y?: string
+ lineHeight?: number
+ color?: string
+ dotSize?: number
+ showDots?: boolean
+ showDuration?: boolean
+ duration?: number
+ state?: "idle" | "active" | "complete"
+ className?: string
+}
+
+const DEFAULT_Y = "50%"
+const DEFAULT_LINE_HEIGHT = 3
+const DEFAULT_DOT_SIZE = 12
+const DEFAULT_COLORS = {
+ idle: "var(--color-neutral-500)",
+ active: "var(--color-blue-400)",
+ complete: "var(--color-blue-500)",
+}
+
+function formatTime(ms: number): string {
+ return `${ms}ms`
+}
+
+export function TimelineSegmentPattern({
+ startX,
+ endX,
+ y = DEFAULT_Y,
+ lineHeight = DEFAULT_LINE_HEIGHT,
+ color,
+ dotSize = DEFAULT_DOT_SIZE,
+ showDots = true,
+ showDuration = false,
+ duration,
+ state = "idle",
+ className = "",
+}: TimelineSegmentPatternProps) {
+ const width = endX - startX
+ const segmentColor = color || DEFAULT_COLORS[state]
+ const midX = startX + width / 2
+
+ return (
+
+ {/* Line */}
+
+
+ {/* Start dot */}
+ {showDots && (
+
+ )}
+
+ {/* End dot */}
+ {showDots && (
+
+ )}
+
+ {/* Duration label */}
+ {showDuration && duration !== undefined && width > 50 && (
+
+
+ {formatTime(duration)}
+
+
+ )}
+
+ )
+}
diff --git a/src/components/patterns/TimelineTicksPattern.tsx b/src/components/patterns/TimelineTicksPattern.tsx
new file mode 100644
index 0000000..2d0723c
--- /dev/null
+++ b/src/components/patterns/TimelineTicksPattern.tsx
@@ -0,0 +1,53 @@
+/**
+ * TimelineTicksPattern - Background tick marks for timeline visualization
+ * Shows vertical lines at regular intervals to mark time progression
+ */
+
+export interface TimelineTicksPatternProps {
+ width: number
+ height?: number
+ spacing?: number
+ tickWidth?: number
+ color?: string
+ className?: string
+}
+
+const DEFAULT_HEIGHT = 50
+const DEFAULT_SPACING = 50
+const DEFAULT_TICK_WIDTH = 1
+const DEFAULT_COLOR = "var(--color-neutral-800)"
+
+export function TimelineTicksPattern({
+ width,
+ height = DEFAULT_HEIGHT,
+ spacing = DEFAULT_SPACING,
+ tickWidth = DEFAULT_TICK_WIDTH,
+ color = DEFAULT_COLOR,
+ className = "",
+}: TimelineTicksPatternProps) {
+ const tickCount = Math.ceil(width / spacing)
+ const ticks: React.ReactElement[] = []
+
+ for (let i = 1; i <= tickCount; i++) {
+ const x = i * spacing
+ ticks.push(
+ ,
+ )
+ }
+
+ return (
+
+ {ticks}
+
+ )
+}
diff --git a/src/components/patterns/index.ts b/src/components/patterns/index.ts
new file mode 100644
index 0000000..5a3dc8e
--- /dev/null
+++ b/src/components/patterns/index.ts
@@ -0,0 +1,20 @@
+export { BorderPulsePattern } from './BorderPulsePattern';
+export { ChunkCardStack } from './ChunkCardStack';
+export { GlowPulsePattern } from './GlowPulsePattern';
+export { JitterPattern } from './JitterPattern';
+export { LightSweepPattern } from './LightSweepPattern';
+export { ShapeMorphPattern } from './ShapeMorphPattern';
+export { CompletionCheckPattern } from './CompletionCheckPattern';
+export { FailureShakePattern } from './FailureShakePattern';
+export { DeathGlitchPattern } from './DeathGlitchPattern';
+export { FlashPattern } from './FlashPattern';
+export { IdleStatePattern } from './IdleStatePattern';
+export { RunningStatePattern } from './RunningStatePattern';
+export { TimelineTicksPattern } from './TimelineTicksPattern';
+export { TimelineDotPattern } from './TimelineDotPattern';
+export { TimelineSegmentPattern } from './TimelineSegmentPattern';
+export { RetryTimelinePattern, InteractiveRetryTimelineDemo } from './RetryTimelinePattern';
+export { CardFaceFront } from './CardFaceFront';
+export { CardFaceBack } from './CardFaceBack';
+export { CardFlip } from './CardFlip';
+export { StackedCard } from './StackedCard';
diff --git a/src/components/playground/StreamPullPrototype.tsx b/src/components/playground/StreamPullPrototype.tsx
new file mode 100644
index 0000000..0481235
--- /dev/null
+++ b/src/components/playground/StreamPullPrototype.tsx
@@ -0,0 +1,229 @@
+'use client';
+
+import { motion } from 'motion/react';
+import { useEffect, useState } from 'react';
+import { ChunkCardStack } from '../patterns/ChunkCardStack';
+
+export function StreamPullPrototype() {
+ const [queue, setQueue] = useState([]);
+ const [consumed, setConsumed] = useState([]);
+ const [pulling, setPulling] = useState(false);
+ const [isInitializing, setIsInitializing] = useState(true);
+
+ // Initialize queue by adding items one at a time
+ // Array order: [newest/top=5, ..., oldest/bottom=1]
+ // We add from oldest to newest, so 1 goes in first (will be at bottom)
+ useEffect(() => {
+ let currentIndex = 1;
+
+ const interval = setInterval(() => {
+ if (currentIndex <= 5) {
+ setQueue((prev) => [currentIndex, ...prev]); // Add to front (top of stack)
+ currentIndex++;
+ } else {
+ clearInterval(interval);
+ setIsInitializing(false);
+ }
+ }, 150);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ const handlePull = () => {
+ if (queue.length === 0 || pulling) return;
+
+ setPulling(true);
+
+ // Animate the pull - pull from bottom (last item)
+ setTimeout(() => {
+ const next = queue[queue.length - 1];
+ setConsumed((prev) => [...prev, next]);
+ setQueue(queue.slice(0, -1));
+ setPulling(false);
+ }, 400);
+ };
+
+ const handleReset = () => {
+ setQueue([]);
+ setConsumed([]);
+ setPulling(false);
+ setIsInitializing(true);
+
+ // Re-initialize
+ setTimeout(() => {
+ let currentIndex = 1;
+
+ const interval = setInterval(() => {
+ if (currentIndex <= 5) {
+ setQueue((prev) => [currentIndex, ...prev]);
+ currentIndex++;
+ } else {
+ clearInterval(interval);
+ setIsInitializing(false);
+ }
+ }, 150);
+ }, 100);
+ };
+
+ const isExhausted = queue.length === 0 && !isInitializing;
+
+ return (
+
+ {/* Visual Layout */}
+
+
+ {/* Controls */}
+
+
+
+
+ Queue: {queue.length} | Consumed: {consumed.length}
+
+
+
+ );
+}
+
+function PullLever({
+ onPull,
+ active,
+ pulling,
+}: {
+ onPull: () => void;
+ active: boolean;
+ pulling: boolean;
+}) {
+ return (
+
+
+ {pulling ? '⚡' : '⬇'}
+ {active && !pulling && (
+
+ )}
+
+
PULL
+
+ {active ? 'Active' : 'Exhausted'}
+
+
+ );
+}
+
+function ConsumedRail({ items }: { items: number[] }) {
+ return (
+
+
Consumed
+
+ {items.length > 0 ? (
+ items.map((num, idx) => (
+
+
+ {num}
+
+
+ ))
+ ) : (
+
+ ∅
+
+ )}
+
+
+ );
+}
+
+function TransferBeam() {
+ return (
+
+ );
+}
diff --git a/src/components/playground/StreamPullPrototypeV2.tsx b/src/components/playground/StreamPullPrototypeV2.tsx
new file mode 100644
index 0000000..eb1bf84
--- /dev/null
+++ b/src/components/playground/StreamPullPrototypeV2.tsx
@@ -0,0 +1,251 @@
+'use client';
+
+import { motion } from 'motion/react';
+import { useEffect, useState } from 'react';
+import { CardFaceFront } from '../patterns/CardFaceFront';
+import { CardFaceBack } from '../patterns/CardFaceBack';
+import { StackedCard } from '../patterns/StackedCard';
+
+export function StreamPullPrototypeV2() {
+ const [queue, setQueue] = useState([]);
+ const [consumed, setConsumed] = useState([]);
+ const [flippedCards, setFlippedCards] = useState>(new Set());
+ const [pulling, setPulling] = useState(false);
+ const [isInitializing, setIsInitializing] = useState(true);
+
+ // Initialize queue by adding items one at a time
+ useEffect(() => {
+ let currentIndex = 1;
+
+ const interval = setInterval(() => {
+ if (currentIndex <= 5) {
+ setQueue((prev) => [currentIndex, ...prev]); // Add to front (top of stack)
+ currentIndex++;
+ } else {
+ clearInterval(interval);
+ setIsInitializing(false);
+ }
+ }, 150);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ const handlePull = () => {
+ if (queue.length === 0 || pulling) return;
+
+ setPulling(true);
+ const bottomCard = queue[queue.length - 1];
+
+ // Remove from queue immediately to prevent double-pull
+ setQueue(queue.slice(0, -1));
+
+ // First flip the card
+ setFlippedCards((prev) => new Set([...prev, bottomCard]));
+
+ // After flip animation, add to consumed
+ setTimeout(() => {
+ setConsumed((prev) => [...prev, bottomCard]);
+ setPulling(false);
+ }, 800); // 600ms flip + 200ms buffer
+ };
+
+ const handleReset = () => {
+ setQueue([]);
+ setConsumed([]);
+ setFlippedCards(new Set());
+ setPulling(false);
+ setIsInitializing(true);
+
+ // Re-initialize
+ setTimeout(() => {
+ let currentIndex = 1;
+
+ const interval = setInterval(() => {
+ if (currentIndex <= 5) {
+ setQueue((prev) => [currentIndex, ...prev]);
+ currentIndex++;
+ } else {
+ clearInterval(interval);
+ setIsInitializing(false);
+ }
+ }, 150);
+ }, 100);
+ };
+
+ const isExhausted = queue.length === 0 && !isInitializing;
+
+ return (
+
+ {/* Visual Layout */}
+
+ {/* Card Stack */}
+
+
Source Queue
+
+ {queue.length > 0 ? (
+
+ {queue.map((value, idx) => (
+
+ ))}
+
+ ) : (
+
+ Empty
+
+ )}
+
+
+
+
+
+ {pulling &&
}
+
+
+ {/* Controls */}
+
+
+
+
+ Queue: {queue.length} | Consumed: {consumed.length}
+
+
+
+ );
+}
+
+function PullLever({
+ onPull,
+ active,
+ pulling,
+}: {
+ onPull: () => void;
+ active: boolean;
+ pulling: boolean;
+}) {
+ return (
+
+
+ {pulling ? '⚡' : '⬇'}
+ {active && !pulling && (
+
+ )}
+
+
PULL
+
+ {active ? 'Active' : 'Exhausted'}
+
+
+ );
+}
+
+function ConsumedRail({ items }: { items: number[] }) {
+ return (
+
+
Consumed
+
+ {items.length > 0 ? (
+ items.map((num, idx) => (
+
+
+
+ ))
+ ) : (
+
+ ∅
+
+ )}
+
+
+ );
+}
+
+function TransferBeam() {
+ return (
+
+ );
+}
diff --git a/src/components/playground/StreamPushPullPrototype.tsx b/src/components/playground/StreamPushPullPrototype.tsx
new file mode 100644
index 0000000..ee3eeaa
--- /dev/null
+++ b/src/components/playground/StreamPushPullPrototype.tsx
@@ -0,0 +1,322 @@
+'use client';
+
+import { motion } from 'motion/react';
+import { useEffect, useState } from 'react';
+import { ChunkCardStack } from '../patterns/ChunkCardStack';
+
+export function StreamPushPullPrototype() {
+ const [queue, setQueue] = useState([]);
+ const [consumed, setConsumed] = useState([]);
+ const [pulling, setPulling] = useState(false);
+ const [pushing, setPushing] = useState(false);
+ const [nextValue, setNextValue] = useState(6);
+ const [isInitializing, setIsInitializing] = useState(true);
+
+ // Initialize queue by adding items one at a time
+ // Array order: [newest/top=5, ..., oldest/bottom=1]
+ // We add from oldest to newest, so 1 goes in first (will be at bottom)
+ useEffect(() => {
+ let currentIndex = 1;
+
+ const interval = setInterval(() => {
+ if (currentIndex <= 5) {
+ setQueue((prev) => [currentIndex, ...prev]); // Add to front (top of stack)
+ currentIndex++;
+ } else {
+ clearInterval(interval);
+ setIsInitializing(false);
+ }
+ }, 150);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ const handlePull = () => {
+ if (queue.length === 0 || pulling) return;
+
+ setPulling(true);
+
+ // Animate the pull - pull from bottom (last item)
+ setTimeout(() => {
+ const next = queue[queue.length - 1];
+ setConsumed((prev) => [...prev, next]);
+ setQueue(queue.slice(0, -1));
+ setPulling(false);
+ }, 400);
+ };
+
+ const handlePush = () => {
+ if (pushing) return;
+
+ setPushing(true);
+
+ // Animate the push - add to top
+ setTimeout(() => {
+ setQueue((prev) => [nextValue, ...prev]);
+ setNextValue((prev) => prev + 1);
+ setPushing(false);
+ }, 400);
+ };
+
+ const handleReset = () => {
+ setQueue([]);
+ setConsumed([]);
+ setPulling(false);
+ setPushing(false);
+ setNextValue(6);
+ setIsInitializing(true);
+
+ // Re-initialize
+ setTimeout(() => {
+ let currentIndex = 1;
+
+ const interval = setInterval(() => {
+ if (currentIndex <= 5) {
+ setQueue((prev) => [currentIndex, ...prev]);
+ currentIndex++;
+ } else {
+ clearInterval(interval);
+ setIsInitializing(false);
+ }
+ }, 150);
+ }, 100);
+ };
+
+ const isExhausted = queue.length === 0 && !isInitializing;
+
+ return (
+
+ {/* Visual Layout */}
+
+ {/* Cloud Push Source */}
+
+
+ {pushing ? '💫' : '☁️'}
+ {!pushing && (
+
+ )}
+
+
PUSH
+
+ Next: {nextValue}
+
+
+
+ {pushing &&
}
+
+
+
+
+ {pulling &&
}
+
+
+ {/* Controls */}
+
+
+
+
+ Queue: {queue.length} | Consumed: {consumed.length}
+
+
+
+ );
+}
+
+function PullLever({
+ onPull,
+ active,
+ pulling,
+}: {
+ onPull: () => void;
+ active: boolean;
+ pulling: boolean;
+}) {
+ return (
+
+
+ {pulling ? '⚡' : '⬇'}
+ {active && !pulling && (
+
+ )}
+
+
PULL
+
+ {active ? 'Active' : 'Exhausted'}
+
+
+ );
+}
+
+function ConsumedRail({ items }: { items: number[] }) {
+ return (
+
+
Consumed
+
+ {items.length > 0 ? (
+ items.map((num, idx) => (
+
+
+ {num}
+
+
+ ))
+ ) : (
+
+ ∅
+
+ )}
+
+
+ );
+}
+
+function TransferBeam() {
+ return (
+
+ );
+}
+
+function PushBeam() {
+ return (
+
+ );
+}
diff --git a/src/components/playground/StreamPushPullPrototypeV2.tsx b/src/components/playground/StreamPushPullPrototypeV2.tsx
new file mode 100644
index 0000000..a83b5c3
--- /dev/null
+++ b/src/components/playground/StreamPushPullPrototypeV2.tsx
@@ -0,0 +1,341 @@
+'use client';
+
+import { motion } from 'motion/react';
+import { useEffect, useState } from 'react';
+import { CardFaceFront } from '../patterns/CardFaceFront';
+import { StackedCard } from '../patterns/StackedCard';
+
+export function StreamPushPullPrototypeV2() {
+ const [queue, setQueue] = useState([]);
+ const [consumed, setConsumed] = useState([]);
+ const [flippedCards, setFlippedCards] = useState>(new Set());
+ const [pulling, setPulling] = useState(false);
+ const [pushing, setPushing] = useState(false);
+ const [nextValue, setNextValue] = useState(6);
+ const [isInitializing, setIsInitializing] = useState(true);
+
+ // Initialize queue by adding items one at a time
+ useEffect(() => {
+ let currentIndex = 1;
+
+ const interval = setInterval(() => {
+ if (currentIndex <= 5) {
+ setQueue((prev) => [currentIndex, ...prev]); // Add to front (top of stack)
+ currentIndex++;
+ } else {
+ clearInterval(interval);
+ setIsInitializing(false);
+ }
+ }, 150);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ const handlePull = () => {
+ if (queue.length === 0 || pulling) return;
+
+ setPulling(true);
+ const bottomCard = queue[queue.length - 1];
+
+ // First flip the card
+ setFlippedCards((prev) => new Set([...prev, bottomCard]));
+
+ // After flip animation, move to consumed
+ setTimeout(() => {
+ setConsumed((prev) => [...prev, bottomCard]);
+ setQueue(queue.slice(0, -1));
+ setPulling(false);
+ }, 800); // 600ms flip + 200ms buffer
+ };
+
+ const handlePush = () => {
+ if (pushing) return;
+
+ setPushing(true);
+
+ // Animate the push - add to top
+ setTimeout(() => {
+ setQueue((prev) => [nextValue, ...prev]);
+ setNextValue((prev) => prev + 1);
+ setPushing(false);
+ }, 400);
+ };
+
+ const handleReset = () => {
+ setQueue([]);
+ setConsumed([]);
+ setFlippedCards(new Set());
+ setPulling(false);
+ setPushing(false);
+ setNextValue(6);
+ setIsInitializing(true);
+
+ // Re-initialize
+ setTimeout(() => {
+ let currentIndex = 1;
+
+ const interval = setInterval(() => {
+ if (currentIndex <= 5) {
+ setQueue((prev) => [currentIndex, ...prev]);
+ currentIndex++;
+ } else {
+ clearInterval(interval);
+ setIsInitializing(false);
+ }
+ }, 150);
+ }, 100);
+ };
+
+ const isExhausted = queue.length === 0 && !isInitializing;
+
+ return (
+
+ {/* Visual Layout */}
+
+ {/* Cloud Push Source */}
+
+
+ {pushing ? '💫' : '☁️'}
+ {!pushing && (
+
+ )}
+
+
PUSH
+
+ Next: {nextValue}
+
+
+
+ {pushing &&
}
+
+ {/* Card Stack */}
+
+
Source Queue
+
+ {queue.length > 0 ? (
+
+ {queue.map((value, idx) => (
+
+ ))}
+
+ ) : (
+
+ Empty
+
+ )}
+
+
+
+
+
+ {pulling &&
}
+
+
+ {/* Controls */}
+
+
+
+
+ Queue: {queue.length} | Consumed: {consumed.length}
+
+
+
+ );
+}
+
+function PullLever({
+ onPull,
+ active,
+ pulling,
+}: {
+ onPull: () => void;
+ active: boolean;
+ pulling: boolean;
+}) {
+ return (
+
+
+ {pulling ? '⚡' : '⬇'}
+ {active && !pulling && (
+
+ )}
+
+
PULL
+
+ {active ? 'Active' : 'Exhausted'}
+
+
+ );
+}
+
+function ConsumedRail({ items }: { items: number[] }) {
+ return (
+
+
Consumed
+
+ {items.length > 0 ? (
+ items.map((num, idx) => (
+
+
+
+ ))
+ ) : (
+
+ ∅
+
+ )}
+
+
+ );
+}
+
+function TransferBeam() {
+ return (
+
+ );
+}
+
+function PushBeam() {
+ return (
+
+ );
+}
diff --git a/src/components/renderers/ChunkBadge.tsx b/src/components/renderers/ChunkBadge.tsx
new file mode 100644
index 0000000..34a0438
--- /dev/null
+++ b/src/components/renderers/ChunkBadge.tsx
@@ -0,0 +1,61 @@
+'use client';
+
+import { Chunk } from 'effect';
+import { motion } from 'motion/react';
+import type { RenderableResult } from './RenderableResult';
+
+export class ChunkBadge implements RenderableResult {
+ constructor(public chunk: Chunk.Chunk) {}
+
+ render() {
+ const values = Chunk.toReadonlyArray(this.chunk);
+ return (
+
+
+ Chunk({values.length})
+
+
+ {values.map((val, i) => (
+
+ {String(val)}
+
+ ))}
+
+
+ );
+ }
+}
diff --git a/src/components/renderers/ChunkResult.tsx b/src/components/renderers/ChunkResult.tsx
new file mode 100644
index 0000000..8cd099d
--- /dev/null
+++ b/src/components/renderers/ChunkResult.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { Chunk } from 'effect';
+import { motion } from 'motion/react';
+import type { RenderableResult } from './RenderableResult';
+
+export class ChunkResult implements RenderableResult {
+ constructor(public chunk: Chunk.Chunk) {}
+
+ render() {
+ const values = Chunk.toReadonlyArray(this.chunk);
+ return (
+
+ Chunk({values.length})
+
+ {values.map((val, i) => (
+
+ {String(val)}
+
+ ))}
+
+
+ );
+ }
+}
diff --git a/src/components/renderers/index.ts b/src/components/renderers/index.ts
index 74bc919..18b9bdb 100644
--- a/src/components/renderers/index.ts
+++ b/src/components/renderers/index.ts
@@ -1,5 +1,7 @@
-export * from "./ArrayResult"
-export * from "./BasicRenderers"
-export * from "./EmojiResult"
-export * from "./RenderableResult"
-export * from "./TemperatureResult"
+export * from './ArrayResult';
+export * from './BasicRenderers';
+export * from './ChunkBadge';
+export * from './ChunkResult';
+export * from './EmojiResult';
+export * from './RenderableResult';
+export * from './TemperatureResult';
diff --git a/src/examples/chunk-make.tsx b/src/examples/chunk-make.tsx
new file mode 100644
index 0000000..f8e22bb
--- /dev/null
+++ b/src/examples/chunk-make.tsx
@@ -0,0 +1,66 @@
+'use client';
+
+import { Chunk } from 'effect';
+import { useMemo } from 'react';
+import { EffectExample } from '@/components/display';
+import { ChunkBadge } from '@/components/renderers';
+import type { ExampleComponentProps } from '@/lib/example-types';
+
+export function ChunkMakeExample({ exampleId, index, metadata }: ExampleComponentProps) {
+ // Just display the chunk - no effects needed, it's a pure value
+ const chunk = useMemo(() => Chunk.make(1, 2, 3, 4, 5), []);
+
+ const codeSnippet = `const chunk = Chunk.make(1, 2, 3, 4, 5)
+// Chunk(5) [1, 2, 3, 4, 5]`;
+
+ return (
+
+ {/* Header */}
+
+
+
+ ✦
+
+
+
{metadata.name}
+
{metadata.description}
+
+
+
+
+ {/* Visual */}
+
+
+ {/* Code */}
+
+
+
+ const chunk{' '}
+ = Chunk
+ .
+ make
+ (
+ 1
+ ,
+ 2
+ ,
+ 3
+ ,
+ 4
+ ,
+ 5
+ )
+ {'\n'}
+ // Chunk(5) [1, 2, 3, 4, 5]
+
+
+
+
+ );
+}
+
+export default ChunkMakeExample;
diff --git a/src/examples/stream-range.tsx b/src/examples/stream-range.tsx
new file mode 100644
index 0000000..cbdc600
--- /dev/null
+++ b/src/examples/stream-range.tsx
@@ -0,0 +1,76 @@
+'use client';
+
+import { Chunk, Effect, Stream } from 'effect';
+import { useMemo } from 'react';
+import { EffectExample } from '@/components/display';
+import { ChunkResult, NumberResult } from '@/components/renderers';
+import { useVisualEffects } from '@/hooks/useVisualEffects';
+import type { ExampleComponentProps } from '@/lib/example-types';
+import { VisualEffect } from '@/VisualEffect';
+import { getDelay } from './helpers';
+
+export function StreamRangeExample({ exampleId, index, metadata }: ExampleComponentProps) {
+ // Individual stream elements being emitted sequentially
+ const { emit1, emit2, emit3, emit4, emit5 } = useVisualEffects({
+ emit1: () => Effect.sleep(getDelay(200, 400)).pipe(Effect.as(new NumberResult(1))),
+ emit2: () => Effect.sleep(getDelay(200, 400)).pipe(Effect.as(new NumberResult(2))),
+ emit3: () => Effect.sleep(getDelay(200, 400)).pipe(Effect.as(new NumberResult(3))),
+ emit4: () => Effect.sleep(getDelay(200, 400)).pipe(Effect.as(new NumberResult(4))),
+ emit5: () => Effect.sleep(getDelay(200, 400)).pipe(Effect.as(new NumberResult(5))),
+ });
+
+ // The final collected result - waits for all emissions to complete
+ const collector = useMemo(() => {
+ const collectorEffect = Effect.all([
+ emit1.effect,
+ emit2.effect,
+ emit3.effect,
+ emit4.effect,
+ emit5.effect,
+ ]).pipe(
+ Effect.map((results) => {
+ // Extract raw values from NumberResult objects and convert to Chunk
+ const numbers = results.map((r) => r.value);
+ return new ChunkResult(Chunk.fromIterable(numbers));
+ }),
+ );
+
+ return new VisualEffect('result', collectorEffect);
+ }, [emit1, emit2, emit3, emit4, emit5]);
+
+ const codeSnippet = `const stream = Stream.range(1, 6)
+
+const result = Stream.runCollect(stream)
+// Chunk(1, 2, 3, 4, 5)`;
+
+ const taskHighlightMap = useMemo(
+ () => ({
+ emit1: { text: 'Stream.range(1, 6)' },
+ emit2: { text: 'Stream.range(1, 6)' },
+ emit3: { text: 'Stream.range(1, 6)' },
+ emit4: { text: 'Stream.range(1, 6)' },
+ emit5: { text: 'Stream.range(1, 6)' },
+ result: { text: 'Stream.runCollect(stream)' },
+ }),
+ [],
+ );
+
+ return (
+ [emit1, emit2, emit3, emit4, emit5],
+ [emit1, emit2, emit3, emit4, emit5],
+ )}
+ resultEffect={collector}
+ effectHighlightMap={taskHighlightMap}
+ {...(index !== undefined && { index })}
+ exampleId={exampleId}
+ />
+ );
+}
+
+export default StreamRangeExample;
diff --git a/src/lib/example-types.ts b/src/lib/example-types.ts
index 1c146b4..0d0224d 100644
--- a/src/lib/example-types.ts
+++ b/src/lib/example-types.ts
@@ -1,21 +1,29 @@
export interface ExampleMeta {
- id: string
- name: string
- variant?: string
- description: string
- section: "constructors" | "concurrency" | "error handling" | "schedule" | "ref" | "scope"
- order?: number
+ id: string;
+ name: string;
+ variant?: string;
+ description: string;
+ section:
+ | 'constructors'
+ | 'concurrency'
+ | 'error handling'
+ | 'schedule'
+ | 'ref'
+ | 'scope'
+ | 'chunks'
+ | 'streams';
+ order?: number;
}
export interface ExampleComponentProps {
- index?: number
- metadata: ExampleMeta
- exampleId: string
+ index?: number;
+ metadata: ExampleMeta;
+ exampleId: string;
}
export interface ExampleItem {
- type: "example"
- metadata: ExampleMeta
+ type: 'example';
+ metadata: ExampleMeta;
}
-export type AppItem = ExampleItem
+export type AppItem = ExampleItem;
diff --git a/src/lib/examples-manifest.ts b/src/lib/examples-manifest.ts
index 76dc964..124a3bd 100644
--- a/src/lib/examples-manifest.ts
+++ b/src/lib/examples-manifest.ts
@@ -1,70 +1,70 @@
-import type { ExampleMeta } from "./example-types"
+import type { ExampleMeta } from './example-types';
// This is the single source of truth for all examples
// Examples are listed in the exact order they should appear
export const examplesManifest: Array = [
// Constructors
{
- id: "effect-succeed",
- name: "Effect.succeed",
- description: "Create an effect that always succeeds with a given value",
- section: "constructors",
+ id: 'effect-succeed',
+ name: 'Effect.succeed',
+ description: 'Create an effect that always succeeds with a given value',
+ section: 'constructors',
},
{
- id: "effect-fail",
- name: "Effect.fail",
- description: "Create an effect that represents a recoverable error",
- section: "constructors",
+ id: 'effect-fail',
+ name: 'Effect.fail',
+ description: 'Create an effect that represents a recoverable error',
+ section: 'constructors',
},
{
- id: "effect-die",
- name: "Effect.die",
- description: "Create an effect that terminates with an unrecoverable defect",
- section: "constructors",
+ id: 'effect-die',
+ name: 'Effect.die',
+ description: 'Create an effect that terminates with an unrecoverable defect',
+ section: 'constructors',
},
{
- id: "effect-sync",
- name: "Effect.sync",
- description: "Create an effect from a synchronous side-effectful computation",
- section: "constructors",
+ id: 'effect-sync',
+ name: 'Effect.sync',
+ description: 'Create an effect from a synchronous side-effectful computation',
+ section: 'constructors',
},
{
- id: "effect-promise",
- name: "Effect.promise",
- description: "Create an effect from an asynchronous computation guaranteed to succeed",
- section: "constructors",
+ id: 'effect-promise',
+ name: 'Effect.promise',
+ description: 'Create an effect from an asynchronous computation guaranteed to succeed',
+ section: 'constructors',
},
{
- id: "effect-sleep",
- name: "Effect.sleep",
- description: "Create an effect that suspends execution for a given duration",
- section: "constructors",
+ id: 'effect-sleep',
+ name: 'Effect.sleep',
+ description: 'Create an effect that suspends execution for a given duration',
+ section: 'constructors',
},
// Concurrency
{
- id: "effect-all",
- name: "Effect.all",
- description: "Combine multiple effects into one, returning results based on input structure",
- section: "concurrency",
+ id: 'effect-all',
+ name: 'Effect.all',
+ description: 'Combine multiple effects into one, returning results based on input structure',
+ section: 'concurrency',
},
{
- id: "effect-race",
- name: "Effect.race",
- description: "Race two effects and return the result of the first successful one",
- section: "concurrency",
+ id: 'effect-race',
+ name: 'Effect.race',
+ description: 'Race two effects and return the result of the first successful one',
+ section: 'concurrency',
},
{
- id: "effect-raceall",
- name: "Effect.raceAll",
- description: "Race multiple effects and return the first successful result",
- section: "concurrency",
+ id: 'effect-raceall',
+ name: 'Effect.raceAll',
+ description: 'Race multiple effects and return the first successful result',
+ section: 'concurrency',
},
{
- id: "effect-foreach",
- name: "Effect.forEach",
- description: "Execute an effectful operation for each element in an iterable",
- section: "concurrency",
+ id: 'effect-foreach',
+ name: 'Effect.forEach',
+ description: 'Execute an effectful operation for each element in an iterable',
+ section: 'concurrency',
},
// {
// id: "effect-semaphore",
@@ -81,103 +81,131 @@ export const examplesManifest: Array = [
// Error Handling
{
- id: "effect-all-short-circuit",
- name: "Effect.all",
- variant: "short circuit",
- description: "Stop execution on the first error encountered",
- section: "error handling",
+ id: 'effect-all-short-circuit',
+ name: 'Effect.all',
+ variant: 'short circuit',
+ description: 'Stop execution on the first error encountered',
+ section: 'error handling',
},
{
- id: "effect-orelse",
- name: "Effect.orElse",
- description: "Try one effect, and if it fails, fall back to another effect",
- section: "error handling",
+ id: 'effect-orelse',
+ name: 'Effect.orElse',
+ description: 'Try one effect, and if it fails, fall back to another effect',
+ section: 'error handling',
},
{
- id: "effect-timeout",
- name: "Effect.timeout",
- description: "Add a time limit to an effect, failing with timeout if exceeded",
- section: "error handling",
+ id: 'effect-timeout',
+ name: 'Effect.timeout',
+ description: 'Add a time limit to an effect, failing with timeout if exceeded',
+ section: 'error handling',
},
{
- id: "effect-eventually",
- name: "Effect.eventually",
- description: "Run an effect repeatedly until it succeeds, ignoring errors",
- section: "error handling",
+ id: 'effect-eventually',
+ name: 'Effect.eventually',
+ description: 'Run an effect repeatedly until it succeeds, ignoring errors',
+ section: 'error handling',
},
{
- id: "effect-partition",
- name: "Effect.partition",
- description: "Execute effects and partition results into successes and failures",
- section: "error handling",
+ id: 'effect-partition',
+ name: 'Effect.partition',
+ description: 'Execute effects and partition results into successes and failures',
+ section: 'error handling',
},
{
- id: "effect-validate",
- name: "Effect.validate",
- description: "Accumulate validation errors instead of short-circuiting",
- section: "error handling",
+ id: 'effect-validate',
+ name: 'Effect.validate',
+ description: 'Accumulate validation errors instead of short-circuiting',
+ section: 'error handling',
},
// Schedule
{
- id: "effect-repeat-spaced",
- name: "Effect.repeat",
- description: "Repeat an effect with a fixed delay between each execution",
- section: "schedule",
- variant: "spaced",
+ id: 'effect-repeat-spaced',
+ name: 'Effect.repeat',
+ description: 'Repeat an effect with a fixed delay between each execution',
+ section: 'schedule',
+ variant: 'spaced',
},
{
- id: "effect-repeat-while-output",
- name: "Effect.repeat",
- variant: "whileOutput",
- description: "Repeat while output matches a condition",
- section: "schedule",
+ id: 'effect-repeat-while-output',
+ name: 'Effect.repeat',
+ variant: 'whileOutput',
+ description: 'Repeat while output matches a condition',
+ section: 'schedule',
},
{
- id: "effect-retry-recurs",
- name: "Effect.retry",
- description: "Retry an effect a fixed number of times",
- section: "schedule",
- variant: "recurs",
+ id: 'effect-retry-recurs',
+ name: 'Effect.retry',
+ description: 'Retry an effect a fixed number of times',
+ section: 'schedule',
+ variant: 'recurs',
},
{
- id: "effect-retry-exponential",
- name: "Effect.retry",
- variant: "exponential",
- description: "Retry with exponential backoff",
- section: "schedule",
+ id: 'effect-retry-exponential',
+ name: 'Effect.retry',
+ variant: 'exponential',
+ description: 'Retry with exponential backoff',
+ section: 'schedule',
},
// Ref
{
- id: "ref-make",
- name: "Ref.make",
- description: "Create a concurrency-safe mutable reference",
- section: "ref",
+ id: 'ref-make',
+ name: 'Ref.make',
+ description: 'Create a concurrency-safe mutable reference',
+ section: 'ref',
},
{
- id: "ref-update-and-get",
- name: "Ref.updateAndGet",
- description: "Update a ref and return the new value",
- section: "ref",
+ id: 'ref-update-and-get',
+ name: 'Ref.updateAndGet',
+ description: 'Update a ref and return the new value',
+ section: 'ref',
},
// Scope
{
- id: "effect-add-finalizer",
- name: "Effect.addFinalizer",
- description: "Register cleanup actions in a scope",
- section: "scope",
+ id: 'effect-add-finalizer',
+ name: 'Effect.addFinalizer',
+ description: 'Register cleanup actions in a scope',
+ section: 'scope',
},
{
- id: "effect-acquire-release",
- name: "Effect.acquireRelease",
- description: "Acquire resources with guaranteed cleanup",
- section: "scope",
+ id: 'effect-acquire-release',
+ name: 'Effect.acquireRelease',
+ description: 'Acquire resources with guaranteed cleanup',
+ section: 'scope',
},
-]
+
+ // Chunks
+ {
+ id: 'chunk-make',
+ name: 'Chunk.make',
+ description: 'Create a chunk from individual values',
+ section: 'chunks',
+ },
+ {
+ id: 'chunk-append',
+ name: 'Chunk.append',
+ description: 'Add an element to the end of a chunk',
+ section: 'chunks',
+ },
+ {
+ id: 'chunk-concat',
+ name: 'Chunk.concat',
+ description: 'Combine two chunks into one',
+ section: 'chunks',
+ },
+
+ // Streams
+ {
+ id: 'stream-range',
+ name: 'Stream.range',
+ description: 'Collect a finite range of integers into a Chunk',
+ section: 'streams',
+ },
+];
// Helper function to get metadata by ID
export function getExampleMeta(id: string): ExampleMeta | undefined {
- return examplesManifest.find(meta => meta.id === id)
+ return examplesManifest.find((meta) => meta.id === id);
}
diff --git a/tsconfig.json b/tsconfig.json
index 01cbc13..24111e0 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
- "lib": ["dom", "dom.iterable", "esnext"],
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
@@ -17,7 +21,7 @@
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"noErrorTruncation": true,
"plugins": [
{
@@ -26,15 +30,30 @@
{
"name": "@effect/language-service",
"transform": "@effect/language-service/transform",
- "namespaceImportPackages": ["effect", "@effect/*"]
+ "namespaceImportPackages": [
+ "effect",
+ "@effect/*"
+ ]
}
],
/* Path mapping */
"baseUrl": ".",
"paths": {
- "@/*": ["src/*"]
+ "@/*": [
+ "src/*"
+ ]
}
},
- "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "next-env.d.ts", "out/types/**/*.ts"],
- "exclude": ["node_modules"]
+ "include": [
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ "next-env.d.ts",
+ "out/types/**/*.ts",
+ "out/dev/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
}