From 6f370ed142d14683c633b39ac9ab0142d9ae7e58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= Date: Mon, 6 Jul 2026 17:33:45 -0700 Subject: [PATCH 1/4] add diagram ReactFlow implementation + New Offchain.io styling --- package.json | 2 + .../DrawioReactFlow/ClickableNode.tsx | 161 ++ .../DrawioReactFlow/DiagramHoverModal.tsx | 115 ++ .../DrawioReactFlow/DiagramModal.tsx | 147 ++ src/components/DrawioReactFlow/HoverEdge.tsx | 62 + src/components/DrawioReactFlow/ImageNode.tsx | 28 + .../DrawioReactFlow/NavigableDiagram.tsx | 360 ++++ src/components/DrawioReactFlow/README.md | 283 +++ .../DrawioReactFlow/SubgraphNode.tsx | 20 + src/components/DrawioReactFlow/index.tsx | 27 + src/components/DrawioReactFlow/types.ts | 80 + .../DrawioReactFlow/utils/convertDiagram.ts | 20 + .../utils/drawioToReactFlow.ts | 617 ++++++ .../utils/excalidrawToReactFlow.ts | 412 ++++ .../DrawioReactFlow/utils/parseManifest.ts | 88 + src/css/custom.scss | 2 + src/css/partials/_diagram-hover-modal.scss | 164 ++ src/css/partials/_drawio-reactflow.scss | 525 +++++ .../haw-transaction-lifecycle.excalidraw | 1817 +++++++++++++++++ .../haw-transaction-lifecycle.manifest.xml | 11 + yarn.lock | 136 +- 21 files changed, 5071 insertions(+), 6 deletions(-) create mode 100644 src/components/DrawioReactFlow/ClickableNode.tsx create mode 100644 src/components/DrawioReactFlow/DiagramHoverModal.tsx create mode 100644 src/components/DrawioReactFlow/DiagramModal.tsx create mode 100644 src/components/DrawioReactFlow/HoverEdge.tsx create mode 100644 src/components/DrawioReactFlow/ImageNode.tsx create mode 100644 src/components/DrawioReactFlow/NavigableDiagram.tsx create mode 100644 src/components/DrawioReactFlow/README.md create mode 100644 src/components/DrawioReactFlow/SubgraphNode.tsx create mode 100644 src/components/DrawioReactFlow/index.tsx create mode 100644 src/components/DrawioReactFlow/types.ts create mode 100644 src/components/DrawioReactFlow/utils/convertDiagram.ts create mode 100644 src/components/DrawioReactFlow/utils/drawioToReactFlow.ts create mode 100644 src/components/DrawioReactFlow/utils/excalidrawToReactFlow.ts create mode 100644 src/components/DrawioReactFlow/utils/parseManifest.ts create mode 100644 src/css/partials/_diagram-hover-modal.scss create mode 100644 src/css/partials/_drawio-reactflow.scss create mode 100644 static/diagrams/haw-transaction-lifecycle.excalidraw create mode 100644 static/diagrams/haw-transaction-lifecycle.manifest.xml diff --git a/package.json b/package.json index 73655a1075..623f76d57d 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "got": "15.1.0", "marked": "^18.0.5", "mermaid": "^11.16.0", + "motion": "^12.23.24", "posthog-docusaurus": "^2.0.5", "posthog-js": "^1.396.6", "prism-react-renderer": "^2.4.1", @@ -83,6 +84,7 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-syntax-highlighter": "^16.1.1", + "reactflow": "^11.11.4", "rehype-katex": "7", "remark-math": "6", "sass": "^1.101.0", diff --git a/src/components/DrawioReactFlow/ClickableNode.tsx b/src/components/DrawioReactFlow/ClickableNode.tsx new file mode 100644 index 0000000000..bf1da2a62c --- /dev/null +++ b/src/components/DrawioReactFlow/ClickableNode.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { NodeProps, Handle, Position } from 'reactflow'; +import { NodeData } from './types'; +import { DiagramHoverModal } from './DiagramHoverModal'; + +export function ClickableNode({ data }: NodeProps) { + const { label, shape, link, centerable, colorToken, onNavigate, hoverContentComponent } = data; + const isClickable = !!link || !!centerable; + + const handleClick = () => { + if (link && onNavigate) { + onNavigate(link); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (isClickable && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + if (link && onNavigate) { + onNavigate(link); + } + } + }; + + // Resolve text alignment — explicit NodeData hints win, then the legacy `topAligned` + // shortcut from drawio, then default (centered) + const alignItems: React.CSSProperties['alignItems'] = + data.verticalAlign === 'top' || data.topAligned + ? 'flex-start' + : data.verticalAlign === 'bottom' + ? 'flex-end' + : 'center'; + const justifyContent: React.CSSProperties['justifyContent'] = + data.textAlign === 'left' ? 'flex-start' : data.textAlign === 'right' ? 'flex-end' : 'center'; + const paddingTop = alignItems === 'flex-start' ? '4px' : undefined; + const paddingBottom = alignItems === 'flex-end' ? '4px' : undefined; + const paddingLeft = justifyContent === 'flex-start' ? '6px' : undefined; + const paddingRight = justifyContent === 'flex-end' ? '6px' : undefined; + + const baseStyle: React.CSSProperties = { + cursor: isClickable ? 'pointer' : 'default', + alignItems, + justifyContent, + ...(paddingTop ? { paddingTop } : {}), + ...(paddingBottom ? { paddingBottom } : {}), + ...(paddingLeft ? { paddingLeft } : {}), + ...(paddingRight ? { paddingRight } : {}), + }; + + // Apply shape-specific transforms + let shapeStyle: React.CSSProperties = {}; + if (shape === 'diamond') { + shapeStyle = { + transform: 'rotate(45deg)', + }; + } + + const contentStyle: React.CSSProperties = + shape === 'diamond' ? { transform: 'rotate(-45deg)' } : {}; + + const labelContent = hoverContentComponent ? ( + {label} + ) : ( + label + ); + + return ( +
+ {/* Connection handles — 4 sides, 3 positions per side (20/50/80%), plus legacy + midpoint-only ids (e.g. "top-source") for the draw.io converter that doesn't + emit along-side offsets. */} + {/* Top — legacy midpoint */} + + + {/* Top — 20/50/80 */} + + + + + + + + {/* Right — legacy midpoint */} + + + {/* Right — 20/50/80 */} + + + + + + + + {/* Bottom — legacy midpoint */} + + + {/* Bottom — 20/50/80 */} + + + + + + + + {/* Left — legacy midpoint */} + + + {/* Left — 20/50/80 */} + + + + + + + +
+ {labelContent} +
+
+ ); +} diff --git a/src/components/DrawioReactFlow/DiagramHoverModal.tsx b/src/components/DrawioReactFlow/DiagramHoverModal.tsx new file mode 100644 index 0000000000..fcd00ea028 --- /dev/null +++ b/src/components/DrawioReactFlow/DiagramHoverModal.tsx @@ -0,0 +1,115 @@ +import React, { useState } from 'react'; +import { + useFloating, + useHover, + useFocus, + useDismiss, + useRole, + useInteractions, + FloatingPortal, + offset, + flip, + shift, + autoUpdate, + useMergeRefs, +} from '@floating-ui/react'; +import { MDXProvider } from '@mdx-js/react'; +type MdxChildrenProps = { children?: React.ReactNode }; +type MdxAnchorProps = MdxChildrenProps & { href?: string }; + +const mdxComponents = { + h1: ({ children }: MdxChildrenProps) => ( +

{children}

+ ), + h2: ({ children }: MdxChildrenProps) => ( +

{children}

+ ), + h3: ({ children }: MdxChildrenProps) => ( +

{children}

+ ), + h4: ({ children }: MdxChildrenProps) => ( +

{children}

+ ), + p: ({ children }: MdxChildrenProps) => ( +

{children}

+ ), + ul: ({ children }: MdxChildrenProps) => , + ol: ({ children }: MdxChildrenProps) =>
    {children}
, + li: ({ children }: MdxChildrenProps) => ( +
  • {children}
  • + ), + strong: ({ children }: MdxChildrenProps) => ( + {children} + ), + code: ({ children }: MdxChildrenProps) => ( + {children} + ), + a: ({ children, href }: MdxAnchorProps) => ( + + {children} + + ), +}; + +interface DiagramHoverModalProps { + ContentComponent: React.ComponentType; + children: React.ReactNode; +} + +export function DiagramHoverModal({ ContentComponent, children }: DiagramHoverModalProps) { + const [isOpen, setIsOpen] = useState(false); + + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + middleware: [offset(10), flip({ fallbackAxisSideDirection: 'start' }), shift({ padding: 5 })], + whileElementsMounted: autoUpdate, + }); + + const hover = useHover(context, { + move: false, + delay: { open: 150, close: 150 }, + }); + const focus = useFocus(context); + const dismiss = useDismiss(context); + const role = useRole(context, { role: 'dialog' }); + + const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role]); + + const ref = useMergeRefs([refs.setReference]); + + return ( + <> + + {children} + + + {isOpen && ( + +
    +
    + +
    +
    + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any -- Docusaurus bundles conflicting @types/react versions */} + + + +
    +
    +
    + )} + + ); +} diff --git a/src/components/DrawioReactFlow/DiagramModal.tsx b/src/components/DrawioReactFlow/DiagramModal.tsx new file mode 100644 index 0000000000..be2f456bec --- /dev/null +++ b/src/components/DrawioReactFlow/DiagramModal.tsx @@ -0,0 +1,147 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import ReactFlow, { ReactFlowProvider } from 'reactflow'; +import { motion, AnimatePresence } from 'motion/react'; +import { ClickableNode } from './ClickableNode'; +import { SubgraphNode } from './SubgraphNode'; +import { ImageNode } from './ImageNode'; +import { HoverEdge } from './HoverEdge'; +import { convertDiagramToReactFlow } from './utils/convertDiagram'; +import { ReactFlowData } from './types'; + +interface DiagramModalProps { + diagramFile: string; + title?: string; + onClose: () => void; +} + +const nodeTypes = { custom: ClickableNode, group: SubgraphNode, image: ImageNode }; +const edgeTypes = { hoverEdge: HoverEdge }; +const defaultEdgeOptions = { + style: { strokeWidth: 1.5, stroke: 'rgba(18, 170, 255, 0.6)' }, + animated: false, +}; + +export function DiagramModal({ diagramFile, title, onClose }: DiagramModalProps) { + const [flowData, setFlowData] = useState({ nodes: [], edges: [] }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const closeButtonRef = useRef(null); + + useEffect(() => { + const controller = new AbortController(); + const load = async () => { + setLoading(true); + setError(null); + try { + const response = await fetch(diagramFile, { signal: controller.signal }); + if (!response.ok) throw new Error(`Failed to load diagram: ${diagramFile}`); + const rawContent = await response.text(); + // Nested navigation is disabled inside the modal — pass a no-op handler. + const data = await convertDiagramToReactFlow( + rawContent, + diagramFile, + () => undefined, + undefined, + ); + if (!controller.signal.aborted) setFlowData(data); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + if (!controller.signal.aborted) setLoading(false); + } + }; + load(); + return () => controller.abort(); + }, [diagramFile]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', onKey); + closeButtonRef.current?.focus(); + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.removeEventListener('keydown', onKey); + document.body.style.overflow = prevOverflow; + }; + }, [onClose]); + + if (typeof document === 'undefined') return null; + + return createPortal( + + { + if (e.target === e.currentTarget) onClose(); + }} + role="presentation" + > + +
    + {title &&

    {title}

    } + +
    +
    + {loading &&
    Loading diagram…
    } + {error && ( +
    + Error: {error} +
    + )} + {!loading && !error && ( + + + + )} +
    +
    +
    +
    , + document.body, + ); +} diff --git a/src/components/DrawioReactFlow/HoverEdge.tsx b/src/components/DrawioReactFlow/HoverEdge.tsx new file mode 100644 index 0000000000..6fbe06c810 --- /dev/null +++ b/src/components/DrawioReactFlow/HoverEdge.tsx @@ -0,0 +1,62 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import React from 'react'; +import { EdgeProps, getSmoothStepPath, EdgeLabelRenderer } from 'reactflow'; +import { DiagramHoverModal } from './DiagramHoverModal'; +import { EdgeData } from './types'; + +export function HoverEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + label, + style = {}, + markerEnd, + data, +}: EdgeProps) { + const [edgePath, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }); + + const ContentComponent = data?.hoverContentComponent; + + return ( + <> + + {label && ( + +
    + {ContentComponent ? ( + + {label} + + ) : ( + {label} + )} +
    +
    + )} + + ); +} diff --git a/src/components/DrawioReactFlow/ImageNode.tsx b/src/components/DrawioReactFlow/ImageNode.tsx new file mode 100644 index 0000000000..3e88ef5182 --- /dev/null +++ b/src/components/DrawioReactFlow/ImageNode.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { NodeProps } from 'reactflow'; + +interface ImageNodeData { + imageUrl: string; +} + +export function ImageNode({ data }: NodeProps) { + return ( +
    + +
    + ); +} diff --git a/src/components/DrawioReactFlow/NavigableDiagram.tsx b/src/components/DrawioReactFlow/NavigableDiagram.tsx new file mode 100644 index 0000000000..14ea99f877 --- /dev/null +++ b/src/components/DrawioReactFlow/NavigableDiagram.tsx @@ -0,0 +1,360 @@ +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import ReactFlow, { ReactFlowProvider, useReactFlow, Node as RFNode, Viewport } from 'reactflow'; +import { useColorMode } from '@docusaurus/theme-common'; +import { motion, AnimatePresence } from 'motion/react'; +import 'reactflow/dist/style.css'; +import { ClickableNode } from './ClickableNode'; +import { SubgraphNode } from './SubgraphNode'; +import { ImageNode } from './ImageNode'; +import { HoverEdge } from './HoverEdge'; +import { DiagramModal } from './DiagramModal'; +import { convertDiagramToReactFlow } from './utils/convertDiagram'; +import { fetchAndParseManifest } from './utils/parseManifest'; +import { ReactFlowData, DiagramProps, NodeData, ManifestData, TransitionConfig } from './types'; + +function useSafeColorMode() { + try { + const { colorMode } = useColorMode(); + return colorMode; + } catch (error) { + return 'light'; + } +} + +function DiagramFlow({ + flowData, + nodeTypes, + edgeTypes, + defaultEdgeOptions, + onDiagramNavigate, + onModalRequest, + transitions, +}: { + flowData: ReactFlowData; + nodeTypes: Record>; + edgeTypes: Record>; + defaultEdgeOptions: Record; + onDiagramNavigate?: (diagramPath: string) => void; + onModalRequest?: (transition: TransitionConfig) => void; + transitions?: TransitionConfig[]; +}) { + const { setCenter, setViewport, getViewport } = useReactFlow(); + const viewportHistory = useRef([]); + const pendingNavTimer = useRef | null>(null); + const [canGoBack, setCanGoBack] = useState(false); + + useEffect(() => { + return () => { + if (pendingNavTimer.current !== null) { + clearTimeout(pendingNavTimer.current); + } + }; + }, []); + + const handleNodeClick = useCallback( + (_event: React.MouseEvent, node: RFNode) => { + if (!node.data?.centerable) return; + + const transConfig = transitions?.find((t) => t.trigger === node.data?.label); + + if (transConfig?.mode === 'modal' && onModalRequest) { + onModalRequest(transConfig); + return; + } + + viewportHistory.current.push(getViewport()); + setCanGoBack(true); + + const nodeWidth = (node.style?.width as number) || node.width || 100; + const nodeHeight = (node.style?.height as number) || node.height || 50; + const x = node.position.x + nodeWidth / 2; + const y = node.position.y + nodeHeight / 2; + + const zoomLevel = transConfig?.zoom.level ?? 4; + const zoomDuration = transConfig?.zoom.duration ?? 1600; + const pauseDuration = transConfig?.pause.duration ?? 200; + + setCenter(x, y, { zoom: zoomLevel, duration: zoomDuration }); + + if (pendingNavTimer.current !== null) { + clearTimeout(pendingNavTimer.current); + pendingNavTimer.current = null; + } + + if (node.data?.navigateTo && onDiagramNavigate) { + pendingNavTimer.current = setTimeout(() => { + pendingNavTimer.current = null; + onDiagramNavigate(node.data.navigateTo!); + }, zoomDuration + pauseDuration); + } + }, + [setCenter, getViewport, onDiagramNavigate, onModalRequest, transitions], + ); + + const handleGoBack = useCallback(() => { + if (pendingNavTimer.current !== null) { + clearTimeout(pendingNavTimer.current); + pendingNavTimer.current = null; + } + const prev = viewportHistory.current.pop(); + if (prev) { + setViewport(prev, { duration: 1600 }); + } + setCanGoBack(viewportHistory.current.length > 0); + }, [setViewport]); + + return ( + <> + {canGoBack && ( +
    + +
    + )} + + + ); +} + +export function NavigableDiagram({ + diagramFile, + manifest, + height, + className = '', + hoverContent, +}: DiagramProps) { + const colorMode = useSafeColorMode(); + const [flowData, setFlowData] = useState({ nodes: [], edges: [] }); + const [currentDiagram, setCurrentDiagram] = useState(''); + const [manifestData, setManifestData] = useState(null); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modalTransition, setModalTransition] = useState(null); + + const handleModalRequest = useCallback((transition: TransitionConfig) => { + setModalTransition(transition); + }, []); + + const handleModalClose = useCallback(() => { + setModalTransition(null); + }, []); + + const nodeTypes = useMemo( + () => ({ custom: ClickableNode, group: SubgraphNode, image: ImageNode }), + [], + ); + + const edgeTypes = useMemo(() => ({ hoverEdge: HoverEdge }), []); + + const defaultEdgeOptions = useMemo( + () => ({ + style: { strokeWidth: 1.5, stroke: 'rgba(18, 170, 255, 0.6)' }, + animated: false, + }), + [], + ); + + const currentTransitions = useMemo(() => { + if (!manifestData) return undefined; + return manifestData.transitions.filter((t) => t.fromFile === currentDiagram); + }, [manifestData, currentDiagram]); + + const handleNavigate = useCallback( + (link: string) => { + setHistory((prev) => [...prev, currentDiagram]); + setCurrentDiagram(link); + }, + [currentDiagram], + ); + + const handleBack = useCallback(() => { + if (history.length > 0) { + const previousDiagram = history[history.length - 1]; + setHistory((prev) => prev.slice(0, -1)); + setCurrentDiagram(previousDiagram); + } + }, [history]); + + useEffect(() => { + if (!manifest) { + if (diagramFile) setCurrentDiagram(diagramFile); + return; + } + const loadManifest = async () => { + try { + const data = await fetchAndParseManifest(manifest); + setManifestData(data); + setCurrentDiagram(data.entryDiagramFile); + } catch (err) { + console.error('Error loading manifest:', err); + setError(err instanceof Error ? err.message : 'Failed to load manifest'); + setLoading(false); + } + }; + loadManifest(); + }, [manifest, diagramFile]); + + useEffect(() => { + if (!currentDiagram) return; + + const controller = new AbortController(); + + const loadDiagram = async () => { + setLoading(true); + setError(null); + try { + const response = await fetch(currentDiagram, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`Failed to load diagram: ${currentDiagram}`); + } + const rawContent = await response.text(); + const data = await convertDiagramToReactFlow( + rawContent, + currentDiagram, + handleNavigate, + currentTransitions, + ); + if (!controller.signal.aborted) { + // Resolve hover content components onto nodes and edges + if (hoverContent) { + for (const node of data.nodes) { + if (node.data?.hoverContentKey && hoverContent[node.data.hoverContentKey]) { + node.data.hoverContentComponent = hoverContent[node.data.hoverContentKey]; + } + } + for (const edge of data.edges) { + const edgeData = edge.data as any; + if (edgeData?.hoverContentKey && hoverContent[edgeData.hoverContentKey]) { + edgeData.hoverContentComponent = hoverContent[edgeData.hoverContentKey]; + } + } + } + setFlowData(data); + } + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + console.error('Error loading diagram:', err); + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + }; + loadDiagram(); + + return () => { + controller.abort(); + }; + }, [currentDiagram, handleNavigate, manifestData, hoverContent]); + + if (loading) { + return ( +
    +
    Loading diagram...
    +
    + ); + } + + if (error) { + return ( +
    +
    Error: {error}
    +
    + ); + } + + return ( +
    + {history.length > 0 && ( +
    + +
    + )} + + + + + + + + {modalTransition && ( + + )} +
    + ); +} diff --git a/src/components/DrawioReactFlow/README.md b/src/components/DrawioReactFlow/README.md new file mode 100644 index 0000000000..b54a221ee3 --- /dev/null +++ b/src/components/DrawioReactFlow/README.md @@ -0,0 +1,283 @@ +# DrawioReactFlow — Interactive Diagram Navigation + +Converts Draw.io (`.drawio`) **and Excalidraw (`.excalidraw`)** diagrams into interactive React Flow visualizations with animated, manifest-driven navigation between diagrams. + +## Overview + +Contributors author diagrams in either [Excalidraw](https://excalidraw.com) (recommended, cleaner data model) or [Draw.io](https://app.diagrams.net), then render them as interactive, click-navigable diagrams inside Arbitrum Docs. A manifest XML file defines which diagrams exist and how users transition between them (zoom, pause, fade). + +Format is chosen by file extension: `.excalidraw` → JSON converter, anything else → Draw.io XML converter. Both formats can coexist inside a single manifest while migrating. + +## Features + +- **Two authoring formats**: Excalidraw JSON or Draw.io XML — pick whichever feels better to draw in +- **Manifest-driven navigation**: XML manifest defines diagram stories with animated transitions +- **Zoom-to-node transitions**: Click a highlighted node to zoom in, pause, then cross-fade to the next diagram +- **Modal transitions**: Opt a transition into `mode="modal"` to open the target diagram in a centered overlay instead of replacing the main view +- **Subgroup support** (Draw.io): Groups render as labeled containers with styled borders +- **Embedded images** (Draw.io): Inline SVG/base64 images render as image nodes +- **Theme integration**: Adapts to Docusaurus light/dark mode via CSS variables +- **Keyboard accessible**: Tab to focus nodes, Enter/Space to activate +- **Responsive**: Works on mobile, tablet, and desktop +- **SSG compatible**: Uses Docusaurus `BrowserOnly` for static site generation + +## Quick start + +### 1. Author a diagram + +**Option A — Excalidraw (recommended):** open [excalidraw.com](https://excalidraw.com), draw rectangles/ellipses/diamonds, connect them with arrows (the binding handles give exact anchor points), then File → Save to `static/diagrams/.excalidraw`. Text can be a free-floating label OR bound to a shape/arrow (double-click inside the shape to bind it). + +**Option B — Draw.io:** create `.drawio` files in `static/diagrams/` using the [draw.io editor](https://app.diagrams.net). + +Nodes whose label matches a `` in the manifest are automatically treated as **interactive trigger nodes** — they blink to signal clickability and navigate on click. Author-time fill colors are ignored; all color comes from the component's glassmorphic theme. + +#### Custom attributes + +| Attribute | Draw.io | Excalidraw | +| ----------------- | ----------------------------------------- | ------------------------------------------------------------- | +| Hover content key | `hoverContent="_partial-key"` on the cell | `customData: { hoverContent: "_partial-key" }` on the element | +| Manual blink | `hoverContent="blinking"` | `customData: { blinking: true }` | +| Color token | `colorToken="yellow"` on the cell | `customData: { colorToken: "yellow" }` | + +### 2. Create a manifest + +Create a manifest XML file (e.g., `static/diagrams/my-story.manifest.xml`) that defines the diagram story: + +```xml + + + + + + + + + + + + + + + + + + +``` + +| Element | Description | +| ---------------- | -------------------------------------------------------------------------------------- | +| `` | Root element. `entryDiagram` points to the first diagram `id`. | +| `` | Maps an `id` to a `.drawio` file path (relative to `static/`). | +| `` | Defines a navigation. `trigger` matches the **node label text** in the `from` diagram. | +| `` | Zoom level and duration (ms) for the zoom-to-node animation. | +| `` | Pause duration (ms) between zoom and cross-fade. | +| `` | Cross-fade duration (ms) and easing for the diagram swap. | + +### Transition attributes + +| Attribute | Values | Default | Description | +| --------- | ----------------- | -------- | --------------------------------------------------------------------------------------------------- | +| `from` | diagram `id` | required | Source diagram. | +| `to` | diagram `id` | required | Target diagram. | +| `trigger` | node label text | required | Label of the node that fires the transition when clicked. | +| `mode` | `zoom` \| `modal` | `zoom` | `zoom` cross-fades to the target diagram (default). `modal` opens the target in a centered overlay. | + +Nested ``, ``, `` are read only in `zoom` mode, but stay valid XML in `modal` mode — useful if you want to flip a transition back to zoom later without re-editing. + +### 3. Use in MDX + +```mdx +--- +title: 'My Architecture' +--- + +import DrawioReactFlow from '@site/src/components/DrawioReactFlow'; + + +``` + +You can also render a single diagram without a manifest: + +```mdx + +``` + +## Component API + +### DrawioReactFlow props + +| Prop | Type | Default | Description | +| ------------- | -------- | ------------------------ | ---------------------------------------------------------------------------------- | +| `manifest` | `string` | — | Path to manifest XML (relative to `static/`). Enables multi-diagram stories. | +| `diagramFile` | `string` | — | Path to a single `.drawio` or `.excalidraw` file. Used when no manifest is needed. | +| `height` | `string` | CSS `aspect-ratio: 16/9` | Container height. Overrides the default aspect ratio. | +| `className` | `string` | `""` | Additional CSS classes on the container. | + +Provide either `manifest` or `diagramFile`, not both. + +## Navigation behavior + +1. **Initial load**: Fetches the manifest, parses it, loads the `entryDiagram` +2. **Source parsing**: `convertDiagramToReactFlow()` dispatches on the file extension — `.excalidraw` → `convertExcalidrawToReactFlow()` (JSON), anything else → `convertDrawioToReactFlow()` (Draw.io XML). Both produce `{ nodes, edges }` for React Flow. +3. **Trigger nodes**: Nodes whose label matches any `` in the manifest blink and are clickable. Detection is manifest-driven — draw.io fill colors are ignored. You can also opt a node into blinking without a transition by setting `hoverContent="blinking"` on the cell. +4. **Zoom transition** (default, `mode="zoom"`): The camera zooms to the node center, pauses, then cross-fades (via Framer Motion `AnimatePresence`) to the target diagram. +5. **Modal transition** (`mode="modal"`): The target diagram opens in a centered overlay (`DiagramModal`) with a blurred backdrop. The parent diagram stays untouched. Close with `Esc`, backdrop click, or the `×` button. Nested navigation inside the modal is disabled. +6. **Back button**: Appears after the first zoom navigation. Returns to the previous diagram. +7. **Viewport history**: Within a single diagram, zoom-clicks are tracked in a viewport stack with their own back navigation. + +## File structure + +``` +src/components/DrawioReactFlow/ +├── index.tsx Main export with BrowserOnly SSG wrapper +├── NavigableDiagram.tsx Navigation state, transitions, ReactFlow rendering +├── DiagramModal.tsx Portal-rendered overlay for mode="modal" transitions +├── DiagramHoverModal.tsx Floating-UI tooltip for hover content on nodes/edges +├── ClickableNode.tsx Custom node with shape variants and keyboard support +├── ImageNode.tsx Renders embedded images from Draw.io cells +├── SubgraphNode.tsx Group container with labeled header +├── HoverEdge.tsx Edge with optional hover content +├── types.ts TypeScript interfaces (ManifestData, TransitionConfig, TransitionMode, NodeData, etc.) +└── utils/ + ├── convertDiagram.ts Extension-based dispatcher (.excalidraw vs .drawio) + ├── drawioToReactFlow.ts Draw.io XML → ReactFlow nodes/edges conversion + ├── excalidrawToReactFlow.ts Excalidraw JSON → ReactFlow nodes/edges conversion + └── parseManifest.ts Manifest XML → ManifestData parser + +static/diagrams/ Diagram source files +├── *.drawio Draw.io diagram files +├── *.manifest.xml Manifest story definitions +└── diagram-background.svg Shared container background + +src/css/partials/ +└── _drawio-reactflow.scss Theme-integrated styling +``` + +## How the converter works + +`drawioToReactFlow.ts` performs these steps: + +1. **Parse XML**: `DOMParser` extracts `` elements from the Draw.io XML +2. **Classify cells**: Each cell is categorized as vertex (node), edge (connection), group, image, or text-only +3. **Detect groups**: Explicit (`group=true` style) and implicit (parent cells with child vertices) groups are identified +4. **Build nodes**: Vertex cells become ReactFlow nodes with absolute positions computed by walking the parent chain. Groups become `SubgraphNode`s. Images become `ImageNode`s. Regular cells become `ClickableNode`s. +5. **Build edges**: Edge cells become ReactFlow edges with handle positions resolved from Draw.io's `exitX/exitY/entryX/entryY` metadata, falling back to geometric inference +6. **Filter**: Edges referencing missing nodes are removed + +## Node types + +| Type | Component | When used | +| --------- | ------------------- | -------------------------------------------------------------- | +| `custom` | `ClickableNode` | Regular nodes with labels, shapes, and optional click behavior | +| `group` | `SubgraphNode` | Draw.io groups with visible fill/border | +| `image` | `ImageNode` | Cells with `shape=image` style (inline SVG/base64) | +| `default` | React Flow built-in | Text-only cells (`text=true` style) | + +## Styling + +### Container + +The `.drawio-reactflow-container` uses a 16:9 aspect ratio with a custom SVG background (`diagram-background.svg`), rounded corners, and theme-aware borders. + +### Node interactions + +- **Trigger nodes** (manifest-matched labels): Blink animation (`2s ease-in-out infinite`). Hover pauses the blink and turns the node green. +- **Clickable nodes**: `cursor: pointer`, `translateY(-2px)` hover lift, focus outline +- **Static nodes**: `cursor: default`, no hover effects + +### Theme support + +Light/dark mode is handled via `data-theme` attribute on the container: + +- Light mode: standard box shadows, white node text +- Dark mode: reduced opacity backgrounds, inverted text colors, adjusted edge labels + +### Accessibility + +- **Keyboard**: Clickable nodes are focusable (`tabIndex={0}`), activated with Enter/Space +- **High contrast**: Increased border width via `prefers-contrast: high` +- **Reduced motion**: All transitions disabled via `prefers-reduced-motion: reduce` +- **ARIA**: Clickable nodes have `role="button"` and descriptive `aria-label` + +## Dependencies + +- **reactflow** (`^11.11.4`): Interactive node-based UI +- **dagre** (`^0.8.5`): Graph layout algorithm (available but not used in current Draw.io path — positions come from the XML) +- **motion/react** (Framer Motion): Cross-fade transitions between diagrams +- **@docusaurus/theme-common**: Theme integration (`useColorMode`) +- **@docusaurus/BrowserOnly**: SSG compatibility + +## Testing + +### Test page + +Visit `/docs/test-interactive-diagrams` to see the system in action with the transaction lifecycle diagram story. + +### Manual testing checklist + +- [ ] Manifest loads and entry diagram renders +- [ ] Trigger nodes (labels matching a manifest ``) blink +- [ ] Clicking a zoom-mode trigger node zooms to it, then fades to next diagram +- [ ] Clicking a `mode="modal"` trigger node opens the target diagram in a centered overlay +- [ ] Modal closes on `Esc`, backdrop click, and `×` button; parent viewport is unchanged +- [ ] Page body does not scroll while the modal is open +- [ ] Back button appears after zoom navigation and returns to previous diagram +- [ ] Theme switching updates colors (light/dark) +- [ ] Keyboard Tab focuses clickable nodes, Enter/Space activates +- [ ] Groups render with labels and styled borders +- [ ] Embedded images render correctly +- [ ] No console errors +- [ ] Mobile responsive + +## Troubleshooting + +### Diagram not loading + +**Error**: "Failed to load diagram: /diagrams/xxx.drawio" + +- Ensure the file exists in `static/diagrams/` +- Path must start with `/diagrams/` (not `static/diagrams/`) +- File extension must be `.drawio` + +### Manifest not loading + +**Error**: "Failed to load manifest" or "entryDiagram not found" + +- Verify the manifest XML is well-formed +- Check that `entryDiagram` attribute matches a `` element +- Ensure all `` paths point to existing `.drawio` files + +### Node not clickable + +- Interactive trigger nodes are detected from the manifest — the node's label must exactly match a `` value (fill color is irrelevant) +- Verify the `` `from` diagram matches the current diagram +- For nodes that should blink without a transition (e.g., hover content only), add `hoverContent="blinking"` to the cell in the drawio + +### Modal opens, but nested node clicks do nothing + +This is intentional — `DiagramModal` passes a no-op navigation handler to the nested diagram to prevent modal-from-modal chains. If you need the nested diagram to be navigable too, use `mode="zoom"` or extend `DiagramModal.tsx` to accept a navigation callback. + +### Unknown `mode` attribute values fall back to zoom + +The parser only recognizes `mode="modal"`. Any other value (including typos like `"MODAL"`, `"Modal"`, or `"dialog"`) silently falls back to the default `zoom` behavior. Check the attribute spelling if a transition isn't opening the modal. + +### Build errors + +**Error**: "Cannot find module 'reactflow'" — Run `yarn install` + +**Error**: TypeScript errors — Check `types.ts` imports match component usage + +## Contributing + +When adding new interactive diagrams: + +1. Create `.drawio` files in `static/diagrams/` using draw.io +2. Use any node label that matches a manifest `` — the component auto-applies the trigger blink (author colors in draw.io are ignored) +3. Create a manifest XML defining the diagram story and transitions +4. Add an MDX page importing `DrawioReactFlow` with the manifest path +5. Test the full navigation flow on the dev server +6. Commit with a descriptive message diff --git a/src/components/DrawioReactFlow/SubgraphNode.tsx b/src/components/DrawioReactFlow/SubgraphNode.tsx new file mode 100644 index 0000000000..319d04ded4 --- /dev/null +++ b/src/components/DrawioReactFlow/SubgraphNode.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { NodeProps, Handle, Position } from 'reactflow'; + +export function SubgraphNode({ data }: NodeProps) { + return ( +
    + + + + + + + + +
    +
    {data.label}
    +
    +
    + ); +} diff --git a/src/components/DrawioReactFlow/index.tsx b/src/components/DrawioReactFlow/index.tsx new file mode 100644 index 0000000000..052b6eced6 --- /dev/null +++ b/src/components/DrawioReactFlow/index.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import BrowserOnly from '@docusaurus/BrowserOnly'; +import { DrawioReactFlowProps } from './types'; + +export default function DrawioReactFlow(props: DrawioReactFlowProps) { + return ( + + Loading diagram... + + } + > + {() => { + const { NavigableDiagram } = require('./NavigableDiagram'); + return ; + }} + + ); +} diff --git a/src/components/DrawioReactFlow/types.ts b/src/components/DrawioReactFlow/types.ts new file mode 100644 index 0000000000..3f8eca542f --- /dev/null +++ b/src/components/DrawioReactFlow/types.ts @@ -0,0 +1,80 @@ +import type React from 'react'; +import { Node, Edge } from 'reactflow'; + +export interface ZoomConfig { + level: number; + duration: number; +} + +export interface PauseConfig { + duration: number; +} + +export interface FadeConfig { + duration: number; + easing: string; +} + +export type TransitionMode = 'zoom' | 'modal'; + +export interface TransitionConfig { + fromFile: string; + trigger: string; + targetDiagram: string; + targetFile: string; + mode: TransitionMode; + zoom: ZoomConfig; + pause: PauseConfig; + fade: FadeConfig; +} + +export interface ManifestData { + id: string; + title: string; + entryDiagramFile: string; + diagrams: Map; + transitions: TransitionConfig[]; +} + +export interface DiagramProps { + diagramFile?: string; + manifest?: string; + height?: string; + className?: string; + hoverContent?: Record; +} + +export type DrawioReactFlowProps = DiagramProps; + +export interface ReactFlowData { + nodes: Node[]; + edges: Edge[]; +} + +export interface NavigationState { + currentDiagram: string; + history: string[]; +} + +export type NodeTextAlign = 'left' | 'center' | 'right'; +export type NodeVerticalAlign = 'top' | 'middle' | 'bottom'; + +export interface NodeData { + label: string; + shape: string; + link?: string; + centerable?: boolean; + navigateTo?: string; + topAligned?: boolean; + textAlign?: NodeTextAlign; + verticalAlign?: NodeVerticalAlign; + colorToken?: string; + onNavigate?: (link: string) => void; + hoverContentKey?: string; + hoverContentComponent?: React.ComponentType; +} + +export interface EdgeData { + hoverContentKey?: string; + hoverContentComponent?: React.ComponentType; +} diff --git a/src/components/DrawioReactFlow/utils/convertDiagram.ts b/src/components/DrawioReactFlow/utils/convertDiagram.ts new file mode 100644 index 0000000000..946554e9b7 --- /dev/null +++ b/src/components/DrawioReactFlow/utils/convertDiagram.ts @@ -0,0 +1,20 @@ +import { ReactFlowData, TransitionConfig } from '../types'; +import { convertDrawioToReactFlow } from './drawioToReactFlow'; +import { convertExcalidrawToReactFlow } from './excalidrawToReactFlow'; + +/** + * Dispatches to the correct diagram converter based on the source URL's extension. + * `.excalidraw` → JSON parser. Anything else (including `.drawio`) falls through to the + * legacy draw.io XML parser. + */ +export async function convertDiagramToReactFlow( + content: string, + sourceUrl: string, + onNavigate?: (link: string) => void, + transitions?: TransitionConfig[], +): Promise { + if (sourceUrl.toLowerCase().endsWith('.excalidraw')) { + return convertExcalidrawToReactFlow(content, onNavigate, transitions); + } + return convertDrawioToReactFlow(content, onNavigate, transitions); +} diff --git a/src/components/DrawioReactFlow/utils/drawioToReactFlow.ts b/src/components/DrawioReactFlow/utils/drawioToReactFlow.ts new file mode 100644 index 0000000000..7be65c8764 --- /dev/null +++ b/src/components/DrawioReactFlow/utils/drawioToReactFlow.ts @@ -0,0 +1,617 @@ +import { Node, Edge, MarkerType, Position } from 'reactflow'; +import { ReactFlowData, NodeData, TransitionConfig } from '../types'; + +interface DrawioCell { + id: string; + value: string; + style: string; + vertex: boolean; + edge: boolean; + source: string; + target: string; + parent: string; + connectable: string; + hoverContent: string; + colorToken: string; + x: number; + y: number; + width: number; + height: number; + waypoints: { x: number; y: number }[]; +} + +const ROOT_IDS = new Set(['0', '1']); +const PADDING = 30; + +function parseStyleString(style: string): Record { + const result: Record = {}; + if (!style) return result; + for (const part of style.split(';')) { + const trimmed = part.trim(); + if (!trimmed) continue; + const eqIndex = trimmed.indexOf('='); + if (eqIndex === -1) { + result[trimmed] = 'true'; + } else { + result[trimmed.slice(0, eqIndex)] = trimmed.slice(eqIndex + 1); + } + } + return result; +} + +function stripHtml(html: string): string { + if (!html) return ''; + return html + .replace(//gi, '\n') + .replace(/<\/div>/gi, '\n') + .replace(/]*>/gi, '\n') + .replace(/<[^>]*>/g, '') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\n{2,}/g, '\n') + .trim(); +} + +function hasTopAlignedLabel(html: string): boolean { + if (!html) return false; + // Detect Draw.io pattern: label text followed by empty

    spacers + return /
    \s*\s*<\/div>\s*$/.test(html); +} + +function isImageCell(styleProps: Record): boolean { + return styleProps['shape'] === 'image'; +} + +function extractImageDataUri(rawStyle: string): string { + const marker = 'image=data:'; + const startIdx = rawStyle.indexOf(marker); + if (startIdx === -1) return ''; + + const dataStart = startIdx + 'image='.length; + const rest = rawStyle.slice(dataStart); + + // Match until we hit ;key=value (e.g. ";container=0") or end of string + const endMatch = rest.search(/;[a-zA-Z]+=(?!\/)/); + let uri = endMatch === -1 ? rest : rest.slice(0, endMatch); + uri = uri.replace(/;$/, ''); + + // Draw.io often stores base64 SVGs as "data:image/svg+xml," without + // the ";base64," segment. Detect and fix this so browsers decode correctly. + const commaIdx = uri.indexOf(','); + if (commaIdx !== -1 && !uri.includes(';base64,')) { + const payload = uri.slice(commaIdx + 1); + const looksBase64 = /^[A-Za-z0-9+/=]+$/.test(payload.slice(0, 64)); + if (looksBase64) { + uri = uri.slice(0, commaIdx) + ';base64,' + payload; + } + } + + return uri; +} + +function isTextOnlyCell(styleProps: Record): boolean { + return styleProps['text'] === 'true'; +} + +function isGroupCell(styleProps: Record): boolean { + return styleProps['group'] === 'true'; +} + +function parseCells(doc: Document): DrawioCell[] { + const cellElements = doc.querySelectorAll('mxCell'); + const cells: DrawioCell[] = []; + + for (const el of Array.from(cellElements)) { + const id = el.getAttribute('id') ?? ''; + const geo = el.querySelector('mxGeometry'); + + // Check for hoverContent on the cell itself or parent UserObject + let hoverContent = el.getAttribute('hoverContent') ?? ''; + if (!hoverContent) { + const parentEl = el.parentElement; + if (parentEl && parentEl.tagName === 'UserObject') { + hoverContent = parentEl.getAttribute('hoverContent') ?? ''; + } + } + + const colorToken = el.getAttribute('colorToken') ?? ''; + + // Parse routing waypoints: + const waypoints: { x: number; y: number }[] = []; + if (geo) { + const pointsArray = geo.querySelector('Array[as="points"]'); + if (pointsArray) { + for (const pt of Array.from(pointsArray.querySelectorAll('mxPoint'))) { + const px = parseFloat(pt.getAttribute('x') ?? ''); + const py = parseFloat(pt.getAttribute('y') ?? ''); + if (!isNaN(px) && !isNaN(py)) waypoints.push({ x: px, y: py }); + } + } + } + + cells.push({ + id, + value: el.getAttribute('value') ?? '', + style: el.getAttribute('style') ?? '', + vertex: el.getAttribute('vertex') === '1', + edge: el.getAttribute('edge') === '1', + source: el.getAttribute('source') ?? '', + target: el.getAttribute('target') ?? '', + parent: el.getAttribute('parent') ?? '', + connectable: el.getAttribute('connectable') ?? '', + hoverContent, + colorToken, + x: parseFloat(geo?.getAttribute('x') ?? '0'), + y: parseFloat(geo?.getAttribute('y') ?? '0'), + width: parseFloat(geo?.getAttribute('width') ?? '0'), + height: parseFloat(geo?.getAttribute('height') ?? '0'), + waypoints, + }); + } + + return cells; +} + +function buildCellMap(cells: DrawioCell[]): Map { + const map = new Map(); + for (const cell of cells) { + map.set(cell.id, cell); + } + return map; +} + +function findGroupIds(cells: DrawioCell[], cellMap: Map): Set { + const groupIds = new Set(); + + for (const cell of cells) { + if (!cell.vertex || ROOT_IDS.has(cell.id)) continue; + const styleProps = parseStyleString(cell.style); + if (isGroupCell(styleProps)) { + groupIds.add(cell.id); + } + } + + // Detect implicit groups: vertex cells that have child vertices + for (const cell of cells) { + if (!cell.vertex || ROOT_IDS.has(cell.id) || ROOT_IDS.has(cell.parent)) { + continue; + } + const parentCell = cellMap.get(cell.parent); + if ( + parentCell && + parentCell.vertex && + !ROOT_IDS.has(parentCell.id) && + !groupIds.has(parentCell.id) + ) { + groupIds.add(parentCell.id); + } + } + + return groupIds; +} + +function getAbsolutePosition( + cell: DrawioCell, + cellMap: Map, +): { x: number; y: number } { + let x = cell.x; + let y = cell.y; + let currentParentId = cell.parent; + + while (currentParentId && !ROOT_IDS.has(currentParentId)) { + const parent = cellMap.get(currentParentId); + if (!parent) break; + x += parent.x; + y += parent.y; + currentParentId = parent.parent; + } + + return { x, y }; +} + +function determineShape(styleProps: Record): string { + if (styleProps['shape'] === 'process') return 'process'; + if (styleProps['ellipse'] === 'true') return 'circle'; + if (styleProps['rounded'] === '1') return 'round'; + return 'rect'; +} + +function buildNodes( + cells: DrawioCell[], + cellMap: Map, + groupIds: Set, + onNavigate?: (link: string) => void, + transitions?: TransitionConfig[], +): { nodes: Node[]; minX: number; minY: number } { + const nodes: Node[] = []; + let minX = Infinity; + let minY = Infinity; + + // First pass: compute min coordinates for normalization + for (const cell of cells) { + if (!cell.vertex || ROOT_IDS.has(cell.id)) continue; + const styleProps = parseStyleString(cell.style); + if (isImageCell(styleProps)) continue; + + const abs = getAbsolutePosition(cell, cellMap); + minX = Math.min(minX, abs.x); + minY = Math.min(minY, abs.y); + } + + if (!isFinite(minX)) minX = 0; + if (!isFinite(minY)) minY = 0; + + // Track which groups are skipped (pure layout containers) + const skippedGroups = new Set(); + + // Second pass: create nodes + for (const cell of cells) { + if (!cell.vertex || ROOT_IDS.has(cell.id)) continue; + + const styleProps = parseStyleString(cell.style); + + // Skip edge label cells — they're handled in buildEdges + if (styleProps['edgeLabel'] === 'true') continue; + + // Render image cells (e.g. envelope icons) as image nodes + if (isImageCell(styleProps)) { + const imageData = extractImageDataUri(cell.style); + if (!imageData || cell.width === 0) continue; + + const hasNonRootParent = + !ROOT_IDS.has(cell.parent) && groupIds.has(cell.parent) && !skippedGroups.has(cell.parent); + + let imgX: number; + let imgY: number; + if (hasNonRootParent) { + imgX = cell.x; + imgY = cell.y; + } else { + const abs = getAbsolutePosition(cell, cellMap); + imgX = abs.x - minX + PADDING; + imgY = abs.y - minY + PADDING; + } + + const imgNode: Node = { + id: cell.id, + type: 'image', + position: { x: imgX, y: imgY }, + data: { imageUrl: imageData }, + style: { width: cell.width, height: cell.height }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + }; + + if (hasNonRootParent) { + imgNode.parentNode = cell.parent; + imgNode.extent = 'parent'; + } + + nodes.push(imgNode); + continue; + } + + const label = stripHtml(cell.value); + const isGroup = groupIds.has(cell.id); + const isText = isTextOnlyCell(styleProps); + + // Skip cells with no label, no group, and no meaningful geometry (visual elements + // like colored rectangles have no label but should still render) + if (!label && !isGroup && !(cell.width > 0 && cell.height > 0)) continue; + + const fillColor = styleProps['fillColor'] ?? 'transparent'; + + const hasNonRootParentGroup = + !ROOT_IDS.has(cell.parent) && groupIds.has(cell.parent) && !skippedGroups.has(cell.parent); + + // Position: relative to parent group, or absolute normalized + let posX: number; + let posY: number; + + if (hasNonRootParentGroup) { + posX = cell.x; + posY = cell.y; + } else { + const abs = getAbsolutePosition(cell, cellMap); + posX = abs.x - minX + PADDING; + posY = abs.y - minY + PADDING; + } + + if (isGroup) { + // Skip pure layout groups (no fill, no visible border) — they're invisible containers + if (isGroupCell(styleProps) && fillColor === 'transparent') { + skippedGroups.add(cell.id); + continue; + } + + const abs = getAbsolutePosition(cell, cellMap); + posX = abs.x - minX + PADDING; + posY = abs.y - minY + PADDING; + + nodes.push({ + id: cell.id, + type: 'group', + position: { x: posX, y: posY }, + data: { label: label || '' }, + style: { + width: cell.width, + height: cell.height, + padding: 0, + }, + }); + continue; + } + + if (isText && !cell.colorToken) { + nodes.push({ + id: cell.id, + type: 'default', + position: { x: posX, y: posY }, + data: { label }, + style: { + width: cell.width, + height: cell.height, + backgroundColor: 'transparent', + border: 'none', + color: '#ffffff', + fontSize: '14px', + lineHeight: '1.2', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + }); + continue; + } + + const shape = determineShape(styleProps); + + const isManifestTrigger = transitions?.some((t) => t.trigger === label) ?? false; + const isBlinking = isManifestTrigger || cell.hoverContent === 'blinking'; + + const nodeData: NodeData = { + label, + shape, + centerable: isBlinking || undefined, + navigateTo: transitions?.find((t) => t.trigger === label)?.targetFile || undefined, + topAligned: hasTopAlignedLabel(cell.value) || undefined, + colorToken: cell.colorToken || undefined, + hoverContentKey: cell.hoverContent || undefined, + onNavigate, + }; + + const node: Node = { + id: cell.id, + type: 'custom', + position: { x: posX, y: posY }, + data: nodeData, + className: isBlinking ? 'node-blinking' : undefined, + style: { + width: cell.width, + height: cell.height, + }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + }; + + if (hasNonRootParentGroup) { + node.parentNode = cell.parent; + node.extent = 'parent'; + } + + nodes.push(node); + } + + return { nodes, minX, minY }; +} + +function resolveHandlePosition(entryX: string | undefined, entryY: string | undefined): Position { + const x = entryX !== undefined ? parseFloat(entryX) : undefined; + const y = entryY !== undefined ? parseFloat(entryY) : undefined; + + if (x !== undefined && y !== undefined) { + if (x <= 0.1) return Position.Left; + if (x >= 0.9) return Position.Right; + if (y <= 0.1) return Position.Top; + if (y >= 0.9) return Position.Bottom; + } + if (x !== undefined) { + if (x <= 0.1) return Position.Left; + if (x >= 0.9) return Position.Right; + } + if (y !== undefined) { + if (y <= 0.1) return Position.Top; + if (y >= 0.9) return Position.Bottom; + } + return Position.Bottom; +} + +function sideFromWaypoint( + waypoint: { x: number; y: number }, + cell: DrawioCell, + cellMap: Map, +): Position { + const abs = getAbsolutePosition(cell, cellMap); + const right = abs.x + cell.width; + const bottom = abs.y + cell.height; + + // Waypoint is outside the cell's bounding box → use the side it lies beyond. + if (waypoint.x < abs.x) return Position.Left; + if (waypoint.x > right) return Position.Right; + if (waypoint.y < abs.y) return Position.Top; + if (waypoint.y > bottom) return Position.Bottom; + + // Waypoint is inside the cell — fall back to the dominant axis from cell center. + const centerX = abs.x + cell.width / 2; + const centerY = abs.y + cell.height / 2; + const dx = waypoint.x - centerX; + const dy = waypoint.y - centerY; + if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? Position.Right : Position.Left; + return dy > 0 ? Position.Bottom : Position.Top; +} + +function inferConnectionSide( + sourceCell: DrawioCell, + targetCell: DrawioCell, + cellMap: Map, +): { sourcePosition: Position; targetPosition: Position } { + const sAbs = getAbsolutePosition(sourceCell, cellMap); + const tAbs = getAbsolutePosition(targetCell, cellMap); + + const sCenterX = sAbs.x + sourceCell.width / 2; + const sCenterY = sAbs.y + sourceCell.height / 2; + const tCenterX = tAbs.x + targetCell.width / 2; + const tCenterY = tAbs.y + targetCell.height / 2; + + const dx = tCenterX - sCenterX; + const dy = tCenterY - sCenterY; + + if (Math.abs(dx) > Math.abs(dy)) { + return dx > 0 + ? { sourcePosition: Position.Right, targetPosition: Position.Left } + : { sourcePosition: Position.Left, targetPosition: Position.Right }; + } + return dy > 0 + ? { sourcePosition: Position.Bottom, targetPosition: Position.Top } + : { sourcePosition: Position.Top, targetPosition: Position.Bottom }; +} + +function findEdgeLabelData( + edgeId: string, + cells: DrawioCell[], +): { text: string; hoverContentKey?: string } { + for (const cell of cells) { + if (cell.parent !== edgeId || !cell.vertex) continue; + const styleProps = parseStyleString(cell.style); + if (styleProps['edgeLabel'] !== 'true') continue; + const text = stripHtml(cell.value); + if (text) return { text, hoverContentKey: cell.hoverContent || undefined }; + } + return { text: '' }; +} + +function buildEdges(cells: DrawioCell[], cellMap: Map): Edge[] { + const edges: Edge[] = []; + + for (const cell of cells) { + if (!cell.edge) continue; + if (!cell.source || !cell.target) continue; + + const styleProps = parseStyleString(cell.style); + const isDashed = styleProps['dashed'] === '1'; + const strokeColor = styleProps['strokeColor'] ?? '#FFFFFF'; + const resolvedStrokeColor = strokeColor === '#000000' ? '#cccccc' : strokeColor; + const strokeWidth = parseFloat(styleProps['strokeWidth'] ?? '1'); + + const sourceCell = cellMap.get(cell.source); + const targetCell = cellMap.get(cell.target); + + // Determine connection sides from Draw.io entry/exit metadata + let sourcePosition: Position; + let targetPosition: Position; + + const hasExitMeta = styleProps['exitX'] !== undefined || styleProps['exitY'] !== undefined; + const hasEntryMeta = styleProps['entryX'] !== undefined || styleProps['entryY'] !== undefined; + const waypoints = cell.waypoints; + const firstWaypoint = waypoints.length > 0 ? waypoints[0] : undefined; + const lastWaypoint = waypoints.length > 0 ? waypoints[waypoints.length - 1] : undefined; + + if (hasExitMeta) { + sourcePosition = resolveHandlePosition(styleProps['exitX'], styleProps['exitY']); + } else if (firstWaypoint && sourceCell) { + sourcePosition = sideFromWaypoint(firstWaypoint, sourceCell, cellMap); + } else if (sourceCell && targetCell) { + sourcePosition = inferConnectionSide(sourceCell, targetCell, cellMap).sourcePosition; + } else { + sourcePosition = Position.Bottom; + } + + if (hasEntryMeta) { + targetPosition = resolveHandlePosition(styleProps['entryX'], styleProps['entryY']); + } else if (lastWaypoint && targetCell) { + targetPosition = sideFromWaypoint(lastWaypoint, targetCell, cellMap); + } else if (sourceCell && targetCell) { + targetPosition = inferConnectionSide(sourceCell, targetCell, cellMap).targetPosition; + } else { + targetPosition = Position.Top; + } + + const startArrow = styleProps['startArrow']; + const hasStartArrow = !!startArrow && startArrow !== 'none'; + + const edge: Edge = { + id: cell.id, + source: cell.source, + target: cell.target, + sourceHandle: `${sourcePosition}-source`, + targetHandle: `${targetPosition}-target`, + type: 'smoothstep', + animated: true, + style: { + stroke: resolvedStrokeColor, + strokeWidth, + ...(isDashed ? { strokeDasharray: '5 5' } : {}), + }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: resolvedStrokeColor, + }, + ...(hasStartArrow + ? { + markerStart: { + type: MarkerType.ArrowClosed, + color: resolvedStrokeColor, + }, + } + : {}), + }; + + const labelData = findEdgeLabelData(cell.id, cells); + const edgeLabel = stripHtml(cell.value) || labelData.text; + const edgeHoverKey = cell.hoverContent || labelData.hoverContentKey; + + if (edgeLabel) { + edge.label = edgeLabel; + } + + if (edgeHoverKey) { + edge.type = 'hoverEdge'; + edge.data = { hoverContentKey: edgeHoverKey }; + } + + edges.push(edge); + } + + return edges; +} + +export async function convertDrawioToReactFlow( + xmlString: string, + onNavigate?: (link: string) => void, + transitions?: TransitionConfig[], +): Promise { + const parser = new DOMParser(); + const doc = parser.parseFromString(xmlString, 'text/xml'); + + const parserError = doc.querySelector('parsererror'); + if (parserError) { + throw new Error(`Failed to parse Draw.io XML: ${parserError.textContent}`); + } + + const cells = parseCells(doc); + const cellMap = buildCellMap(cells); + const groupIds = findGroupIds(cells, cellMap); + + const { nodes } = buildNodes(cells, cellMap, groupIds, onNavigate, transitions); + const edges = buildEdges(cells, cellMap); + + // Filter edges that reference nodes not in our node set + const nodeIds = new Set(nodes.map((n) => n.id)); + const validEdges = edges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target)); + + return { nodes, edges: validEdges }; +} diff --git a/src/components/DrawioReactFlow/utils/excalidrawToReactFlow.ts b/src/components/DrawioReactFlow/utils/excalidrawToReactFlow.ts new file mode 100644 index 0000000000..fdbfc1a71e --- /dev/null +++ b/src/components/DrawioReactFlow/utils/excalidrawToReactFlow.ts @@ -0,0 +1,412 @@ +import { Node, Edge, MarkerType, Position } from 'reactflow'; +import { + ReactFlowData, + NodeData, + NodeTextAlign, + NodeVerticalAlign, + TransitionConfig, +} from '../types'; + +const PADDING = 30; + +interface ExcalidrawBinding { + elementId: string; + mode?: 'orbit' | 'inside'; + fixedPoint?: [number, number]; +} + +interface ExcalidrawElement { + id: string; + type: string; + x: number; + y: number; + width: number; + height: number; + isDeleted?: boolean; + link?: string | null; + customData?: Record; + boundElements?: Array<{ id: string; type: string }> | null; + // text-specific + text?: string; + containerId?: string | null; + fontSize?: number; + textAlign?: string; + verticalAlign?: string; + // image-specific + fileId?: string; + // arrow-specific + points?: [number, number][]; + startBinding?: ExcalidrawBinding | null; + endBinding?: ExcalidrawBinding | null; + startArrowhead?: string | null; + endArrowhead?: string | null; + groupIds?: string[]; +} + +interface ExcalidrawFileEntry { + mimeType?: string; + dataURL?: string; +} + +interface ExcalidrawFile { + type?: string; + version?: number; + elements?: ExcalidrawElement[]; + files?: Record; +} + +function remapFixedPoint( + fp: [number, number], + fromEl: ExcalidrawElement, + toEl: ExcalidrawElement, +): [number, number] { + if (toEl.width === 0 || toEl.height === 0) return fp; + const absX = fromEl.x + fp[0] * fromEl.width; + const absY = fromEl.y + fp[1] * fromEl.height; + return [(absX - toEl.x) / toEl.width, (absY - toEl.y) / toEl.height]; +} + +type AlongSidePercent = 20 | 50 | 80; + +function pickAlongSidePercent(coord: number): AlongSidePercent { + if (coord < 0.35) return 20; + if (coord > 0.65) return 80; + return 50; +} + +function fixedPointToSideAndOffset( + fp: [number, number] | undefined, + self: ExcalidrawElement, + other: ExcalidrawElement | undefined, +): { side: Position; alongPercent: AlongSidePercent } { + const side = fixedPointToSide(fp, self, other); + if (!fp) return { side, alongPercent: 50 }; + const along = side === Position.Top || side === Position.Bottom ? fp[0] : fp[1]; + return { side, alongPercent: pickAlongSidePercent(along) }; +} + +function fixedPointToSide( + fixedPoint: [number, number] | undefined, + self: ExcalidrawElement, + other: ExcalidrawElement | undefined, +): Position { + if (!fixedPoint) return Position.Top; + const [x, y] = fixedPoint; + const distLeft = x; + const distRight = 1 - x; + const distTop = y; + const distBottom = 1 - y; + const min = Math.min(distLeft, distRight, distTop, distBottom); + + // Epsilon in normalized units — anchor points within this distance count as "tied" + const TIE_EPS = 0.05; + const tiedSides: Position[] = []; + if (distLeft <= min + TIE_EPS) tiedSides.push(Position.Left); + if (distRight <= min + TIE_EPS) tiedSides.push(Position.Right); + if (distTop <= min + TIE_EPS) tiedSides.push(Position.Top); + if (distBottom <= min + TIE_EPS) tiedSides.push(Position.Bottom); + + if (tiedSides.length === 1) return tiedSides[0]; + + // Tie-break: pick the side of `self` that faces `other`. For a near-corner anchor, + // this aligns the handle with the direction the arrow actually travels. + if (other) { + const sc = { x: self.x + self.width / 2, y: self.y + self.height / 2 }; + const oc = { x: other.x + other.width / 2, y: other.y + other.height / 2 }; + const dx = oc.x - sc.x; + const dy = oc.y - sc.y; + if (Math.abs(dx) > Math.abs(dy)) { + return dx > 0 ? Position.Right : Position.Left; + } + return dy > 0 ? Position.Bottom : Position.Top; + } + + // Fallback — prefer top/bottom over left/right, since most diagram flows are vertical + if (min === distTop) return Position.Top; + if (min === distBottom) return Position.Bottom; + if (min === distLeft) return Position.Left; + return Position.Right; +} + +function determineShape(type: string): string { + if (type === 'diamond') return 'diamond'; + if (type === 'ellipse') return 'circle'; + return 'rect'; +} + +function isArrowhead(value: string | null | undefined): boolean { + if (!value) return false; + return value === 'arrow' || value === 'triangle' || value === 'bar' || value === 'dot'; +} + +function getCustomString( + data: Record | undefined, + key: string, +): string | undefined { + if (!data) return undefined; + const value = data[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function normalizeTextAlign(value: string | undefined): NodeTextAlign | undefined { + if (value === 'left' || value === 'center' || value === 'right') return value; + return undefined; +} + +function normalizeVerticalAlign(value: string | undefined): NodeVerticalAlign | undefined { + if (value === 'top' || value === 'middle' || value === 'bottom') return value; + return undefined; +} + +export async function convertExcalidrawToReactFlow( + jsonString: string, + onNavigate?: (link: string) => void, + transitions?: TransitionConfig[], +): Promise { + let parsed: ExcalidrawFile; + try { + parsed = JSON.parse(jsonString) as ExcalidrawFile; + } catch (err) { + throw new Error( + `Failed to parse Excalidraw JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const elements = (parsed.elements ?? []).filter((el) => !el.isDeleted); + + // Top-level files dict maps fileId -> { dataURL } for image elements + const filesMap = new Map(); + if (parsed.files) { + for (const [fileId, entry] of Object.entries(parsed.files)) { + if (entry?.dataURL) filesMap.set(fileId, entry.dataURL); + } + } + + // Lookup by id (used for arrow side resolution below) + const elementById = new Map(); + for (const el of elements) { + elementById.set(el.id, el); + } + + // Map container element id -> bound text element (used for labels on shapes and arrows) + const containerToText = new Map(); + for (const el of elements) { + if (el.type === 'text' && el.containerId) { + containerToText.set(el.containerId, el); + } + } + + // Spatial auto-binding: floating text whose center lies inside a shape that has no bound label + // is promoted to that shape's label. Matches the common authoring pattern of dropping text + // over a box without explicitly binding it. Ties broken by choosing the smallest enclosing shape. + const autoBoundTextIds = new Set(); + const isShapeType = (t: string) => t === 'rectangle' || t === 'ellipse' || t === 'diamond'; + + for (const text of elements) { + if (text.type !== 'text' || text.containerId) continue; + const tcx = text.x + text.width / 2; + const tcy = text.y + text.height / 2; + + let bestShape: ExcalidrawElement | undefined; + let bestArea = Infinity; + for (const shape of elements) { + if (!isShapeType(shape.type)) continue; + if (containerToText.has(shape.id)) continue; + const inside = + tcx >= shape.x && + tcx <= shape.x + shape.width && + tcy >= shape.y && + tcy <= shape.y + shape.height; + if (!inside) continue; + const area = shape.width * shape.height; + if (area < bestArea) { + bestArea = area; + bestShape = shape; + } + } + + if (bestShape) { + containerToText.set(bestShape.id, text); + autoBoundTextIds.add(text.id); + } + } + + // Inverse map: text element id -> container shape id. Used to redirect arrows that + // are bound to a text element (common when the author clicks on the visible text + // overlay) to the underlying shape. + const textToContainer = new Map(); + for (const [shapeId, textEl] of containerToText.entries()) { + textToContainer.set(textEl.id, shapeId); + } + + // Compute minX/minY across non-arrow elements for coordinate normalization + let minX = Infinity; + let minY = Infinity; + for (const el of elements) { + if (el.type === 'arrow' || el.type === 'line') continue; + if (el.type === 'text' && el.containerId) continue; // bound text follows its container + if (el.type === 'text' && autoBoundTextIds.has(el.id)) continue; // auto-bound follows its shape + minX = Math.min(minX, el.x); + minY = Math.min(minY, el.y); + } + if (!isFinite(minX)) minX = 0; + if (!isFinite(minY)) minY = 0; + + const nodes: Node[] = []; + const edges: Edge[] = []; + + for (const el of elements) { + // Text bound (explicitly OR auto-bound) to a container is rendered as its label, not a node + if (el.type === 'text' && el.containerId) continue; + if (el.type === 'text' && autoBoundTextIds.has(el.id)) continue; + + // Shape nodes + if (el.type === 'rectangle' || el.type === 'ellipse' || el.type === 'diamond') { + const boundText = containerToText.get(el.id); + const label = boundText?.text ?? ''; + const hoverKey = getCustomString(el.customData, 'hoverContent'); + const colorToken = getCustomString(el.customData, 'colorToken'); + const isManifestTrigger = !!transitions?.some((t) => t.trigger === label); + const isBlinking = isManifestTrigger || el.customData?.blinking === true; + const textAlign = normalizeTextAlign(boundText?.textAlign); + const verticalAlign = normalizeVerticalAlign(boundText?.verticalAlign); + + const nodeData: NodeData = { + label, + shape: determineShape(el.type), + centerable: isBlinking || undefined, + navigateTo: transitions?.find((t) => t.trigger === label)?.targetFile || undefined, + colorToken, + hoverContentKey: hoverKey, + textAlign, + verticalAlign, + onNavigate, + }; + + nodes.push({ + id: el.id, + type: 'custom', + position: { x: el.x - minX + PADDING, y: el.y - minY + PADDING }, + data: nodeData, + style: { width: el.width, height: el.height }, + className: isBlinking ? 'node-blinking' : undefined, + sourcePosition: Position.Right, + targetPosition: Position.Left, + }); + continue; + } + + // Standalone floating text (not bound to any container) — rendered as a transparent text node + if (el.type === 'text') { + nodes.push({ + id: el.id, + type: 'default', + position: { x: el.x - minX + PADDING, y: el.y - minY + PADDING }, + data: { label: el.text ?? '' }, + style: { + width: el.width, + height: el.height, + backgroundColor: 'transparent', + border: 'none', + color: '#ffffff', + fontSize: `${el.fontSize ?? 14}px`, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + padding: 0, + }, + }); + continue; + } + + // Image nodes — resolve the data URL via fileId -> top-level files dict + if (el.type === 'image') { + if (!el.fileId) continue; + const dataUrl = filesMap.get(el.fileId); + if (!dataUrl) continue; + nodes.push({ + id: el.id, + type: 'image', + position: { x: el.x - minX + PADDING, y: el.y - minY + PADDING }, + data: { imageUrl: dataUrl }, + style: { width: el.width, height: el.height }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + }); + continue; + } + + // Arrows — require both bindings to render as a ReactFlow edge + if (el.type === 'arrow') { + if (!el.startBinding?.elementId || !el.endBinding?.elementId) continue; + + const srcRawId = el.startBinding.elementId; + const tgtRawId = el.endBinding.elementId; + const srcShapeId = textToContainer.get(srcRawId) ?? srcRawId; + const tgtShapeId = textToContainer.get(tgtRawId) ?? tgtRawId; + + const srcRawEl = elementById.get(srcRawId); + const tgtRawEl = elementById.get(tgtRawId); + const srcEl = elementById.get(srcShapeId); + const tgtEl = elementById.get(tgtShapeId); + if (!srcEl || !tgtEl) continue; + + // Remap fixedPoints from the bound element's coordinate space to the shape's, + // so side resolution is computed relative to the actual rendered node geometry. + const sfp = + srcRawEl && srcRawEl !== srcEl && el.startBinding.fixedPoint + ? remapFixedPoint(el.startBinding.fixedPoint, srcRawEl, srcEl) + : el.startBinding.fixedPoint; + const efp = + tgtRawEl && tgtRawEl !== tgtEl && el.endBinding.fixedPoint + ? remapFixedPoint(el.endBinding.fixedPoint, tgtRawEl, tgtEl) + : el.endBinding.fixedPoint; + + const sourceAnchor = fixedPointToSideAndOffset(sfp, srcEl, tgtEl); + const targetAnchor = fixedPointToSideAndOffset(efp, tgtEl, srcEl); + const sourcePosition = sourceAnchor.side; + const targetPosition = targetAnchor.side; + + const labelEl = containerToText.get(el.id); + const label = labelEl?.text ?? ''; + const hoverKey = getCustomString(el.customData, 'hoverContent'); + // customData.edgeType lets authors opt an arrow out of orthogonal routing + // (e.g. "straight" draws one direct stroke between the two anchors) + const edgeType = + getCustomString(el.customData, 'edgeType') === 'straight' ? 'straight' : 'smoothstep'; + + const edge: Edge = { + id: el.id, + source: srcShapeId, + target: tgtShapeId, + sourceHandle: `${sourcePosition}-${sourceAnchor.alongPercent}-source`, + targetHandle: `${targetPosition}-${targetAnchor.alongPercent}-target`, + type: hoverKey ? 'hoverEdge' : edgeType, + animated: false, + style: { strokeWidth: 1.5, stroke: '#969696' }, + // Render edges above nodes so arrows stay visible when they terminate on a + // node drawn inside a larger container node (e.g., contracts on the parent chain) + zIndex: 1, + }; + + if (isArrowhead(el.endArrowhead)) { + edge.markerEnd = { type: MarkerType.ArrowClosed, color: '#969696' }; + } + if (isArrowhead(el.startArrowhead)) { + edge.markerStart = { type: MarkerType.ArrowClosed, color: '#969696' }; + } + if (label) edge.label = label; + if (hoverKey) edge.data = { hoverContentKey: hoverKey }; + + edges.push(edge); + continue; + } + } + + // Filter edges whose source or target didn't produce a node (e.g., deleted, or bound to a text) + const nodeIds = new Set(nodes.map((n) => n.id)); + const filteredEdges = edges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target)); + + return { nodes, edges: filteredEdges }; +} diff --git a/src/components/DrawioReactFlow/utils/parseManifest.ts b/src/components/DrawioReactFlow/utils/parseManifest.ts new file mode 100644 index 0000000000..56bcd84e15 --- /dev/null +++ b/src/components/DrawioReactFlow/utils/parseManifest.ts @@ -0,0 +1,88 @@ +import { ManifestData, TransitionConfig } from '../types'; + +const DEFAULT_ZOOM = { level: 4, duration: 1600 }; +const DEFAULT_PAUSE = { duration: 200 }; +const DEFAULT_FADE = { duration: 1500, easing: 'easeInOut' }; + +function parseFloat2(value: string | null, fallback: number): number { + if (!value) return fallback; + const parsed = parseFloat(value); + return isNaN(parsed) ? fallback : parsed; +} + +function parseTransition(el: Element, diagrams: Map): TransitionConfig | null { + const from = el.getAttribute('from') ?? ''; + const to = el.getAttribute('to') ?? ''; + const trigger = el.getAttribute('trigger') ?? ''; + const fromFile = diagrams.get(from) ?? ''; + const targetFile = diagrams.get(to) ?? ''; + + if (!from || !to || !trigger || !fromFile || !targetFile) return null; + + const zoomEl = el.querySelector('zoom'); + const pauseEl = el.querySelector('pause'); + const fadeEl = el.querySelector('fade'); + + const rawMode = el.getAttribute('mode'); + const mode: TransitionConfig['mode'] = rawMode === 'modal' ? 'modal' : 'zoom'; + + return { + fromFile, + trigger, + targetDiagram: to, + targetFile, + mode, + zoom: { + level: parseFloat2(zoomEl?.getAttribute('level') ?? null, DEFAULT_ZOOM.level), + duration: parseFloat2(zoomEl?.getAttribute('duration') ?? null, DEFAULT_ZOOM.duration), + }, + pause: { + duration: parseFloat2(pauseEl?.getAttribute('duration') ?? null, DEFAULT_PAUSE.duration), + }, + fade: { + duration: parseFloat2(fadeEl?.getAttribute('duration') ?? null, DEFAULT_FADE.duration), + easing: fadeEl?.getAttribute('easing') ?? DEFAULT_FADE.easing, + }, + }; +} + +export async function fetchAndParseManifest(manifestUrl: string): Promise { + const response = await fetch(manifestUrl); + if (!response.ok) { + throw new Error(`Failed to load manifest: ${manifestUrl} (${response.status})`); + } + + const xmlString = await response.text(); + const parser = new DOMParser(); + const doc = parser.parseFromString(xmlString, 'text/xml'); + + const parserError = doc.querySelector('parsererror'); + if (parserError) { + throw new Error(`Invalid manifest XML: ${parserError.textContent}`); + } + + const root = doc.documentElement; + const id = root.getAttribute('id') ?? ''; + const title = root.getAttribute('title') ?? ''; + const entryDiagramId = root.getAttribute('entryDiagram') ?? ''; + + const diagrams = new Map(); + for (const el of Array.from(root.querySelectorAll('diagram'))) { + const diagId = el.getAttribute('id') ?? ''; + const file = el.getAttribute('file') ?? ''; + if (diagId && file) diagrams.set(diagId, file); + } + + const entryDiagramFile = diagrams.get(entryDiagramId) ?? ''; + if (!entryDiagramFile) { + throw new Error(`entryDiagram "${entryDiagramId}" not found in manifest diagrams`); + } + + const transitions: TransitionConfig[] = []; + for (const el of Array.from(root.querySelectorAll('transition'))) { + const config = parseTransition(el, diagrams); + if (config) transitions.push(config); + } + + return { id, title, entryDiagramFile, diagrams, transitions }; +} diff --git a/src/css/custom.scss b/src/css/custom.scss index e08b1880c6..f5f3652ce1 100644 --- a/src/css/custom.scss +++ b/src/css/custom.scss @@ -15,6 +15,8 @@ @use './partials/_modal.scss'; @use './partials/_image-zoom.scss'; @use './partials/_edge-challenge-flow.scss'; +@use './partials/_drawio-reactflow.scss'; +@use './partials/_diagram-hover-modal.scss'; // ======================================== // Global Styles diff --git a/src/css/partials/_diagram-hover-modal.scss b/src/css/partials/_diagram-hover-modal.scss new file mode 100644 index 0000000000..95b42b944a --- /dev/null +++ b/src/css/partials/_diagram-hover-modal.scss @@ -0,0 +1,164 @@ +// Diagram Hover Modal — Arbitrum glassmorphic style +// Hover-triggered floating modal for diagram cell annotations + +.diagram-hover-modal__trigger { + border-bottom: 1px dotted rgba(18, 170, 255, 0.6); + cursor: pointer; + transition: border-color var(--arbitrum-transition-normal, 0.2s ease); + + &:hover { + border-bottom-color: rgba(18, 170, 255, 1); + } +} + +.diagram-hover-modal__content { + background: rgba(33, 49, 71, 0.95); + border: 1px solid rgba(18, 170, 255, 0.3); + border-radius: 0; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3), 0 0 20px rgba(18, 170, 255, 0.1); + backdrop-filter: blur(12px); + max-width: 500px; + max-height: 60vh; + min-width: 320px; + z-index: 9999; + display: flex; + flex-direction: column; + overflow: hidden; + font-family: var(--arbitrum-font-family, var(--ifm-font-family-base)); +} + +.diagram-hover-modal__header { + display: flex; + justify-content: flex-end; + padding: 6px 10px 0; + flex-shrink: 0; +} + +.diagram-hover-modal__close { + background: none; + border: none; + font-size: 18px; + cursor: pointer; + color: rgba(157, 204, 237, 0.6); + padding: 4px 8px; + border-radius: 0; + transition: all var(--arbitrum-transition-normal, 0.2s ease); + font-family: var(--arbitrum-font-family, var(--ifm-font-family-base)); + + &:hover { + color: #ffffff; + background: rgba(18, 170, 255, 0.15); + } +} + +.diagram-hover-modal__body { + padding: 0 20px 20px; + overflow-y: auto; + flex: 1; + color: #ffffff; +} + +.diagram-hover-modal__title { + font-size: 20px; + font-weight: 600; + margin: 0 0 12px 0; + color: #ffffff; +} + +.diagram-hover-modal__subtitle { + font-size: 17px; + font-weight: 600; + margin: 16px 0 8px 0; + color: #9dcced; +} + +.diagram-hover-modal__heading3 { + font-size: 15px; + font-weight: 600; + margin: 14px 0 6px 0; + color: #9dcced; +} + +.diagram-hover-modal__heading4 { + font-size: 14px; + font-weight: 600; + margin: 12px 0 6px 0; + color: rgba(157, 204, 237, 0.8); +} + +.diagram-hover-modal__paragraph { + margin: 0 0 12px 0; + line-height: 1.6; + font-size: 14px; + color: rgba(255, 255, 255, 0.85); +} + +.diagram-hover-modal__list { + margin: 0 0 12px 0; + padding-left: 18px; +} + +.diagram-hover-modal__list-item { + margin-bottom: 6px; + line-height: 1.5; + font-size: 14px; + color: rgba(255, 255, 255, 0.85); +} + +.diagram-hover-modal__strong { + font-weight: 600; + color: #ffffff; +} + +.diagram-hover-modal__inline-code { + background: rgba(18, 170, 255, 0.1); + border: 1px solid rgba(18, 170, 255, 0.2); + padding: 1px 5px; + border-radius: 0; + font-family: var(--arbitrum-mono-font, var(--ifm-font-family-monospace)); + font-size: 0.85em; + color: rgba(18, 170, 255, 0.9); +} + +.diagram-hover-modal__link { + color: rgba(18, 170, 255, 0.9); + text-decoration: underline; + text-decoration-color: rgba(18, 170, 255, 0.4); + + &:hover { + color: #12aaff; + text-decoration-color: #12aaff; + } +} + +.diagram-hover-modal__body::-webkit-scrollbar { + width: 6px; +} + +.diagram-hover-modal__body::-webkit-scrollbar-track { + background: transparent; +} + +.diagram-hover-modal__body::-webkit-scrollbar-thumb { + background: rgba(18, 170, 255, 0.2); + border-radius: 0; + + &:hover { + background: rgba(18, 170, 255, 0.4); + } +} + +@media (max-width: 768px) { + .diagram-hover-modal__content { + min-width: 280px; + max-width: calc(100vw - 32px); + max-height: 50vh; + } +} + +@media (prefers-reduced-motion: reduce) { + .diagram-hover-modal__trigger, + .diagram-hover-modal__close { + transition: none; + } +} diff --git a/src/css/partials/_drawio-reactflow.scss b/src/css/partials/_drawio-reactflow.scss new file mode 100644 index 0000000000..9829e0e9c5 --- /dev/null +++ b/src/css/partials/_drawio-reactflow.scss @@ -0,0 +1,525 @@ +// Draw.io React Flow Interactive Diagrams +// Offchain Labs (offchain.io) design language: near-black canvas, hairline borders, +// flat pastel gradient cards with dark text, orange/yellow accents. + +:root { + // Offchain palette + --oc-black: #010610; + --oc-navy: #061739; + --oc-orange: #f0771b; + --oc-orange-light: #f79e19; + --oc-yellow: #faca14; + --oc-grey-150: #e8e5e2; + --oc-grey-200: #d2d2d2; + --oc-grey-400: #969696; + --oc-grey-500: #565656; + --oc-grey-550: #373737; + + // Node defaults — dark hairline cards on the black canvas + --node-rect-bg: rgba(255, 255, 255, 0.04); + --node-rect-border: var(--oc-grey-550); + --node-diamond-bg: rgba(255, 255, 255, 0.04); + --node-diamond-border: var(--oc-grey-550); + --node-circle-bg: rgba(255, 255, 255, 0.06); + --node-circle-border: var(--oc-grey-500); + --node-stadium-bg: rgba(255, 255, 255, 0.04); + --node-stadium-border: var(--oc-grey-550); + --node-round-bg: rgba(255, 255, 255, 0.04); + --node-round-border: var(--oc-grey-550); + --node-process-bg: rgba(255, 255, 255, 0.04); + --node-process-border: var(--oc-grey-550); + + // Subgraph rotating palette — hairline containers, barely-there fills + --subgraph-color-0-bg: rgba(255, 255, 255, 0.03); + --subgraph-color-0-border: var(--oc-grey-550); + --subgraph-color-1-bg: rgba(240, 119, 27, 0.05); + --subgraph-color-1-border: rgba(240, 119, 27, 0.4); + --subgraph-color-2-bg: rgba(250, 202, 20, 0.05); + --subgraph-color-2-border: rgba(250, 202, 20, 0.4); + --subgraph-color-3-bg: rgba(255, 255, 255, 0.03); + --subgraph-color-3-border: var(--oc-grey-500); + --subgraph-color-4-bg: rgba(237, 202, 173, 0.06); + --subgraph-color-4-border: rgba(237, 202, 173, 0.4); +} + +.drawio-reactflow-container { + width: 100%; + aspect-ratio: 16 / 9; + border: 1px solid var(--oc-grey-550); + border-radius: 0; + background: linear-gradient(180deg, var(--oc-grey-550) 0%, var(--oc-black) 100%); + margin: var(--ifm-spacing-vertical) 0; + overflow: hidden; + position: relative; + + // Image nodes (e.g. envelope icons) — no border, background, or shadow + .react-flow__node-image { + background: transparent !important; + border: none !important; + box-shadow: none !important; + padding: 0; + } + + // Hide ReactFlow connection handle dots + .react-flow__handle { + opacity: 0; + width: 1px; + height: 1px; + } + + // The diagram always uses the black offchain.io canvas in both site themes + &[data-theme='light'], + &[data-theme='dark'] { + .react-flow__node { + box-shadow: none; + } + + .react-flow__controls { + background-color: var(--oc-black); + border: 1px solid var(--oc-grey-550); + border-radius: 0; + + button { + background-color: transparent; + border-bottom: 1px solid var(--oc-grey-550); + color: #ffffff; + + &:hover { + background-color: rgba(255, 255, 255, 0.08); + } + } + } + } +} + +// Edge labels — yellow accent chips with near-black text +.drawio-reactflow-container, +.diagram-modal { + .react-flow__edge-textbg { + fill: var(--oc-yellow); + } + + .react-flow__edge-text { + fill: var(--oc-black); + font-weight: 500; + font-size: 15px; + } +} + +// Animated edge flow — slow, subtle directional dash movement +.drawio-reactflow-container .react-flow__edge.animated path { + stroke-dasharray: 5 5; + animation: flowDash 1.5s linear infinite; +} + +@keyframes flowDash { + to { + stroke-dashoffset: -20; + } +} + +// Slow blink animation for highlighted nodes +.node-blinking .custom-node { + animation: blink 2s ease-in-out infinite; + transition: background-color 0.3s ease; + + &:hover { + animation-play-state: paused; + background-color: rgba(240, 119, 27, 0.35) !important; + opacity: 1; + } +} + +// Custom node styling — flat offchain.io cards, sharp corners +.custom-node { + padding: 4px 8px; + font-family: var(--arbitrum-font-family, var(--ifm-font-family-base)); + font-size: 20px; + font-weight: 500; + line-height: 1.2; + color: #ffffff; + transition: all var(--arbitrum-transition-normal, 0.2s ease); + border: 1px solid var(--oc-grey-550); + border-radius: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + word-wrap: break-word; + white-space: pre-wrap; + overflow: hidden; + + &.node-static { + cursor: default; + } + + &.node-clickable { + cursor: pointer; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 4px 20px rgba(240, 119, 27, 0.2); + border-color: var(--oc-orange); + } + + &:focus { + outline: 1px solid var(--oc-orange); + outline-offset: 2px; + } + + &:active { + transform: translateY(0); + } + } + + // Shape variations — sharp corners by default + &.shape-rect { + border-radius: 0; + background-color: var(--node-rect-bg); + border-color: var(--node-rect-border); + } + + &.shape-round { + border-radius: 0; + background-color: var(--node-round-bg); + border-color: var(--node-round-border); + } + + &.shape-stadium { + border-radius: 0; + padding: 10px 30px; + background-color: var(--node-stadium-bg); + border-color: var(--node-stadium-border); + } + + &.shape-circle { + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--node-circle-bg); + border-color: var(--node-circle-border); + } + + &.shape-diamond { + border-radius: 0; + min-width: 120px; + min-height: 120px; + background-color: var(--node-diamond-bg); + border-color: var(--node-diamond-border); + } + + &.shape-process { + border-radius: 0; + background-color: var(--node-process-bg); + border-color: var(--node-process-border); + } + + // Color token overrides — offchain.io palette. + // Solid accent tokens carry near-black text; pastel gradient cards carry navy text. + &.color-orange { + background: var(--oc-orange); + border-color: var(--oc-orange-light); + color: var(--oc-black); + } + + &.color-yellow { + background: var(--oc-yellow); + border-color: var(--oc-orange-light); + color: var(--oc-black); + } + + &.color-purple { + background: linear-gradient(#cbc0b4 0%, #e8e5e2 100%); + border-color: var(--oc-grey-200); + color: var(--oc-black); + } + + &.color-red { + background: #d85a30; + border-color: var(--oc-orange-light); + color: var(--oc-black); + } + + &.color-blue { + background: linear-gradient(#ffffff 0%, #d2d2d2 100%); + border-color: var(--oc-grey-200); + color: var(--oc-black); + } + + &.color-green { + background: linear-gradient(#e8e5e2 0%, #ffffff 100%); + border-color: var(--oc-grey-200); + color: var(--oc-black); + } + + &.color-grey { + background: rgba(255, 255, 255, 0.08); + border-color: var(--oc-grey-500); + color: #ffffff; + } + + &.color-lightblue { + background: linear-gradient(#ffffff 0%, #fff7f0 100%); + border-color: var(--oc-grey-200); + color: var(--oc-black); + } + + .node-content { + word-wrap: break-word; + white-space: pre-wrap; + text-align: center; + } +} + +// Navigation controls — hairline offchain style +.diagram-nav-controls { + position: absolute; + top: 10px; + left: 10px; + z-index: 5; + + .back-button { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background-color: rgba(255, 255, 255, 0.06); + color: #ffffff; + border: 1px solid var(--oc-grey-500); + border-radius: 0; + font-family: var(--arbitrum-font-family, var(--ifm-font-family-base)); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all var(--arbitrum-transition-normal, 0.2s ease); + + &:hover { + background-color: rgba(240, 119, 27, 0.15); + border-color: var(--oc-orange); + box-shadow: 0 4px 20px rgba(240, 119, 27, 0.15); + } + + &:active { + transform: scale(0.98); + } + + &:focus-visible { + outline: 1px solid var(--oc-orange); + outline-offset: 2px; + } + + .back-icon { + font-size: 16px; + line-height: 1; + } + } +} + +// Responsive design +@media (max-width: 768px) { + .drawio-reactflow-container { + .custom-node { + font-size: 16px; + padding: 8px 12px; + } + + .back-button { + font-size: 12px; + padding: 6px 12px; + } + } +} + +// Accessibility - high contrast mode +@media (prefers-contrast: high) { + .custom-node.node-clickable { + border-width: 3px; + } + + .back-button { + border-width: 2px; + } +} + +// Accessibility - reduced motion +@media (prefers-reduced-motion: reduce) { + .custom-node, + .back-button { + transition: none; + } + + .custom-node.node-clickable:hover { + transform: none; + } +} + +// Subgraph/Group Node Styles +.react-flow__node-group { + transform: none !important; + + .subgraph-node { + width: 100%; + height: 100%; + position: relative; + transform: none !important; + } +} + +.subgraph-header { + position: absolute; + top: 12px; + left: 12px; + z-index: 10; + display: flex; + align-items: center; + gap: 8px; +} + +.subgraph-title { + margin: 0; + font-size: 13px; + font-weight: 500; + text-align: left; + font-family: var(--arbitrum-font-family, var(--ifm-font-family-base)); + background: var(--oc-black); + padding: 6px 14px; + border-radius: 0; + display: inline-block; + border: 1px solid var(--oc-grey-550); + color: #f0f0f0; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.drawio-reactflow-container[data-theme='dark'] { + .subgraph-title { + background: var(--oc-black); + color: #f0f0f0; + border-color: var(--oc-grey-550); + } +} + +// Diagram modal — full-screen overlay rendering a nested ReactFlow diagram +.diagram-modal-backdrop { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(1, 6, 16, 0.78); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + padding: 2vh 5vw; +} + +.diagram-modal { + position: relative; + width: min(90vw, 1600px); + height: 85vh; + display: flex; + flex-direction: column; + background: linear-gradient(180deg, var(--oc-grey-550) 0%, var(--oc-black) 100%); + border: 1px solid var(--oc-grey-550); + border-radius: 0; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.55); + overflow: hidden; +} + +.diagram-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 20px; + background-color: rgba(255, 255, 255, 0.04); + border-bottom: 1px solid var(--oc-grey-550); +} + +.diagram-modal__title { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #ffffff; + letter-spacing: 0.01em; +} + +.diagram-modal__close { + appearance: none; + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + border: 1px solid var(--oc-grey-500); + border-radius: 0; + width: 32px; + height: 32px; + font-size: 20px; + line-height: 1; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background-color 150ms ease, transform 150ms ease; + + &:hover { + background: rgba(255, 255, 255, 0.18); + } + + &:focus-visible { + outline: 2px solid var(--oc-orange); + outline-offset: 2px; + } +} + +.diagram-modal__body { + flex: 1 1 auto; + position: relative; + min-height: 0; + + .react-flow { + width: 100%; + height: 100%; + } +} + +.diagram-modal__status { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.8); + font-size: 14px; +} + +.diagram-modal__status--error { + color: var(--ifm-color-danger); +} + +// Responsive — smaller viewports get a tighter modal +@media (max-width: 768px) { + .diagram-modal-backdrop { + padding: 0; + } + + .diagram-modal { + width: 100vw; + height: 100vh; + border-radius: 0; + border: none; + } + + .diagram-modal__title { + font-size: 14px; + } +} + +// Reduced motion — suppress backdrop blur animation +@media (prefers-reduced-motion: reduce) { + .diagram-modal-backdrop, + .diagram-modal { + transition: none; + } +} diff --git a/static/diagrams/haw-transaction-lifecycle.excalidraw b/static/diagrams/haw-transaction-lifecycle.excalidraw new file mode 100644 index 0000000000..7047ca509d --- /dev/null +++ b/static/diagrams/haw-transaction-lifecycle.excalidraw @@ -0,0 +1,1817 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "elements": [ + { + "id": "user", + "type": "rectangle", + "x": 0, + "y": 0, + "width": 220, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1000, + "version": 1, + "versionNonce": 1001, + "isDeleted": false, + "boundElements": [ + { + "id": "user-label", + "type": "text" + }, + { + "id": "a-user-sequencer", + "type": "arrow" + }, + { + "id": "a-user-delayed", + "type": "arrow" + } + ], + "updated": 1783383929124, + "link": null, + "locked": false, + "customData": { + "colorToken": "grey" + } + }, + { + "id": "user-label", + "type": "text", + "x": 105, + "y": 28, + "width": 10, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1002, + "version": 1, + "versionNonce": 1003, + "isDeleted": false, + "boundElements": null, + "updated": 1783383929124, + "link": null, + "locked": false, + "text": "User transactions", + "fontSize": 20, + "fontFamily": 6, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "user", + "originalText": "User transactions", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "sequencer", + "type": "rectangle", + "x": 260, + "y": 0, + "width": 220, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1004, + "version": 1, + "versionNonce": 1005, + "isDeleted": false, + "boundElements": [ + { + "id": "sequencer-label", + "type": "text" + }, + { + "id": "a-user-sequencer", + "type": "arrow" + }, + { + "id": "a-sequencer-feed", + "type": "arrow" + }, + { + "id": "a-sequencer-seqtxs", + "type": "arrow" + }, + { + "id": "a-delayed-sequencer", + "type": "arrow" + } + ], + "updated": 1783383929124, + "link": null, + "locked": false, + "customData": { + "colorToken": "blue" + } + }, + { + "id": "sequencer-label", + "type": "text", + "x": 365, + "y": 28, + "width": 10, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1006, + "version": 1, + "versionNonce": 1007, + "isDeleted": false, + "boundElements": null, + "updated": 1783383929124, + "link": null, + "locked": false, + "text": "Sequencer", + "fontSize": 20, + "fontFamily": 6, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "sequencer", + "originalText": "Sequencer", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "feed", + "type": "rectangle", + "x": 520, + "y": 0, + "width": 220, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1008, + "version": 1, + "versionNonce": 1009, + "isDeleted": false, + "boundElements": [ + { + "id": "feed-label", + "type": "text" + }, + { + "id": "a-sequencer-feed", + "type": "arrow" + } + ], + "updated": 1783383929124, + "link": null, + "locked": false, + "customData": { + "colorToken": "blue" + } + }, + { + "id": "feed-label", + "type": "text", + "x": 625, + "y": 28, + "width": 10, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1010, + "version": 1, + "versionNonce": 1011, + "isDeleted": false, + "boundElements": null, + "updated": 1783383929124, + "link": null, + "locked": false, + "text": "Sequencer feed\nSoft confirmation", + "fontSize": 20, + "fontFamily": 6, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "feed", + "originalText": "Sequencer feed\nSoft confirmation", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "seq-txs", + "type": "rectangle", + "x": 260, + "y": 130, + "width": 220, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1012, + "version": 1, + "versionNonce": 1013, + "isDeleted": false, + "boundElements": [ + { + "id": "seq-txs-label", + "type": "text" + }, + { + "id": "a-sequencer-seqtxs", + "type": "arrow" + }, + { + "id": "a-seqtxs-stf", + "type": "arrow" + }, + { + "id": "a-seqtxs-batch", + "type": "arrow" + } + ], + "updated": 1783383929124, + "link": null, + "locked": false, + "customData": { + "colorToken": "blue" + } + }, + { + "id": "seq-txs-label", + "type": "text", + "x": 365, + "y": 158, + "width": 10, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1014, + "version": 1, + "versionNonce": 1015, + "isDeleted": false, + "boundElements": null, + "updated": 1783383929124, + "link": null, + "locked": false, + "text": "Sequenced transactions", + "fontSize": 20, + "fontFamily": 6, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "seq-txs", + "originalText": "Sequenced transactions", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "stf", + "type": "rectangle", + "x": 520, + "y": 130, + "width": 220, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1016, + "version": 1, + "versionNonce": 1017, + "isDeleted": false, + "boundElements": [ + { + "id": "stf-label", + "type": "text" + }, + { + "id": "a-seqtxs-stf", + "type": "arrow" + }, + { + "id": "a-stf-l2blocks", + "type": "arrow" + } + ], + "updated": 1783383929124, + "link": null, + "locked": false, + "customData": { + "colorToken": "purple" + } + }, + { + "id": "stf-label", + "type": "text", + "x": 625, + "y": 158, + "width": 10, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1018, + "version": 1, + "versionNonce": 1019, + "isDeleted": false, + "boundElements": null, + "updated": 1783383929124, + "link": null, + "locked": false, + "text": "State Transition Function\nArbOS + Geth", + "fontSize": 20, + "fontFamily": 6, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "stf", + "originalText": "State Transition Function\nArbOS + Geth", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "batch", + "type": "rectangle", + "x": 260, + "y": 260, + "width": 220, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1020, + "version": 1, + "versionNonce": 1021, + "isDeleted": false, + "boundElements": [ + { + "id": "batch-label", + "type": "text" + }, + { + "id": "a-seqtxs-batch", + "type": "arrow" + }, + { + "id": "a-batch-seqinbox", + "type": "arrow" + } + ], + "updated": 1783383929124, + "link": null, + "locked": false, + "customData": { + "colorToken": "blue" + } + }, + { + "id": "batch-label", + "type": "text", + "x": 365, + "y": 288, + "width": 10, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1022, + "version": 1, + "versionNonce": 1023, + "isDeleted": false, + "boundElements": null, + "updated": 1783383929124, + "link": null, + "locked": false, + "text": "Batch and compress", + "fontSize": 20, + "fontFamily": 6, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "batch", + "originalText": "Batch and compress", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "l2-blocks", + "type": "rectangle", + "x": 520, + "y": 260, + "width": 220, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1024, + "version": 1, + "versionNonce": 1025, + "isDeleted": false, + "boundElements": [ + { + "id": "l2-blocks-label", + "type": "text" + }, + { + "id": "a-stf-l2blocks", + "type": "arrow" + }, + { + "id": "a-l2blocks-validator", + "type": "arrow" + } + ], + "updated": 1783383929124, + "link": null, + "locked": false, + "customData": { + "colorToken": "purple" + } + }, + { + "id": "l2-blocks-label", + "type": "text", + "x": 625, + "y": 288, + "width": 10, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1026, + "version": 1, + "versionNonce": 1027, + "isDeleted": false, + "boundElements": null, + "updated": 1783383929124, + "link": null, + "locked": false, + "text": "L2 blocks\nNew state and receipts", + "fontSize": 20, + "fontFamily": 6, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "l2-blocks", + "originalText": "L2 blocks\nNew state and receipts", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "validator", + "type": "rectangle", + "x": 520, + "y": 390, + "width": 220, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1028, + "version": 1, + "versionNonce": 1029, + "isDeleted": false, + "boundElements": [ + { + "id": "validator-label", + "type": "text" + }, + { + "id": "a-l2blocks-validator", + "type": "arrow" + }, + { + "id": "a-validator-rollup", + "type": "arrow" + }, + { + "id": "a-seqinbox-validator", + "type": "arrow" + } + ], + "updated": 1783383929124, + "link": null, + "locked": false, + "customData": { + "colorToken": "purple" + } + }, + { + "id": "validator-label", + "type": "text", + "x": 625, + "y": 418, + "width": 10, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1030, + "version": 1, + "versionNonce": 1031, + "isDeleted": false, + "boundElements": null, + "updated": 1783383929124, + "link": null, + "locked": false, + "text": "Validator\nRe-runs the STF", + "fontSize": 20, + "fontFamily": 6, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "validator", + "originalText": "Validator\nRe-runs the STF", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "parent-chain", + "type": "rectangle", + "x": 0, + "y": 520, + "width": 740, + "height": 190, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1032, + "version": 1, + "versionNonce": 1033, + "isDeleted": false, + "boundElements": [ + { + "id": "parent-chain-label", + "type": "text" + } + ], + "updated": 1783383929124, + "link": null, + "locked": false + }, + { + "id": "parent-chain-label", + "type": "text", + "x": 365, + "y": 603, + "width": 10, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1034, + "version": 1, + "versionNonce": 1035, + "isDeleted": false, + "boundElements": null, + "updated": 1783383929124, + "link": null, + "locked": false, + "text": "Ethereum (parent chain)", + "fontSize": 20, + "fontFamily": 6, + "textAlign": "left", + "verticalAlign": "top", + "containerId": "parent-chain", + "originalText": "Ethereum (parent chain)", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "delayed-inbox", + "type": "rectangle", + "x": 70, + "y": 590, + "width": 200, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1036, + "version": 1, + "versionNonce": 1037, + "isDeleted": false, + "boundElements": [ + { + "id": "delayed-inbox-label", + "type": "text" + }, + { + "id": "a-user-delayed", + "type": "arrow" + }, + { + "id": "a-delayed-sequencer", + "type": "arrow" + } + ], + "updated": 1783383929124, + "link": null, + "locked": false, + "customData": { + "colorToken": "orange" + } + }, + { + "id": "delayed-inbox-label", + "type": "text", + "x": 165, + "y": 618, + "width": 10, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1038, + "version": 1, + "versionNonce": 1039, + "isDeleted": false, + "boundElements": null, + "updated": 1783383929124, + "link": null, + "locked": false, + "text": "Delayed Inbox", + "fontSize": 20, + "fontFamily": 6, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "delayed-inbox", + "originalText": "Delayed Inbox", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "seq-inbox", + "type": "rectangle", + "x": 280, + "y": 590, + "width": 180, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1040, + "version": 1, + "versionNonce": 1041, + "isDeleted": false, + "boundElements": [ + { + "id": "seq-inbox-label", + "type": "text" + }, + { + "id": "a-batch-seqinbox", + "type": "arrow" + }, + { + "id": "a-seqinbox-validator", + "type": "arrow" + } + ], + "updated": 1783383929124, + "link": null, + "locked": false, + "customData": { + "colorToken": "orange" + } + }, + { + "id": "seq-inbox-label", + "type": "text", + "x": 365, + "y": 618, + "width": 10, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1042, + "version": 1, + "versionNonce": 1043, + "isDeleted": false, + "boundElements": null, + "updated": 1783383929124, + "link": null, + "locked": false, + "text": "Sequencer Inbox", + "fontSize": 20, + "fontFamily": 6, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "seq-inbox", + "originalText": "Sequencer Inbox", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "rollup", + "type": "rectangle", + "x": 540, + "y": 590, + "width": 180, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1044, + "version": 1, + "versionNonce": 1045, + "isDeleted": false, + "boundElements": [ + { + "id": "rollup-label", + "type": "text" + }, + { + "id": "a-validator-rollup", + "type": "arrow" + } + ], + "updated": 1783383929124, + "link": null, + "locked": false, + "customData": { + "colorToken": "orange" + } + }, + { + "id": "rollup-label", + "type": "text", + "x": 625, + "y": 618, + "width": 10, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1046, + "version": 1, + "versionNonce": 1047, + "isDeleted": false, + "boundElements": null, + "updated": 1783383929124, + "link": null, + "locked": false, + "text": "Rollup contract", + "fontSize": 20, + "fontFamily": 6, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "rollup", + "originalText": "Rollup contract", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "a-user-sequencer", + "type": "arrow", + "x": 220, + "y": 40, + "width": 40, + "height": 0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1048, + "version": 1, + "versionNonce": 1049, + "isDeleted": false, + "boundElements": [], + "updated": 1783383929124, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 40, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "user", + "mode": "orbit", + "fixedPoint": [ + 1, + 0.5 + ] + }, + "endBinding": { + "elementId": "sequencer", + "mode": "orbit", + "fixedPoint": [ + 0, + 0.5 + ] + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a-sequencer-feed", + "type": "arrow", + "x": 480, + "y": 40, + "width": 40, + "height": 0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1050, + "version": 1, + "versionNonce": 1051, + "isDeleted": false, + "boundElements": [], + "updated": 1783383929124, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 40, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "sequencer", + "mode": "orbit", + "fixedPoint": [ + 1, + 0.5 + ] + }, + "endBinding": { + "elementId": "feed", + "mode": "orbit", + "fixedPoint": [ + 0, + 0.5 + ] + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a-sequencer-seqtxs", + "type": "arrow", + "x": 370, + "y": 80, + "width": 0, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1052, + "version": 1, + "versionNonce": 1053, + "isDeleted": false, + "boundElements": [], + "updated": 1783383929124, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0, + 50 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "sequencer", + "mode": "orbit", + "fixedPoint": [ + 0.5, + 1 + ] + }, + "endBinding": { + "elementId": "seq-txs", + "mode": "orbit", + "fixedPoint": [ + 0.5, + 0 + ] + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a-seqtxs-stf", + "type": "arrow", + "x": 480, + "y": 170, + "width": 40, + "height": 0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1054, + "version": 1, + "versionNonce": 1055, + "isDeleted": false, + "boundElements": [], + "updated": 1783383929124, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 40, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "seq-txs", + "mode": "orbit", + "fixedPoint": [ + 1, + 0.5 + ] + }, + "endBinding": { + "elementId": "stf", + "mode": "orbit", + "fixedPoint": [ + 0, + 0.5 + ] + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a-seqtxs-batch", + "type": "arrow", + "x": 370, + "y": 210, + "width": 0, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1056, + "version": 1, + "versionNonce": 1057, + "isDeleted": false, + "boundElements": [], + "updated": 1783383929124, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0, + 50 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "seq-txs", + "mode": "orbit", + "fixedPoint": [ + 0.5, + 1 + ] + }, + "endBinding": { + "elementId": "batch", + "mode": "orbit", + "fixedPoint": [ + 0.5, + 0 + ] + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a-stf-l2blocks", + "type": "arrow", + "x": 630, + "y": 210, + "width": 0, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1058, + "version": 1, + "versionNonce": 1059, + "isDeleted": false, + "boundElements": [], + "updated": 1783383929124, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0, + 50 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "stf", + "mode": "orbit", + "fixedPoint": [ + 0.5, + 1 + ] + }, + "endBinding": { + "elementId": "l2-blocks", + "mode": "orbit", + "fixedPoint": [ + 0.5, + 0 + ] + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a-l2blocks-validator", + "type": "arrow", + "x": 630, + "y": 340, + "width": 0, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1060, + "version": 1, + "versionNonce": 1061, + "isDeleted": false, + "boundElements": [], + "updated": 1783383929124, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0, + 50 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "l2-blocks", + "mode": "orbit", + "fixedPoint": [ + 0.5, + 1 + ] + }, + "endBinding": { + "elementId": "validator", + "mode": "orbit", + "fixedPoint": [ + 0.5, + 0 + ] + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a-validator-rollup", + "type": "arrow", + "x": 630, + "y": 470, + "width": 0, + "height": 120, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1062, + "version": 1, + "versionNonce": 1063, + "isDeleted": false, + "boundElements": [ + { + "id": "a-validator-rollup-label", + "type": "text" + } + ], + "updated": 1783383929124, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0, + 120 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "validator", + "mode": "orbit", + "fixedPoint": [ + 0.5, + 1 + ] + }, + "endBinding": { + "elementId": "rollup", + "mode": "orbit", + "fixedPoint": [ + 0.5, + 0 + ] + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a-validator-rollup-label", + "type": "text", + "x": 630, + "y": 530, + "width": 10, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1064, + "version": 1, + "versionNonce": 1065, + "isDeleted": false, + "boundElements": null, + "updated": 1783383929124, + "link": null, + "locked": false, + "text": "Assertion", + "fontSize": 20, + "fontFamily": 6, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "a-validator-rollup", + "originalText": "Assertion", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "a-batch-seqinbox", + "type": "arrow", + "x": 370, + "y": 340, + "width": 0, + "height": 250, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1066, + "version": 1, + "versionNonce": 1067, + "isDeleted": false, + "boundElements": [], + "updated": 1783383929124, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0, + 250 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "batch", + "mode": "orbit", + "fixedPoint": [ + 0.5, + 1 + ] + }, + "endBinding": { + "elementId": "seq-inbox", + "mode": "orbit", + "fixedPoint": [ + 0.5, + 0 + ] + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a-user-delayed", + "type": "arrow", + "x": 110, + "y": 80, + "width": 0, + "height": 510, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1068, + "version": 1, + "versionNonce": 1069, + "isDeleted": false, + "boundElements": [ + { + "id": "a-user-delayed-label", + "type": "text" + } + ], + "updated": 1783383929124, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0, + 510 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "user", + "mode": "orbit", + "fixedPoint": [ + 0.5, + 1 + ] + }, + "endBinding": { + "elementId": "delayed-inbox", + "mode": "orbit", + "fixedPoint": [ + 0.2, + 0 + ] + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a-user-delayed-label", + "type": "text", + "x": 110, + "y": 335, + "width": 10, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1070, + "version": 1, + "versionNonce": 1071, + "isDeleted": false, + "boundElements": null, + "updated": 1783383929124, + "link": null, + "locked": false, + "text": "L1 transaction", + "fontSize": 20, + "fontFamily": 6, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "a-user-delayed", + "originalText": "L1 transaction", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "a-delayed-sequencer", + "type": "arrow", + "x": 170, + "y": 590, + "width": 90, + "height": 526, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1072, + "version": 1, + "versionNonce": 1073, + "isDeleted": false, + "boundElements": [], + "updated": 1783383929124, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 90, + -526 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "delayed-inbox", + "mode": "orbit", + "fixedPoint": [ + 0.5, + 0 + ] + }, + "endBinding": { + "elementId": "sequencer", + "mode": "orbit", + "fixedPoint": [ + 0, + 0.8 + ] + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false, + "customData": { + "edgeType": "straight" + } + }, + { + "id": "a-seqinbox-validator", + "type": "arrow", + "x": 424, + "y": 590, + "width": 96, + "height": 160, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1074, + "version": 1, + "versionNonce": 1075, + "isDeleted": false, + "boundElements": [ + { + "id": "a-seqinbox-validator-label", + "type": "text" + } + ], + "updated": 1783383929124, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 96, + -160 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "seq-inbox", + "mode": "orbit", + "fixedPoint": [ + 0.8, + 0 + ] + }, + "endBinding": { + "elementId": "validator", + "mode": "orbit", + "fixedPoint": [ + 0, + 0.5 + ] + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a-seqinbox-validator-label", + "type": "text", + "x": 472, + "y": 510, + "width": 10, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1076, + "version": 1, + "versionNonce": 1077, + "isDeleted": false, + "boundElements": null, + "updated": 1783383929124, + "link": null, + "locked": false, + "text": "Batch data", + "fontSize": 20, + "fontFamily": 6, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "a-seqinbox-validator", + "originalText": "Batch data", + "autoResize": true, + "lineHeight": 1.25 + } + ], + "appState": { + "gridSize": 20, + "gridStep": 5, + "gridModeEnabled": false, + "viewBackgroundColor": "#ffffff" + }, + "files": {} +} \ No newline at end of file diff --git a/static/diagrams/haw-transaction-lifecycle.manifest.xml b/static/diagrams/haw-transaction-lifecycle.manifest.xml new file mode 100644 index 0000000000..40a63bd37f --- /dev/null +++ b/static/diagrams/haw-transaction-lifecycle.manifest.xml @@ -0,0 +1,11 @@ + + + + diff --git a/yarn.lock b/yarn.lock index 382c083828..7e0d8fd88a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5177,6 +5177,72 @@ "@react-spring/types" "~10.1.2" csstype "^3.2.3" +"@reactflow/background@11.3.14": + version "11.3.14" + resolved "https://registry.yarnpkg.com/@reactflow/background/-/background-11.3.14.tgz#778ca30174f3de77fc321459ab3789e66e71a699" + integrity sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA== + dependencies: + "@reactflow/core" "11.11.4" + classcat "^5.0.3" + zustand "^4.4.1" + +"@reactflow/controls@11.2.14": + version "11.2.14" + resolved "https://registry.yarnpkg.com/@reactflow/controls/-/controls-11.2.14.tgz#508ed2c40d23341b3b0919dd11e76fd49cf850c7" + integrity sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw== + dependencies: + "@reactflow/core" "11.11.4" + classcat "^5.0.3" + zustand "^4.4.1" + +"@reactflow/core@11.11.4": + version "11.11.4" + resolved "https://registry.yarnpkg.com/@reactflow/core/-/core-11.11.4.tgz#89bd86d1862aa1416f3f49926cede7e8c2aab6a7" + integrity sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q== + dependencies: + "@types/d3" "^7.4.0" + "@types/d3-drag" "^3.0.1" + "@types/d3-selection" "^3.0.3" + "@types/d3-zoom" "^3.0.1" + classcat "^5.0.3" + d3-drag "^3.0.0" + d3-selection "^3.0.0" + d3-zoom "^3.0.0" + zustand "^4.4.1" + +"@reactflow/minimap@11.7.14": + version "11.7.14" + resolved "https://registry.yarnpkg.com/@reactflow/minimap/-/minimap-11.7.14.tgz#298d7a63cb1da06b2518c99744f716560c88ca73" + integrity sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ== + dependencies: + "@reactflow/core" "11.11.4" + "@types/d3-selection" "^3.0.3" + "@types/d3-zoom" "^3.0.1" + classcat "^5.0.3" + d3-selection "^3.0.0" + d3-zoom "^3.0.0" + zustand "^4.4.1" + +"@reactflow/node-resizer@2.2.14": + version "2.2.14" + resolved "https://registry.yarnpkg.com/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz#1810c0ce51aeb936f179466a6660d1e02c7a77a8" + integrity sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA== + dependencies: + "@reactflow/core" "11.11.4" + classcat "^5.0.4" + d3-drag "^3.0.0" + d3-selection "^3.0.0" + zustand "^4.4.1" + +"@reactflow/node-toolbar@1.3.14": + version "1.3.14" + resolved "https://registry.yarnpkg.com/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz#c6ffc76f82acacdce654f2160dc9852162d6e7c9" + integrity sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ== + dependencies: + "@reactflow/core" "11.11.4" + classcat "^5.0.3" + zustand "^4.4.1" + "@sec-ant/readable-stream@^0.4.1": version "0.4.1" resolved "https://registry.yarnpkg.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz#60de891bb126abfdc5410fdc6166aca065f10a0c" @@ -5457,7 +5523,7 @@ resolved "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz" integrity sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ== -"@types/d3-drag@*": +"@types/d3-drag@*", "@types/d3-drag@^3.0.1": version "3.0.7" resolved "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz" integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ== @@ -5542,7 +5608,7 @@ dependencies: "@types/d3-time" "*" -"@types/d3-selection@*": +"@types/d3-selection@*", "@types/d3-selection@^3.0.3": version "3.0.11" resolved "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz" integrity sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w== @@ -5576,7 +5642,7 @@ dependencies: "@types/d3-selection" "*" -"@types/d3-zoom@*": +"@types/d3-zoom@*", "@types/d3-zoom@^3.0.1": version "3.0.8" resolved "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz" integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw== @@ -5584,7 +5650,7 @@ "@types/d3-interpolate" "*" "@types/d3-selection" "*" -"@types/d3@^7.4.3": +"@types/d3@^7.4.0", "@types/d3@^7.4.3": version "7.4.3" resolved "https://registry.yarnpkg.com/@types/d3/-/d3-7.4.3.tgz#d4550a85d08f4978faf0a4c36b848c61eaac07e2" integrity sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww== @@ -7057,6 +7123,11 @@ class-variance-authority@0.7.1: dependencies: clsx "^2.1.1" +classcat@^5.0.3, classcat@^5.0.4: + version "5.0.5" + resolved "https://registry.yarnpkg.com/classcat/-/classcat-5.0.5.tgz#8c209f359a93ac302404a10161b501eba9c09c77" + integrity sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w== + clean-css@^5.2.2, clean-css@^5.3.3, clean-css@~5.3.2: version "5.3.3" resolved "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz" @@ -7634,7 +7705,7 @@ d3-delaunay@6: resolved "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz" integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg== -"d3-drag@2 - 3", d3-drag@3: +"d3-drag@2 - 3", d3-drag@3, d3-drag@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz" integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg== @@ -7797,7 +7868,7 @@ d3-shape@^1.2.0: d3-interpolate "1 - 3" d3-timer "1 - 3" -d3-zoom@3: +d3-zoom@3, d3-zoom@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz" integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw== @@ -8768,6 +8839,15 @@ fraction.js@^5.3.4: resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-5.3.4.tgz#8c0fcc6a9908262df4ed197427bdeef563e0699a" integrity sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ== +framer-motion@^12.42.2: + version "12.42.2" + resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-12.42.2.tgz#8628ad31a9b5c1ea6f908ea1764784e33870b711" + integrity sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw== + dependencies: + motion-dom "^12.42.2" + motion-utils "^12.39.0" + tslib "^2.4.0" + fresh@~0.5.2: version "0.5.2" resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz" @@ -11341,6 +11421,26 @@ mlly@^1.7.4, mlly@^1.8.0: pkg-types "^1.3.1" ufo "^1.6.3" +motion-dom@^12.42.2: + version "12.42.2" + resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.42.2.tgz#b4661b9b3394ae7e990d76dc954bc1e321c59305" + integrity sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA== + dependencies: + motion-utils "^12.39.0" + +motion-utils@^12.39.0: + version "12.39.0" + resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.39.0.tgz#e1c66f0e912999804bc5e69b4630c3bc794ef29f" + integrity sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ== + +motion@^12.23.24: + version "12.42.2" + resolved "https://registry.yarnpkg.com/motion/-/motion-12.42.2.tgz#7a0954acb1975e1899ad65639a46fc12a3d24999" + integrity sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q== + dependencies: + framer-motion "^12.42.2" + tslib "^2.4.0" + mrmime@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz" @@ -12829,6 +12929,18 @@ react@^19.2.7: resolved "https://registry.yarnpkg.com/react/-/react-19.2.7.tgz#1f47a1bfc06f8ec885752c6f4af14369a9f8260b" integrity sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ== +reactflow@^11.11.4: + version "11.11.4" + resolved "https://registry.yarnpkg.com/reactflow/-/reactflow-11.11.4.tgz#e3593e313420542caed81aecbd73fb9bc6576653" + integrity sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og== + dependencies: + "@reactflow/background" "11.3.14" + "@reactflow/controls" "11.2.14" + "@reactflow/core" "11.11.4" + "@reactflow/minimap" "11.7.14" + "@reactflow/node-resizer" "2.2.14" + "@reactflow/node-toolbar" "1.3.14" + readable-stream@^2.0.1: version "2.3.8" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz" @@ -14440,6 +14552,11 @@ use-sidecar@^1.1.3: detect-node-es "^1.1.0" tslib "^2.0.0" +use-sync-external-store@^1.2.2: + version "1.6.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" + integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== + use-sync-external-store@^1.4.0: version "1.5.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#55122e2a3edd2a6c106174c27485e0fd59bcfca0" @@ -14789,6 +14906,13 @@ yocto-queue@^1.0.0: resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== +zustand@^4.4.1: + version "4.5.7" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.5.7.tgz#7d6bb2026a142415dd8be8891d7870e6dbe65f55" + integrity sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw== + dependencies: + use-sync-external-store "^1.2.2" + zwitch@^2.0.0, zwitch@^2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz" From 75a5712653ef0332aad908fce8bbe0b811ace73c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= Date: Mon, 6 Jul 2026 17:34:42 -0700 Subject: [PATCH 2/4] Convert PNG diagram into a Reactflow diagram --- docs/how-arbitrum-works/01-inside-arbitrum-nitro.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/how-arbitrum-works/01-inside-arbitrum-nitro.mdx b/docs/how-arbitrum-works/01-inside-arbitrum-nitro.mdx index 52fdc29f10..985b5b0c3f 100644 --- a/docs/how-arbitrum-works/01-inside-arbitrum-nitro.mdx +++ b/docs/how-arbitrum-works/01-inside-arbitrum-nitro.mdx @@ -7,15 +7,15 @@ user_story: "As a developer, I need to understand how transactions flow through content_type: concept --- +import DrawioReactFlow from '@site/src/components/DrawioReactFlow'; + ## Transaction processing journey on Arbitrum As a developer initiating a transaction on Arbitrum, gaining a clear understanding of the end-to-end flow—from initial submission through to finality—is helpful, but it isn't required. This overview methodically traces the transaction lifecycle, emphasizing how Arbitrum’s architecture ensures precise, efficient, and secure handling at every stage. This article covers the complete Arbitrum Nitro stack, beginning with the Sequencer, which is responsible for transaction ordering, advancing to the State Transition Function (STF) for execution, and culminating in validation mechanisms that uphold integrity. - - Transaction lifecycle - + Along the way, you'll learn about Arbitrum's ability to deliver security on par with Ethereum, while achieving fee reductions by a factor of ten and transaction speeds accelerated by a factor of 100 using optimized mechanisms. From 8f498182ace55247b89b345444539d1d8f6dd37d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= Date: Mon, 6 Jul 2026 17:39:33 -0700 Subject: [PATCH 3/4] Potential fix for pull request finding 'CodeQL / Double escaping or unescaping' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/components/DrawioReactFlow/utils/drawioToReactFlow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/DrawioReactFlow/utils/drawioToReactFlow.ts b/src/components/DrawioReactFlow/utils/drawioToReactFlow.ts index 7be65c8764..14e2bc03e1 100644 --- a/src/components/DrawioReactFlow/utils/drawioToReactFlow.ts +++ b/src/components/DrawioReactFlow/utils/drawioToReactFlow.ts @@ -46,9 +46,9 @@ function stripHtml(html: string): string { .replace(/<\/div>/gi, '\n') .replace(/]*>/gi, '\n') .replace(/<[^>]*>/g, '') - .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, "'") + .replace(/&/g, '&') .replace(/\n{2,}/g, '\n') .trim(); } From 90a63ce96348c5a1239ccd6cd10e74049d434d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= Date: Mon, 6 Jul 2026 17:45:48 -0700 Subject: [PATCH 4/4] Fix CodeQL sanitization alert in drawio label stripping --- .../DrawioReactFlow/utils/drawioToReactFlow.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/DrawioReactFlow/utils/drawioToReactFlow.ts b/src/components/DrawioReactFlow/utils/drawioToReactFlow.ts index 14e2bc03e1..c46c8f8350 100644 --- a/src/components/DrawioReactFlow/utils/drawioToReactFlow.ts +++ b/src/components/DrawioReactFlow/utils/drawioToReactFlow.ts @@ -41,16 +41,16 @@ function parseStyleString(style: string): Record { function stripHtml(html: string): string { if (!html) return ''; - return html + const withBreaks = html .replace(//gi, '\n') .replace(/<\/div>/gi, '\n') - .replace(/]*>/gi, '\n') - .replace(/<[^>]*>/g, '') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, '&') - .replace(/\n{2,}/g, '\n') - .trim(); + .replace(/]*>/gi, '\n'); + // Parse as inert HTML and read textContent: strips every tag (however nested or + // malformed) and decodes all entities, with no regex sanitization to get wrong. + // DOMParser does not execute scripts, and this module only runs client-side + // (the component is wrapped in BrowserOnly). + const text = new DOMParser().parseFromString(withBreaks, 'text/html').body.textContent ?? ''; + return text.replace(/\n{2,}/g, '\n').trim(); } function hasTopAlignedLabel(html: string): boolean {