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 */}
-
-
- {/* Spotlight */}
- {spot ? (
-
- ) : (
-
- )}
-
- {/* Tooltip */}
-
-
- {isPostDemo ? 'How it works' : 'Tour'} — {stepIndex + 1} /{' '}
- {steps.length}
-
-
-
- {step.title}
-
-
-
- {step.body}
-
-
-
-
-
-
-
-
-
- )
-}
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}