From 54cfdc829c3c6c6b3e3af988084ed1d7c0d707c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Bokisch?= Date: Mon, 23 Mar 2026 10:26:43 +0100 Subject: [PATCH] docs: add flow and machine documentation pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @pyreon/flow — reactive flow diagrams with auto-layout, selection, undo/redo - @pyreon/machine — reactive state machines with type-safe transitions Co-Authored-By: Claude Opus 4.6 (1M context) --- content/docs/flow/index.mdx | 466 +++++++++++++++++++++++++++++++++ content/docs/machine/index.mdx | 425 ++++++++++++++++++++++++++++++ 2 files changed, 891 insertions(+) create mode 100644 content/docs/flow/index.mdx create mode 100644 content/docs/machine/index.mdx diff --git a/content/docs/flow/index.mdx b/content/docs/flow/index.mdx new file mode 100644 index 0000000..54ccf5c --- /dev/null +++ b/content/docs/flow/index.mdx @@ -0,0 +1,466 @@ +--- +title: Flow +description: Reactive flow diagrams for Pyreon — signal-native nodes, edges, pan/zoom, auto-layout via elkjs. +--- + +`@pyreon/flow` provides reactive flow diagrams for Pyreon. Signal-native nodes and edges, pan/zoom without D3, auto-layout via elkjs, and per-node O(1) reactivity. Built from the ground up for signal-based frameworks. + + + +## Installation + +```package-install +@pyreon/flow +``` + +## Quick Start + +```tsx +import { createFlow, Flow, Background, MiniMap, Controls } from '@pyreon/flow' + +const flow = createFlow({ + nodes: [ + { id: '1', type: 'input', position: { x: 0, y: 0 }, data: { label: 'Start' } }, + { id: '2', position: { x: 200, y: 100 }, data: { label: 'Process' } }, + { id: '3', type: 'output', position: { x: 400, y: 0 }, data: { label: 'End' } }, + ], + edges: [ + { source: '1', target: '2' }, + { source: '2', target: '3' }, + ], +}) + +function WorkflowBuilder() { + return ( + + + + + + ) +} +``` + +No callbacks, no `applyNodeChanges`. The flow instance manages everything. + +## Creating a Flow + +`createFlow()` accepts a config object and returns a reactive `FlowInstance`: + +```tsx +const flow = createFlow({ + nodes: [...], + edges: [...], + snapToGrid: true, + snapGrid: 20, + connectionRules: { ... }, + nodeExtent: { x: [0, 1000], y: [0, 800] }, +}) +``` + +### Config Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `nodes` | `FlowNode[]` | `[]` | Initial nodes | +| `edges` | `FlowEdge[]` | `[]` | Initial edges | +| `snapToGrid` | `boolean` | `false` | Snap node positions to grid | +| `snapGrid` | `number` | `20` | Grid size in pixels | +| `connectionRules` | `Record` | — | Connection validation rules by node type | +| `nodeExtent` | `{ x: [min, max], y: [min, max] }` | — | Constrain node positions within bounds | +| `minZoom` | `number` | `0.1` | Minimum zoom level | +| `maxZoom` | `number` | `4` | Maximum zoom level | + +## Reactive Signals + +All state is exposed as reactive signals: + +```tsx +flow.nodes() // Signal +flow.edges() // Signal +flow.viewport() // Signal — { x, y, zoom } +flow.zoom() // Computed — just the zoom level +flow.selectedNodes() // Computed +flow.selectedEdges() // Computed +``` + +## Node Operations + +```tsx +// Add a node +flow.addNode({ + id: '4', + position: { x: 300, y: 200 }, + data: { label: 'New Node' }, +}) + +// Remove a node (also removes connected edges) +flow.removeNode('4') + +// Update node properties +flow.updateNode('2', { data: { label: 'Updated' } }) + +// Update position (respects snapToGrid and nodeExtent) +flow.updateNodePosition('2', { x: 250, y: 150 }) + +// Get a specific node +const node = flow.getNode('2') // FlowNode | undefined +``` + +## Edge Operations + +```tsx +// Add an edge (id auto-generated if not provided) +flow.addEdge({ source: '1', target: '3' }) + +// Add with type +flow.addEdge({ source: '1', target: '3', type: 'smoothstep', label: 'yes' }) + +// Remove an edge +flow.removeEdge('e1-3') + +// Get a specific edge +const edge = flow.getEdge('e1-3') + +// Duplicate edges are prevented automatically +``` + +### Edge Types + +Four built-in edge path algorithms: + +| Type | Description | +|---|---| +| `bezier` | Smooth cubic bezier curve (default) | +| `smoothstep` | Right-angle path with rounded corners | +| `step` | Right-angle path with sharp corners | +| `straight` | Direct line between nodes | + +### Edge Waypoints + +Add bend points to edges: + +```tsx +flow.addEdgeWaypoint('e1-2', { x: 150, y: 50 }) +flow.addEdgeWaypoint('e1-2', { x: 200, y: 75 }, 1) // at specific index +flow.updateEdgeWaypoint('e1-2', 0, { x: 160, y: 60 }) +flow.removeEdgeWaypoint('e1-2', 0) +``` + +## Selection + +```tsx +flow.selectNode('1') // select a node +flow.selectNode('2', { additive: true }) // add to selection +flow.selectEdge('e1-2') // select an edge +flow.selectAll() // select all nodes +flow.clearSelection() // deselect everything +flow.deleteSelected() // remove selected nodes and edges +flow.deselectNode('1') // remove from selection +``` + +## Viewport + +```tsx +flow.zoomIn() // zoom in by 0.2 +flow.zoomOut() // zoom out by 0.2 +flow.zoomTo(1.5) // set exact zoom (clamped to min/max) +flow.fitView() // fit all nodes in viewport +flow.fitView(['1', '2']) // fit specific nodes +flow.panTo({ x: 100, y: 200 }) // pan to position + +// Reactive zoom level +flow.zoom() // Computed + +// Check if a node is visible +flow.isNodeVisible('1') // boolean +``` + +## Auto-Layout + +Layout nodes automatically using elkjs (lazy-loaded — zero cost until called): + +```tsx +// Layered layout (DAG/pipeline) +await flow.layout('layered', { direction: 'RIGHT', spacing: 50 }) + +// Tree layout +await flow.layout('tree', { direction: 'DOWN', spacing: 40 }) + +// Force-directed +await flow.layout('force') + +// Available algorithms +await flow.layout('stress') +await flow.layout('radial') +await flow.layout('box') +``` + +### Layout Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `direction` | `'DOWN' \| 'RIGHT' \| 'UP' \| 'LEFT'` | `'DOWN'` | Layout direction | +| `spacing` | `number` | `50` | Spacing between nodes | +| `layerSpacing` | `number` | `spacing` | Spacing between layers | + +elkjs is loaded on demand — only imported when `flow.layout()` is first called. + +## Connection Rules + +Define type-safe rules for which node types can connect: + +```tsx +const flow = createFlow({ + nodes: [...], + edges: [...], + connectionRules: { + input: { allowedTargets: ['process'] }, + process: { allowedTargets: ['process', 'output'] }, + output: { allowedTargets: [] }, + }, +}) + +// Check if a connection is valid +flow.isValidConnection({ source: '1', target: '2' }) // boolean +``` + +## Graph Queries + +```tsx +// Get all edges connected to a node +flow.getConnectedEdges('2') // FlowEdge[] + +// Get upstream nodes (nodes with edges pointing to this node) +flow.getIncomers('2') // FlowNode[] + +// Get downstream nodes (nodes this node points to) +flow.getOutgoers('2') // FlowNode[] +``` + +## Search and Filter + +```tsx +// Find nodes by predicate +flow.findNodes(n => n.type === 'process') // FlowNode[] + +// Search by label text (case-insensitive) +flow.searchNodes('start') // FlowNode[] +``` + +## Undo / Redo + +```tsx +flow.undo() // restore previous state +flow.redo() // restore undone state +``` + +## Copy / Paste + +```tsx +flow.copy() // copy selected nodes to clipboard +flow.paste() // paste with offset, new IDs generated +``` + +## Collision Detection + +```tsx +// Find nodes overlapping with a given node +flow.getOverlappingNodes('2') // FlowNode[] + +// Resolve collisions — push overlapping nodes apart +flow.resolveCollisions('2') +``` + +## Proximity Connect + +```tsx +// Find nearest unconnected node within distance +flow.findNearestNode('1', 200) // FlowNode | null +``` + +## Serialization + +```tsx +// Export flow state as JSON +const json = flow.toJSON() +// { nodes: [...], edges: [...], viewport: { x, y, zoom } } + +// Import flow state +flow.fromJSON(json) +flow.fromJSON(json, { resetViewport: true }) +``` + +## Listeners + +```tsx +// Connection created +flow.onConnect((edge) => { + console.log('Connected:', edge.source, '→', edge.target) +}) + +// Node changes (position, add, remove) +flow.onNodesChange((change) => { + console.log(change.type, change.id) +}) + +// Click handlers +flow.onNodeClick((nodeId) => { ... }) +flow.onEdgeClick((edgeId) => { ... }) + +// All return unsubscribe functions +const unsub = flow.onConnect(...) +unsub() +``` + +## Batch Operations + +```tsx +flow.batch(() => { + flow.addNode({ id: '10', position: { x: 0, y: 0 }, data: { label: 'A' } }) + flow.addNode({ id: '11', position: { x: 200, y: 0 }, data: { label: 'B' } }) + flow.addEdge({ source: '10', target: '11' }) +}) +// Single signal notification for all changes +``` + +## Components + +### `` + +The main container component: + +```tsx + + + + + +``` + +### `` + +Decorative background pattern: + +```tsx + + + +``` + +### `` + +Scaled overview with viewport indicator: + +```tsx + node.type === 'input' ? '#6366f1' : '#94a3b8'} + maskColor="rgba(0,0,0,0.2)" +/> +``` + +### `` + +Zoom and fit controls: + +```tsx + +``` + +### `` + +Connection points on nodes: + +```tsx +import { Handle, Position } from '@pyreon/flow' + +function CustomNode({ data }) { + return ( +
+ + {data.label} + +
+ ) +} +``` + +### `` + +Positioned overlay panels: + +```tsx + + + + + + +``` + +### `` + +Drag handles for resizing nodes: + +```tsx + +``` + +### `` + +Floating toolbar that appears when a node is selected: + +```tsx + + + + +``` + +## Edge Path Utilities + +Pure functions for generating SVG paths: + +```tsx +import { + getBezierPath, + getSmoothStepPath, + getStraightPath, + getStepPath, +} from '@pyreon/flow' + +const [path, labelX, labelY] = getBezierPath({ + sourceX: 0, sourceY: 0, + targetX: 200, targetY: 100, + sourcePosition: Position.Right, + targetPosition: Position.Left, +}) +``` + +## Position Enum + +```tsx +import { Position } from '@pyreon/flow' + +Position.Top // 'top' +Position.Right // 'right' +Position.Bottom // 'bottom' +Position.Left // 'left' +``` + +## Cleanup + +```tsx +flow.dispose() // remove all listeners, clear state +``` + +## Comparison with React Flow + +| Feature | React Flow | @pyreon/flow | +|---|---|---| +| Update 1 of 1000 nodes | New array → diff all | 1 signal → 1 DOM update | +| Bundle size | ~1.2MB (React + D3) | ~50KB + elkjs on demand | +| State management | 3 callbacks + applyChanges | Automatic — zero boilerplate | +| Auto-layout | Separate elkjs setup | `flow.layout('layered')` | +| Undo/redo | DIY | Built-in | +| Connection rules | `isValidConnection` callback | Declarative config | diff --git a/content/docs/machine/index.mdx b/content/docs/machine/index.mdx new file mode 100644 index 0000000..1410edd --- /dev/null +++ b/content/docs/machine/index.mdx @@ -0,0 +1,425 @@ +--- +title: Machine +description: Reactive state machines for Pyreon — constrained signals with type-safe transitions. +--- + +`@pyreon/machine` provides reactive state machines — constrained signals that can only hold specific values and transition between them via specific events. Replace nested booleans with explicit states and type-safe transitions. + + + +## Installation + +```package-install +@pyreon/machine +``` + +## Quick Start + +```tsx +import { createMachine } from '@pyreon/machine' + +const machine = createMachine({ + initial: 'idle', + states: { + idle: { on: { FETCH: 'loading' } }, + loading: { on: { SUCCESS: 'done', ERROR: 'error' } }, + done: {}, + error: { on: { RETRY: 'loading' } }, + }, +}) + +machine() // 'idle' — reads like a signal +machine.send('FETCH') +machine() // 'loading' +``` + +## Why State Machines? + +State machines prevent impossible states. Compare: + +```tsx +// ❌ Nested booleans — 16 possible combinations, most invalid +const isLoading = signal(false) +const isError = signal(false) +const isSuccess = signal(false) +const isOpen = signal(false) +// What does isLoading=true + isSuccess=true mean? 🤷 + +// ✅ State machine — only valid states exist +const dialog = createMachine({ + initial: 'closed', + states: { + closed: { on: { OPEN: 'confirming' } }, + confirming: { on: { CONFIRM: 'loading', CANCEL: 'closed' } }, + loading: { on: { SUCCESS: 'success', ERROR: 'error' } }, + success: { on: { CLOSE: 'closed' } }, + error: { on: { RETRY: 'loading', CLOSE: 'closed' } }, + }, +}) +``` + +## Reading State + +The machine instance is callable — it reads like a signal and is reactive in effects, computeds, and JSX: + +```tsx +const machine = createMachine({ + initial: 'idle', + states: { + idle: { on: { START: 'running' } }, + running: { on: { STOP: 'idle', PAUSE: 'paused' } }, + paused: { on: { RESUME: 'running', STOP: 'idle' } }, + }, +}) + +// Read current state +machine() // 'idle' + +// Reactive in JSX +function StatusBadge() { + return {() => machine()} +} +``` + +## Sending Events + +Transition between states by sending events: + +```tsx +machine.send('START') // idle → running +machine.send('PAUSE') // running → paused +machine.send('RESUME') // paused → running +machine.send('STOP') // running → idle + +// With payload +machine.send('SELECT', { id: 42 }) + +// Invalid events are silently ignored +machine.send('PAUSE') // ignored when in 'idle' — no transition defined +``` + +## Guards + +Use guards for conditional transitions: + +```tsx +const form = createMachine({ + initial: 'editing', + states: { + editing: { + on: { + SUBMIT: { target: 'submitting', guard: () => isValid() }, + SAVE_DRAFT: 'saving', + }, + }, + submitting: { on: { SUCCESS: 'done', ERROR: 'editing' } }, + saving: { on: { SAVED: 'editing' } }, + done: {}, + }, +}) + +// SUBMIT only transitions if guard returns true +form.send('SUBMIT') // ignored if isValid() is false + +// Guards can also receive the event payload +const transfer = createMachine({ + initial: 'idle', + states: { + idle: { + on: { + SEND: { target: 'confirming', guard: (payload) => payload.amount > 0 }, + }, + }, + confirming: { on: { CONFIRM: 'done', CANCEL: 'idle' } }, + done: {}, + }, +}) + +transfer.send('SEND', { amount: 100 }) // guard passes → confirming +transfer.send('SEND', { amount: 0 }) // guard fails → stays idle +``` + +## Checking State + +### `matches()` + +Check if the machine is in one or more states — reactive in JSX and effects: + +```tsx +machine.matches('loading') // true if in 'loading' +machine.matches('success', 'error') // true if in either + +// Reactive rendering +function App() { + return () => { + if (machine.matches('idle')) + return + if (machine.matches('loading')) + return + if (machine.matches('error')) + return machine.send('RETRY')} /> + if (machine.matches('done')) + return + return null + } +} +``` + +### `can()` + +Check if an event would trigger a valid transition from the current state: + +```tsx +machine.can('FETCH') // true if FETCH is defined in current state's transitions + +// Disable buttons for invalid actions + +``` + +### `nextEvents()` + +Get all available events from the current state: + +```tsx +machine.nextEvents() // ['FETCH', 'RESET'] — depends on current state + +// Useful for command palettes or help dialogs +const availableActions = machine.nextEvents() +``` + +## Side Effects with `onEnter` + +Fire a callback when the machine enters a specific state: + +```tsx +const fetchMachine = createMachine({ + initial: 'idle', + states: { + idle: { on: { FETCH: 'loading' } }, + loading: { on: { SUCCESS: 'done', ERROR: 'error' } }, + done: {}, + error: { on: { RETRY: 'loading' } }, + }, +}) + +const data = signal(null) +const error = signal(null) + +// Side effect — fetch when entering 'loading' +fetchMachine.onEnter('loading', async () => { + try { + const result = await fetch('/api/data').then(r => r.json()) + data.set(result) + fetchMachine.send('SUCCESS') + } catch (e) { + error.set(e) + fetchMachine.send('ERROR') + } +}) +``` + +`onEnter` returns an unsubscribe function: + +```tsx +const unsub = machine.onEnter('loading', () => { ... }) +unsub() // remove the listener +``` + +## Transition Listener + +React to any transition: + +```tsx +machine.onTransition((from, to, event) => { + console.log(`${from} → ${to} via ${event.type}`) + analytics.track('state_change', { from, to, event: event.type }) +}) +``` + +## Reset + +Return to the initial state: + +```tsx +machine.reset() // back to 'idle' (or whatever initial was) +``` + +## Cleanup + +Remove all listeners: + +```tsx +machine.dispose() // clears all onEnter and onTransition listeners +``` + +## Type Safety + +States and events are inferred from the definition — no manual type annotations needed: + +```tsx +const machine = createMachine({ + initial: 'idle', + states: { + idle: { on: { FETCH: 'loading', RESET: 'idle' } }, + loading: { on: { SUCCESS: 'done', ERROR: 'error' } }, + done: {}, + error: { on: { RETRY: 'loading' } }, + }, +}) + +machine() // type: 'idle' | 'loading' | 'done' | 'error' +machine.send('FETCH') // ✓ valid event +machine.send('FLY') // TS error — not a valid event +machine.matches('idle') // ✓ valid state +machine.matches('x') // TS error — not a valid state +``` + +## Real-World Patterns + +### Multi-Step Wizard + +```tsx +const wizard = createMachine({ + initial: 'step1', + states: { + step1: { on: { NEXT: 'step2' } }, + step2: { on: { NEXT: 'step3', BACK: 'step1' } }, + step3: { on: { SUBMIT: 'submitting', BACK: 'step2' } }, + submitting: { on: { SUCCESS: 'done', ERROR: 'step3' } }, + done: {}, + }, +}) + +const formData = signal({ name: '', email: '' }) + +wizard.onEnter('submitting', async () => { + try { + await submitData(formData()) + wizard.send('SUCCESS') + } catch { + wizard.send('ERROR') + } +}) + +function WizardUI() { + return () => { + if (wizard.matches('step1')) + return wizard.send('NEXT')} /> + if (wizard.matches('step2')) + return wizard.send('NEXT')} onBack={() => wizard.send('BACK')} /> + if (wizard.matches('step3')) + return wizard.send('SUBMIT')} onBack={() => wizard.send('BACK')} /> + if (wizard.matches('submitting')) + return + if (wizard.matches('done')) + return + return null + } +} +``` + +### Auth Flow + +```tsx +const auth = createMachine({ + initial: 'idle', + states: { + idle: { on: { LOGIN: 'authenticating' } }, + authenticating: { on: { SUCCESS: 'authenticated', ERROR: 'idle' } }, + authenticated: { on: { LOGOUT: 'idle' } }, + }, +}) + +const user = signal(null) + +auth.onEnter('authenticating', async (event) => { + try { + const result = await login(event.payload.email, event.payload.password) + user.set(result) + auth.send('SUCCESS') + } catch { + auth.send('ERROR') + } +}) + +auth.onEnter('idle', () => user.set(null)) +``` + +### File Upload + +```tsx +const upload = createMachine({ + initial: 'idle', + states: { + idle: { on: { SELECT: 'selected' } }, + selected: { on: { UPLOAD: 'uploading', CANCEL: 'idle' } }, + uploading: { on: { PROGRESS: 'uploading', SUCCESS: 'done', ERROR: 'error' } }, + done: { on: { RESET: 'idle' } }, + error: { on: { RETRY: 'uploading', CANCEL: 'idle' } }, + }, +}) + +const progress = signal(0) +const file = signal(null) +``` + +## Data Alongside Machines + +Machines manage transitions, signals manage data. They compose naturally: + +```tsx +// ✅ Signals for data, machine for state +const count = signal(0) +const error = signal(null) + +const machine = createMachine({ + initial: 'idle', + states: { + idle: { on: { INCREMENT: 'idle', SUBMIT: 'submitting' } }, + submitting: { on: { SUCCESS: 'done', ERROR: 'idle' } }, + done: {}, + }, +}) + +machine.onEnter('idle', (event) => { + if (event.type === 'INCREMENT') count.update(n => n + 1) +}) +``` + +## API Reference + +### `createMachine(config)` + +| Property | Type | Description | +|---|---|---| +| `config.initial` | `string` | Initial state | +| `config.states` | `Record` | State definitions with transitions | + +### `Machine` instance + +| Method | Returns | Description | +|---|---|---| +| `machine()` | `TState` | Read current state (reactive) | +| `machine.send(event, payload?)` | `void` | Send event to trigger transition | +| `machine.matches(...states)` | `boolean` | Check if in any of the given states (reactive) | +| `machine.can(event)` | `boolean` | Check if event would trigger a transition | +| `machine.nextEvents()` | `TEvent[]` | Available events from current state | +| `machine.reset()` | `void` | Return to initial state | +| `machine.onEnter(state, callback)` | `() => void` | Fire callback on state entry, returns unsubscribe | +| `machine.onTransition(callback)` | `() => void` | Fire on any transition, returns unsubscribe | +| `machine.dispose()` | `void` | Remove all listeners | + +### `StateConfig` + +```ts +interface StateConfig { + on?: Record> +} + +interface TransitionConfig { + target: TState + guard?: (payload?: unknown) => boolean +} +```