From 9304646dfc0b4ea566058354f5d06b6c0b23b51e Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:11:31 +0000 Subject: [PATCH] feat(virtual-addresses): replace panel walkthrough with React Flow graph demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inspired by the cross-border payments PayrollDemoFlow, replaces the 3-column panel walkthrough with a React Flow graph visualization of the TIP-1022 flow: - 5 nodes: Exchange, Virtual Registry, Virtual Address, Sender, TIP-1022 Resolver - 6 GSAP-animated edges with draw-in, particles, and subtitle slide-in - Black label pills with Tempo icon on edges (potemkin style) - Step-by-step progression (register → derive → send → resolve → receive) - Timeline bar, keyboard navigation (←/→/Space), autoplay - Numeric step machine replacing granular string-based substeps - Keeps existing server API endpoints unchanged Co-authored-by: 0xrusowsky <90208954+0xrusowsky@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d90b2-ce1e-755b-b116-ceaa45170a1e --- apps/virtual-addresses/package.json | 2 + .../src/comps/walkthrough/event-log.tsx | 95 ---- .../src/comps/walkthrough/exchange-panel.tsx | 301 ----------- .../comps/walkthrough/flow/animated-edge.tsx | 338 ++++++++++++ .../src/comps/walkthrough/flow/graph-model.ts | 325 ++++++++++++ .../src/comps/walkthrough/flow/nodes.tsx | 130 +++++ .../src/comps/walkthrough/flow/styles.css | 500 ++++++++++++++++++ .../src/comps/walkthrough/guide-overlay.tsx | 440 --------------- .../src/comps/walkthrough/protocol-panel.tsx | 457 ---------------- .../src/comps/walkthrough/sender-panel.tsx | 222 -------- .../src/comps/walkthrough/status-badge.tsx | 45 -- .../comps/walkthrough/walkthrough-demo.tsx | 439 +++++++++++---- .../src/lib/walkthrough-types.ts | 38 +- .../src/store/walkthrough-store.ts | 321 ++++++----- biome.json | 13 + pnpm-lock.yaml | 197 ++++++- pnpm-workspace.yaml | 2 + 17 files changed, 2004 insertions(+), 1861 deletions(-) delete mode 100644 apps/virtual-addresses/src/comps/walkthrough/event-log.tsx delete mode 100644 apps/virtual-addresses/src/comps/walkthrough/exchange-panel.tsx create mode 100644 apps/virtual-addresses/src/comps/walkthrough/flow/animated-edge.tsx create mode 100644 apps/virtual-addresses/src/comps/walkthrough/flow/graph-model.ts create mode 100644 apps/virtual-addresses/src/comps/walkthrough/flow/nodes.tsx create mode 100644 apps/virtual-addresses/src/comps/walkthrough/flow/styles.css delete mode 100644 apps/virtual-addresses/src/comps/walkthrough/guide-overlay.tsx delete mode 100644 apps/virtual-addresses/src/comps/walkthrough/protocol-panel.tsx delete mode 100644 apps/virtual-addresses/src/comps/walkthrough/sender-panel.tsx delete mode 100644 apps/virtual-addresses/src/comps/walkthrough/status-badge.tsx diff --git a/apps/virtual-addresses/package.json b/apps/virtual-addresses/package.json index 2e3fee28d..738987456 100644 --- a/apps/virtual-addresses/package.json +++ b/apps/virtual-addresses/package.json @@ -18,7 +18,9 @@ "@noble/hashes": "^1.7.2", "@tailwindcss/vite": "catalog:", "@tanstack/react-query": "catalog:", + "@xyflow/react": "catalog:", "framer-motion": "catalog:", + "gsap": "catalog:", "hash-wasm": "catalog:", "hono": "catalog:", "ox": "catalog:", diff --git a/apps/virtual-addresses/src/comps/walkthrough/event-log.tsx b/apps/virtual-addresses/src/comps/walkthrough/event-log.tsx deleted file mode 100644 index b229c8a12..000000000 --- a/apps/virtual-addresses/src/comps/walkthrough/event-log.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import type * as React from 'react' -import { motion, AnimatePresence } from 'framer-motion' - -export function EventLog(props: EventLog.Props): React.JSX.Element { - const { entries } = props - - return ( -
- - {entries.map((entry, i) => ( - -
- - {entry.type} - - {entry.txHash && ( - - {entry.txHash.slice(0, 10)}… - - )} -
-
- {entry.message} -
-
- ))} -
-
- ) -} - -function typeColor(type: string): string { - switch (type) { - case 'register': - return 'var(--color-accent)' - case 'transfer': - return 'var(--color-positive)' - case 'balance': - return 'var(--color-warning)' - default: - return 'var(--color-text-tertiary)' - } -} - -export declare namespace EventLog { - type Entry = { - id?: string - type: 'register' | 'transfer' | 'balance' - message: string - txHash?: string - } - type Props = { - entries: Entry[] - } -} diff --git a/apps/virtual-addresses/src/comps/walkthrough/exchange-panel.tsx b/apps/virtual-addresses/src/comps/walkthrough/exchange-panel.tsx deleted file mode 100644 index c39dd69f1..000000000 --- a/apps/virtual-addresses/src/comps/walkthrough/exchange-panel.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import * as React from 'react' -import { motion, AnimatePresence } from 'framer-motion' - -import { useWalkthroughStore } from '#store/walkthrough-store' -import { StatusBadge } from './status-badge' -import { EventLog } from './event-log' -import type { EventLog as EventLogNs } from './event-log' - -const activeSteps = new Set([ - 'register-start', - 'register-mining', - 'register-tx', - 'register-confirmed', - 'balances-final', -]) - -function formatNumber(n: number): string { - if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B` - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` - return n.toString() -} - -function stepMessage(step: string, txPending: boolean): string { - switch (step) { - case 'register-start': - return 'Preparing registration…' - case 'register-mining': - return 'Mining valid salt (32-bit PoW)…' - case 'register-tx': - return txPending ? 'Registering on-chain…' : 'Awaiting confirmation…' - case 'register-confirmed': - return 'Master registered ✓' - case 'balances-final': - return 'Balance updated' - default: - return 'Idle' - } -} - -export function ExchangePanel(): React.JSX.Element { - const step = useWalkthroughStore((s) => s.step) - const demoState = useWalkthroughStore((s) => s.demoState) - const txPending = useWalkthroughStore((s) => s.txPending) - const data = useWalkthroughStore((s) => s.data) - - const isActive = activeSteps.has(step) - - const logEntries = React.useMemo(() => { - const entries: EventLogNs.Entry[] = [] - if (data.registerTxHash) { - entries.push({ - id: 'reg', - type: 'register', - message: `registerVirtualMaster(${data.salt})`, - txHash: data.registerTxHash, - }) - } - if (data.masterId) { - entries.push({ - id: 'mid', - type: 'register', - message: `MasterRegistered → ${data.masterId}`, - }) - } - if (step === 'balances-final' || demoState === 'complete') { - entries.push({ - id: 'bal', - type: 'balance', - message: `PathUSD balance: ${data.exchangeBalance}`, - }) - } - return entries - }, [ - data.registerTxHash, - data.salt, - data.masterId, - data.exchangeBalance, - step, - demoState, - ]) - - return ( - - {/* Header */} -
- Exchange - -
- -
- {/* Address */} -
-
- Master Address -
- {data.exchangeAddress ? ( -
- {data.exchangeAddress} -
- ) : ( -
- )} -
- - {/* Registration section */} -
-
- Registration -
- -
- {/* Salt */} -
-
- salt -
- - {data.salt ? ( - - {data.salt} - - ) : ( -
- )} -
-
- - {/* Master ID */} -
-
- masterId -
- - {data.masterId ? ( - - {data.masterId} - - ) : ( -
- )} -
-
- - {/* Mining progress */} - - {step === 'register-mining' && data.miningProgress && ( - -
- - Mining with {data.miningProgress.workerCount} workers… -
-
- - {formatNumber(data.miningProgress.totalAttempts)} hashes - - - {formatNumber(data.miningProgress.hashesPerSecond)}/s - -
-
- )} -
- - {/* Tx status */} - - {txPending && step === 'register-tx' && ( - - - Registering on-chain… - - )} - -
-
- - {/* Balance section */} -
-
- PathUSD Balance -
- - - {data.exchangeBalance} - - -
- - {/* Status */} -
- {stepMessage(step, txPending)} -
- - {/* Event log */} - {logEntries.length > 0 && } -
-
- ) -} diff --git a/apps/virtual-addresses/src/comps/walkthrough/flow/animated-edge.tsx b/apps/virtual-addresses/src/comps/walkthrough/flow/animated-edge.tsx new file mode 100644 index 000000000..6b9e927ae --- /dev/null +++ b/apps/virtual-addresses/src/comps/walkthrough/flow/animated-edge.tsx @@ -0,0 +1,338 @@ +import { useId, useRef, useEffect, useCallback } from 'react' +import { getBezierPath, type EdgeProps } from '@xyflow/react' +import gsap from 'gsap' +import type { FlowEdgeData } from './graph-model' + +const PARTICLE_SIZES = [2, 3, 1.5] + +export function AnimatedEdge({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, +}: EdgeProps): React.JSX.Element { + const d = data as FlowEdgeData | undefined + const pathRef = useRef(null) + const glowRef = useRef(null) + const subtitleRef = useRef(null) + const particlesRef = useRef<(SVGCircleElement | null)[]>([]) + const tweensRef = useRef([]) + const hasAnimated = useRef(false) + + const uid = useId() + const arrowId = `va-arrow-${uid}` + const glowId = `va-glow-${uid}` + + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }) + + const status = d?.status ?? 'idle' + const isDashed = d?.dashed ?? false + const isActive = status === 'active' + const isDone = status === 'done' + const isVisible = isActive || isDone + + const killTweens = useCallback(() => { + for (const t of tweensRef.current) t.kill() + tweensRef.current = [] + }, []) + + // Draw-in + particle animation when active + useEffect(() => { + const path = pathRef.current + if (!path || !isActive) return + + killTweens() + hasAnimated.current = true + + const length = path.getTotalLength() + if (length === 0) return + + path.style.strokeDasharray = `${length}` + path.style.strokeDashoffset = `${length}` + + // Hide subtitle behind pill + if (subtitleRef.current) { + gsap.set(subtitleRef.current, { opacity: 1, y: -20 }) + } + + const drawIn = gsap.to(path, { + strokeDashoffset: 0, + duration: 0.7, + ease: 'power2.inOut', + onComplete() { + path.style.strokeDasharray = isDashed ? '6 4' : '' + path.style.strokeDashoffset = '' + // Slide subtitle down + if (subtitleRef.current) { + const sub = gsap.to(subtitleRef.current, { + y: 0, + duration: 0.5, + ease: 'power3.out', + }) + tweensRef.current.push(sub) + } + }, + }) + tweensRef.current.push(drawIn) + + if (glowRef.current) { + glowRef.current.style.strokeDasharray = `${length}` + glowRef.current.style.strokeDashoffset = `${length}` + const glowDraw = gsap.to(glowRef.current, { + strokeDashoffset: 0, + duration: 0.7, + ease: 'power2.inOut', + onComplete() { + if (glowRef.current) { + glowRef.current.style.strokeDasharray = isDashed ? '6 4' : '' + glowRef.current.style.strokeDashoffset = '' + } + }, + }) + tweensRef.current.push(glowDraw) + } + + // Staggered particles + particlesRef.current.forEach((circle, i) => { + if (!circle) return + const proxy = { t: 0 } + const fadeIn = gsap.fromTo( + circle, + { opacity: 0 }, + { opacity: 0.8, duration: 0.15, delay: 0.35 + i * 0.12 }, + ) + const move = gsap.to(proxy, { + t: 1, + duration: 1, + ease: 'none', + repeat: -1, + delay: 0.4 + i * 0.25, + onUpdate() { + try { + const pt = path.getPointAtLength(proxy.t * length) + circle.setAttribute('cx', String(pt.x)) + circle.setAttribute('cy', String(pt.y)) + } catch { + /* not ready */ + } + }, + }) + tweensRef.current.push(fadeIn, move) + }) + + return killTweens + }, [isActive, isDashed, killTweens]) + + // Re-trigger subtitle slide on text change + const prevSubtitle = useRef(d?.subtitle) + useEffect(() => { + if (!subtitleRef.current || !isActive) return + const changed = prevSubtitle.current !== d?.subtitle + prevSubtitle.current = d?.subtitle + if (changed && d?.subtitle) { + gsap.fromTo( + subtitleRef.current, + { y: -18 }, + { y: 0, duration: 0.4, ease: 'back.out(1.5)' }, + ) + } + }, [d?.subtitle, isActive]) + + useEffect(() => { + if (isDone && hasAnimated.current) { + killTweens() + particlesRef.current.forEach((c) => { + if (c) c.setAttribute('opacity', '0') + }) + if (pathRef.current) { + pathRef.current.style.strokeDasharray = isDashed ? '6 4' : '' + pathRef.current.style.strokeDashoffset = '' + } + if (glowRef.current) { + glowRef.current.style.strokeDasharray = isDashed ? '6 4' : '' + glowRef.current.style.strokeDashoffset = '' + } + } + }, [isDone, isDashed, killTweens]) + + useEffect(() => { + if (status === 'idle') { + hasAnimated.current = false + killTweens() + particlesRef.current.forEach((c) => { + if (c) c.setAttribute('opacity', '0') + }) + } + }, [status, killTweens]) + + const groupOpacity = isActive ? 1 : isDone ? 0.5 : 0.12 + const activeColor = '#60a5fa' + const strokeColor = isActive + ? activeColor + : isDone + ? '#93c5fd' + : 'var(--color-border)' + + return ( + + + + + + + + + + + + + + + {/* Glow trail */} + {isActive && ( + + )} + + + + {/* Particles */} + {PARTICLE_SIZES.map((size, i) => ( + { + particlesRef.current[i] = el + }} + r={size} + fill={activeColor} + opacity={0} + /> + ))} + + {/* Edge label pill — black bg, white text, Tempo icon (PayrollDemoFlow style) */} + {d?.amount && + (() => { + const iconSpace = 18 + const textLen = d.amount.length * 6.5 + const padX = 12 + const padY = 7 + const pillW = iconSpace + textLen + padX * 2 + const topH = 14 + padY * 2 + const subH = d.subtitle ? 18 : 0 + const pillX = labelX - pillW / 2 + const pillY = labelY - topH / 2 + const clipId = `va-sub-clip-${uid}` + return ( + <> + {/* Subtitle slides out from behind pill */} + {d.subtitle && ( + <> + + + + + + + {d.subtitle} + + + + )} + {/* Black pill on top */} + + {/* Tempo "T" icon */} + + + + + {d.amount} + + + ) + })()} + + ) +} diff --git a/apps/virtual-addresses/src/comps/walkthrough/flow/graph-model.ts b/apps/virtual-addresses/src/comps/walkthrough/flow/graph-model.ts new file mode 100644 index 000000000..2403464eb --- /dev/null +++ b/apps/virtual-addresses/src/comps/walkthrough/flow/graph-model.ts @@ -0,0 +1,325 @@ +import type { Node, Edge } from '@xyflow/react' +import type { + FlowStep, + NodeStatus, + StepDef, + WalkthroughData, +} from '#lib/walkthrough-types' + +// ── Step definitions ───────────────────────────────────────────────────────── + +export const STEPS: StepDef[] = [ + { + id: 0, + label: 'Ready', + description: + 'Click the play button to walk through TIP-1022 virtual address resolution with real on-chain transactions.', + }, + { + id: 1, + label: 'Register master', + description: + 'The exchange registers as a virtual-address master in the Virtual Registry with a pre-mined salt and receives a 4-byte masterId.', + }, + { + id: 2, + label: 'Derive virtual address', + description: + 'The exchange derives a virtual address offline by concatenating masterId + magic bytes (0xFDFD…FD) + userTag. No on-chain transaction needed.', + }, + { + id: 3, + label: 'Send to virtual address', + description: + 'The sender transfers 100 PathUSD to the derived virtual address — a standard TIP-20 transfer.', + }, + { + id: 4, + label: 'Protocol resolves', + description: + 'The TIP-20 precompile detects magic bytes, extracts the masterId, looks up the registered master address, and forwards tokens automatically.', + }, + { + id: 5, + label: 'Master receives funds', + description: + 'The exchange receives the funds directly. The virtual address balance remains zero — no sweep transaction needed.', + }, + { + id: 6, + label: 'Complete', + description: + 'A sender paid a derived virtual address while the protocol routed funds to the master. Two Transfer events preserve the full audit trail.', + }, +] + +// ── Node positions (React Flow coordinates) ────────────────────────────────── + +export const NODE_POSITIONS = { + exchange: { x: 0, y: 200 }, + registry: { x: 370, y: 0 }, + virtual: { x: 370, y: 400 }, + sender: { x: 740, y: 400 }, + protocol: { x: 740, y: 200 }, +} as const + +// ── Node tooltips ──────────────────────────────────────────────────────────── + +const NODE_TOOLTIPS: Record = { + exchange: + 'Registers as a virtual-address master and receives forwarded funds', + registry: 'On-chain registry mapping masterId → master address', + virtual: 'Derived offline from masterId + magic + userTag', + sender: 'Sends TIP-20 tokens to the virtual address', + protocol: 'TIP-20 precompile that detects and resolves virtual addresses', +} + +// ── Step participants (for dimming) ────────────────────────────────────────── + +export const STEP_PARTICIPANTS: Record = { + 0: null, + 1: ['exchange', 'registry'], + 2: ['exchange', 'virtual'], + 3: ['sender', 'virtual'], + 4: ['virtual', 'protocol', 'registry'], + 5: ['protocol', 'exchange'], + 6: null, +} + +// ── Step focus (for fitView) ───────────────────────────────────────────────── + +export const STEP_FOCUS: Record< + number, + { nodes: string[]; padding: number } | null +> = { + 0: { nodes: ['exchange'], padding: 1.2 }, + 1: { nodes: ['exchange', 'registry'], padding: 0.4 }, + 2: { nodes: ['exchange', 'virtual'], padding: 0.4 }, + 3: { nodes: ['sender', 'virtual'], padding: 0.4 }, + 4: { nodes: ['virtual', 'protocol', 'registry'], padding: 0.3 }, + 5: { nodes: ['protocol', 'exchange'], padding: 0.4 }, + 6: null, +} + +// ── Node data types ────────────────────────────────────────────────────────── + +export type FlowNodeData = { + label: string + subtitle?: string + tooltip?: string + status: NodeStatus + props?: { key: string; value: string }[] + [k: string]: unknown +} + +export type FlowEdgeData = { + amount: string + subtitle?: string + status: NodeStatus + dashed?: boolean + [key: string]: unknown +} + +// ── Build nodes ────────────────────────────────────────────────────────────── + +function statusFor(step: FlowStep, activeAt: number[]): NodeStatus { + if (step === 6) return 'active' + if (activeAt.includes(step)) return 'active' + if (activeAt.some((i) => step > i)) return 'done' + return 'idle' +} + +export function buildNodes( + step: FlowStep, + data: WalkthroughData, +): Node[] { + return [ + { + id: 'exchange', + type: 'flow-card', + position: NODE_POSITIONS.exchange, + data: { + label: 'Exchange / Master', + subtitle: 'Virtual-address master', + tooltip: NODE_TOOLTIPS.exchange, + status: step === 0 ? 'active' : statusFor(step, [1, 2, 5]), + props: [ + ...(data.masterId ? [{ key: 'masterId', value: data.masterId }] : []), + ...(data.exchangeBalance !== '0' + ? [{ key: 'PathUSD', value: data.exchangeBalance }] + : []), + ], + }, + }, + { + id: 'registry', + type: 'flow-card', + position: NODE_POSITIONS.registry, + data: { + label: 'Virtual Registry', + subtitle: 'Precompile', + tooltip: NODE_TOOLTIPS.registry, + status: statusFor(step, [1, 4]), + props: data.masterId + ? [{ key: 'mapping', value: `${data.masterId} → master` }] + : [], + }, + }, + { + id: 'virtual', + type: 'flow-card', + position: NODE_POSITIONS.virtual, + data: { + label: 'Virtual Address', + subtitle: data.virtualAddress + ? `${data.virtualAddress.slice(0, 10)}…${data.virtualAddress.slice(-6)}` + : 'Not yet derived', + tooltip: NODE_TOOLTIPS.virtual, + status: statusFor(step, [2, 3, 4]), + props: [ + ...(data.virtualBalance !== '0' + ? [{ key: 'PathUSD', value: data.virtualBalance }] + : []), + ], + }, + }, + { + id: 'sender', + type: 'flow-card', + position: NODE_POSITIONS.sender, + data: { + label: 'Sender', + subtitle: data.senderAddress + ? `${data.senderAddress.slice(0, 8)}…${data.senderAddress.slice(-4)}` + : undefined, + tooltip: NODE_TOOLTIPS.sender, + status: statusFor(step, [3]), + props: [ + ...(data.senderBalance !== '0' + ? [{ key: 'PathUSD', value: data.senderBalance }] + : []), + ], + }, + }, + { + id: 'protocol', + type: 'flow-card', + position: NODE_POSITIONS.protocol, + data: { + label: 'TIP-1022 Resolver', + subtitle: 'TIP-20 Precompile', + tooltip: NODE_TOOLTIPS.protocol, + status: statusFor(step, [4, 5]), + props: [], + }, + }, + ] +} + +// ── Build edges ────────────────────────────────────────────────────────────── + +function edgeStatus(step: FlowStep, activeAt: number): NodeStatus { + if (step === 6) return 'active' + if (step === activeAt) return 'active' + if (step > activeAt) return 'done' + return 'idle' +} + +export function buildEdges( + step: FlowStep, + data: WalkthroughData, + phase: string | null, +): Edge[] { + const txSub = (hash: string | null, fallback?: string) => { + if (hash) return `${hash.slice(0, 8)}…${hash.slice(-4)}` + return fallback + } + + return [ + { + id: 'e-exchange-registry', + source: 'exchange', + target: 'registry', + sourceHandle: 'top', + targetHandle: 'left', + type: 'animated', + data: { + amount: 'register master', + subtitle: txSub( + data.registerTxHash, + step === 1 ? (phase ?? 'Registering…') : undefined, + ), + status: edgeStatus(step, 1), + }, + }, + { + id: 'e-exchange-virtual', + source: 'exchange', + target: 'virtual', + sourceHandle: 'bottom', + targetHandle: 'left', + type: 'animated', + data: { + amount: 'derive address', + subtitle: data.virtualAddress + ? `${data.virtualAddress.slice(0, 10)}…` + : undefined, + status: edgeStatus(step, 2), + dashed: true, + }, + }, + { + id: 'e-sender-virtual', + source: 'sender', + target: 'virtual', + sourceHandle: 'left', + targetHandle: 'right', + type: 'animated', + data: { + amount: 'send 100 PathUSD', + subtitle: txSub( + data.transferTxHash, + step === 3 ? (phase ?? 'Sending…') : undefined, + ), + status: edgeStatus(step, 3), + }, + }, + { + id: 'e-virtual-protocol', + source: 'virtual', + target: 'protocol', + sourceHandle: 'top-right', + targetHandle: 'bottom', + type: 'animated', + data: { + amount: 'magic detected', + status: edgeStatus(step, 4), + }, + }, + { + id: 'e-protocol-registry', + source: 'protocol', + target: 'registry', + sourceHandle: 'top', + targetHandle: 'right', + type: 'animated', + data: { + amount: 'lookup masterId', + status: edgeStatus(step, 4), + dashed: true, + }, + }, + { + id: 'e-protocol-exchange', + source: 'protocol', + target: 'exchange', + sourceHandle: 'left', + targetHandle: 'right', + type: 'animated', + data: { + amount: 'forward to master', + status: edgeStatus(step, 5), + }, + }, + ] +} diff --git a/apps/virtual-addresses/src/comps/walkthrough/flow/nodes.tsx b/apps/virtual-addresses/src/comps/walkthrough/flow/nodes.tsx new file mode 100644 index 000000000..454a3a380 --- /dev/null +++ b/apps/virtual-addresses/src/comps/walkthrough/flow/nodes.tsx @@ -0,0 +1,130 @@ +import { memo, useRef, useEffect, useState } from 'react' +import { Handle, Position } from '@xyflow/react' +import type { FlowNodeData } from './graph-model' + +function NodeTooltip(props: { + tooltip?: string + children: React.ReactNode +}): React.JSX.Element { + const [show, setShow] = useState(false) + + if (!props.tooltip) return <>{props.children} + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: tooltip hover behavior +
setShow(true)} + onMouseLeave={() => setShow(false)} + > + {props.children} + {show &&
{props.tooltip}
} +
+ ) +} + +export const FlowCardNode = memo( + ({ data }: { data: FlowNodeData }): React.JSX.Element => { + const ref = useRef(null) + const prevStatus = useRef(data.status) + + useEffect(() => { + if (!ref.current) return + if (data.status === 'active' && prevStatus.current !== 'active') { + ref.current.style.transform = 'scale(0.95)' + ref.current.style.opacity = '0.5' + requestAnimationFrame(() => { + if (!ref.current) return + ref.current.style.transition = + 'transform 0.4s ease-out, opacity 0.4s ease-out' + ref.current.style.transform = 'scale(1)' + ref.current.style.opacity = '1' + }) + } + prevStatus.current = data.status + }, [data.status]) + + return ( +
+ {/* Handles — all four sides + extra positions */} + + + + + + + + + {/* Extra handle for virtual → protocol diagonal */} + + + +
+
+
{data.label}
+ {data.subtitle && ( +
{data.subtitle}
+ )} +
+ {data.props && data.props.length > 0 && ( +
+ {data.props.map((p) => ( +
+ {p.key} + {p.value} +
+ ))} +
+ )} +
+
+
+ ) + }, +) diff --git a/apps/virtual-addresses/src/comps/walkthrough/flow/styles.css b/apps/virtual-addresses/src/comps/walkthrough/flow/styles.css new file mode 100644 index 000000000..8c4d1505e --- /dev/null +++ b/apps/virtual-addresses/src/comps/walkthrough/flow/styles.css @@ -0,0 +1,500 @@ +/* ── Virtual Addresses React Flow Demo — Potemkin Style ─────────────────── */ +/* Mirrors the PayrollDemoFlow light aesthetic from tempo-web */ + +.va-demo { + display: flex; + flex-direction: column; + height: calc(100vh - 53px); + outline: none; +} + +.va-demo__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 24px; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); +} + +.va-demo__label { + font-family: var(--font-mono); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-tertiary); +} + +/* ── Controls bar ── */ + +.va-controls { + padding: 12px 24px; + border-bottom: 1px solid var(--color-border); + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + background: var(--color-surface); +} + +.va-controls__label { + flex: 1; + min-width: 0; + display: flex; + align-items: baseline; + gap: 8px; +} + +.va-controls__counter { + font-family: var(--font-mono); + font-size: 12px; + color: var(--color-text-tertiary); +} + +.va-controls__step-title { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); +} + +.va-controls__buttons { + display: flex; + gap: 8px; + align-items: center; +} + +/* ── Step description ── */ + +.va-step-desc { + padding: 16px 24px; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); +} + +.va-step-desc p { + font-size: 14px; + font-weight: 400; + line-height: 1.5; + color: var(--color-text-secondary); + max-width: 640px; + margin: 0; +} + +/* ── Error banner ── */ + +.va-error { + padding: 8px 24px; + background: rgba(239, 68, 68, 0.08); + border-bottom: 1px solid rgba(239, 68, 68, 0.2); + color: var(--color-negative); + font-size: 12px; + display: flex; + align-items: center; + justify-content: space-between; +} + +/* ── Canvas wrapper ── */ + +.va-canvas-wrap { + flex: 1; + position: relative; + min-height: 0; +} + +.va-canvas-wrap .react-flow { + background-color: var(--color-bg); +} + +.va-canvas-wrap .react-flow__viewport { + overflow: visible; +} + +.va-canvas-wrap .react-flow svg { + max-width: none; + overflow: visible; +} + +.va-canvas-wrap .react-flow__controls, +.va-canvas-wrap .react-flow__minimap, +.va-canvas-wrap .react-flow__attribution { + display: none; +} + +.va-canvas-badge { + position: absolute; + bottom: 12px; + left: 16px; + display: flex; + align-items: center; + gap: 6px; + color: var(--color-text-tertiary); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.02em; + pointer-events: none; + z-index: 5; +} + +.va-canvas-badge svg { + flex-shrink: 0; +} + +/* ── Timeline ── */ + +.va-timeline { + position: relative; + padding: 12px 24px 20px; + border-top: 1px solid var(--color-border); + background: var(--color-surface); +} + +.va-timeline__steps { + display: flex; + align-items: center; + justify-content: space-between; + position: relative; +} + +.va-timeline__steps::before { + content: ""; + position: absolute; + top: 4px; + left: 5px; + right: 5px; + height: 2px; + background: var(--color-border); + z-index: 0; +} + +.va-timeline__bar { + position: absolute; + top: 4px; + left: 5px; + right: 5px; + height: 2px; + background: transparent; + z-index: 0; +} + +.va-timeline__fill { + height: 100%; + background: var(--color-text-secondary); + transition: width 0.5s ease; +} + +.va-timeline__step { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + background: none; + border: none; + cursor: pointer; + padding: 0; + z-index: 2; +} + +.va-timeline__dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--color-surface-2); + border: 2px solid var(--color-border); + transition: all 0.3s; +} + +.va-timeline__step--active .va-timeline__dot { + background: var(--color-accent); + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.15); +} + +.va-timeline__step--done .va-timeline__dot { + background: var(--color-text-secondary); + border-color: var(--color-text-secondary); +} + +.va-timeline__step-label { + font-family: var(--font-mono); + font-size: 9px; + color: var(--color-text-tertiary); + transition: color 0.3s; +} + +.va-timeline__step--active .va-timeline__step-label { + color: var(--color-accent); + font-weight: 600; +} + +.va-timeline__step:hover .va-timeline__dot { + border-color: var(--color-text-tertiary); +} + +/* ── Icon buttons (circular, like PayrollDemoFlow) ── */ + +.va-icon-btn { + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid var(--color-border); + background: var(--color-surface-2); + color: var(--color-text-tertiary); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: + background 0.15s, + color 0.15s, + border-color 0.15s; +} + +.va-icon-btn:hover { + background: var(--color-surface-3); + color: var(--color-text-secondary); + border-color: var(--color-border-active); +} + +.va-icon-btn--primary { + width: 40px; + height: 40px; + background: var(--color-accent); + color: #0a0a0a; + border-color: var(--color-accent); +} + +.va-icon-btn--primary:hover { + background: var(--color-accent-hover); + color: #0a0a0a; + border-color: var(--color-accent-hover); +} + +.va-icon-btn--primary:disabled { + opacity: 0.7; + cursor: wait; +} + +.va-icon-btn__spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(10, 10, 10, 0.3); + border-top-color: #0a0a0a; + border-radius: 50%; + animation: va-spin 0.6s linear infinite; +} + +@keyframes va-spin { + to { + transform: rotate(360deg); + } +} + +/* ── Sending dot ── */ + +.va-sending-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-accent); + margin-left: 8px; + vertical-align: middle; + animation: va-sending-pulse 1s ease infinite; +} + +@keyframes va-sending-pulse { + 0%, + 100% { + opacity: 0.3; + } + 50% { + opacity: 1; + } +} + +/* ── Node cards (potemkin style) ── */ + +.va-card { + width: 200px; + outline: 1px solid var(--color-border); + background: var(--color-surface); + font-family: var(--font-sans); + overflow: hidden; + display: inline-flex; + flex-direction: column; + transition: + opacity 0.4s, + outline-color 0.4s, + box-shadow 0.4s; +} + +.va-card[data-status="idle"] { + opacity: 0.3; +} + +.va-card[data-status="active"] { + opacity: 1; +} + +.va-card[data-status="done"] { + opacity: 0.65; +} + +.va-card:hover { + box-shadow: + 0 0 10px rgba(96, 165, 250, 0.08), + 0 0 24px rgba(96, 165, 250, 0.04); + transition: box-shadow 0.25s ease; +} + +.va-card__head { + padding: 12px 14px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; +} + +.va-card__name { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); + line-height: 1.3; +} + +.va-card__subtitle { + font-size: 11px; + font-weight: 500; + color: var(--color-text-tertiary); + margin-top: 2px; + line-height: 1.3; +} + +.va-card__footer { + display: flex; + flex-direction: column; +} + +.va-card__metric { + display: inline-flex; + justify-content: space-between; + align-items: center; + padding: 8px 14px; + background: var(--color-surface-2); +} + +.va-card__metric-label { + font-size: 12px; + color: var(--color-text-tertiary); + font-weight: 500; +} + +.va-card__metric-value { + font-size: 13px; + color: var(--color-text-primary); + font-weight: 500; + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; +} + +/* ── Handles: invisible but functional ── */ + +.va-handle { + width: 1px; + height: 1px; + background: transparent; + border: none; + min-width: 0; + min-height: 0; +} + +/* ── Tooltip ── */ + +.va-tooltip-wrap { + position: relative; +} + +.va-tooltip { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: var(--color-surface-3); + color: var(--color-text-primary); + font-size: 11px; + font-weight: 500; + padding: 6px 12px; + white-space: nowrap; + pointer-events: none; + z-index: 10; + animation: va-tooltip-in 0.15s ease; +} + +@keyframes va-tooltip-in { + from { + opacity: 0; + transform: translateX(-50%) translateY(4px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +/* ── Keyboard hint ── */ + +.va-kbd-hint { + position: absolute; + top: 12px; + left: 16px; + display: flex; + align-items: center; + gap: 4px; + z-index: 5; + pointer-events: none; + opacity: 0.45; +} + +.va-kbd-hint kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 5px; + background: var(--color-surface-2); + border: 1px solid var(--color-border); + border-bottom-width: 2px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--color-text-tertiary); + line-height: 1; +} + +.va-kbd-hint span { + font-size: 10px; + color: var(--color-text-tertiary); + margin-left: 4px; +} + +.va-kbd-hint kbd { + transition: + background 0.3s ease, + border-color 0.3s ease, + color 0.3s ease; +} + +.va-kbd-hint--active { + opacity: 1; + transition: opacity 0.05s ease; +} + +.va-kbd--pressed { + background: var(--color-accent); + border-color: var(--color-accent); + color: #0a0a0a; + transition: none; +} diff --git a/apps/virtual-addresses/src/comps/walkthrough/guide-overlay.tsx b/apps/virtual-addresses/src/comps/walkthrough/guide-overlay.tsx deleted file mode 100644 index b853ff6bf..000000000 --- a/apps/virtual-addresses/src/comps/walkthrough/guide-overlay.tsx +++ /dev/null @@ -1,440 +0,0 @@ -import * as React from 'react' -import { motion, AnimatePresence } from 'framer-motion' -import { useWalkthroughStore } from '#store/walkthrough-store' - -const STORAGE_KEY = 'virtual-addresses-guide-seen' - -type GuideStep = { - target: string - additionalTargets?: string[] - title: string - body: string - tooltip: 'right' | 'left' | 'below' -} - -const INTRO_STEPS: GuideStep[] = [ - { - target: '[data-guide="exchange"]', - title: 'Exchange — Master Registration', - body: 'The exchange registers as a virtual-address master on-chain. It mines a salt, calls the registry precompile, and receives a 4-byte masterId.', - tooltip: 'right', - }, - { - target: '[data-guide="protocol"]', - title: 'TIP-1022 Protocol Layer', - body: 'The Virtual Registry maps masterId → master address. The TIP-20 precompile detects virtual addresses and auto-forwards tokens to the registered master.', - tooltip: 'right', - }, - { - target: '[data-guide="sender"]', - title: 'Sender', - body: "A sender sends PathUSD to a virtual address. They don't need to know it's virtual — it looks like any other address.", - tooltip: 'left', - }, - { - target: '[data-guide="start-demo"]', - title: 'Ready to Go', - body: 'Click Start Demo to run the full flow with real on-chain transactions on Tempo Moderato testnet.', - tooltip: 'below', - }, -] - -const POST_DEMO_STEPS: GuideStep[] = [ - { - target: '[data-guide-section="registration"]', - title: '1. Register Virtual Master', - body: 'The exchange calls registerVirtualMaster(salt) on the registry precompile. The salt is a 32-byte value whose keccak256 hash (with the address) has 4 leading zero bytes.', - tooltip: 'right', - }, - { - target: '[data-guide-section="registration"]', - title: '2. Receive masterId', - body: 'The registry returns a 4-byte masterId derived from bytes 4-8 of the hash. This ID uniquely identifies the exchange as a virtual-address master.', - tooltip: 'right', - }, - { - target: '[data-guide-section="virtual-address"]', - title: '3. Derive Virtual Address', - body: 'Virtual addresses are derived offline: [masterId][FDFDFDFDFDFDFDFDFDFD magic][userTag]. No on-chain transaction needed — generate unlimited deposit addresses instantly.', - tooltip: 'left', - }, - { - target: '[data-guide-section="transfer"]', - title: '4. Send to Virtual Address', - body: "The sender calls transfer(virtualAddress, amount) on the TIP-20 token — a standard ERC-20 transfer. The sender doesn't need to know the address is virtual.", - tooltip: 'left', - }, - { - target: '[data-guide-section="precompile"]', - title: '5. Magic Bytes Detected', - body: 'The TIP-20 precompile checks bytes 4-14 of the recipient. If they match the FDFD…FD magic pattern, it identifies the address as virtual.', - tooltip: 'right', - }, - { - target: '[data-guide-section="precompile"]', - title: '6. Resolve masterId', - body: 'The precompile extracts the 4-byte masterId from the virtual address and looks up the registered master address in the registry.', - tooltip: 'right', - }, - { - target: '[data-guide-section="precompile"]', - title: '7. Forward to Master', - body: 'Tokens are credited directly to the master address. The virtual address balance stays at 0 — no sweep transaction needed.', - tooltip: 'right', - }, - { - target: '[data-guide-section="balance"]', - title: '8. Two Transfer Events', - body: 'Two Transfer events are emitted: Transfer(sender → virtual) and Transfer(virtual → master). This preserves the audit trail while the master receives the funds.', - tooltip: 'right', - additionalTargets: ['[data-guide-section="precompile"]'], - }, -] - -type GuideMode = 'intro' | 'post-demo' | null - -type TargetRect = { - top: number - left: number - width: number - height: number -} - -export function GuideOverlay(): React.JSX.Element | null { - const demoState = useWalkthroughStore((s) => s.demoState) - const prevDemoState = React.useRef(demoState) - const [mode, setMode] = React.useState(null) - const [stepIndex, setStepIndex] = React.useState(0) - const [targetRect, setTargetRect] = React.useState(null) - const rafRef = React.useRef(0) - - // Show intro on first visit - React.useEffect(() => { - const seen = localStorage.getItem(STORAGE_KEY) - if (!seen) { - setMode('intro') - setStepIndex(0) - } - }, []) - - // Post-demo trigger when settlement completes - React.useEffect(() => { - if (prevDemoState.current !== 'complete' && demoState === 'complete') { - setTimeout(() => { - setMode('post-demo') - setStepIndex(0) - }, 1500) - } - prevDemoState.current = demoState - }, [demoState]) - - const steps = - mode === 'intro' ? INTRO_STEPS : mode === 'post-demo' ? POST_DEMO_STEPS : [] - const step = steps[stepIndex] ?? null - - // Measure target element - const measureTarget = React.useCallback(() => { - if (!step) { - setTargetRect(null) - return - } - const el = document.querySelector(step.target) - if (!el) { - setTargetRect(null) - return - } - - const rect = el.getBoundingClientRect() - let combined = { - top: rect.top, - left: rect.left, - right: rect.right, - bottom: rect.bottom, - } - - if (step.additionalTargets) { - for (const selector of step.additionalTargets) { - const el2 = document.querySelector(selector) - if (el2) { - const rect2 = el2.getBoundingClientRect() - combined = { - top: Math.min(combined.top, rect2.top), - left: Math.min(combined.left, rect2.left), - right: Math.max(combined.right, rect2.right), - bottom: Math.max(combined.bottom, rect2.bottom), - } - } - } - } - - setTargetRect({ - top: combined.top, - left: combined.left, - width: combined.right - combined.left, - height: combined.bottom - combined.top, - }) - }, [step]) - - React.useEffect(() => { - measureTarget() - - const handleResize = () => { - cancelAnimationFrame(rafRef.current) - rafRef.current = requestAnimationFrame(measureTarget) - } - - window.addEventListener('resize', handleResize) - window.addEventListener('scroll', handleResize, true) - const interval = setInterval(measureTarget, 500) - - return () => { - window.removeEventListener('resize', handleResize) - window.removeEventListener('scroll', handleResize, true) - clearInterval(interval) - cancelAnimationFrame(rafRef.current) - } - }, [measureTarget]) - - const advance = React.useCallback(() => { - if (stepIndex < steps.length - 1) { - setStepIndex(stepIndex + 1) - } else { - setMode(null) - setStepIndex(0) - if (mode === 'intro') { - localStorage.setItem(STORAGE_KEY, 'true') - } - } - }, [stepIndex, steps.length, mode]) - - const skip = React.useCallback(() => { - setMode(null) - setStepIndex(0) - if (mode === 'intro') { - localStorage.setItem(STORAGE_KEY, 'true') - } - }, [mode]) - - if (!mode || !step) return null - - const pad = 8 - const vh = window.innerHeight - const vw = window.innerWidth - - const spot = targetRect - ? { - top: targetRect.top - pad, - left: targetRect.left - pad, - width: targetRect.width + pad * 2, - height: targetRect.height + pad * 2, - } - : null - - const tooltipWidth = 320 - const tooltipHeight = 200 - const tooltipPos: React.CSSProperties = {} - if (spot) { - if (step.tooltip === 'right') { - tooltipPos.left = spot.left + spot.width + 16 - const centerY = spot.top + spot.height / 2 - tooltipHeight / 2 - tooltipPos.top = Math.max(16, Math.min(centerY, vh - tooltipHeight - 16)) - } else if (step.tooltip === 'left') { - tooltipPos.left = spot.left - tooltipWidth - 16 - const centerY = spot.top + spot.height / 2 - tooltipHeight / 2 - tooltipPos.top = Math.max(16, Math.min(centerY, vh - tooltipHeight - 16)) - } else { - const spaceBelow = vh - (spot.top + spot.height) - if (spaceBelow > tooltipHeight) { - tooltipPos.top = spot.top + spot.height + 12 - } else { - tooltipPos.top = spot.top - tooltipHeight - 12 - } - tooltipPos.left = Math.max( - 16, - Math.min(spot.left, vw - tooltipWidth - 16), - ) - } - } else { - tooltipPos.top = '50%' - tooltipPos.left = '50%' - tooltipPos.transform = 'translate(-50%, -50%)' - } - - const isLast = stepIndex === steps.length - 1 - const isPostDemo = mode === 'post-demo' - - return ( - - - {/* Click-catcher — catches all clicks, advances step */} - - - - - - - ) -} diff --git a/apps/virtual-addresses/src/comps/walkthrough/protocol-panel.tsx b/apps/virtual-addresses/src/comps/walkthrough/protocol-panel.tsx deleted file mode 100644 index 566d0afe0..000000000 --- a/apps/virtual-addresses/src/comps/walkthrough/protocol-panel.tsx +++ /dev/null @@ -1,457 +0,0 @@ -import type * as React from 'react' -import { motion, AnimatePresence } from 'framer-motion' -import { useWalkthroughStore } from '#store/walkthrough-store' -import { AddressAnatomy } from '#comps/address-anatomy' -import { formatAddress } from '#lib/virtual-address' -import { StatusBadge } from './status-badge' - -const registrySteps = new Set([ - 'register-start', - 'register-mining', - 'register-tx', - 'register-confirmed', -]) -const precompileSteps = new Set([ - 'send-tx', - 'resolve-detect', - 'resolve-lookup', - 'resolve-forward', - 'transfer-events', -]) - -function FlowDot(props: { - active: boolean - reverse?: boolean -}): React.JSX.Element { - const { active, reverse } = props - return ( -
- {active && ( - - )} -
- ) -} - -function registryMessage(step: string): string { - switch (step) { - case 'register-start': - return 'Preparing registration…' - case 'register-mining': - return 'Mining salt — 32-bit PoW in progress…' - case 'register-tx': - return 'registerVirtualMaster(salt) — tx pending…' - case 'register-confirmed': - return 'MasterRegistered event emitted ✓' - default: - return '' - } -} - -function precompileMessage(step: string): string { - switch (step) { - case 'send-tx': - return 'transfer() received for virtual address' - case 'resolve-detect': - return 'Magic bytes detected — virtual address!' - case 'resolve-lookup': - return 'Looking up masterId → master address' - case 'resolve-forward': - return 'Forwarding tokens to master' - case 'transfer-events': - return 'Two Transfer events emitted ✓' - default: - return '' - } -} - -export function ProtocolPanel(): React.JSX.Element { - const step = useWalkthroughStore((s) => s.step) - const demoState = useWalkthroughStore((s) => s.demoState) - const data = useWalkthroughStore((s) => s.data) - - const registryActive = registrySteps.has(step) - const precompileActive = precompileSteps.has(step) - - return ( -
- {/* Header */} -
- TIP-1022 Protocol - -
- -
- {/* Virtual Registry box */} - -
- - - Virtual Registry - -
- - {/* Flow line */} -
- - Exchange - - - - ⇄ - - - - Registry - -
- - {/* Active message */} - - {registryActive && ( - - {registryMessage(step)} - - )} - - - {/* Completed items */} - {!registryActive && data.masterId && ( -
-
- ✓ Registered -
-
- {data.masterId} - {' → '} - {data.exchangeAddress ? ( - - {formatAddress(data.exchangeAddress)} - - ) : ( - '…' - )} -
-
- )} -
- - {/* TIP-20 Precompile box */} - -
- - - TIP-20 Precompile - -
- - {/* Flow line: Sender → Virtual → Master */} -
- - Sender - - - - Virtual - - - - Master - -
- - {/* Active message */} - - {precompileActive && ( - - {precompileMessage(step)} - - )} - - - {/* Virtual address anatomy */} - - {data.virtualAddress && - (step === 'resolve-detect' || - step === 'resolve-lookup' || - step === 'resolve-forward' || - step === 'transfer-events' || - step === 'balances-final' || - demoState === 'complete') && ( - -
- Resolving -
- -
- )} -
- - {/* Transfer events */} - - {(step === 'transfer-events' || - step === 'balances-final' || - demoState === 'complete') && - data.transferEvents.map((evt, i) => ( - - - Transfer - -
- - {formatAddress(evt.from as `0x${string}`)} - - {' → '} - - {formatAddress(evt.to as `0x${string}`)} - - - {' '} - {evt.amount} PathUSD - -
-
- ))} -
-
-
-
- ) -} diff --git a/apps/virtual-addresses/src/comps/walkthrough/sender-panel.tsx b/apps/virtual-addresses/src/comps/walkthrough/sender-panel.tsx deleted file mode 100644 index de50cb126..000000000 --- a/apps/virtual-addresses/src/comps/walkthrough/sender-panel.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import * as React from 'react' -import { motion, AnimatePresence } from 'framer-motion' -import { useWalkthroughStore } from '#store/walkthrough-store' -import { AddressAnatomy } from '#comps/address-anatomy' -import { StatusBadge } from './status-badge' -import { EventLog } from './event-log' -import type { EventLog as EventLogNs } from './event-log' - -const activeSteps = new Set([ - 'send-start', - 'send-tx', - 'derive-virtual', - 'derive-anatomy', -]) - -function stepMessage(step: string, txPending: boolean): string { - switch (step) { - case 'derive-virtual': - return 'Deriving virtual address…' - case 'derive-anatomy': - return 'Address structure breakdown' - case 'send-start': - return 'Preparing transfer…' - case 'send-tx': - return txPending ? 'Transaction pending…' : 'Sending PathUSD…' - default: - return 'Idle' - } -} - -export function SenderPanel(): React.JSX.Element { - const step = useWalkthroughStore((s) => s.step) - const demoState = useWalkthroughStore((s) => s.demoState) - const txPending = useWalkthroughStore((s) => s.txPending) - const data = useWalkthroughStore((s) => s.data) - - const isActive = activeSteps.has(step) - - const logEntries = React.useMemo(() => { - const entries: EventLogNs.Entry[] = [] - if ( - data.virtualAddress && - (step === 'derive-anatomy' || - step === 'send-start' || - step === 'send-tx' || - demoState === 'sending' || - demoState === 'resolving' || - demoState === 'complete') - ) { - entries.push({ - id: 'derive', - type: 'register', - message: `Virtual: ${data.virtualAddress.slice(0, 14)}…`, - }) - } - if (data.transferTxHash) { - entries.push({ - id: 'tx', - type: 'transfer', - message: `Sent 100 PathUSD to virtual`, - txHash: data.transferTxHash, - }) - } - return entries - }, [data.virtualAddress, data.transferTxHash, step, demoState]) - - return ( - - {/* Header */} -
- Sender - -
- -
- {/* Address */} -
-
- Sender Address -
- {data.senderAddress ? ( -
- {data.senderAddress} -
- ) : ( -
- )} -
- - {/* Virtual address anatomy */} - - {data.virtualAddress && - (step === 'derive-anatomy' || - step === 'send-start' || - step === 'send-tx' || - demoState === 'sending' || - demoState === 'resolving' || - demoState === 'complete') && ( - -
- Virtual Address -
- -
- )} -
- - {/* Transfer section */} -
-
- Transfer -
- -
-
-
- PathUSD -
-
- {data.senderBalance} -
-
- - - {txPending && step === 'send-tx' && ( - - - Sending to virtual address… - - )} - - - {data.transferTxHash && ( -
- ✓ Transfer confirmed -
- )} -
-
- - {/* Status */} -
- {stepMessage(step, txPending)} -
- - {/* Event log */} - {logEntries.length > 0 && } -
-
- ) -} diff --git a/apps/virtual-addresses/src/comps/walkthrough/status-badge.tsx b/apps/virtual-addresses/src/comps/walkthrough/status-badge.tsx deleted file mode 100644 index 943d0770b..000000000 --- a/apps/virtual-addresses/src/comps/walkthrough/status-badge.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type * as React from 'react' - -const stateColors: Record = { - idle: 'var(--color-text-tertiary)', - registering: 'var(--color-accent)', - deriving: 'var(--color-virtual-magic)', - sending: 'var(--color-accent)', - resolving: 'var(--color-positive)', - complete: 'var(--color-positive)', -} - -export function StatusBadge(props: StatusBadge.Props): React.JSX.Element { - const { state } = props - const color = stateColors[state] ?? 'var(--color-text-tertiary)' - - return ( - - - {state} - - ) -} - -export declare namespace StatusBadge { - type Props = { state: string } -} diff --git a/apps/virtual-addresses/src/comps/walkthrough/walkthrough-demo.tsx b/apps/virtual-addresses/src/comps/walkthrough/walkthrough-demo.tsx index a6ad4fce5..ce94a29eb 100644 --- a/apps/virtual-addresses/src/comps/walkthrough/walkthrough-demo.tsx +++ b/apps/virtual-addresses/src/comps/walkthrough/walkthrough-demo.tsx @@ -1,104 +1,286 @@ -import type * as React from 'react' -import { cx } from '#lib/css' +import { useState, useEffect, useRef, useMemo } from 'react' +import { + ReactFlow, + ReactFlowProvider, + useReactFlow, + type NodeTypes, + type EdgeTypes, +} from '@xyflow/react' +import '@xyflow/react/dist/style.css' +import './flow/styles.css' + import { useWalkthroughStore } from '#store/walkthrough-store' -import { ExchangePanel } from './exchange-panel' -import { ProtocolPanel } from './protocol-panel' -import { SenderPanel } from './sender-panel' -import { GuideOverlay } from './guide-overlay' +import type { FlowStep } from '#lib/walkthrough-types' +import { + STEPS, + STEP_PARTICIPANTS, + STEP_FOCUS, + buildNodes, + buildEdges, +} from './flow/graph-model' +import { FlowCardNode } from './flow/nodes' +import { AnimatedEdge } from './flow/animated-edge' -const SPEEDS = [0.1, 0.5, 1, 2] as const +const nodeTypes: NodeTypes = { + 'flow-card': FlowCardNode, +} -export function WalkthroughDemo(): React.JSX.Element { - const speed = useWalkthroughStore((s) => s.speed) - const demoState = useWalkthroughStore((s) => s.demoState) +const edgeTypes: EdgeTypes = { + animated: AnimatedEdge, +} + +function WalkthroughDemoInner(): React.JSX.Element { + const step = useWalkthroughStore((s) => s.step) + const isPlaying = useWalkthroughStore((s) => s.isPlaying) + const isBusy = useWalkthroughStore((s) => s.isBusy) const error = useWalkthroughStore((s) => s.error) - const setSpeed = useWalkthroughStore((s) => s.setSpeed) - const startDemo = useWalkthroughStore((s) => s.startDemo) + const phase = useWalkthroughStore((s) => s.phase) + const data = useWalkthroughStore((s) => s.data) + const advance = useWalkthroughStore((s) => s.advance) + const goToStep = useWalkthroughStore((s) => s.goToStep) + const togglePlay = useWalkthroughStore((s) => s.togglePlay) const reset = useWalkthroughStore((s) => s.reset) - const isRunning = demoState !== 'idle' && demoState !== 'complete' + const { fitView } = useReactFlow() + const containerRef = useRef(null) + const [activeKey, setActiveKey] = useState(null) + const activeKeyTimer = useRef | null>(null) + + const currentStep = STEPS[step] + const isComplete = step >= 6 + + // Build nodes and edges + const nodes = useMemo(() => buildNodes(step, data), [step, data]) + const edges = useMemo( + () => buildEdges(step, data, phase), + [step, data, phase], + ) + + // At step 0 only show exchange node. Otherwise apply dimming. + const dimmedNodes = useMemo(() => { + if (step === 0) return nodes.filter((n) => n.id === 'exchange') + const participants = STEP_PARTICIPANTS[step] + if (!participants) return nodes + return nodes.map((n) => { + if ( + participants.includes(n.id) || + n.data.status === 'active' || + n.data.status === 'done' + ) + return n + return { + ...n, + data: { + ...n.data, + status: 'idle' as const, + }, + } + }) + }, [nodes, step]) + + const visibleEdges = useMemo(() => (step === 0 ? [] : edges), [step, edges]) + + // Fit on mount + useEffect(() => { + const t = setTimeout( + () => + fitView({ + nodes: [{ id: 'exchange' }], + padding: 1.2, + duration: 400, + }), + 60, + ) + return () => clearTimeout(t) + }, [fitView]) + + // Zoom to focus nodes on step change + useEffect(() => { + const focus = STEP_FOCUS[step] + const t = setTimeout(() => { + if (focus) { + fitView({ + nodes: focus.nodes.map((id) => ({ id })), + padding: focus.padding, + duration: 600, + }) + } else { + fitView({ padding: 0.15, duration: 600 }) + } + }, 80) + return () => clearTimeout(t) + }, [step, fitView]) + + // Keyboard navigation + useEffect(() => { + const flash = (key: string) => { + if (activeKeyTimer.current) clearTimeout(activeKeyTimer.current) + setActiveKey(key) + activeKeyTimer.current = setTimeout(() => setActiveKey(null), 1000) + } + + const onDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return + + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + e.preventDefault() + flash('right') + if (!isBusy && step < 6) advance() + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + e.preventDefault() + flash('left') + if (!isBusy && step > 0) { + goToStep(Math.max(step - 1, 0) as FlowStep) + } + } else if (e.key === ' ') { + e.preventDefault() + flash('space') + if (!isBusy) togglePlay() + } + } + + window.addEventListener('keydown', onDown) + return () => { + window.removeEventListener('keydown', onDown) + if (activeKeyTimer.current) clearTimeout(activeKeyTimer.current) + } + }, [advance, togglePlay, isBusy, step, goToStep]) return ( -
- {/* Control bar */} -
- {/* Speed selector */} -
- - Speed +
+ {/* Demo label header */} +
+ + DEMO — TIP-1022 Virtual Address Resolution + {isBusy && } + +
+ + {/* Controls bar */} +
+ {step > 0 ? ( + + + {step}/{STEPS.length - 1} + + {currentStep.label} + {isBusy && } - {SPEEDS.map((s) => ( + ) : ( + + )} + +
+ {step > 0 && !isComplete && ( - ))} + )} + {isPlaying ? ( + + ) : isComplete ? ( + + ) : ( + + )}
+
- {/* Actions */} -
- - + {/* Step description */} + {step > 0 && ( +
+

{currentStep.description}

-
+ )} {/* Error banner */} {error && ( -
+
{error} + ))} +
+
) } + +export function WalkthroughDemo(): React.JSX.Element { + return ( + + + + ) +} diff --git a/apps/virtual-addresses/src/lib/walkthrough-types.ts b/apps/virtual-addresses/src/lib/walkthrough-types.ts index 1eee90a80..efa364f19 100644 --- a/apps/virtual-addresses/src/lib/walkthrough-types.ts +++ b/apps/virtual-addresses/src/lib/walkthrough-types.ts @@ -1,29 +1,8 @@ import type { Address, Hex } from 'viem' -export type DemoState = - | 'idle' - | 'registering' - | 'deriving' - | 'sending' - | 'resolving' - | 'complete' +export type FlowStep = 0 | 1 | 2 | 3 | 4 | 5 | 6 -export type DemoStep = - | 'idle' - | 'register-start' - | 'register-mining' - | 'register-tx' - | 'register-confirmed' - | 'derive-virtual' - | 'derive-anatomy' - | 'send-start' - | 'send-tx' - | 'resolve-detect' - | 'resolve-lookup' - | 'resolve-forward' - | 'transfer-events' - | 'balances-final' - | 'complete' +export type NodeStatus = 'idle' | 'active' | 'done' export type TransferEvent = { from: string @@ -33,18 +12,11 @@ export type TransferEvent = { txHash?: string } -export type MiningProgress = { - totalAttempts: number - hashesPerSecond: number - workerCount: number -} - export type WalkthroughData = { exchangeAddress: Address | null senderAddress: Address | null salt: Hex | null masterId: Hex | null - miningProgress: MiningProgress | null virtualAddress: Address | null userTag: Hex | null registerTxHash: string | null @@ -54,3 +26,9 @@ export type WalkthroughData = { virtualBalance: string transferEvents: TransferEvent[] } + +export type StepDef = { + id: FlowStep + label: string + description: string +} diff --git a/apps/virtual-addresses/src/store/walkthrough-store.ts b/apps/virtual-addresses/src/store/walkthrough-store.ts index a33ea87bf..701e9dfb2 100644 --- a/apps/virtual-addresses/src/store/walkthrough-store.ts +++ b/apps/virtual-addresses/src/store/walkthrough-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import type { Hex } from 'viem' +import type { FlowStep, WalkthroughData } from '#lib/walkthrough-types' import { buildVirtualAddress, randomUserTag } from '#lib/virtual-address' import { demoRegister, @@ -7,24 +8,17 @@ import { demoBalance, demoFund, } from '#lib/demo-client' -import type { - DemoState, - DemoStep, - WalkthroughData, -} from '#lib/walkthrough-types' -// Pre-mined salt for the demo exchange account const DEMO_SALT: Hex = '0x45864ef08bed66119277f37508c74bf955512a70eef5f96000000000bcb326b6' -let stepTimer: ReturnType | null = null +const TOTAL_STEPS = 7 const initialData: WalkthroughData = { exchangeAddress: null, senderAddress: null, salt: null, masterId: null, - miningProgress: null, virtualAddress: null, userTag: null, registerTxHash: null, @@ -36,218 +30,201 @@ const initialData: WalkthroughData = { } type WalkthroughStore = { - step: DemoStep - demoState: DemoState + step: FlowStep + prevStep: FlowStep + isPlaying: boolean + isBusy: boolean speed: number - txPending: boolean error: string | null + phase: string | null data: WalkthroughData - startDemo: () => void - setSpeed: (speed: number) => void + advance: () => Promise + goToStep: (target: FlowStep) => void + togglePlay: () => void reset: () => void } -function clearTimer() { - if (stepTimer !== null) { - clearTimeout(stepTimer) - stepTimer = null - } -} - export const useWalkthroughStore = create((set, get) => { - function scheduleNext(delay: number) { - clearTimer() - const { speed } = get() - stepTimer = setTimeout(() => advanceStep(), delay / speed) - } + let playingRef = false + let cancelRef = false - async function fetchBalances() { - const { data } = get() + async function executeStep(targetStep: FlowStep): Promise { + set({ isBusy: true, error: null }) try { - const result = await demoBalance(data.virtualAddress ?? undefined) - set((s) => ({ - data: { - ...s.data, - exchangeBalance: result.exchange, - senderBalance: result.sender, - virtualBalance: result.virtual, - exchangeAddress: result.exchangeAddress ?? s.data.exchangeAddress, - senderAddress: result.senderAddress ?? s.data.senderAddress, - }, - })) - } catch { - // Node unreachable - } - } - - async function advanceStep() { - const { step } = get() - - switch (step) { - case 'idle': { - await demoFund().catch(() => {}) - await fetchBalances() - set({ step: 'register-start', demoState: 'registering' }) - scheduleNext(1500) - break - } - - case 'register-start': { - set((s) => ({ - step: 'register-tx', - txPending: true, - error: null, - data: { ...s.data, salt: DEMO_SALT }, - })) - try { - const result = await demoRegister(DEMO_SALT) + switch (targetStep) { + case 1: { + set({ phase: 'Funding accounts…' }) + await demoFund().catch(() => {}) + set({ phase: 'Registering master…' }) + const reg = await demoRegister(DEMO_SALT) + const balances = await demoBalance().catch(() => null) set((s) => ({ - step: 'register-confirmed', - txPending: false, data: { ...s.data, - registerTxHash: result.txHash, - masterId: result.masterId, - exchangeAddress: result.exchangeAddress, + salt: DEMO_SALT, + masterId: reg.masterId as Hex, + exchangeAddress: reg.exchangeAddress as `0x${string}`, + registerTxHash: reg.txHash, + ...(balances + ? { + exchangeBalance: balances.exchange, + senderBalance: balances.sender, + senderAddress: + (balances.senderAddress as `0x${string}` | null) ?? null, + } + : {}), }, })) - scheduleNext(1500) - } catch (e) { - set({ - txPending: false, - error: e instanceof Error ? e.message : 'Register failed', - }) + break } - break - } - - case 'register-mining': - case 'register-tx': - break - - case 'register-confirmed': { - set({ step: 'derive-virtual', demoState: 'deriving' }) - scheduleNext(1200) - break - } - - case 'derive-virtual': { - const { data: d } = get() - if (d.masterId) { - const userTag = randomUserTag() - const virtualAddress = buildVirtualAddress(d.masterId, userTag) - set((s) => ({ - data: { ...s.data, userTag, virtualAddress }, - })) + case 2: { + set({ phase: 'Deriving address…' }) + const { data: d } = get() + if (d.masterId) { + const userTag = randomUserTag() + const virtualAddress = buildVirtualAddress(d.masterId, userTag) + set((s) => ({ + data: { ...s.data, userTag, virtualAddress }, + })) + } + break } - set({ step: 'derive-anatomy' }) - scheduleNext(2000) - break - } - - case 'derive-anatomy': { - set({ step: 'send-start', demoState: 'sending' }) - scheduleNext(1200) - break - } - - case 'send-start': { - const { data: d } = get() - if (!d.virtualAddress) break - set({ step: 'send-tx', txPending: true, error: null }) - try { - const result = await demoTransfer(d.virtualAddress, '100') - const transferEvents = result.events.map((e, i) => ({ + case 3: { + set({ phase: 'Sending PathUSD…' }) + const { data: d } = get() + if (!d.virtualAddress) break + const tx = await demoTransfer(d.virtualAddress, '100') + const events = tx.events.map((e, i) => ({ ...e, - label: i === 0 ? 'sender → virtual' : 'virtual → exchange', - txHash: result.txHash, + label: i === 0 ? 'sender → virtual' : 'virtual → master', + txHash: tx.txHash, })) set((s) => ({ - txPending: false, data: { ...s.data, - transferTxHash: result.txHash, - transferEvents, + transferTxHash: tx.txHash, + transferEvents: events, }, })) - set({ step: 'resolve-detect', demoState: 'resolving' }) - scheduleNext(1200) - } catch (e) { - set({ - txPending: false, - error: e instanceof Error ? e.message : 'Transfer failed', - }) + break } - break - } - - case 'resolve-detect': { - set({ step: 'resolve-lookup' }) - scheduleNext(1200) - break - } - - case 'resolve-lookup': { - set({ step: 'resolve-forward' }) - scheduleNext(1200) - break + case 4: { + set({ phase: 'Resolving virtual address…' }) + await new Promise((r) => setTimeout(r, 800)) + break + } + case 5: { + set({ phase: 'Updating balances…' }) + const { data: d } = get() + const balances = await demoBalance(d.virtualAddress ?? undefined) + set((s) => ({ + data: { + ...s.data, + exchangeBalance: balances.exchange, + senderBalance: balances.sender, + virtualBalance: balances.virtual, + }, + })) + break + } + default: + break } + } catch (e) { + set({ error: e instanceof Error ? e.message : 'Step failed' }) + } finally { + set({ isBusy: false, phase: null }) + } + } - case 'resolve-forward': { - set({ step: 'transfer-events' }) - scheduleNext(1500) - break - } + async function runAutoplay() { + const { step: startStep } = get() + let current = startStep === 0 || startStep >= 6 ? 0 : startStep - case 'transfer-events': { - await fetchBalances() - set({ step: 'balances-final' }) - scheduleNext(2000) - break - } + if (current === 0) { + set({ + prevStep: 0 as FlowStep, + step: 1 as FlowStep, + }) + current = 1 + await executeStep(1 as FlowStep) + await new Promise((r) => setTimeout(r, 1200)) + } - case 'balances-final': { - set({ step: 'complete', demoState: 'complete' }) - break + while (!cancelRef && playingRef && current < TOTAL_STEPS - 1) { + const next = (current + 1) as FlowStep + set({ prevStep: current as FlowStep, step: next }) + if (next >= 1 && next <= 5) await executeStep(next) + current = next + if (current < TOTAL_STEPS - 1) { + await new Promise((r) => setTimeout(r, 1200)) } + } - case 'complete': - break + if (!cancelRef) { + set({ isPlaying: false }) + playingRef = false } } return { - step: 'idle', - demoState: 'idle', + step: 0 as FlowStep, + prevStep: 0 as FlowStep, + isPlaying: false, + isBusy: false, speed: 1, - txPending: false, error: null, + phase: null, data: { ...initialData }, - startDemo() { - clearTimer() + async advance() { + const { step, isBusy } = get() + if (isBusy || step >= 6) return + const next = Math.min(step + 1, 6) as FlowStep + set({ prevStep: step, step: next }) + if (next >= 1 && next <= 5) { + await executeStep(next) + } + }, + + goToStep(target: FlowStep) { + const { isBusy, step } = get() + if (isBusy) return set({ - step: 'idle', - demoState: 'idle', - txPending: false, - error: null, - data: { ...initialData }, + prevStep: step, + step: target, + isPlaying: false, }) - advanceStep() + playingRef = false + for (let s = 1; s <= Math.min(target, 5); s++) { + executeStep(s as FlowStep) + } }, - setSpeed(speed: number) { - set({ speed }) + togglePlay() { + const { isPlaying } = get() + if (isPlaying) { + set({ isPlaying: false }) + playingRef = false + } else { + cancelRef = false + playingRef = true + set({ isPlaying: true }) + runAutoplay() + } }, reset() { - clearTimer() + playingRef = false + cancelRef = true set({ - step: 'idle', - demoState: 'idle', + step: 0 as FlowStep, + prevStep: 0 as FlowStep, + isPlaying: false, + isBusy: false, speed: 1, - txPending: false, error: null, + phase: null, data: { ...initialData }, }) }, diff --git a/biome.json b/biome.json index df3db769a..3f2fd75e7 100644 --- a/biome.json +++ b/biome.json @@ -138,6 +138,19 @@ } } } + }, + { + "includes": ["./apps/virtual-addresses/src/comps/walkthrough/**"], + "linter": { + "rules": { + "correctness": { + "useUniqueElementIds": "off" + }, + "a11y": { + "noSvgWithoutTitle": "off" + } + } + } } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9a71cf18..319410221 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ catalogs: '@wagmi/core': specifier: ^3.4.0 version: 3.4.0 + '@xyflow/react': + specifier: ^12.6.5 + version: 12.10.2 abitype: specifier: ^1.2.3 version: 1.2.3 @@ -156,6 +159,9 @@ catalogs: globals: specifier: ^17.4.0 version: 17.4.0 + gsap: + specifier: ^3.12.7 + version: 3.15.0 hash-wasm: specifier: ^4.12.0 version: 4.12.0 @@ -256,7 +262,7 @@ importers: version: 0.3.6 '@tempoxyz/lints': specifier: github:tempoxyz/lints - version: https://codeload.github.com/tempoxyz/lints/tar.gz/1647d724f9bca135d8c529ddb5ccd7ec7954639e + version: https://codeload.github.com/tempoxyz/lints/tar.gz/0dbe62767b6e15963a652f5476bc0b8ae111d6fc '@typescript/native-preview': specifier: ^7.0.0-dev.20260319.1 version: 7.0.0-dev.20260319.1 @@ -780,9 +786,15 @@ importers: '@tanstack/react-query': specifier: 'catalog:' version: 5.91.2(react@19.2.4) + '@xyflow/react': + specifier: 'catalog:' + version: 12.10.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) framer-motion: specifier: 'catalog:' version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + gsap: + specifier: 'catalog:' + version: 3.15.0 hash-wasm: specifier: 'catalog:' version: 4.12.0 @@ -3348,8 +3360,8 @@ packages: peerDependencies: vue: ^2.7.0 || ^3.0.0 - '@tempoxyz/lints@https://codeload.github.com/tempoxyz/lints/tar.gz/1647d724f9bca135d8c529ddb5ccd7ec7954639e': - resolution: {tarball: https://codeload.github.com/tempoxyz/lints/tar.gz/1647d724f9bca135d8c529ddb5ccd7ec7954639e} + '@tempoxyz/lints@https://codeload.github.com/tempoxyz/lints/tar.gz/0dbe62767b6e15963a652f5476bc0b8ae111d6fc': + resolution: {tarball: https://codeload.github.com/tempoxyz/lints/tar.gz/0dbe62767b6e15963a652f5476bc0b8ae111d6fc} version: 0.1.1 engines: {node: '>=18'} hasBin: true @@ -3372,6 +3384,24 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -3688,6 +3718,15 @@ packages: typescript: optional: true + '@xyflow/react@12.10.2': + resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@xyflow/system@0.0.76': + resolution: {integrity: sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==} + abitype@1.2.3: resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} peerDependencies: @@ -4017,6 +4056,9 @@ packages: cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -4156,6 +4198,44 @@ packages: typescript: optional: true + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -4711,6 +4791,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gsap@3.15.0: + resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==} + guess-json-indent@3.0.1: resolution: {integrity: sha512-LWZ3Vr8BG7DHE3TzPYFqkhjNRw4vYgFSsv2nfMuHklAlOfiy54/EwiDQuQfFVLxENCVv20wpbjfTayooQHrEhQ==} engines: {node: '>=18.18.0'} @@ -6734,6 +6817,21 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zustand@5.0.0: resolution: {integrity: sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==} engines: {node: '>=12.20.0'} @@ -9226,7 +9324,7 @@ snapshots: '@tanstack/virtual-core': 3.13.23 vue: 3.5.30(typescript@5.9.3) - '@tempoxyz/lints@https://codeload.github.com/tempoxyz/lints/tar.gz/1647d724f9bca135d8c529ddb5ccd7ec7954639e': + '@tempoxyz/lints@https://codeload.github.com/tempoxyz/lints/tar.gz/0dbe62767b6e15963a652f5476bc0b8ae111d6fc': dependencies: '@ast-grep/cli': 0.40.5 commander: 12.1.0 @@ -9251,6 +9349,27 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -9568,6 +9687,29 @@ snapshots: - react - use-sync-external-store + '@xyflow/react@12.10.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@xyflow/system': 0.0.76 + classcat: 5.0.5 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + zustand: 4.5.7(@types/react@19.2.14)(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - immer + + '@xyflow/system@0.0.76': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + abitype@1.2.3(typescript@5.9.3)(zod@4.3.6): optionalDependencies: typescript: 5.9.3 @@ -9899,6 +10041,8 @@ snapshots: cjs-module-lexer@1.4.3: {} + classcat@5.0.5: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -10032,6 +10176,42 @@ snapshots: optionalDependencies: typescript: 5.9.3 + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + data-uri-to-buffer@4.0.1: {} data-uri-to-buffer@6.0.2: {} @@ -10574,6 +10754,8 @@ snapshots: graceful-fs@4.2.11: {} + gsap@3.15.0: {} + guess-json-indent@3.0.1: {} h3@2.0.1-rc.16: @@ -12868,6 +13050,13 @@ snapshots: zod@4.3.6: {} + zustand@4.5.7(@types/react@19.2.14)(react@19.2.4): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 + zustand@5.0.0(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): optionalDependencies: '@types/react': 19.2.14 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 52d13af33..005d867a8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ catalog: '@biomejs/biome': ^2.4.8 '@cloudflare/puppeteer': ^1.0.6 '@cloudflare/vite-plugin': ^1.29.1 + '@xyflow/react': ^12.6.5 '@cloudflare/vitest-pool-workers': ^0.13.2 '@cloudflare/workers-types': ^4.20260317.1 '@hono/zod-validator': ^0.7.6 @@ -54,6 +55,7 @@ catalog: esbuild: ^0.27.4 framer-motion: ^12.38.0 globals: ^17.4.0 + gsap: ^3.12.7 hash-wasm: ^4.12.0 hono: ^4.12.8 hono-rate-limiter: ^0.5.3