From 22a4ff0b8812f4e8d581cc500ac344ad82e53979 Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Wed, 30 Sep 2026 00:23:55 +0800 Subject: [PATCH 01/14] feat(core): model workflow, lifecycle and compact cards --- packages/core/src/card-metrics.ts | 13 +- packages/core/src/card-text.ts | 17 +- packages/core/src/diagram-stats.ts | 17 +- packages/core/src/samples/gallery.ts | 439 +++++++++++++++++++++++++++ packages/core/src/samples/index.ts | 1 + packages/core/src/tokens.ts | 26 +- packages/core/src/types.ts | 81 ++++- 7 files changed, 582 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/samples/gallery.ts diff --git a/packages/core/src/card-metrics.ts b/packages/core/src/card-metrics.ts index 5f49982..69808fb 100644 --- a/packages/core/src/card-metrics.ts +++ b/packages/core/src/card-metrics.ts @@ -14,7 +14,18 @@ export const CARD = { radius: 14, } as const; -export function cardSize(card: CardData): { width: number; height: number } { +/** Step, state and participant cards (workflow, lifecycle, sequence): title, subtitle, brand, tag. */ +export const COMPACT = { + width: 176, + header: 60, + tagRow: 24, + radius: 12, +} as const; + +export type CardVariant = 'full' | 'compact'; + +export function cardSize(card: CardData, variant: CardVariant = 'full'): { width: number; height: number } { + if (variant === 'compact') return { width: COMPACT.width, height: COMPACT.header + (card.tag ? COMPACT.tagRow : 0) }; let height = CARD.header; if (card.rows?.length) height += CARD.rowsPad * 2 + card.rows.length * CARD.row; if (card.stats?.length) { diff --git a/packages/core/src/card-text.ts b/packages/core/src/card-text.ts index c60f5b2..a6fc2a2 100644 --- a/packages/core/src/card-text.ts +++ b/packages/core/src/card-text.ts @@ -1,4 +1,4 @@ -import { CARD } from './card-metrics'; +import { CARD, COMPACT, type CardVariant } from './card-metrics'; import { measureText, type FontFace } from './text-measure'; import type { CardData } from './types'; @@ -21,12 +21,25 @@ const GAP = 12; // gap-3 const PANEL = CARD.width - 2 * 12; // 256: stats and CTA sit in px-3 const ROW_VALUE_MAX = 0.55; // max-w-[55%] -export function cardTextSlots(card: CardData): TextSlot[] { +// StepCard: px-3, size-7 tile, gap-2.5; the tag pill (px-2) lines up with the title. +const C_PAD = 12; +const C_TILE = 28; +const C_GAP = 10; +const C_TEXT = COMPACT.width - 2 * C_PAD - C_TILE - C_GAP; // 114 + +export function cardTextSlots(card: CardData, variant: CardVariant = 'full'): TextSlot[] { const slots: TextSlot[] = []; const add = (path: string, text: string | undefined, face: FontFace, size: number, maxWidth: number) => { if (text !== undefined) slots.push({ path, text, face, size, maxWidth }); }; + if (variant === 'compact') { + add('/title', card.title, 'sans500', 13, C_TEXT); + add('/subtitle', card.subtitle, 'sans400', 11.5, C_TEXT); + add('/tag', card.tag, 'sans500', 11, C_TEXT - 2 * 8); + return slots; + } + const header = INNER - ICON_TILE - GAP; add('/title', card.title, 'sans500', 13.5, header); add('/subtitle', card.subtitle, 'sans400', 12, header); diff --git a/packages/core/src/diagram-stats.ts b/packages/core/src/diagram-stats.ts index c555069..048fad7 100644 --- a/packages/core/src/diagram-stats.ts +++ b/packages/core/src/diagram-stats.ts @@ -1,4 +1,12 @@ -import { NODE_TYPES, type DiagramNode, type NodeType } from './types'; +import { NODE_TYPES, type DiagramKind, type DiagramNode, type NodeType } from './types'; + +export const KIND_LABELS: Record = { + architecture: 'Architecture', + dataflow: 'Dataflow', + workflow: 'Workflow', + lifecycle: 'Lifecycle', + sequence: 'Sequence', +}; export const TYPE_LABELS: Record = { client: 'Client', @@ -10,6 +18,13 @@ export const TYPE_LABELS: Record = { storage: 'Storage', external: 'External', security: 'Security', + start: 'Start', + active: 'Active', + waiting: 'Waiting', + decision: 'Decision', + success: 'Success', + failure: 'Failure', + neutral: 'Neutral', }; export function countByType(nodes: DiagramNode[]): [NodeType, number][] { diff --git a/packages/core/src/samples/gallery.ts b/packages/core/src/samples/gallery.ts new file mode 100644 index 0000000..d248211 --- /dev/null +++ b/packages/core/src/samples/gallery.ts @@ -0,0 +1,439 @@ +import type { DiagramDraft } from '../types'; + +// The archify gallery (https://tt-a1i.github.io/archify/gallery.html, MIT) recreated in stackmap's schema: the +// acceptance set for workflow, lifecycle, sequence, architecture and dataflow support. Facts follow archify's +// examples; layout is stackmap's own. + +export const releaseDelivery: DiagramDraft = { + kind: 'workflow', + title: 'Release delivery', + subtitle: 'From commit to announced release', + lanes: [ + { id: 'dev', label: 'Developer' }, + { id: 'ci', label: 'Continuous integration' }, + { id: 'approval', label: 'Release governance' }, + { id: 'environment', label: 'Production environment' }, + { id: 'communication', label: 'Release communication' }, + { id: 'exceptions', label: 'Failure and rollback', tone: 'exception' }, + ], + phases: [ + { id: 'change', label: 'Change', nodes: ['commit', 'pull-request'] }, + { id: 'verify', label: 'Build and verify', nodes: ['build', 'checks', 'failed'] }, + { id: 'promote', label: 'Promote and observe', nodes: ['approve', 'deploy', 'verify-prod', 'announce', 'rollback'] }, + ], + groups: [ + { id: 'blocking-checks', label: 'Blocking checks' }, + { id: 'recovery', label: 'Recovery path', tone: 'security' }, + ], + nodes: [ + { id: 'commit', type: 'client', lane: 'dev', card: { title: 'Commit', subtitle: 'Signed change' } }, + { id: 'pull-request', type: 'client', lane: 'dev', card: { title: 'Pull request', subtitle: 'Reviewed diff' } }, + { id: 'build', type: 'service', lane: 'ci', group: 'blocking-checks', card: { title: 'Build', subtitle: 'Locked inputs', tag: 'reproducible', brand: 'githubactions' } }, + { id: 'checks', type: 'security', lane: 'ci', group: 'blocking-checks', card: { title: 'Quality gates', subtitle: 'Test and scan', tag: 'blocking' } }, + { id: 'approve', type: 'security', lane: 'approval', card: { title: 'Approve', subtitle: 'Release owner', tag: 'human gate' } }, + { id: 'deploy', type: 'gateway', lane: 'environment', card: { title: 'Deploy', subtitle: 'Canary 10%', tag: 'production', brand: 'kubernetes' } }, + { id: 'verify-prod', type: 'service', lane: 'environment', card: { title: 'Verify', subtitle: 'Smoke and SLO' } }, + { id: 'announce', type: 'external', lane: 'communication', card: { title: 'Announce', subtitle: 'Status and notes' } }, + { id: 'failed', type: 'security', lane: 'exceptions', card: { title: 'Stop release', subtitle: 'Gate failed' } }, + { id: 'rollback', type: 'queue', lane: 'exceptions', group: 'recovery', card: { title: 'Rollback', subtitle: 'Last good image', tag: 'owner: on-call' } }, + ], + edges: [ + { id: 'e1', from: 'commit', to: 'pull-request', tone: 'main' }, + { id: 'e2', from: 'pull-request', to: 'build', label: 'merge', tone: 'main' }, + { id: 'e3', from: 'build', to: 'checks', tone: 'main' }, + { id: 'e4', from: 'checks', to: 'approve', tone: 'main' }, + { id: 'e5', from: 'approve', to: 'deploy', tone: 'main' }, + { id: 'e6', from: 'deploy', to: 'verify-prod', tone: 'main' }, + { id: 'e7', from: 'verify-prod', to: 'announce', label: 'healthy', tone: 'main' }, + { id: 'e8', from: 'checks', to: 'failed', label: 'red', tone: 'error' }, + { id: 'e9', from: 'verify-prod', to: 'rollback', label: 'unhealthy', tone: 'error' }, + { id: 'e10', from: 'rollback', to: 'deploy', label: 'restore', kind: 'return' }, + ], + views: [ + { id: 'happy', label: 'Happy path', caption: 'Every change is reviewed, built once and verified in production before it is announced.', nodes: ['commit', 'pull-request', 'build', 'checks', 'approve', 'deploy', 'verify-prod', 'announce'] }, + { id: 'stops', label: 'Stop conditions', caption: 'A red gate stops promotion; an unhealthy release rolls back to the last good image.', nodes: ['checks', 'failed', 'verify-prod', 'rollback', 'deploy'] }, + ], + notes: [ + { title: 'One happy path', items: ['Every change is reviewed before a reproducible build', 'Blocking checks must be green before human approval', 'Production is complete only after smoke and SLO verification'] }, + { title: 'Stop conditions', items: ['Test or security failure stops promotion', 'Production health can reverse a release', 'Rollback ownership is visible before an incident'] }, + ], +}; + +export const incidentResponse: DiagramDraft = { + kind: 'workflow', + title: 'Incident response runbook', + subtitle: 'Detect, command, mitigate, verify', + lanes: [ + { id: 'signals', label: 'Signals' }, + { id: 'command', label: 'Incident command' }, + { id: 'mitigation', label: 'Service mitigation' }, + { id: 'recovery', label: 'Recovery evidence' }, + { id: 'communication', label: 'Stakeholder communication' }, + { id: 'exceptions', label: 'Escalation and rollback', tone: 'exception' }, + ], + phases: [ + { id: 'detect', label: 'Detect', nodes: ['alert', 'page'] }, + { id: 'triage', label: 'Triage and mitigate', nodes: ['triage', 'declare', 'contain', 'update', 'escalate'] }, + { id: 'close', label: 'Verify and close', nodes: ['recover', 'verify', 'resolve', 'rollback'] }, + ], + groups: [{ id: 'owners', label: 'Command owns the incident' }], + nodes: [ + { id: 'alert', type: 'queue', lane: 'signals', card: { title: 'SLO alert', subtitle: 'Burn rate', brand: 'pagerduty' } }, + { id: 'page', type: 'external', lane: 'command', group: 'owners', card: { title: 'Page on-call', subtitle: 'Acknowledge' } }, + { id: 'triage', type: 'service', lane: 'command', group: 'owners', card: { title: 'Triage', subtitle: 'Scope impact' } }, + { id: 'declare', type: 'security', lane: 'command', group: 'owners', card: { title: 'Declare', subtitle: 'Assign commander', tag: 'SEV-1/2' } }, + { id: 'contain', type: 'service', lane: 'mitigation', card: { title: 'Contain', subtitle: 'Stop growth' } }, + { id: 'recover', type: 'gateway', lane: 'mitigation', card: { title: 'Recover', subtitle: 'Restore service' } }, + { id: 'verify', type: 'database', lane: 'recovery', card: { title: 'Verify', subtitle: 'SLO and traces', tag: '15 min stable' } }, + { id: 'update', type: 'client', lane: 'communication', card: { title: 'Status update', subtitle: 'Impact and ETA' } }, + { id: 'resolve', type: 'external', lane: 'communication', card: { title: 'Resolve', subtitle: 'Final update' } }, + { id: 'escalate', type: 'security', lane: 'exceptions', card: { title: 'Escalate', subtitle: 'Specialist' } }, + { id: 'rollback', type: 'queue', lane: 'exceptions', card: { title: 'Rollback', subtitle: 'Last good' } }, + ], + edges: [ + { id: 'e1', from: 'alert', to: 'page', label: 'page', tone: 'main' }, + { id: 'e2', from: 'page', to: 'triage', tone: 'main' }, + { id: 'e3', from: 'triage', to: 'contain', tone: 'main' }, + { id: 'e4', from: 'contain', to: 'recover', tone: 'main' }, + { id: 'e5', from: 'recover', to: 'verify', tone: 'main' }, + { id: 'e6', from: 'verify', to: 'resolve', tone: 'main' }, + { id: 'e7', from: 'triage', to: 'declare', tone: 'security' }, + { id: 'e8', from: 'declare', to: 'update', kind: 'async' }, + { id: 'e9', from: 'update', to: 'escalate', label: 'still impacted', tone: 'security' }, + { id: 'e10', from: 'verify', to: 'rollback', label: 'regressed', tone: 'error' }, + ], + views: [ + { id: 'command', label: 'Command', caption: 'A page is not an incident until someone owns command.', nodes: ['alert', 'page', 'triage', 'declare', 'update'] }, + { id: 'recovery', label: 'Recovery', caption: 'Mitigation reduces impact; only a stable SLO window proves recovery.', nodes: ['contain', 'recover', 'verify', 'resolve', 'rollback'] }, + ], + notes: [ + { title: 'Ownership first', items: ['A page is not an incident until someone owns command', 'Severity and scope are explicit before mitigation spreads', 'Escalation names the missing expertise'] }, + { title: 'Recovery is evidence', items: ['Mitigation can reduce impact without proving recovery', 'SLOs and traces must stay healthy for a fixed window', 'The final update follows verification, not optimism'] }, + ], +}; + +export const agentToolCall: DiagramDraft = { + kind: 'workflow', + title: 'Agent tool call', + subtitle: 'Request, plan, approve, execute, reply', + lanes: [ + { id: 'ui', label: 'User interface' }, + { id: 'agent', label: 'Agent runtime' }, + { id: 'policy', label: 'Policy and recovery', tone: 'exception' }, + { id: 'tools', label: 'Tool execution and evidence' }, + ], + phases: [ + { id: 'intake', label: 'Intake', nodes: ['user', 'chat', 'store'] }, + { id: 'plan', label: 'Plan and route', nodes: ['planner', 'router', 'approval'] }, + { id: 'execute', label: 'Execute and report', nodes: ['tool', 'external', 'blocked', 'retry', 'final'] }, + ], + groups: [ + { id: 'loop', label: 'Planning loop' }, + { id: 'stop', label: 'Human or policy stop', tone: 'security' }, + ], + nodes: [ + { id: 'user', type: 'external', lane: 'ui', card: { title: 'User', subtitle: 'Asks for work' } }, + { id: 'chat', type: 'client', lane: 'ui', card: { title: 'Chat surface', subtitle: 'Thread and files' } }, + { id: 'final', type: 'service', lane: 'ui', card: { title: 'Final reply', subtitle: 'Answer and changes' } }, + { id: 'planner', type: 'service', lane: 'agent', group: 'loop', card: { title: 'Agent planner', subtitle: 'Plan next step', tag: 'context aware', brand: 'anthropic' } }, + { id: 'router', type: 'service', lane: 'agent', group: 'loop', card: { title: 'Tool router', subtitle: 'Choose capability' } }, + { id: 'approval', type: 'security', lane: 'policy', group: 'stop', card: { title: 'Approval gate', subtitle: 'Scope and consent', tag: 'blocks risky ops' } }, + { id: 'blocked', type: 'security', lane: 'policy', group: 'stop', card: { title: 'Blocked', subtitle: 'Wait or reject' } }, + { id: 'retry', type: 'queue', lane: 'policy', group: 'stop', card: { title: 'Retry path', subtitle: 'Revise request' } }, + { id: 'tool', type: 'queue', lane: 'tools', card: { title: 'Tool call', subtitle: 'Shell, browser, MCP', tag: 'structured result' } }, + { id: 'external', type: 'gateway', lane: 'tools', card: { title: 'External API', subtitle: 'Network service' } }, + { id: 'store', type: 'database', lane: 'tools', card: { title: 'Context store', subtitle: 'Repo and memory' } }, + { id: 'trace', type: 'database', lane: 'tools', card: { title: 'Trace log', subtitle: 'Events and output' } }, + ], + edges: [ + { id: 'e1', from: 'user', to: 'chat', tone: 'main' }, + { id: 'e2', from: 'chat', to: 'planner', label: 'plan', tone: 'main' }, + { id: 'e3', from: 'planner', to: 'router', tone: 'main' }, + { id: 'e4', from: 'router', to: 'approval', label: 'needs approval?', tone: 'security' }, + { id: 'e5', from: 'approval', to: 'tool', label: 'approved', tone: 'main' }, + { id: 'e6', from: 'approval', to: 'blocked', label: 'denied', tone: 'error' }, + { id: 'e7', from: 'blocked', to: 'retry', kind: 'async' }, + { id: 'e8', from: 'retry', to: 'planner', label: 'revise', kind: 'return' }, + { id: 'e9', from: 'tool', to: 'external', tone: 'main' }, + { id: 'e10', from: 'external', to: 'final', label: 'result', kind: 'return', tone: 'main' }, + { id: 'e11', from: 'external', to: 'trace', label: 'record', kind: 'async' }, + { id: 'e12', from: 'store', to: 'trace', kind: 'async' }, + { id: 'e13', from: 'store', to: 'planner', label: 'context', kind: 'async' }, + ], + views: [{ id: 'gate', label: 'Approval gate', caption: 'Risky work waits for scope and consent before any tool runs.', nodes: ['router', 'approval', 'tool', 'blocked', 'retry'] }], + notes: [ + { title: 'Runtime semantics', items: ['Approval gates risky work before tool execution', 'A denial loops back to planning instead of failing silently', 'Evidence returns through the trace log and memory'] }, + ], +}; + +export const agentRun: DiagramDraft = { + kind: 'lifecycle', + title: 'Agent run lifecycle', + subtitle: 'States of one agent run', + lanes: [ + { id: 'main', label: 'Lifecycle phases' }, + { id: 'waiting', label: 'Interruptions' }, + { id: 'recovery', label: 'Recovery loop', tone: 'exception' }, + { id: 'terminal', label: 'Terminal exits' }, + ], + nodes: [ + { id: 'queued', type: 'start', lane: 'main', card: { title: 'Queued', subtitle: 'Request accepted' } }, + { id: 'planning', type: 'active', lane: 'main', card: { title: 'Planning', subtitle: 'Build task graph' } }, + { id: 'executing', type: 'active', lane: 'main', card: { title: 'Executing', subtitle: 'Tool calls' } }, + { id: 'reviewing', type: 'decision', lane: 'main', card: { title: 'Reviewing', subtitle: 'Quality gate' } }, + { id: 'completed', type: 'success', lane: 'main', card: { title: 'Completed', subtitle: 'Final response' } }, + { id: 'approval', type: 'waiting', lane: 'waiting', card: { title: 'Needs approval', subtitle: 'Human gate', tag: 'pause' } }, + { id: 'blocked', type: 'waiting', lane: 'waiting', card: { title: 'Blocked', subtitle: 'Missing input' } }, + { id: 'failed', type: 'failure', lane: 'recovery', card: { title: 'Failed', subtitle: 'Recoverable error', tag: 'retryable' } }, + { id: 'cancelled', type: 'failure', lane: 'terminal', card: { title: 'Cancelled', subtitle: 'User stopped' } }, + { id: 'expired', type: 'failure', lane: 'terminal', card: { title: 'Expired', subtitle: 'Timeout' } }, + ], + edges: [ + { id: 'plan', from: 'queued', to: 'planning', tone: 'main' }, + { id: 'execute', from: 'planning', to: 'executing', tone: 'main' }, + { id: 'review', from: 'executing', to: 'reviewing', tone: 'main' }, + { id: 'complete', from: 'reviewing', to: 'completed', tone: 'main' }, + { id: 'needs-approval', from: 'executing', to: 'approval', label: 'risky op', tone: 'security' }, + { id: 'approved', from: 'approval', to: 'executing', label: 'approved', kind: 'return' }, + { id: 'review-blocked', from: 'reviewing', to: 'blocked', label: 'missing input' }, + { id: 'execution-failed', from: 'executing', to: 'failed', label: 'error', tone: 'error' }, + { id: 'retry', from: 'failed', to: 'executing', label: 'retry', kind: 'return' }, + { id: 'approval-cancelled', from: 'approval', to: 'cancelled', label: 'denied', tone: 'error' }, + { id: 'block-expired', from: 'blocked', to: 'expired', label: 'timeout', tone: 'error' }, + ], + notes: [ + { title: 'Main path and waits', items: ['The run has five ordered phases from queue to completion', 'Approval and missing input pause the run without ending it'] }, + { title: 'Recovery and terminal exits', items: ['Failed loops back while retry budget remains', 'Cancelled and Expired are terminal exits with no return path'] }, + ], +}; + +export const deploymentRelease: DiagramDraft = { + kind: 'lifecycle', + title: 'Deployment release lifecycle', + subtitle: 'States of one release object', + lanes: [ + { id: 'main', label: 'Release phases' }, + { id: 'waiting', label: 'Approval and health wait' }, + { id: 'recovery', label: 'Rollback controller', tone: 'exception' }, + { id: 'terminal', label: 'Terminal exits' }, + ], + nodes: [ + { id: 'queued', type: 'start', lane: 'main', card: { title: 'Queued', subtitle: 'Change accepted' } }, + { id: 'building', type: 'active', lane: 'main', card: { title: 'Building', subtitle: 'Immutable image' } }, + { id: 'verifying', type: 'decision', lane: 'main', card: { title: 'Verifying', subtitle: 'Tests and policy', tag: 'gate' } }, + { id: 'ready', type: 'waiting', lane: 'main', card: { title: 'Ready', subtitle: 'Promotion pending' } }, + { id: 'live', type: 'success', lane: 'main', card: { title: 'Live', subtitle: 'Production healthy' } }, + { id: 'approval', type: 'waiting', lane: 'waiting', card: { title: 'Needs approval', subtitle: 'Release owner', tag: 'pause' } }, + { id: 'paused', type: 'waiting', lane: 'waiting', card: { title: 'Health paused', subtitle: 'SLO regression' } }, + { id: 'rolling-back', type: 'active', lane: 'recovery', card: { title: 'Rolling back', subtitle: 'Last good image', tag: 'automatic' } }, + { id: 'cancelled', type: 'failure', lane: 'terminal', card: { title: 'Cancelled', subtitle: 'Approval denied' } }, + { id: 'failed', type: 'failure', lane: 'terminal', card: { title: 'Failed', subtitle: 'Rollback failed' } }, + { id: 'rolled-back', type: 'success', lane: 'terminal', card: { title: 'Rolled back', subtitle: 'Service restored' } }, + ], + edges: [ + { id: 'build', from: 'queued', to: 'building', tone: 'main' }, + { id: 'verify', from: 'building', to: 'verifying', tone: 'main' }, + { id: 'pass', from: 'verifying', to: 'ready', tone: 'main' }, + { id: 'promote', from: 'ready', to: 'live', tone: 'main' }, + { id: 'sign-off', from: 'verifying', to: 'approval', label: 'needs sign-off', tone: 'security' }, + { id: 'approved', from: 'approval', to: 'ready', label: 'approved' }, + { id: 'denied', from: 'approval', to: 'cancelled', label: 'denied', tone: 'error' }, + { id: 'abort', from: 'ready', to: 'rolling-back', label: 'abort', tone: 'error' }, + { id: 'rollback-failed', from: 'rolling-back', to: 'failed', tone: 'error' }, + { id: 'regress', from: 'live', to: 'paused', label: 'SLO regression', kind: 'async' }, + { id: 'rollback', from: 'paused', to: 'rolling-back', label: 'roll back' }, + { id: 'restored', from: 'rolling-back', to: 'rolled-back', label: 'restored', tone: 'main' }, + ], + notes: [ + { title: 'Promotion rail', items: ['The release moves through five ordered phases', 'Verification and approval remain distinct states', 'Live means production health is currently proven'] }, + { title: 'Explicit endings', items: ['Denied approval ends as Cancelled', 'A failed rollback ends as Failed', 'A successful rollback is a terminal restored outcome'] }, + ], +}; + +export const productionDeployment: DiagramDraft = { + kind: 'architecture', + density: 'compact', + title: 'Production deployment ownership', + subtitle: 'Who runs what, and where traffic crosses', + direction: 'RIGHT', + groups: [ + { id: 'us-east', label: 'AWS us-east-1 · production' }, + { id: 'private', label: 'Private application network', parent: 'us-east', tone: 'security' }, + { id: 'eu-west', label: 'AWS eu-west-1 · disaster recovery' }, + ], + nodes: [ + { id: 'clients', type: 'client', card: { title: 'Customers', subtitle: 'Web and mobile' } }, + { id: 'edge', type: 'gateway', group: 'us-east', card: { title: 'Global edge', subtitle: 'CDN and WAF', brand: 'cloudflare', tag: 'Platform' } }, + { id: 'gateway', type: 'security', group: 'us-east', card: { title: 'API gateway', subtitle: 'Public :443', tag: 'Platform' } }, + { id: 'api-a', type: 'service', group: 'private', card: { title: 'API pods · AZ-a', subtitle: 'Private subnet', brand: 'kubernetes', tag: 'Orders team' } }, + { id: 'api-b', type: 'service', group: 'private', card: { title: 'API pods · AZ-b', subtitle: 'Private subnet', brand: 'kubernetes', tag: 'Orders team' } }, + { id: 'redis', type: 'cache', group: 'private', card: { title: 'Redis', subtitle: 'Multi-AZ cache', brand: 'redis', tag: 'Platform' } }, + { id: 'postgres', type: 'database', group: 'private', card: { title: 'PostgreSQL', subtitle: 'Primary, encrypted', brand: 'postgresql', tag: 'Data' } }, + { id: 'events', type: 'queue', group: 'private', card: { title: 'Event bus', subtitle: 'orders.v1', tag: 'Platform' } }, + { id: 'workers', type: 'service', group: 'private', card: { title: 'Workers', subtitle: 'Private workload', tag: 'Orders team' } }, + { id: 'audit', type: 'storage', group: 'us-east', card: { title: 'Audit archive', subtitle: 'Immutable objects', tag: 'Security' } }, + { id: 'replica', type: 'database', group: 'eu-west', card: { title: 'DR replica', subtitle: 'eu-west-1', brand: 'postgresql', tag: 'Data' } }, + { id: 'observability', type: 'external', card: { title: 'Observability', subtitle: 'Metrics and traces', tag: 'SRE' } }, + ], + edges: [ + { id: 'e1', from: 'clients', to: 'edge', label: 'HTTPS', tone: 'main' }, + { id: 'e2', from: 'edge', to: 'gateway', label: 'mTLS', tone: 'security' }, + { id: 'e3', from: 'gateway', to: 'api-a', label: 'VPC route', tone: 'main' }, + { id: 'e4', from: 'gateway', to: 'api-b', label: 'VPC route', tone: 'main' }, + { id: 'e5', from: 'api-a', to: 'redis', label: 'cache' }, + { id: 'e6', from: 'api-b', to: 'postgres', label: 'SQL' }, + { id: 'e7', from: 'api-a', to: 'events', label: 'publish', kind: 'async' }, + { id: 'e8', from: 'api-b', to: 'events', kind: 'async' }, + { id: 'e9', from: 'events', to: 'workers', tone: 'main' }, + { id: 'e10', from: 'postgres', to: 'replica', label: 'cross-region WAL', tone: 'security' }, + { id: 'e11', from: 'workers', to: 'audit', label: 'evidence', kind: 'async' }, + { id: 'e12', from: 'workers', to: 'observability', label: 'OTLP', kind: 'async' }, + ], + views: [ + { id: 'crossings', label: 'Crossings', caption: 'Public HTTPS ends at the edge; mTLS crosses into the private network; WAL crosses regions.', nodes: ['clients', 'edge', 'gateway', 'postgres', 'replica'] }, + { id: 'async', label: 'Async work', caption: 'API pods publish to the event bus; workers drain it and leave evidence.', nodes: ['api-a', 'api-b', 'events', 'workers', 'audit', 'observability'] }, + ], + notes: [ + { title: 'Runtime ownership', items: ['Platform owns the edge, gateway, cache and event bus', 'Application teams own API pods and workers', 'Data owns primary and disaster-recovery state'] }, + { title: 'Named crossings', items: ['Public HTTPS terminates at the managed edge', 'mTLS crosses into the application network', 'Cross-region WAL is explicit and encrypted'] }, + ], +}; + +export const webApp: DiagramDraft = { + kind: 'architecture', + density: 'compact', + title: 'Sample web app', + subtitle: 'Three tiers on AWS', + direction: 'RIGHT', + groups: [ + { id: 'region', label: 'AWS us-west-2' }, + { id: 'sg-api', label: 'sg-api · :443 / :8000', parent: 'region', tone: 'security' }, + ], + nodes: [ + { id: 'users', type: 'client', card: { title: 'Users', subtitle: 'Browser and mobile' } }, + { id: 'auth', type: 'security', card: { title: 'Auth provider', subtitle: 'OAuth 2.0' } }, + { id: 'cdn', type: 'gateway', group: 'region', card: { title: 'CloudFront', subtitle: 'CDN' } }, + { id: 'lb', type: 'gateway', group: 'sg-api', card: { title: 'Load balancer', subtitle: 'HTTPS', tag: ':443' } }, + { id: 'api', type: 'service', group: 'sg-api', card: { title: 'API server', subtitle: 'FastAPI', brand: 'fastapi', tag: ':8000' } }, + { id: 'cache', type: 'cache', group: 'region', card: { title: 'Redis', subtitle: 'Read-through cache', brand: 'redis', tag: ':6379' } }, + { id: 'db', type: 'database', group: 'region', card: { title: 'PostgreSQL', subtitle: 'Primary', brand: 'postgresql', tag: ':5432' } }, + { id: 's3', type: 'storage', group: 'region', card: { title: 'S3', subtitle: 'Static assets' } }, + { id: 'queue', type: 'queue', group: 'region', card: { title: 'SQS', subtitle: 'Job queue' } }, + { id: 'worker', type: 'service', group: 'region', card: { title: 'Worker', subtitle: 'Async jobs' } }, + ], + edges: [ + { id: 'e1', from: 'users', to: 'cdn', label: 'HTTPS', tone: 'main' }, + { id: 'e2', from: 'cdn', to: 'lb', tone: 'main' }, + { id: 'e3', from: 'cdn', to: 's3', label: 'static', kind: 'async' }, + { id: 'e4', from: 'lb', to: 'api', tone: 'main' }, + { id: 'e5', from: 'auth', to: 'api', label: 'verify JWT', tone: 'security' }, + { id: 'e6', from: 'api', to: 'cache', label: 'read-through' }, + { id: 'e7', from: 'api', to: 'db', label: 'SQL', tone: 'main' }, + { id: 'e8', from: 'api', to: 'queue', label: 'enqueue', kind: 'async' }, + { id: 'e9', from: 'queue', to: 'worker' }, + ], + notes: [ + { title: 'Edge', items: ['CloudFront fronts all traffic', 'S3 serves static assets'] }, + { title: 'Security', items: ['OAuth 2.0 with JWT and PKCE', 'API and load balancer isolated in a security group'] }, + ], +}; + +export const productAnalytics: DiagramDraft = { + kind: 'dataflow', + density: 'compact', + title: 'Product analytics', + subtitle: 'From SDK events to dashboards and models', + direction: 'RIGHT', + phases: [ + { id: 'sources', label: 'Sources', nodes: ['web', 'mobile'] }, + { id: 'ingest', label: 'Ingest', nodes: ['edge'] }, + { id: 'process', label: 'Process', nodes: ['consent', 'stream'] }, + { id: 'store', label: 'Store', nodes: ['pii', 'warehouse', 'features'] }, + { id: 'consume', label: 'Consume', nodes: ['dashboards', 'model'] }, + ], + nodes: [ + { id: 'web', type: 'client', card: { title: 'Web app', subtitle: 'Browser SDK' } }, + { id: 'mobile', type: 'client', card: { title: 'Mobile', subtitle: 'iOS and Android' } }, + { id: 'edge', type: 'gateway', card: { title: 'Edge API', subtitle: 'Collector' } }, + { id: 'consent', type: 'security', card: { title: 'Consent gate', subtitle: 'Policy filter' } }, + { id: 'stream', type: 'queue', card: { title: 'Event stream', subtitle: 'Kafka topic' } }, + { id: 'pii', type: 'security', card: { title: 'PII vault', subtitle: 'Encrypted' } }, + { id: 'warehouse', type: 'database', card: { title: 'Warehouse', subtitle: 'Analytics tables', brand: 'snowflake' } }, + { id: 'features', type: 'database', card: { title: 'Feature store', subtitle: 'Daily batch' } }, + { id: 'dashboards', type: 'service', card: { title: 'Dashboards', subtitle: 'Product metrics' } }, + { id: 'model', type: 'service', card: { title: 'ML model', subtitle: 'Ranking job' } }, + ], + edges: [ + { id: 'e1', from: 'web', to: 'edge', label: 'clickstream', tone: 'main' }, + { id: 'e2', from: 'mobile', to: 'edge', label: 'app events' }, + { id: 'e3', from: 'edge', to: 'consent', label: 'identity', tone: 'security' }, + { id: 'e4', from: 'edge', to: 'stream', label: 'accepted events', tone: 'main' }, + { id: 'e5', from: 'consent', to: 'pii', label: 'identity map', tone: 'security' }, + { id: 'e6', from: 'stream', to: 'warehouse', label: 'normalized facts', tone: 'main' }, + { id: 'e7', from: 'warehouse', to: 'features', label: 'daily aggregates', kind: 'async' }, + { id: 'e8', from: 'warehouse', to: 'dashboards', label: 'metrics SQL' }, + { id: 'e9', from: 'features', to: 'model', label: 'feature vectors', kind: 'async' }, + { id: 'e10', from: 'pii', to: 'dashboards', label: 'restricted join', tone: 'security' }, + ], + notes: [ + { title: 'Sensitive boundary', items: ['Consent and PII paths are security flows', 'PII lands in a restricted vault, apart from the warehouse', 'Restricted joins are visible without implying default access'] }, + ], +}; + +export const eventStream: DiagramDraft = { + kind: 'dataflow', + density: 'compact', + title: 'Order event-stream topology', + subtitle: 'Topics, consumer groups and the dead-letter path', + direction: 'RIGHT', + phases: [ + { id: 'producers', label: 'Producers', nodes: ['checkout', 'billing'] }, + { id: 'transit', label: 'Transit', nodes: ['orders', 'payments'] }, + { id: 'processors', label: 'Processors', nodes: ['validate', 'enrich'] }, + { id: 'state', label: 'State and recovery', nodes: ['order-state', 'dlq'] }, + { id: 'consumers', label: 'Consumers', nodes: ['fulfillment', 'analytics', 'replay', 'ops'] }, + ], + nodes: [ + { id: 'checkout', type: 'service', card: { title: 'Checkout API', subtitle: 'Order producer' } }, + { id: 'billing', type: 'service', card: { title: 'Billing API', subtitle: 'Payment producer', brand: 'stripe' } }, + { id: 'orders', type: 'queue', card: { title: 'orders.v1', subtitle: 'Topic', tag: '12 partitions' } }, + { id: 'payments', type: 'queue', card: { title: 'payments.v2', subtitle: 'Topic', tag: '8 partitions' } }, + { id: 'validate', type: 'service', card: { title: 'Order validate', subtitle: 'Group fulfillment' } }, + { id: 'enrich', type: 'service', card: { title: 'Payment enrich', subtitle: 'Group analytics' } }, + { id: 'order-state', type: 'database', card: { title: 'Order state', subtitle: 'Materialized view' } }, + { id: 'dlq', type: 'queue', card: { title: 'events.dlq', subtitle: 'Poison events' } }, + { id: 'fulfillment', type: 'service', card: { title: 'Fulfillment', subtitle: 'Shipping workflow' } }, + { id: 'analytics', type: 'database', card: { title: 'Analytics', subtitle: 'Streaming facts' } }, + { id: 'replay', type: 'security', card: { title: 'Replay tool', subtitle: 'Approved batch' } }, + { id: 'ops', type: 'external', card: { title: 'On-call', subtitle: 'DLQ owner', brand: 'pagerduty' } }, + ], + edges: [ + { id: 'e1', from: 'checkout', to: 'orders', label: 'OrderPlaced', tone: 'main' }, + { id: 'e2', from: 'billing', to: 'payments', label: 'PaymentCaptured', tone: 'main' }, + { id: 'e3', from: 'orders', to: 'validate', label: 'ordered', tone: 'main' }, + { id: 'e4', from: 'payments', to: 'enrich', label: 'payment facts', tone: 'main' }, + { id: 'e5', from: 'validate', to: 'order-state', label: 'valid order', tone: 'main' }, + { id: 'e6', from: 'enrich', to: 'order-state', label: 'enriched' }, + { id: 'e7', from: 'order-state', to: 'fulfillment', label: 'ready orders', tone: 'main' }, + { id: 'e8', from: 'order-state', to: 'analytics', label: 'order facts' }, + { id: 'e9', from: 'validate', to: 'dlq', label: 'invalid', tone: 'error' }, + { id: 'e10', from: 'enrich', to: 'dlq', label: 'poison', tone: 'error' }, + { id: 'e11', from: 'dlq', to: 'ops', label: 'sample', tone: 'error' }, + { id: 'e12', from: 'dlq', to: 'replay', label: 'approved replay', kind: 'async' }, + ], + notes: [ + { title: 'Failure ownership', items: ['Poison events land in a retained dead-letter topic', 'On-call inspects samples before replay', 'Replay is gated, batched and auditable'] }, + ], +}; + +export const GALLERY = { + 'release-delivery': releaseDelivery, + 'incident-response': incidentResponse, + 'agent-tool-call': agentToolCall, + 'agent-run': agentRun, + 'deployment-release': deploymentRelease, + 'production-deployment': productionDeployment, + 'web-app': webApp, + 'product-analytics': productAnalytics, + 'event-stream': eventStream, +} as const satisfies Record; diff --git a/packages/core/src/samples/index.ts b/packages/core/src/samples/index.ts index e2068b5..6f1e5f5 100644 --- a/packages/core/src/samples/index.ts +++ b/packages/core/src/samples/index.ts @@ -121,3 +121,4 @@ export const groupedPlatform: DiagramDraft = { { id: 'e10', from: 'jobs', to: 'blobs', kind: 'async' }, ], }; +export * from './gallery'; diff --git a/packages/core/src/tokens.ts b/packages/core/src/tokens.ts index 1ab0589..1dc9e46 100644 --- a/packages/core/src/tokens.ts +++ b/packages/core/src/tokens.ts @@ -1,4 +1,4 @@ -import type { NodeType } from './types'; +import type { InfraType, NodeType, StateType } from './types'; export type ThemeName = 'light' | 'dark'; @@ -30,6 +30,22 @@ export interface ThemeTokens { tints: Record; } +// Lifecycle states reuse the infra palette, picked for meaning: success green, failure red, waiting amber. +const STATE_TINT: Record = { + start: 'client', + active: 'database', + waiting: 'gateway', + decision: 'queue', + success: 'service', + failure: 'cache', + neutral: 'external', +}; + +const withStates = (t: Record): Record => ({ + ...t, + ...(Object.fromEntries(Object.entries(STATE_TINT).map(([s, i]) => [s, t[i]])) as Record), +}); + const light: ThemeTokens = { page: '#f4f4f3', stage: '#ffffff', @@ -44,7 +60,7 @@ const light: ThemeTokens = { primaryText: '#ffffff', groupFill: '#fafaf9', groupBorder: '#8f8f8a', - tints: { + tints: withStates({ client: { fill: '#e3f0ef', border: '#cfe4e2', tile: '#cfe4e2', accent: '#2a7f7b' }, service: { fill: '#e9f0e8', border: '#dce6da', tile: '#dce6da', accent: '#4e7f3f' }, gateway: { fill: '#f8ece2', border: '#efdccb', tile: '#f0dcca', accent: '#b25e14' }, @@ -54,7 +70,7 @@ const light: ThemeTokens = { storage: { fill: '#f4f0de', border: '#e6dfc2', tile: '#e7dfc0', accent: '#8a7417' }, external: { fill: '#efefed', border: '#e1e1de', tile: '#e1e1de', accent: '#5f5f5c' }, security: { fill: '#f8e6ef', border: '#edd0df', tile: '#efd1e0', accent: '#b83c74' }, - }, + }), }; const dark: ThemeTokens = { @@ -71,7 +87,7 @@ const dark: ThemeTokens = { primaryText: '#141414', groupFill: '#1b1b1b', groupBorder: '#686868', - tints: { + tints: withStates({ client: { fill: '#141c1c', border: '#28504d', tile: '#1b2d2c', accent: '#6fd1c9' }, service: { fill: '#181c16', border: '#33402b', tile: '#232b1e', accent: '#a6d97a' }, gateway: { fill: '#1d1914', border: '#4a3822', tile: '#2e2419', accent: '#f0b05a' }, @@ -81,7 +97,7 @@ const dark: ThemeTokens = { storage: { fill: '#1c1a13', border: '#4a4426', tile: '#2b2819', accent: '#dec96a' }, external: { fill: '#1a1a1a', border: '#383838', tile: '#262626', accent: '#b3b3b1' }, security: { fill: '#1f1419', border: '#55293f', tile: '#331d29', accent: '#f07db4' }, - }, + }), }; export const tokens: Record = { light, dark }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e144bea..25d6fb4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,4 +1,5 @@ -export const NODE_TYPES = [ +/** Components: every kind except lifecycle. */ +export const INFRA_TYPES = [ 'client', 'service', 'gateway', @@ -9,8 +10,27 @@ export const NODE_TYPES = [ 'external', 'security', ] as const; +export type InfraType = (typeof INFRA_TYPES)[number]; + +/** Lifecycle states. */ +export const STATE_TYPES = ['start', 'active', 'waiting', 'decision', 'success', 'failure', 'neutral'] as const; +export type StateType = (typeof STATE_TYPES)[number]; + +export const NODE_TYPES = [...INFRA_TYPES, ...STATE_TYPES] as const; export type NodeType = (typeof NODE_TYPES)[number]; +export const DIAGRAM_KINDS = ['architecture', 'dataflow', 'workflow', 'lifecycle', 'sequence'] as const; +export type DiagramKind = (typeof DIAGRAM_KINDS)[number]; + +/** Kinds drawn as swimlanes: nodes sit in `lanes`, columns follow the flow. */ +export const LANE_KINDS = ['workflow', 'lifecycle'] as const; +/** Kinds whose nodes render as compact step/state/participant cards. */ +export const COMPACT_KINDS = ['workflow', 'lifecycle', 'sequence'] as const; +export const isLaneKind = (k: DiagramKind): k is (typeof LANE_KINDS)[number] => (LANE_KINDS as readonly string[]).includes(k); +export const isCompactKind = (k: DiagramKind): k is (typeof COMPACT_KINDS)[number] => (COMPACT_KINDS as readonly string[]).includes(k); +/** Whether the diagram's nodes render as compact cards: always in compact kinds, on request (`density`) elsewhere. */ +export const usesCompactCards = (d: { kind: DiagramKind; density?: 'compact' }) => isCompactKind(d.kind) || d.density === 'compact'; + export type FooterIcon = 'region' | 'secure' | 'members'; export interface CardRow { @@ -40,6 +60,8 @@ export interface CardData { statsNote?: string; footer?: { left?: FooterItem; right?: FooterItem }; cta?: { label: string; href?: string }; + /** compact cards only (workflow, lifecycle, sequence): a short pill such as "human gate" */ + tag?: string; } /** Where in the codebase a node comes from; shown in the inspector, never on the card. */ @@ -54,6 +76,8 @@ export interface DiagramNode { id: string; type: NodeType; group?: string; + /** workflow and lifecycle: the lane the node sits in */ + lane?: string; card: CardData; evidence?: Evidence[]; } @@ -63,13 +87,45 @@ export interface DiagramEdge { from: string; to: string; label?: string; - kind?: 'sync' | 'async'; + /** async renders dashed; return (a reply, a roll back) renders dotted and muted */ + kind?: EdgeKind; + /** main = the happy path; security and error borrow those tints */ + tone?: EdgeTone; } +export type EdgeKind = 'sync' | 'async' | 'return'; +export type EdgeTone = 'main' | 'security' | 'error'; + export interface DiagramGroup { id: string; label: string; parent?: string; + /** a trust boundary: outline in the security tint */ + tone?: 'security'; +} + +export interface DiagramLane { + id: string; + label: string; + /** failure and recovery paths */ + tone?: 'exception'; +} + +/** + * Ordered stages. Lane kinds: a header over the columns `nodes` occupy. Architecture and dataflow: a band around + * `nodes`, in flow order. Sequence: a time band around the messages `edges`. + */ +export interface DiagramPhase { + id: string; + label: string; + nodes?: string[]; + edges?: string[]; +} + +/** A takeaway about the diagram, listed in the inspector. */ +export interface DiagramNote { + title: string; + items: string[]; } export interface DiagramView { @@ -82,16 +138,21 @@ export interface DiagramView { export type Direction = 'RIGHT' | 'DOWN'; export interface DiagramDraft { - kind: 'architecture' | 'dataflow'; + kind: DiagramKind; + /** architecture and dataflow: compact cards (title, subtitle, brand, tag) for wide or summary diagrams */ + density?: 'compact'; title: string; subtitle?: string; direction?: Direction; /** base URL evidence files are linked under, e.g. "https://github.com/acme/shop/blob/main" */ source?: { url: string }; groups?: DiagramGroup[]; + lanes?: DiagramLane[]; + phases?: DiagramPhase[]; nodes: DiagramNode[]; edges: DiagramEdge[]; views?: DiagramView[]; + notes?: DiagramNote[]; } export interface Point { @@ -113,4 +174,18 @@ export interface LaidOutDiagram { groups: Record; edges: Record; bounds: { width: number; height: number }; + /** lane kinds: full-width lane bands */ + lanes?: Record; + /** lane kinds: header bands; sequence: time bands */ + phases?: Record; + /** where an edge's label sits, when the layout places it (else the route's midpoint) */ + labels?: Record; + sequence?: SequenceLayout; +} + +export interface SequenceLayout { + /** per participant: the lifeline's x and vertical extent */ + lifelines: Record; + /** activation bars; depth > 0 is nested (self-calls) */ + activations: { participant: string; rect: Rect; depth: number }[]; } From bf62fec4d58ae9bc04088507a9c26aba5e992fd7 Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Wed, 30 Sep 2026 00:23:55 +0800 Subject: [PATCH 02/14] feat(schema): validate workflow and lifecycle diagrams --- packages/schema/src/rules/card-fit.ts | 8 +- packages/schema/src/rules/kinds.ts | 151 ++++++++++++++++++++++++ packages/schema/src/rules/refs.ts | 18 +++ packages/schema/src/rules/schema.ts | 3 + packages/schema/src/rules/semantics.ts | 12 +- packages/schema/src/schema.ts | 55 +++++++-- packages/schema/src/validate.ts | 5 +- packages/schema/stackmap.schema.json | 156 +++++++++++++++++++++++-- packages/schema/test/kinds.test.ts | 128 ++++++++++++++++++++ packages/schema/test/validate.test.ts | 4 +- skill/references/authoring-contract.md | 32 ++++- skill/references/schema.md | 42 ++++++- skill/references/stackmap.schema.json | 156 +++++++++++++++++++++++-- 13 files changed, 732 insertions(+), 38 deletions(-) create mode 100644 packages/schema/src/rules/kinds.ts create mode 100644 packages/schema/test/kinds.test.ts diff --git a/packages/schema/src/rules/card-fit.ts b/packages/schema/src/rules/card-fit.ts index 399d85f..59f9f9f 100644 --- a/packages/schema/src/rules/card-fit.ts +++ b/packages/schema/src/rules/card-fit.ts @@ -1,4 +1,4 @@ -import { cardTextSlots, measureText, type DiagramDraft } from '@stackmap/core'; +import { cardTextSlots, measureText, usesCompactCards, type DiagramDraft } from '@stackmap/core'; import type { Diagnostic } from '../diagnostics'; const round = (n: number) => Math.round(n * 10) / 10; @@ -6,8 +6,9 @@ const round = (n: number) => Math.round(n * 10) / 10; /** Text that the card would truncate with an ellipsis, measured headlessly with Geist metrics. */ export function cardFitDiagnostics(d: DiagramDraft): Diagnostic[] { const out: Diagnostic[] = []; + const variant = usesCompactCards(d) ? 'compact' : 'full'; d.nodes.forEach((n, i) => { - for (const slot of cardTextSlots(n.card)) { + for (const slot of cardTextSlots(n.card, variant)) { // Cards render with white-space: nowrap, which collapses runs of whitespace and trims the ends. const shown = slot.text.replace(/\s+/g, ' ').trim(); const width = measureText(shown, slot.face, slot.size); @@ -26,7 +27,8 @@ export function cardFitDiagnostics(d: DiagramDraft): Diagnostic[] { evidence: { text: slot.text, width: round(width), maxWidth: round(slot.maxWidth), maxChars }, allowedFixes: [ `shorten to at most ${maxChars} characters`, - ...(header ? ['move the detail into a card row'] : []), + ...(header && variant === 'full' ? ['move the detail into a card row'] : []), + ...(header && variant === 'compact' ? ['move the detail into an evidence note'] : []), ...(rowLabel ? [`shorten the value at /nodes/${i}/card${slot.path.replace(/label$/, 'value')} to give the label room`] : []), ], }); diff --git a/packages/schema/src/rules/kinds.ts b/packages/schema/src/rules/kinds.ts new file mode 100644 index 0000000..4683181 --- /dev/null +++ b/packages/schema/src/rules/kinds.ts @@ -0,0 +1,151 @@ +import { INFRA_TYPES, isCompactKind, isLaneKind, STATE_TYPES, usesCompactCards, type DiagramDraft, type NodeType } from '@stackmap/core'; +import type { Diagnostic } from '../diagnostics'; + +const error = (code: string, subject: string, message: string, evidence: Record, allowedFixes: string[]): Diagnostic => ({ + code, + severity: 'error', + subject, + message, + evidence, + allowedFixes, +}); + +// The state a component most often stands for, and back: the fix names a concrete type, not just the list. +const STATE_FOR: Partial> = { client: 'start', external: 'neutral', security: 'decision', queue: 'waiting' }; +const INFRA_FOR: Partial> = { start: 'client', active: 'service', waiting: 'queue', decision: 'security', success: 'service', failure: 'external', neutral: 'external' }; + +const COMPACT_ONLY_SECTIONS = ['rows', 'stats', 'statsNote', 'footer', 'cta'] as const; + +/** Rules that depend on the diagram kind: lanes, phases, compact cards and the node types each kind uses. */ +export function kindDiagnostics(d: DiagramDraft): Diagnostic[] { + const out: Diagnostic[] = []; + const lanes = isLaneKind(d.kind); + const compact = usesCompactCards(d); + const types: readonly NodeType[] = d.kind === 'lifecycle' ? STATE_TYPES : INFRA_TYPES; + const laneIds = (d.lanes ?? []).map((l) => l.id); + + d.nodes.forEach((n, i) => { + if (!types.includes(n.type)) { + const near = (d.kind === 'lifecycle' ? STATE_FOR : INFRA_FOR)[n.type]; + out.push( + error('semantics/type-for-kind', `/nodes/${i}/type`, `A ${d.kind} diagram has no "${n.type}" nodes`, { type: n.type, kind: d.kind, allowed: types }, [ + ...(near ? [`use "${near}"`] : []), + `use one of: ${types.join(', ')}`, + ]), + ); + } + if (lanes && n.lane === undefined && d.lanes?.length) + out.push(error('semantics/missing-lane', `/nodes/${i}`, `Node "${n.id}" has no lane`, { id: n.id, lanes: laneIds }, laneIds.slice(0, 3).map((l) => `set "lane": "${l}"`))); + if (!lanes && n.lane !== undefined) + out.push(error('semantics/lane-for-kind', `/nodes/${i}/lane`, `Only workflow and lifecycle nodes sit in lanes`, { kind: d.kind }, ['remove "lane"', ...(d.kind === 'sequence' ? [] : ['use "group" instead'])])); + if (compact) { + for (const section of COMPACT_ONLY_SECTIONS) { + if (n.card[section] !== undefined) + out.push( + error('semantics/card-section-for-kind', `/nodes/${i}/card/${section}`, `${isCompactKind(d.kind) ? d.kind : 'Compact'} cards show no ${section}`, { section, kind: d.kind }, [ + `remove "${section}"`, + 'move the detail into an evidence note', + ...(isCompactKind(d.kind) ? [] : ['remove "density" to use full cards']), + ]), + ); + } + } else if (n.card.tag !== undefined) { + out.push(error('semantics/tag-for-kind', `/nodes/${i}/card/tag`, 'Only compact cards show a tag', { kind: d.kind }, ['move it into a card row', 'remove "tag"', 'set "density": "compact"'])); + } + }); + + if (isCompactKind(d.kind) && d.density !== undefined) + out.push({ ...error('semantics/density-ignored', '/density', `${d.kind} cards are always compact`, { kind: d.kind }, ['remove "density"']), severity: 'warning' }); + + if (lanes && !d.lanes?.length) + out.push(error('semantics/missing-lanes', '', `A ${d.kind} diagram needs "lanes"`, { kind: d.kind }, ['add "lanes": [{ "id": …, "label": … }] and set each node\'s "lane"'])); + if (!lanes && d.lanes) + out.push(error('semantics/lanes-for-kind', '/lanes', 'Only workflow and lifecycle diagrams have lanes', { kind: d.kind }, ['remove "lanes"', ...(d.kind === 'sequence' ? [] : ['use "groups" instead'])])); + if (lanes) { + const used = new Set(d.nodes.map((n) => n.lane)); + d.lanes?.forEach((l, i) => { + if (!used.has(l.id)) + out.push({ ...error('semantics/empty-lane', `/lanes/${i}`, `Lane "${l.id}" has no nodes`, { id: l.id }, [`set "lane": "${l.id}" on its nodes`, 'remove the lane']), severity: 'warning' }); + }); + } + + if ((lanes || d.kind === 'sequence') && d.direction !== undefined) + out.push({ + ...error('semantics/direction-ignored', '/direction', `${d.kind} diagrams always flow left to right${d.kind === 'sequence' ? ' and top to bottom' : ''}`, { kind: d.kind }, ['remove "direction"']), + severity: 'warning', + }); + + if (d.kind === 'sequence' && d.groups) + out.push(error('semantics/groups-for-kind', '/groups', 'Sequence diagrams have no groups', { kind: d.kind }, ['remove "groups"', 'use "phases" to band messages'])); + if (lanes) { + const laneOf = new Map(d.nodes.map((n) => [n.id, n.lane])); + d.groups?.forEach((g, i) => { + if (g.parent !== undefined) + out.push(error('semantics/nested-group-for-kind', `/groups/${i}/parent`, `Groups in a ${d.kind} diagram don't nest`, { id: g.id }, ['remove "parent"'])); + const members = d.nodes.filter((n) => n.group === g.id); + const memberLanes = [...new Set(members.map((n) => laneOf.get(n.id)))]; + if (memberLanes.length > 1) + out.push( + error('semantics/group-spans-lanes', `/groups/${i}`, `Group "${g.id}" spans lanes ${memberLanes.join(', ')}; a group sits inside one lane`, { id: g.id, lanes: memberLanes }, [ + 'split it into one group per lane', + 'move its nodes into one lane', + ]), + ); + }); + } + + out.push(...phaseDiagnostics(d)); + return out; +} + +function phaseDiagnostics(d: DiagramDraft): Diagnostic[] { + const out: Diagnostic[] = []; + if (!d.phases) return out; + const lanes = isLaneKind(d.kind); + const byNodes = d.kind !== 'sequence'; + const want = byNodes ? 'nodes' : 'edges'; + const other = byNodes ? 'edges' : 'nodes'; + const owner = new Map(); + d.phases.forEach((p, i) => { + if (p[other] !== undefined) + out.push(error('semantics/phase-members', `/phases/${i}/${other}`, `${d.kind} phases list ${want}, not ${other}`, { kind: d.kind }, [`replace "${other}" with the "${want}" this phase spans`])); + const members = p[want]; + if (!members?.length) { + out.push(error('semantics/phase-members', `/phases/${i}`, `Phase "${p.id}" lists no ${want}`, { id: p.id }, [`list the ${want} this phase spans in "${want}"`, 'remove the phase'])); + return; + } + members.forEach((m, j) => { + const first = owner.get(m); + if (first !== undefined && first !== i) + out.push(error('semantics/phase-overlap', `/phases/${i}/${want}/${j}`, `"${m}" is already in phase "${d.phases![first]!.id}"`, { id: m, phase: d.phases![first]!.id }, [`remove "${m}" from one of the two phases`])); + else owner.set(m, i); + }); + }); + + if (byNodes && !lanes) { + // Stages are laid out as ELK partitions of the top level: a grouped node can't take part. + d.nodes.forEach((n, i) => { + if (n.group !== undefined && owner.has(n.id)) + out.push(error('semantics/phase-member-in-group', `/nodes/${i}/group`, `Node "${n.id}" is in a phase and a group; phases hold ungrouped nodes`, { id: n.id }, ['remove "group"', `remove "${n.id}" from its phase`])); + else if (!owner.has(n.id) && n.group === undefined) + out.push({ ...error('semantics/unphased-node', `/nodes/${i}`, `Node "${n.id}" is in no phase; it is placed in its first predecessor's`, { id: n.id }, ['add it to a phase\'s "nodes"']), severity: 'warning' }); + }); + } + + if (byNodes) { + // An edge from a later phase into an earlier one is laid out as a back edge; say so, it's usually a mistake. + d.edges.forEach((e, i) => { + const a = owner.get(e.from); + const b = owner.get(e.to); + if (a !== undefined && b !== undefined && b < a && e.kind !== 'return') + out.push({ + ...error('semantics/phase-order', `/edges/${i}`, `Edge "${e.id}" runs from phase "${d.phases![a]!.id}" back into "${d.phases![b]!.id}"`, { id: e.id }, [ + 'mark it "kind": "return" if it loops back on purpose', + 'reorder the phases', + ]), + severity: 'warning', + }); + }); + } + return out; +} diff --git a/packages/schema/src/rules/refs.ts b/packages/schema/src/rules/refs.ts index 5778e53..5a09aba 100644 --- a/packages/schema/src/rules/refs.ts +++ b/packages/schema/src/rules/refs.ts @@ -72,6 +72,24 @@ export function refsDiagnostics(d: DiagramDraft): Diagnostic[] { } }); + const laneIds = (d.lanes ?? []).map((l) => l.id); + const laneSet = new Set(laneIds); + d.nodes.forEach((n, i) => { + if (n.lane !== undefined && d.lanes && !laneSet.has(n.lane)) + out.push(unknownRef('refs/unknown-lane', `/nodes/${i}/lane`, 'lane', n.lane, laneIds, `add lane "${n.lane}"`)); + }); + + const edgeIds = d.edges.map((e) => e.id); + const edgeSet = new Set(edgeIds); + d.phases?.forEach((p, i) => { + p.nodes?.forEach((id, j) => { + if (!nodeSet.has(id)) out.push(unknownRef('refs/unknown-phase-node', `/phases/${i}/nodes/${j}`, 'node', id, nodeIds, `remove "${id}" from the phase`)); + }); + p.edges?.forEach((id, j) => { + if (!edgeSet.has(id)) out.push(unknownRef('refs/unknown-phase-edge', `/phases/${i}/edges/${j}`, 'edge', id, edgeIds, `remove "${id}" from the phase`)); + }); + }); + d.edges.forEach((e, i) => { for (const end of ['from', 'to'] as const) { if (!nodeSet.has(e[end])) diff --git a/packages/schema/src/rules/schema.ts b/packages/schema/src/rules/schema.ts index 63c15c7..5099a52 100644 --- a/packages/schema/src/rules/schema.ts +++ b/packages/schema/src/rules/schema.ts @@ -82,6 +82,9 @@ function fixes(issue: z.core.$ZodIssue, received: unknown): string[] { return issue.keys.flatMap((k) => { const alias = ALIASES[k]; if (alias && allowed.includes(alias)) return [`rename "${k}" to "${alias}"`]; + // A node's `label` means its card title, not the similarly spelt `lane`. + const aliasHome = alias && childAccepting(issue.path, alias); + if (aliasHome) return [`move "${k}" into "${aliasHome}" as "${alias}"`, `remove "${k}"`]; const near = closest(k, allowed, 1)[0]; if (near) return [`rename "${k}" to "${near}"`]; const home = childAccepting(issue.path, k); diff --git a/packages/schema/src/rules/semantics.ts b/packages/schema/src/rules/semantics.ts index 8a290f7..aec7b12 100644 --- a/packages/schema/src/rules/semantics.ts +++ b/packages/schema/src/rules/semantics.ts @@ -69,6 +69,8 @@ export function semanticsDiagnostics(d: DiagramDraft): Diagnostic[] { ...duplicates(d.edges, 'edges'), ...duplicates(d.groups, 'groups'), ...duplicates(d.views, 'views'), + ...duplicates(d.lanes, 'lanes'), + ...duplicates(d.phases, 'phases'), ]; d.nodes.forEach((n, i) => { @@ -98,8 +100,9 @@ export function semanticsDiagnostics(d: DiagramDraft): Diagnostic[] { }); } + // A sequence message to oneself is a self-call, drawn as a loop on the lifeline. d.edges.forEach((e, i) => { - if (e.from === e.to) + if (e.from === e.to && d.kind !== 'sequence') out.push({ code: 'semantics/self-loop', severity: 'warning', @@ -111,11 +114,12 @@ export function semanticsDiagnostics(d: DiagramDraft): Diagnostic[] { }); if (d.kind === 'dataflow') { - // An async edge is an explicit feedback path (retry, event back-channel), so it doesn't close a cycle. - const flow = d.edges.filter((e) => e.kind !== 'async'); + // An async or return edge is an explicit feedback path (retry, event back-channel), so it doesn't close a cycle. + const feedback = (e: { kind?: string }) => e.kind === 'async' || e.kind === 'return'; + const flow = d.edges.filter((e) => !feedback(e)); for (const comp of cycles(d.nodes.map((n) => n.id), flow.map((e) => [e.from, e.to]))) { const members = new Set(comp); - const i = d.edges.findIndex((e) => e.kind !== 'async' && members.has(e.from) && members.has(e.to) && e.from !== e.to); + const i = d.edges.findIndex((e) => !feedback(e) && members.has(e.from) && members.has(e.to) && e.from !== e.to); out.push({ code: 'semantics/dataflow-cycle', severity: 'warning', diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index b985686..1316dc9 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -1,12 +1,12 @@ import * as z from 'zod'; -import { NODE_TYPES } from '@stackmap/core'; +import { DIAGRAM_KINDS, NODE_TYPES } from '@stackmap/core'; // Mirrors the hand-written types in @stackmap/core (a type test keeps them identical). Objects are strict // so a misspelt key is reported instead of silently dropped. export const ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/; // Size caps keep validation and layout fast and the diagram readable; beyond them, split into views. -export const LIMITS = { rows: 6, stats: 3, evidence: 8, edgeLabel: 24, nodes: 500, edges: 2000, groups: 200, views: 50 } as const; +export const LIMITS = { rows: 6, stats: 3, evidence: 8, edgeLabel: 24, nodes: 500, edges: 2000, groups: 200, views: 50, lanes: 20, phases: 20, notes: 6, noteItems: 6 } as const; export const VISIBLE_TEXT = /\S/; const id = z.string().regex(ID_PATTERN); @@ -44,6 +44,7 @@ const Card = z.strictObject({ href: httpUrl.optional(), }) .optional(), + tag: text.optional().meta({ description: 'Workflow, lifecycle and sequence cards only: a short pill, e.g. "human gate".' }), }); const Evidence = z.strictObject({ @@ -54,8 +55,11 @@ const Evidence = z.strictObject({ const Node = z.strictObject({ id, - type: z.enum(NODE_TYPES).meta({ description: 'Sets the card color. Never color by brand.' }), + type: z.enum(NODE_TYPES).meta({ + description: 'Sets the card color. Never color by brand. Lifecycle diagrams use the state types (start, active, waiting, decision, success, failure, neutral); every other kind uses the component types.', + }), group: id.optional().meta({ description: 'Id of the group the node sits in.' }), + lane: id.optional().meta({ description: 'Workflow and lifecycle: id of the lane the node sits in (required there).' }), card: Card, evidence: z .array(Evidence) @@ -69,13 +73,39 @@ const Edge = z.strictObject({ from: id.meta({ description: 'Source node id.' }), to: id.meta({ description: 'Target node id.' }), label: text.max(LIMITS.edgeLabel).optional().meta({ description: 'Short label; use sparingly.' }), - kind: z.enum(['sync', 'async']).optional().meta({ description: 'async edges render dashed.' }), + kind: z.enum(['sync', 'async', 'return']).optional().meta({ description: 'async renders dashed; return (a reply, a roll back) renders dotted.' }), + tone: z + .enum(['main', 'security', 'error']) + .optional() + .meta({ description: 'main marks the happy path; security and error paths take those tints. Use sparingly.' }), }); const Group = z.strictObject({ id, - label: text.meta({ description: 'Shown small-caps above the frame.' }), + label: text.meta({ description: 'Shown above the frame.' }), parent: id.optional().meta({ description: 'Id of the enclosing group, for nesting.' }), + tone: z.enum(['security']).optional().meta({ description: 'A trust boundary (private network, PII zone).' }), +}); + +const Lane = z.strictObject({ + id, + label: text, + tone: z.enum(['exception']).optional().meta({ description: 'A lane for failure and recovery paths.' }), +}); + +const Phase = z.strictObject({ + id, + label: text, + nodes: z + .array(id) + .optional() + .meta({ description: 'Workflow and lifecycle: the nodes whose columns this phase spans. Architecture and dataflow: the nodes in this stage.' }), + edges: z.array(id).optional().meta({ description: 'Sequence: the messages this time band spans.' }), +}); + +const Note = z.strictObject({ + title: text, + items: z.array(text).min(1).max(LIMITS.noteItems), }); const View = z.strictObject({ @@ -88,7 +118,11 @@ const View = z.strictObject({ export const DiagramDraftSchema = z .strictObject({ $schema: z.string().optional().meta({ description: `Optional; set it to ${'`'}https://unpkg.com/@hyamero/stackmap@0.1.0/dist/stackmap.schema.json${'`'} for editor completion.` }), - kind: z.enum(['architecture', 'dataflow']), + kind: z.enum(DIAGRAM_KINDS), + density: z + .enum(['compact']) + .optional() + .meta({ description: 'Architecture and dataflow: compact cards (title, subtitle, brand, tag) for long chains or summaries.' }), title: text, subtitle: text.optional(), source: z @@ -97,9 +131,16 @@ export const DiagramDraftSchema = z .meta({ description: 'Base URL for evidence links: `/#L`.' }), direction: z.enum(['RIGHT', 'DOWN']).optional().meta({ description: 'Layout flow. Default RIGHT; prefer DOWN for tiered/grouped diagrams.' }), groups: z.array(Group).max(LIMITS.groups).optional(), + lanes: z.array(Lane).max(LIMITS.lanes).optional().meta({ description: 'Workflow and lifecycle: swimlanes, top to bottom.' }), + phases: z + .array(Phase) + .max(LIMITS.phases) + .optional() + .meta({ description: 'Ordered stages: header bands over columns (workflow, lifecycle), stage bands in flow order (architecture, dataflow) or time bands (sequence).' }), nodes: z.array(Node).min(1).max(LIMITS.nodes), - edges: z.array(Edge).max(LIMITS.edges), + edges: z.array(Edge).max(LIMITS.edges).meta({ description: 'Connections. In a sequence, the messages, in time order.' }), views: z.array(View).max(LIMITS.views).optional(), + notes: z.array(Note).max(LIMITS.notes).optional().meta({ description: 'Takeaways about the diagram, shown in the inspector.' }), }) .meta({ title: 'stackmap diagram', description: 'Agent-authored diagram. Layout is computed by stackmap; never give coordinates.' }); diff --git a/packages/schema/src/validate.ts b/packages/schema/src/validate.ts index 90ff0e3..bef043d 100644 --- a/packages/schema/src/validate.ts +++ b/packages/schema/src/validate.ts @@ -1,6 +1,7 @@ import type { DiagramDraft } from '@stackmap/core'; import type { ValidationResult } from './diagnostics'; import { cardFitDiagnostics } from './rules/card-fit'; +import { kindDiagnostics } from './rules/kinds'; import { refsDiagnostics } from './rules/refs'; import { schemaDiagnostics } from './rules/schema'; import { semanticsDiagnostics } from './rules/semantics'; @@ -8,13 +9,13 @@ import { DiagramDraftSchema } from './schema'; /** * Schema first: the other families need a typed diagram, so schema errors are returned alone. Otherwise - * refs, semantics and card-fit all run, in that order, each in document order. + * refs, semantics (general, then kind rules) and card-fit all run, in that order, each in document order. */ export function validateDiagram(input: unknown): ValidationResult { const parsed = DiagramDraftSchema.safeParse(input); if (!parsed.success) return { ok: false, diagnostics: schemaDiagnostics(parsed.error.issues, input) }; const { $schema: _, ...diagram } = parsed.data; const d: DiagramDraft = diagram; - const diagnostics = [...refsDiagnostics(d), ...semanticsDiagnostics(d), ...cardFitDiagnostics(d)]; + const diagnostics = [...refsDiagnostics(d), ...semanticsDiagnostics(d), ...kindDiagnostics(d), ...cardFitDiagnostics(d)]; return { ok: !diagnostics.some((x) => x.severity === 'error'), diagram: d, diagnostics }; } diff --git a/packages/schema/stackmap.schema.json b/packages/schema/stackmap.schema.json index 9d77861..ddc5cbe 100644 --- a/packages/schema/stackmap.schema.json +++ b/packages/schema/stackmap.schema.json @@ -10,7 +10,17 @@ "type": "string", "enum": [ "architecture", - "dataflow" + "dataflow", + "workflow", + "lifecycle", + "sequence" + ] + }, + "density": { + "description": "Architecture and dataflow: compact cards (title, subtitle, brand, tag) for long chains or summaries.", + "type": "string", + "enum": [ + "compact" ] }, "title": { @@ -56,12 +66,88 @@ "label": { "type": "string", "pattern": "\\S", - "description": "Shown small-caps above the frame." + "description": "Shown above the frame." }, "parent": { "description": "Id of the enclosing group, for nesting.", "type": "string", "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, + "tone": { + "description": "A trust boundary (private network, PII zone).", + "type": "string", + "enum": [ + "security" + ] + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false + } + }, + "lanes": { + "description": "Workflow and lifecycle: swimlanes, top to bottom.", + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, + "label": { + "type": "string", + "pattern": "\\S" + }, + "tone": { + "description": "A lane for failure and recovery paths.", + "type": "string", + "enum": [ + "exception" + ] + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false + } + }, + "phases": { + "description": "Ordered stages: header bands over columns (workflow, lifecycle), stage bands in flow order (architecture, dataflow) or time bands (sequence).", + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, + "label": { + "type": "string", + "pattern": "\\S" + }, + "nodes": { + "description": "Workflow and lifecycle: the nodes whose columns this phase spans. Architecture and dataflow: the nodes in this stage.", + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + } + }, + "edges": { + "description": "Sequence: the messages this time band spans.", + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + } } }, "required": [ @@ -93,15 +179,27 @@ "queue", "storage", "external", - "security" + "security", + "start", + "active", + "waiting", + "decision", + "success", + "failure", + "neutral" ], - "description": "Sets the card color. Never color by brand." + "description": "Sets the card color. Never color by brand. Lifecycle diagrams use the state types (start, active, waiting, decision, success, failure, neutral); every other kind uses the component types." }, "group": { "description": "Id of the group the node sits in.", "type": "string", "pattern": "^[a-z0-9][a-z0-9_-]*$" }, + "lane": { + "description": "Workflow and lifecycle: id of the lane the node sits in (required there).", + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, "card": { "type": "object", "properties": { @@ -236,6 +334,11 @@ "label" ], "additionalProperties": false + }, + "tag": { + "description": "Workflow, lifecycle and sequence cards only: a short pill, e.g. \"human gate\".", + "type": "string", + "pattern": "\\S" } }, "required": [ @@ -307,11 +410,21 @@ "pattern": "\\S" }, "kind": { - "description": "async edges render dashed.", + "description": "async renders dashed; return (a reply, a roll back) renders dotted.", "type": "string", "enum": [ "sync", - "async" + "async", + "return" + ] + }, + "tone": { + "description": "main marks the happy path; security and error paths take those tints. Use sparingly.", + "type": "string", + "enum": [ + "main", + "security", + "error" ] } }, @@ -321,7 +434,8 @@ "to" ], "additionalProperties": false - } + }, + "description": "Connections. In a sequence, the messages, in time order." }, "views": { "maxItems": 50, @@ -357,6 +471,34 @@ ], "additionalProperties": false } + }, + "notes": { + "description": "Takeaways about the diagram, shown in the inspector.", + "maxItems": 6, + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string", + "pattern": "\\S" + }, + "items": { + "minItems": 1, + "maxItems": 6, + "type": "array", + "items": { + "type": "string", + "pattern": "\\S" + } + } + }, + "required": [ + "title", + "items" + ], + "additionalProperties": false + } } }, "required": [ diff --git a/packages/schema/test/kinds.test.ts b/packages/schema/test/kinds.test.ts new file mode 100644 index 0000000..da2d7b6 --- /dev/null +++ b/packages/schema/test/kinds.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; +import { validateDiagram } from '../src/validate'; + +const workflow = (over: Record = {}) => ({ + kind: 'workflow', + title: 'Release', + lanes: [ + { id: 'dev', label: 'Developer' }, + { id: 'ci', label: 'CI' }, + ], + nodes: [ + { id: 'commit', type: 'client', lane: 'dev', card: { title: 'Commit' } }, + { id: 'build', type: 'service', lane: 'ci', card: { title: 'Build', tag: 'reproducible' } }, + ], + edges: [{ id: 'e1', from: 'commit', to: 'build', tone: 'main' }], + ...over, +}); +const codes = (input: unknown) => validateDiagram(input).diagnostics.map((d) => `${d.severity}:${d.code}@${d.subject}`); + +describe('kind rules', () => { + it('accepts a workflow with lanes, phases, tags, tones and notes', () => { + const d = workflow({ + phases: [ + { id: 'change', label: 'Change', nodes: ['commit'] }, + { id: 'verify', label: 'Verify', nodes: ['build'] }, + ], + notes: [{ title: 'Happy path', items: ['Every change is built once'] }], + }); + expect(validateDiagram(d)).toMatchObject({ ok: true, diagnostics: [] }); + }); + + it('requires lanes and a lane per node in lane kinds', () => { + expect(codes(workflow({ lanes: undefined, nodes: [{ id: 'a', type: 'service', card: { title: 'A' } }], edges: [] }))).toEqual(['error:semantics/missing-lanes@']); + const noLane = workflow({ nodes: [{ id: 'a', type: 'service', card: { title: 'A' } }, { id: 'b', type: 'service', lane: 'dev', card: { title: 'B' } }], edges: [{ id: 'e', from: 'a', to: 'b' }] }); + expect(codes(noLane)).toEqual(['error:semantics/missing-lane@/nodes/0', 'warning:semantics/empty-lane@/lanes/1']); + expect(validateDiagram(noLane).diagnostics[0]!.allowedFixes).toEqual(['set "lane": "dev"', 'set "lane": "ci"']); + }); + + it('reports unknown lanes with the closest id', () => { + const d = workflow({ nodes: [{ id: 'commit', type: 'client', lane: 'devs', card: { title: 'Commit' } }, { id: 'build', type: 'service', lane: 'ci', card: { title: 'Build' } }] }); + const [diag] = validateDiagram(d).diagnostics; + expect(diag).toMatchObject({ code: 'refs/unknown-lane', subject: '/nodes/0/lane', allowedFixes: ['use "dev"', 'add lane "devs"'] }); + }); + + it('keeps lanes, tags and phases out of the kinds that have none', () => { + const arch = { + kind: 'architecture', + title: 'T', + lanes: [{ id: 'l', label: 'L' }], + phases: [{ id: 'p', label: 'P', nodes: ['a'] }], + nodes: [{ id: 'a', type: 'service', lane: 'l', card: { title: 'A', tag: 'x' } }], + edges: [], + }; + expect(codes(arch)).toEqual([ + 'error:semantics/lane-for-kind@/nodes/0/lane', + 'error:semantics/tag-for-kind@/nodes/0/card/tag', + 'error:semantics/lanes-for-kind@/lanes', + ]); + }); + + it('lifecycle nodes are states; other kinds use component types', () => { + const life = workflow({ kind: 'lifecycle', nodes: [{ id: 'commit', type: 'client', lane: 'dev', card: { title: 'Queued' } }, { id: 'build', type: 'active', lane: 'ci', card: { title: 'Running' } }] }); + const [diag] = validateDiagram(life).diagnostics; + expect(diag).toMatchObject({ code: 'semantics/type-for-kind', subject: '/nodes/0/type', allowedFixes: ['use "start"', 'use one of: start, active, waiting, decision, success, failure, neutral'] }); + expect(codes(workflow({ nodes: [{ id: 'commit', type: 'success', lane: 'dev', card: { title: 'C' } }, { id: 'build', type: 'service', lane: 'ci', card: { title: 'B' } }] }))).toEqual([ + 'error:semantics/type-for-kind@/nodes/0/type', + ]); + }); + + it('compact cards reject full-card sections', () => { + const d = workflow({ nodes: [{ id: 'commit', type: 'client', lane: 'dev', card: { title: 'C', rows: [{ label: 'k', value: 'v' }], cta: { label: 'Open' } } }, { id: 'build', type: 'service', lane: 'ci', card: { title: 'B' } }] }); + expect(codes(d)).toEqual(['error:semantics/card-section-for-kind@/nodes/0/card/rows', 'error:semantics/card-section-for-kind@/nodes/0/card/cta']); + }); + + it('checks phase members, overlaps and order', () => { + expect(codes(workflow({ phases: [{ id: 'p', label: 'P', edges: ['e1'] }] }))).toEqual(['error:semantics/phase-members@/phases/0/edges', 'error:semantics/phase-members@/phases/0']); + expect(codes(workflow({ phases: [{ id: 'p', label: 'P', nodes: ['commit'] }, { id: 'q', label: 'Q', nodes: ['commit', 'build'] }] }))).toEqual([ + 'error:semantics/phase-overlap@/phases/1/nodes/0', + ]); + expect(codes(workflow({ phases: [{ id: 'p', label: 'P', nodes: ['build'] }, { id: 'q', label: 'Q', nodes: ['commit'] }] }))).toEqual(['warning:semantics/phase-order@/edges/0']); + expect(codes(workflow({ phases: [{ id: 'p', label: 'P', nodes: ['ghost'] }] }))[0]).toBe('error:refs/unknown-phase-node@/phases/0/nodes/0'); + }); + + it('a group in a lane kind sits in one lane and does not nest', () => { + const d = workflow({ + groups: [{ id: 'g', label: 'G' }, { id: 'h', label: 'H', parent: 'g' }], + nodes: [ + { id: 'commit', type: 'client', lane: 'dev', group: 'g', card: { title: 'C' } }, + { id: 'build', type: 'service', lane: 'ci', group: 'g', card: { title: 'B' } }, + ], + }); + expect(codes(d)).toEqual(['warning:semantics/empty-group@/groups/1', 'error:semantics/group-spans-lanes@/groups/0', 'error:semantics/nested-group-for-kind@/groups/1/parent']); + }); + + it('architecture and dataflow take stage phases of ungrouped nodes, and compact cards on request', () => { + const flow = { + kind: 'dataflow', + title: 'T', + density: 'compact', + groups: [{ id: 'g', label: 'G' }], + phases: [{ id: 'src', label: 'Sources', nodes: ['a', 'b'] }], + nodes: [ + { id: 'a', type: 'client', card: { title: 'A', tag: 'SDK' } }, + { id: 'b', type: 'service', group: 'g', card: { title: 'B' } }, + { id: 'c', type: 'database', card: { title: 'C', rows: [{ label: 'k', value: 'v' }] } }, + ], + edges: [ + { id: 'e1', from: 'a', to: 'b' }, + { id: 'e2', from: 'b', to: 'c' }, + ], + }; + expect(codes(flow)).toEqual(['error:semantics/card-section-for-kind@/nodes/2/card/rows', 'error:semantics/phase-member-in-group@/nodes/1/group', 'warning:semantics/unphased-node@/nodes/2']); + expect(codes({ ...flow, kind: 'workflow', lanes: [{ id: 'l', label: 'L' }], groups: undefined, phases: undefined, nodes: flow.nodes.map((n) => ({ ...n, group: undefined, lane: 'l', card: { title: n.card.title } })) })).toEqual([ + 'warning:semantics/density-ignored@/density', + ]); + }); + + it('warns that lane kinds ignore direction', () => { + expect(codes(workflow({ direction: 'DOWN' }))).toEqual(['warning:semantics/direction-ignored@/direction']); + }); + + it('measures compact cards against the compact budget', () => { + const d = workflow({ nodes: [{ id: 'commit', type: 'client', lane: 'dev', card: { title: 'Production deployment approval' } }, { id: 'build', type: 'service', lane: 'ci', card: { title: 'B' } }] }); + const [diag] = validateDiagram(d).diagnostics; + expect(diag).toMatchObject({ code: 'card-fit/overflow', subject: '/nodes/0/card/title', evidence: { maxWidth: 114 } }); + expect(diag!.allowedFixes).toContain('move the detail into an evidence note'); + }); +}); diff --git a/packages/schema/test/validate.test.ts b/packages/schema/test/validate.test.ts index 9f45c3a..63a44b3 100644 --- a/packages/schema/test/validate.test.ts +++ b/packages/schema/test/validate.test.ts @@ -69,9 +69,9 @@ describe('validateDiagram', () => { it('only suggests keys valid on the offending object, never the key itself', () => { const onNode = diags(base({ nodes: [node('a', { label: 'x' } as never), node('b')] }))[0]; - expect(onNode).toMatchObject({ subject: '/nodes/0', allowedFixes: ['remove "label" (not valid here; allowed: id, type, group, card, evidence)'] }); + expect(onNode).toMatchObject({ subject: '/nodes/0', allowedFixes: ['move "label" into "card" as "title"', 'remove "label"'] }); const onEdge = diags(base({ edges: [{ id: 'ab', from: 'a', to: 'b', subtitel: 'x' } as never] }))[0]; - expect(onEdge!.allowedFixes).toEqual(['remove "subtitel" (not valid here; allowed: id, from, to, label, kind)']); + expect(onEdge!.allowedFixes).toEqual(['remove "subtitel" (not valid here; allowed: id, from, to, label, kind, tone)']); const onRoot = diags({ ...base(), titel: 'x' })[0]; expect(onRoot).toMatchObject({ subject: '', allowedFixes: ['rename "titel" to "title"'] }); }); diff --git a/skill/references/authoring-contract.md b/skill/references/authoring-contract.md index c1cfb72..a70a96a 100644 --- a/skill/references/authoring-contract.md +++ b/skill/references/authoring-contract.md @@ -12,9 +12,13 @@ How to model a system as a stackmap diagram. The [schema reference](schema.md) s - `architecture`: from the **initiator** to what it calls or uses — `api → db` even when the api only reads. Queues and streams are the exception: draw `producer → queue → consumer` (the queue delivers), even though consumers technically pull. - `dataflow`: the direction **data moves** — `db → cdc → kafka → job → store`; a reader of a store is `store → reader`. - `kind: "async"` for queues, events, fire-and-forget and callbacks — it renders dashed. Don't use it for "happens at build time" or "optional"; say that in a label or in the node's subtitle. +- `kind: "return"` for a reply, a roll back or a retry that loops back to an earlier step — it renders dotted, and layout never lets it push its target to a later column. +- **`tone`** marks the few edges the reader must tell apart: `main` for the happy path (drawn in ink), `security` for a trust crossing or a policy check, `error` for a failure path. Leave the rest untoned; a diagram where everything has a tone has none. - **Edge labels** (≤ 24 chars) only where the relationship isn't obvious from the two ends: a protocol (`gRPC`), a verb (`enqueue`), a topic. Most edges need none. - **Direction.** `RIGHT` (default) reads as a request path and suits 2–4 stages. Prefer `DOWN` for tiered or grouped systems (edge → app → data) and for anything with more than ~5 stages; it avoids long wrap-around edges. - **Groups** are boundaries a reader should see: tiers, trust zones, VPCs, clusters, teams. Nest with `parent`. A group with one node is usually noise. +- **Stages** (`phases` with `nodes`) in `architecture` and `dataflow` are an ordered pipeline (sources → ingest → process → store → consume): each stage is drawn as a band, in flow order. Stage members must be ungrouped; use stages *or* groups for a node, not both. +- **Notes** are the diagram's takeaways (`notes: [{ "title": "Stop conditions", "items": [...] }]`), listed in the inspector. Two or three, a few short items each; they say what the picture means, not what it shows. - **Views** are named focus sets for the tabs above the canvas ("Data tier", "Checkout path"): the view dims everything else and fits its members. Add one when the diagram answers more than one question; the first tab is always Overview. ## Size @@ -39,6 +43,32 @@ Read Mermaid for topology and meaning, then write fresh stackmap JSON — don't | `
` detail in a label | `subtitle` or a card row | | `classDef`, `style`, `linkStyle` | ignore | +## Workflows and lifecycles + +Both are drawn as **swimlanes**: `lanes` are full-width rows in the order you list them, and stackmap picks the columns from the edges (a step's successor in the same lane moves right; a hand-off to another lane may drop straight down). Every node needs a `lane`. + +- **Lanes** are owners or phases of attention: *Developer, CI, Release governance*; *Lifecycle phases, Interruptions, Terminal exits*. Put failure and recovery in their own lane with `"tone": "exception"`. Four to six lanes read well. +- **Phases** (`phases` with `nodes`) label the columns above the lanes: *Change → Build and verify → Promote*. Each phase starts after the previous one ends, so list them in order and put each node in at most one. +- **Groups** in a lane frame a few neighbouring steps (*Blocking checks*); they can't span lanes or nest. `"tone": "security"` on a group marks a policy stop. +- **Cards are compact:** `title`, `subtitle`, `brand` and a short `tag` pill (*human gate*, *owner: on-call*, *15 min stable*) — no rows, stats, footer or CTA. Put detail in `evidence` notes. +- **Workflow nodes** use the component types (who or what does the step). **Lifecycle nodes** use the state types: + +| type | use for | +|---|---| +| `start` | where the thing begins (drawn with an initial marker) | +| `active` | work in progress: building, executing, rolling back | +| `waiting` | paused on something outside: approval, input, a timer | +| `decision` | a check that sends the thing one way or another | +| `success` | a good end, or a good stop along the way | +| `failure` | an error or a bad end | +| `neutral` | anything else | + +A `success` or `failure` state with no outgoing transition is drawn as an end state. A retry is a `return` edge back to the state it retries. + +## Compact cards + +`"density": "compact"` gives an `architecture` or `dataflow` diagram the compact cards workflows use (title, subtitle, brand, tag). Use it for long chains (more than ~5 stages), overviews and summaries, where full cards would make the diagram too small to read at fit; keep full cards when rows, stats and footers carry the answer. + ## Node types and colour `type` sets the card colour (never the brand). Pick by role: @@ -59,7 +89,7 @@ Read Mermaid for topology and meaning, then write fresh stackmap JSON — don't ## Cards -A card is a fixed 280px-wide tile. Only `title` is required; add sections when they carry information the reader needs at a glance. +A full card (architecture and dataflow) is a fixed 280px-wide tile; a compact card is 176px wide (title ≈ 15 characters, subtitle ≈ 17, tag ≈ 14). Only `title` is required; add sections when they carry information the reader needs at a glance. - `title` — the name people use (`orders-api`, `Orders`). `subtitle` — what it is (`PostgreSQL cluster`, `Payments API`). - `rows` (≤ 6) — key/value facts: size, port, runtime, schedule. `mono: true` for ports, IPs, paths. diff --git a/skill/references/schema.md b/skill/references/schema.md index 068b804..09402ed 100644 --- a/skill/references/schema.md +++ b/skill/references/schema.md @@ -8,15 +8,19 @@ Agent-authored diagram. Layout is computed by stackmap; never give coordinates. | Field | Type | Required | Notes | |---|---|---|---| | `$schema` | string | no | Optional; set it to `https://unpkg.com/@hyamero/stackmap@0.1.0/dist/stackmap.schema.json` for editor completion | -| `kind` | enum | yes | one of `architecture`, `dataflow` | +| `kind` | enum | yes | one of `architecture`, `dataflow`, `workflow`, `lifecycle`, `sequence` | +| `density` | enum | no | Architecture and dataflow: compact cards (title, subtitle, brand, tag) for long chains or summaries. one of `compact` | | `title` | string | yes | non-blank | | `subtitle` | string | no | non-blank | | `source` | object | no | Base URL for evidence links: `/#L`. see [source](#source) | | `direction` | enum | no | Layout flow. Default RIGHT; prefer DOWN for tiered/grouped diagrams. one of `RIGHT`, `DOWN` | | `groups` | array | no | at most 200. see [groups[]](#groups) | +| `lanes` | array | no | Workflow and lifecycle: swimlanes, top to bottom. at most 20. see [lanes[]](#lanes) | +| `phases` | array | no | Ordered stages: header bands over columns (workflow, lifecycle), stage bands in flow order (architecture, dataflow) or time bands (sequence). at most 20. see [phases[]](#phases) | | `nodes` | array | yes | at least 1. at most 500. see [nodes[]](#nodes) | -| `edges` | array | yes | at most 2000. see [edges[]](#edges) | +| `edges` | array | yes | Connections. In a sequence, the messages, in time order. at most 2000. see [edges[]](#edges) | | `views` | array | no | at most 50. see [views[]](#views) | +| `notes` | array | no | Takeaways about the diagram, shown in the inspector. at most 6. see [notes[]](#notes) | ## source @@ -31,16 +35,35 @@ Base URL for evidence links: `/#L`. | Field | Type | Required | Notes | |---|---|---|---| | `id` | string | yes | id | -| `label` | string | yes | Shown small-caps above the frame. non-blank | +| `label` | string | yes | Shown above the frame. non-blank | | `parent` | string | no | Id of the enclosing group, for nesting | +| `tone` | enum | no | A trust boundary (private network, PII zone). one of `security` | + +## lanes[] + +| Field | Type | Required | Notes | +|---|---|---|---| +| `id` | string | yes | id | +| `label` | string | yes | non-blank | +| `tone` | enum | no | A lane for failure and recovery paths. one of `exception` | + +## phases[] + +| Field | Type | Required | Notes | +|---|---|---|---| +| `id` | string | yes | id | +| `label` | string | yes | non-blank | +| `nodes` | array | no | Workflow and lifecycle: the nodes whose columns this phase spans. Architecture and dataflow: the nodes in this stage. items: lowercase id: `a-z`, `0-9`, `-`, `_`; starts with a letter or digit | +| `edges` | array | no | Sequence: the messages this time band spans. items: lowercase id: `a-z`, `0-9`, `-`, `_`; starts with a letter or digit | ## nodes[] | Field | Type | Required | Notes | |---|---|---|---| | `id` | string | yes | id | -| `type` | enum | yes | Sets the card color. Never color by brand. one of `client`, `service`, `gateway`, `database`, `cache`, `queue`, `storage`, `external`, `security` | +| `type` | enum | yes | Sets the card color. Never color by brand. Lifecycle diagrams use the state types (start, active, waiting, decision, success, failure, neutral); every other kind uses the component types. one of `client`, `service`, `gateway`, `database`, `cache`, `queue`, `storage`, `external`, `security`, `start`, `active`, `waiting`, `decision`, `success`, `failure`, `neutral` | | `group` | string | no | Id of the group the node sits in | +| `lane` | string | no | Workflow and lifecycle: id of the lane the node sits in (required there) | | `card` | object | yes | see [nodes[].card](#nodescard) | | `evidence` | array | no | Source locations backing this node; listed in the inspector. at most 8. see [nodes[].evidence[]](#nodesevidence) | @@ -56,6 +79,7 @@ Base URL for evidence links: `/#L`. | `statsNote` | string | no | Line under the stat tiles; only shown with stats. non-blank | | `footer` | object | no | see [nodes[].card.footer](#nodescardfooter) | | `cta` | object | no | see [nodes[].card.cta](#nodescardcta) | +| `tag` | string | no | Workflow, lifecycle and sequence cards only: a short pill, e.g. "human gate". non-blank | ## nodes[].card.rows[] @@ -116,7 +140,8 @@ Base URL for evidence links: `/#L`. | `from` | string | yes | Source node id | | `to` | string | yes | Target node id | | `label` | string | no | Short label; use sparingly. non-blank. at most 24 characters | -| `kind` | enum | no | async edges render dashed. one of `sync`, `async` | +| `kind` | enum | no | async renders dashed; return (a reply, a roll back) renders dotted. one of `sync`, `async`, `return` | +| `tone` | enum | no | main marks the happy path; security and error paths take those tints. Use sparingly. one of `main`, `security`, `error` | ## views[] @@ -127,6 +152,13 @@ Base URL for evidence links: `/#L`. | `caption` | string | no | non-blank | | `nodes` | array | yes | Node ids this guided view focuses; the rest are dimmed. items: lowercase id: `a-z`, `0-9`, `-`, `_`; starts with a letter or digit | +## notes[] + +| Field | Type | Required | Notes | +|---|---|---|---| +| `title` | string | yes | non-blank | +| `items` | array | yes | at least 1. at most 6. items: non-blank | + ## Brand slugs Values allowed in `nodes[].card.brand` (Simple Icons, CC0). Anything else is a warning and the type icon is shown. diff --git a/skill/references/stackmap.schema.json b/skill/references/stackmap.schema.json index 9d77861..ddc5cbe 100644 --- a/skill/references/stackmap.schema.json +++ b/skill/references/stackmap.schema.json @@ -10,7 +10,17 @@ "type": "string", "enum": [ "architecture", - "dataflow" + "dataflow", + "workflow", + "lifecycle", + "sequence" + ] + }, + "density": { + "description": "Architecture and dataflow: compact cards (title, subtitle, brand, tag) for long chains or summaries.", + "type": "string", + "enum": [ + "compact" ] }, "title": { @@ -56,12 +66,88 @@ "label": { "type": "string", "pattern": "\\S", - "description": "Shown small-caps above the frame." + "description": "Shown above the frame." }, "parent": { "description": "Id of the enclosing group, for nesting.", "type": "string", "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, + "tone": { + "description": "A trust boundary (private network, PII zone).", + "type": "string", + "enum": [ + "security" + ] + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false + } + }, + "lanes": { + "description": "Workflow and lifecycle: swimlanes, top to bottom.", + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, + "label": { + "type": "string", + "pattern": "\\S" + }, + "tone": { + "description": "A lane for failure and recovery paths.", + "type": "string", + "enum": [ + "exception" + ] + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false + } + }, + "phases": { + "description": "Ordered stages: header bands over columns (workflow, lifecycle), stage bands in flow order (architecture, dataflow) or time bands (sequence).", + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, + "label": { + "type": "string", + "pattern": "\\S" + }, + "nodes": { + "description": "Workflow and lifecycle: the nodes whose columns this phase spans. Architecture and dataflow: the nodes in this stage.", + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + } + }, + "edges": { + "description": "Sequence: the messages this time band spans.", + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + } } }, "required": [ @@ -93,15 +179,27 @@ "queue", "storage", "external", - "security" + "security", + "start", + "active", + "waiting", + "decision", + "success", + "failure", + "neutral" ], - "description": "Sets the card color. Never color by brand." + "description": "Sets the card color. Never color by brand. Lifecycle diagrams use the state types (start, active, waiting, decision, success, failure, neutral); every other kind uses the component types." }, "group": { "description": "Id of the group the node sits in.", "type": "string", "pattern": "^[a-z0-9][a-z0-9_-]*$" }, + "lane": { + "description": "Workflow and lifecycle: id of the lane the node sits in (required there).", + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, "card": { "type": "object", "properties": { @@ -236,6 +334,11 @@ "label" ], "additionalProperties": false + }, + "tag": { + "description": "Workflow, lifecycle and sequence cards only: a short pill, e.g. \"human gate\".", + "type": "string", + "pattern": "\\S" } }, "required": [ @@ -307,11 +410,21 @@ "pattern": "\\S" }, "kind": { - "description": "async edges render dashed.", + "description": "async renders dashed; return (a reply, a roll back) renders dotted.", "type": "string", "enum": [ "sync", - "async" + "async", + "return" + ] + }, + "tone": { + "description": "main marks the happy path; security and error paths take those tints. Use sparingly.", + "type": "string", + "enum": [ + "main", + "security", + "error" ] } }, @@ -321,7 +434,8 @@ "to" ], "additionalProperties": false - } + }, + "description": "Connections. In a sequence, the messages, in time order." }, "views": { "maxItems": 50, @@ -357,6 +471,34 @@ ], "additionalProperties": false } + }, + "notes": { + "description": "Takeaways about the diagram, shown in the inspector.", + "maxItems": 6, + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string", + "pattern": "\\S" + }, + "items": { + "minItems": 1, + "maxItems": 6, + "type": "array", + "items": { + "type": "string", + "pattern": "\\S" + } + } + }, + "required": [ + "title", + "items" + ], + "additionalProperties": false + } } }, "required": [ From ff91ea53e86c7d135599fcbcf0ce63fc53936b4f Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Wed, 30 Sep 2026 00:23:55 +0800 Subject: [PATCH 03/14] feat(layout): lay out swimlanes with an orthogonal router --- packages/layout/debug.tmp.ts | 17 + packages/layout/scripts/bun-smoke.ts | 4 +- packages/layout/scripts/layout-samples.ts | 11 +- packages/layout/src/index.ts | 121 +- packages/layout/src/labels.ts | 64 + packages/layout/src/lanes.ts | 283 + packages/layout/src/route.ts | 429 ++ packages/layout/test/lanes.test.ts | 140 + packages/viewer/src/samples/gallery.layout.ts | 4840 +++++++++++++++++ 9 files changed, 5896 insertions(+), 13 deletions(-) create mode 100644 packages/layout/debug.tmp.ts create mode 100644 packages/layout/src/labels.ts create mode 100644 packages/layout/src/lanes.ts create mode 100644 packages/layout/src/route.ts create mode 100644 packages/layout/test/lanes.test.ts create mode 100644 packages/viewer/src/samples/gallery.layout.ts diff --git a/packages/layout/debug.tmp.ts b/packages/layout/debug.tmp.ts new file mode 100644 index 0000000..416c490 --- /dev/null +++ b/packages/layout/debug.tmp.ts @@ -0,0 +1,17 @@ +import { writeFileSync } from 'node:fs'; +import { GALLERY } from '@stackmap/core/samples'; +import { layoutDiagram } from './src/index'; +const out = process.argv[2]!; +for (const [name, d] of Object.entries(GALLERY)) { + if (!['workflow', 'lifecycle'].includes(d.kind)) continue; + const l = await layoutDiagram(d); + const el: string[] = []; + for (const [id, r] of Object.entries(l.lanes ?? {})) el.push(`${d.lanes!.find((x) => x.id === id)!.label}`); + for (const [id, r] of Object.entries(l.phases ?? {})) el.push(`${d.phases!.find((x) => x.id === id)!.label}`); + for (const [id, r] of Object.entries(l.groups)) el.push(`${d.groups!.find((x) => x.id === id)!.label}`); + for (const e of d.edges) { const p = l.edges[e.id]; if (!p) continue; const c = e.tone === 'error' ? '#c4432f' : e.tone === 'security' ? '#b83c74' : e.tone === 'main' ? '#161616' : '#8a8a86'; el.push(``); } + for (const n of d.nodes) { const r = l.nodes[n.id]!; el.push(`${n.card.title}${n.type}`); } + for (const e of d.edges) { const p = l.labels?.[e.id]; if (p && e.label) el.push(`${e.label}`); } + writeFileSync(`${out}/${name}.svg`, `${el.join('')}`); + console.log(name, l.bounds); +} diff --git a/packages/layout/scripts/bun-smoke.ts b/packages/layout/scripts/bun-smoke.ts index a1bd467..d4cd151 100644 --- a/packages/layout/scripts/bun-smoke.ts +++ b/packages/layout/scripts/bun-smoke.ts @@ -1,5 +1,6 @@ import { deepStrictEqual } from 'node:assert'; -import { commerceApi, groupedPlatform } from '@stackmap/core/samples'; +import { commerceApi, GALLERY, groupedPlatform } from '@stackmap/core/samples'; +import { galleryLayouts } from '../../viewer/src/samples/gallery.layout'; import { commerceApiLayout } from '../../viewer/src/samples/commerce-api.layout'; import { groupedPlatformLayout } from '../../viewer/src/samples/grouped-platform.layout'; import { layoutDiagram } from '../src/index'; @@ -7,4 +8,5 @@ import { layoutDiagram } from '../src/index'; // Runs under the Bun runtime (vitest runs under Node), and doubles as a drift check on the baked samples. deepStrictEqual(await layoutDiagram(commerceApi), commerceApiLayout); deepStrictEqual(await layoutDiagram(groupedPlatform), groupedPlatformLayout); +for (const [name, draft] of Object.entries(GALLERY)) deepStrictEqual(await layoutDiagram(draft), galleryLayouts[name]); console.log('✓ bun runtime: layouts match the baked samples'); diff --git a/packages/layout/scripts/layout-samples.ts b/packages/layout/scripts/layout-samples.ts index 2d062c8..5e7cce2 100644 --- a/packages/layout/scripts/layout-samples.ts +++ b/packages/layout/scripts/layout-samples.ts @@ -1,5 +1,5 @@ import { mkdirSync, writeFileSync } from 'node:fs'; -import { commerceApi, groupedPlatform } from '@stackmap/core/samples'; +import { commerceApi, GALLERY, groupedPlatform } from '@stackmap/core/samples'; import { layoutDiagram } from '../src/index'; const outDir = new URL('../../viewer/src/samples/', import.meta.url); @@ -13,3 +13,12 @@ for (const [file, [exportName, draft]] of Object.entries(samples)) { writeFileSync(new URL(`${file}.layout.ts`, outDir), source); console.log(`wrote ${file}.layout.ts`); } + +// The gallery set (archify's examples in stackmap's schema), one record for the dev pages. +const gallery: Record = {}; +for (const [name, draft] of Object.entries(GALLERY)) gallery[name] = await layoutDiagram(draft); +writeFileSync( + new URL('gallery.layout.ts', outDir), + `// Generated by packages/layout/scripts/layout-samples.ts — do not edit.\nimport type { LaidOutDiagram } from '@stackmap/core';\n\nexport const galleryLayouts: Record = ${JSON.stringify(gallery, null, 2)};\n`, +); +console.log('wrote gallery.layout.ts'); diff --git a/packages/layout/src/index.ts b/packages/layout/src/index.ts index 67636ca..39daa18 100644 --- a/packages/layout/src/index.ts +++ b/packages/layout/src/index.ts @@ -2,10 +2,18 @@ import { createRequire } from 'node:module'; import ElkApi from 'elkjs/lib/elk-api.js'; import ElkBundled from 'elkjs/lib/elk.bundled.js'; import type { ElkExtendedEdge, ElkNode, ElkPort } from 'elkjs/lib/elk-api'; -import { cardSize, type DiagramDraft, type Direction, type LaidOutDiagram, type Point, type Rect } from '@stackmap/core'; +import { cardSize, isLaneKind, usesCompactCards, type DiagramDraft, type Direction, type LaidOutDiagram, type Point, type Rect } from '@stackmap/core'; +import { findLabelSpot, labelWidth, placeLabel } from './labels'; +import { layoutLanes } from './lanes'; + +export { layoutLanes } from './lanes'; /** Vertical space reserved at the top of a group for its label. */ export const GROUP_LABEL_BAND = 48; +/** Stage bands (architecture/dataflow phases): the label band and the padding around their nodes. */ +export const STAGE_LABEL_BAND = 44; +const STAGE_PAD = 20; +const COMPACT_GAP = { right: 72, max: 168 } as const; const GROUP_PREFIX = 'group:'; // elkjs's bundled build treats any runtime with a global `self` and no `document` as a web worker @@ -22,7 +30,7 @@ function createElk(): { elk: InstanceType; dispose: () => voi return { elk, dispose: () => elk.terminateWorker() }; } -const rootOptions = (direction: Direction): Record => ({ +const rootOptions = (direction: Direction, compact: boolean): Record => ({ 'elk.algorithm': 'layered', 'elk.direction': direction, 'elk.edgeRouting': 'ORTHOGONAL', @@ -32,7 +40,8 @@ const rootOptions = (direction: Direction): Record => ({ 'elk.spacing.nodeNode': '40', 'elk.spacing.edgeNode': '20', // The layer gap must fit an edge label pill, which is far wider than it is tall. - 'elk.layered.spacing.nodeNodeBetweenLayers': direction === 'RIGHT' ? '120' : '72', + // Full cards reserve a label pill in every gap; compact layouts start tight and widen for labels (compactLabels). + 'elk.layered.spacing.nodeNodeBetweenLayers': direction === 'RIGHT' ? String(compact ? COMPACT_GAP.right : 120) : '72', 'elk.layered.spacing.edgeNodeBetweenLayers': '24', 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', // Draft order is the author's intended stacking; ELK otherwise reorders ties freely. @@ -77,8 +86,20 @@ function ports(id: string, width: number, height: number, direction: Direction): const round = (n: number) => Math.round(n * 100) / 100; export async function layoutDiagram(draft: DiagramDraft): Promise { + if (isLaneKind(draft.kind)) return layoutLanes(draft); const direction = draft.direction ?? 'RIGHT'; - const root: ElkNode = { id: 'root', layoutOptions: rootOptions(direction), children: [], edges: [] }; + const compact = usesCompactCards(draft); + const variant = compact ? 'compact' : 'full'; + const stages = draft.phases?.length ? stagePartitions(draft) : null; + const root: ElkNode = { id: 'root', layoutOptions: rootOptions(direction, compact), children: [], edges: [] }; + if (stages) { + // Stages are ELK partitions: every node of stage i is laid out before (left of / above) stage i+1. + root.layoutOptions!['elk.partitioning.activate'] = 'true'; + // Room for the stage labels above (RIGHT) or beside (DOWN) the first node row. + root.layoutOptions!['elk.padding'] = direction === 'RIGHT' ? `[top=${40 + STAGE_LABEL_BAND},left=40,bottom=40,right=40]` : `[top=40,left=${40 + 160},bottom=40,right=40]`; + // A stage band pads its nodes on both sides of the gap: keep a visible gutter between neighbours. + root.layoutOptions!['elk.layered.spacing.nodeNodeBetweenLayers'] = String(Math.max(Number(root.layoutOptions!['elk.layered.spacing.nodeNodeBetweenLayers']), 2 * STAGE_PAD + 64)); + } const groupNodes = new Map(); for (const g of draft.groups ?? []) { @@ -106,12 +127,13 @@ export async function layoutDiagram(draft: DiagramDraft): Promise(); for (const n of draft.nodes) { nodeIds.add(n.id); - const { width, height } = cardSize(n.card); + const { width, height } = cardSize(n.card, variant); + const partition = stages?.get(n.id); containerOf(n.group, `Node '${n.id}'`).children!.push({ id: n.id, width, height, - layoutOptions: { 'elk.portConstraints': 'FIXED_POS' }, + layoutOptions: { 'elk.portConstraints': 'FIXED_POS', ...(partition === undefined ? {} : { 'elk.partitioning.partition': String(partition) }) }, ports: ports(n.id, width, height, direction), }); } @@ -124,16 +146,45 @@ export async function layoutDiagram(draft: DiagramDraft): Promise MAX_UNWRAPPED_WIDTH) { - result = await elk.layout({ ...structuredClone(root), layoutOptions: { ...root.layoutOptions, ...wrapOptions } }); + const run = async (gap?: number) => { + const opts = gap === undefined ? root.layoutOptions! : { ...root.layoutOptions, 'elk.layered.spacing.nodeNodeBetweenLayers': String(gap) }; + let result = await elk.layout({ ...structuredClone(root), layoutOptions: opts }); + // Wrapping would fold stages back onto each other. + if ((result.width ?? 0) > MAX_UNWRAPPED_WIDTH && !stages) result = await elk.layout({ ...structuredClone(root), layoutOptions: { ...opts, ...wrapOptions } }); + return collect(draft, result, direction, stages !== null); + }; + let laid = await run(); + if (!compact) return laid; + // Compact layouts start with tight layer gaps and widen them once if a label found no room to sit. + let spots = compactLabels(draft, laid); + if (spots.misfit > 0) { + laid = await run(Math.min(COMPACT_GAP.max, Math.max(Number(root.layoutOptions!['elk.layered.spacing.nodeNodeBetweenLayers']), spots.misfit + 24))); + spots = compactLabels(draft, laid); } + return { ...laid, labels: spots.labels }; } finally { dispose(); } +} +/** Widest label (px) that found no free spot on its route; 0 when all fit. Labels are placed either way. */ +function compactLabels(draft: DiagramDraft, laid: LaidOutDiagram): { labels: Record; misfit: number } { + const cards = Object.values(laid.nodes); + const taken: Rect[] = []; + const labels: Record = {}; + let misfit = 0; + const pending = draft.edges.filter((e) => e.label && laid.edges[e.id]); + for (const e of pending) { + const spot = findLabelSpot(laid.edges[e.id]!, e.label!, cards, taken); + if (spot) labels[e.id] = spot; + else misfit = Math.max(misfit, labelWidth(e.label!)); + } + for (const e of pending) if (!labels[e.id]) labels[e.id] = placeLabel(laid.edges[e.id]!, e.label!, cards, taken); + return { labels, misfit }; +} + +function collect(draft: DiagramDraft, result: ElkNode, direction: Direction, staged: boolean): LaidOutDiagram { const nodes: Record = {}; const groups: Record = {}; const edges: Record = {}; @@ -153,5 +204,53 @@ export async function layoutDiagram(draft: DiagramDraft): Promise { + const part = new Map(); + draft.phases!.forEach((p, i) => p.nodes?.forEach((n) => part.has(n) || part.set(n, i))); + for (let pass = 0; pass < draft.nodes.length; pass++) { + let changed = false; + for (const e of draft.edges) { + if (part.has(e.from) && !part.has(e.to)) { + part.set(e.to, part.get(e.from)!); + changed = true; + } + } + if (!changed) break; + } + // Partitions only order the top level; grouped nodes are left to the group's own layout. + const grouped = new Set(draft.nodes.filter((n) => n.group !== undefined).map((n) => n.id)); + for (const n of draft.nodes) if (!part.has(n.id)) part.set(n.id, 0); + for (const id of grouped) part.delete(id); + return part; +} + +/** One band per stage: its nodes' extent along the flow, the whole diagram across it, plus the label band. */ +function stageBands(draft: DiagramDraft, nodes: Record, direction: Direction): Record { + const out: Record = {}; + const all = Object.values(nodes); + if (!all.length) return out; + const top = Math.min(...all.map((r) => r.y)); + const bottom = Math.max(...all.map((r) => r.y + r.height)); + const left = Math.min(...all.map((r) => r.x)); + const right = Math.max(...all.map((r) => r.x + r.width)); + for (const p of draft.phases ?? []) { + const members = (p.nodes ?? []).flatMap((id) => nodes[id] ?? []); + if (!members.length) continue; + if (direction === 'RIGHT') { + const x = Math.min(...members.map((r) => r.x)) - STAGE_PAD; + const x2 = Math.max(...members.map((r) => r.x + r.width)) + STAGE_PAD; + out[p.id] = { x: round(x), y: round(top - STAGE_PAD - STAGE_LABEL_BAND), width: round(x2 - x), height: round(bottom - top + 2 * STAGE_PAD + STAGE_LABEL_BAND) }; + } else { + const y = Math.min(...members.map((r) => r.y)) - STAGE_PAD; + const y2 = Math.max(...members.map((r) => r.y + r.height)) + STAGE_PAD; + out[p.id] = { x: round(left - STAGE_PAD - 160), y: round(y), width: round(right - left + 2 * STAGE_PAD + 160), height: round(y2 - y) }; + } + } + return out; } diff --git a/packages/layout/src/labels.ts b/packages/layout/src/labels.ts new file mode 100644 index 0000000..8370fba --- /dev/null +++ b/packages/layout/src/labels.ts @@ -0,0 +1,64 @@ +import { measureText, type Point, type Rect } from '@stackmap/core'; + +// Edge label placement shared by the lane and compact ELK layouts: the viewer's pill (11px Geist, 8px padding +// each side, 20px tall) on a run of the route, clear of cards and of other labels. + +const LABEL_H = 20; +export const labelWidth = (text: string) => Math.ceil(measureText(text, 'sans400', 11)) + 16; +const round = (n: number) => Math.round(n * 100) / 100; + +const overlaps = (a: Rect, b: Rect) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height; + +/** + * The label goes on the longest run that holds its pill without touching a card or another label; along a run + * it may slide off the midpoint to clear a neighbour's pill. Horizontal runs are preferred. + */ +export function placeLabel(points: Point[], text: string, cards: Rect[], taken: Rect[]): Point { + return findLabelSpot(points, text, cards, taken) ?? claimFallback(points, text, taken); +} + +/** A spot that fits, claimed in `taken`; null when none does. */ +export function findLabelSpot(points: Point[], text: string, cards: Rect[], taken: Rect[]): Point | null { + const w = labelWidth(text); + const pillAt = (p: Point): Rect => ({ x: p.x - w / 2, y: p.y - LABEL_H / 2, width: w, height: LABEL_H }); + let best: { p: Point; score: number } | null = null; + for (let i = 1; i < points.length; i++) { + const a = points[i - 1]!; + const b = points[i]!; + const horizontal = a.y === b.y; + const len = Math.abs(a.x - b.x) + Math.abs(a.y - b.y); + const room = horizontal ? len >= w + 16 : len >= LABEL_H + 16; + if (!room) continue; + // On the line first; a vertical run may also carry the pill just beside it (right, then left). + const sides = horizontal ? [0] : [0, w / 2 + 4, -(w / 2 + 4)]; + search: for (const side of sides) { + for (const t of [0.5, 0.35, 0.65, 0.2, 0.8]) { + const p = { x: round(a.x + (b.x - a.x) * t + side), y: round(a.y + (b.y - a.y) * t) }; + const pill = pillAt(p); + if (cards.some((r) => overlaps(pill, r)) || taken.some((r) => overlaps(pill, r))) continue; + // Longer and horizontal runs first; off-centre and off-line spots lose a little. + const score = (horizontal ? 2 : 1) * len - Math.abs(t - 0.5) * 40 - (side ? 30 : 0); + if (!best || score > best.score) best = { p, score }; + break search; + } + } + } + if (!best) return null; + taken.push(pillAt(best.p)); + return best.p; +} + +/** No spot fits: the middle of the longest run, overlapping whatever is there. */ +function claimFallback(points: Point[], text: string, taken: Rect[]): Point { + let best = { p: points[0]!, len: -1 }; + for (let i = 1; i < points.length; i++) { + const a = points[i - 1]!; + const b = points[i]!; + const len = Math.abs(a.x - b.x) + Math.abs(a.y - b.y); + if (len > best.len) best = { p: { x: round((a.x + b.x) / 2), y: round((a.y + b.y) / 2) }, len }; + } + const w = labelWidth(text); + taken.push({ x: best.p.x - w / 2, y: best.p.y - LABEL_H / 2, width: w, height: LABEL_H }); + return best.p; +} + diff --git a/packages/layout/src/lanes.ts b/packages/layout/src/lanes.ts new file mode 100644 index 0000000..41aef9d --- /dev/null +++ b/packages/layout/src/lanes.ts @@ -0,0 +1,283 @@ +import { cardSize, type DiagramDraft, type DiagramEdge, type LaidOutDiagram, type Point, type Rect } from '@stackmap/core'; +import { labelWidth, placeLabel } from './labels'; +import { nudge, routeEdges, STUB, type PortChoice, type RouteRequest } from './route'; + +// Swimlane layout for workflow and lifecycle diagrams: lanes are full-width rows in draft order, columns +// follow the flow (longest path), and stackmap routes the edges itself (./route.ts). + +const PAD = 40; +/** Left rail of every lane, holding its label (wrapped to the rail). */ +export const LANE_HEAD = 144; +const LANE_PAD_X = 32; +const LANE_PAD_TOP = 20; +/** Room under a lane's cards for the channel same-lane back edges take. */ +const LANE_PAD_BOTTOM = 28; +const LANE_GAP = 16; +const STACK_GAP = 28; +const MIN_GUTTER = 64; +const MAX_GUTTER = 176; +/** Header band above the lanes, when there are phases. */ +export const PHASE_BAND = 40; +const PHASE_GAP = 8; +/** Group frames inside a lane: padding around members and the label band above them. */ +export const GROUP_PAD = 12; +export const GROUP_LABEL = 28; +/** Room left of a lifecycle start state for its initial marker. */ +export const START_MARK = 28; + + +const round = (n: number) => Math.round(n * 100) / 100; +const roundRect = (r: Rect): Rect => ({ x: round(r.x), y: round(r.y), width: round(r.width), height: round(r.height) }); + +/** Edges that don't push their target to a later column: replies, and whatever closes a cycle. */ +export function backEdges(draft: DiagramDraft): Set { + const back = new Set(draft.edges.filter((e) => e.kind === 'return' || e.from === e.to).map((e) => e.id)); + const phaseOf = new Map(); + draft.phases?.forEach((p, i) => p.nodes?.forEach((n) => phaseOf.has(n) || phaseOf.set(n, i))); + for (const e of draft.edges) { + const a = phaseOf.get(e.from); + const b = phaseOf.get(e.to); + if (a !== undefined && b !== undefined && b < a) back.add(e.id); + } + // DFS in draft order; an edge into a node still on the stack closes a cycle. + const out = new Map(draft.nodes.map((n) => [n.id, [] as DiagramEdge[]])); + for (const e of draft.edges) if (!back.has(e.id)) out.get(e.from)?.push(e); + const state = new Map(); + const visit = (id: string) => { + state.set(id, 1); + for (const e of out.get(id) ?? []) { + const s = state.get(e.to); + if (s === 1) back.add(e.id); + else if (s === undefined && out.has(e.to)) visit(e.to); + } + state.set(id, 2); + }; + const hasIn = new Set(draft.edges.filter((e) => !back.has(e.id)).map((e) => e.to)); + for (const n of draft.nodes) if (!hasIn.has(n.id) && !state.has(n.id)) visit(n.id); + for (const n of draft.nodes) if (!state.has(n.id)) visit(n.id); + return back; +} + +/** + * Column per node: longest path over forward edges. Within a lane a successor moves one column right; across + * lanes it may stay in the same column (a straight drop). A phase starts after the previous phase ends. + */ +export function assignColumns(draft: DiagramDraft, back: Set): Map { + const laneOf = new Map(draft.nodes.map((n) => [n.id, n.lane])); + const laneIndex = new Map((draft.lanes ?? []).map((l, i) => [l.id, i])); + const forward = draft.edges.filter((e) => !back.has(e.id) && laneOf.has(e.from) && laneOf.has(e.to)); + const phases = (draft.phases ?? []).map((p) => (p.nodes ?? []).filter((n) => laneOf.has(n))); + const phaseOf = new Map(); + phases.forEach((ns, i) => ns.forEach((n) => phaseOf.has(n) || phaseOf.set(n, i))); + // Cross-lane edges that must still step right: their straight drop would run through a card. + const step = new Set(); + const settle = () => { + const col = new Map(draft.nodes.map((n) => [n.id, 0])); + const starts = phases.map(() => 0); + // Monotone: columns only grow, and the forward edges are acyclic, so this settles (bounded for safety). + for (let round = 0; round < draft.nodes.length * 4 + 8; round++) { + let changed = false; + const raise = (id: string, to: number) => { + if (to > col.get(id)!) { + col.set(id, to); + changed = true; + } + }; + for (const [id, p] of phaseOf) raise(id, starts[p]!); + for (const e of forward) raise(e.to, col.get(e.from)! + (laneOf.get(e.from) === laneOf.get(e.to) || step.has(e.id) ? 1 : 0)); + for (let p = 1; p < phases.length; p++) { + const prevEnd = Math.max(starts[p - 1]!, ...phases[p - 1]!.map((n) => col.get(n)!)); + if (prevEnd + 1 > starts[p]!) { + starts[p] = prevEnd + 1; + changed = true; + } + } + if (!changed) break; + } + return col; + }; + let col = settle(); + for (let pass = 0; pass < forward.length; pass++) { + // Stepping right is worth a column only inside the existing width; past it, routing around is cheaper. + const last = Math.max(...col.values()); + const blocked = forward.find((e) => { + if (step.has(e.id) || col.get(e.from) !== col.get(e.to) || col.get(e.from)! + 1 > last) return false; + const [a, b] = [laneIndex.get(laneOf.get(e.from)!)!, laneIndex.get(laneOf.get(e.to)!)!].sort((x, y) => x - y); + return draft.nodes.some((n) => n.id !== e.from && n.id !== e.to && col.get(n.id) === col.get(e.from) && laneIndex.get(n.lane!)! > a! && laneIndex.get(n.lane!)! < b!); + }); + if (!blocked) break; + step.add(blocked.id); + col = settle(); + } + return col; +} + +function portChoices(sameLane: boolean, dCol: number, dLane: number, isBack: boolean): { sources: PortChoice[]; targets: PortChoice[] } { + const c = (side: PortChoice['side'], cost: number): PortChoice => ({ side, cost }); + if (isBack) { + if (sameLane) return { sources: [c('bottom', 0), c('top', 30), c('left', 60)], targets: [c('bottom', 0), c('top', 30), c('right', 60)] }; + return dLane > 0 + ? { sources: [c('bottom', 0), c('left', 20)], targets: [c('right', 0), c('top', 20), c('left', 40)] } + : { sources: [c('top', 0), c('left', 20)], targets: [c('right', 0), c('bottom', 20), c('left', 40)] }; + } + if (sameLane) return { sources: [c('right', 0), c('bottom', 80), c('top', 80)], targets: [c('left', 0), c('top', 80), c('bottom', 80)] }; + if (dLane > 0) + return dCol === 0 + ? { sources: [c('bottom', 0)], targets: [c('top', 0)] } + : { sources: [c('bottom', 0), c('right', 30)], targets: [c('top', 0), c('left', 30)] }; + return dCol === 0 + ? { sources: [c('top', 0)], targets: [c('bottom', 0)] } + : { sources: [c('top', 0), c('right', 30)], targets: [c('bottom', 0), c('left', 30)] }; +} + +export function layoutLanes(draft: DiagramDraft): LaidOutDiagram { + const lanes = draft.lanes ?? []; + const laneIndex = new Map(lanes.map((l, i) => [l.id, i])); + const nodes = draft.nodes.filter((n) => n.lane !== undefined && laneIndex.has(n.lane)); + const back = backEdges(draft); + const col = assignColumns(draft, back); + const size = new Map(nodes.map((n) => [n.id, cardSize(n.card, 'compact')])); + const cols = Math.max(0, ...nodes.map((n) => col.get(n.id)!)) + 1; + const cardW = Math.max(...[...size.values()].map((s) => s.width)); + + // A gutter wide enough for the widest label on an edge between neighbouring columns of one lane. + const laneOf = new Map(nodes.map((n) => [n.id, n.lane!])); + const neighbourLabels = draft.edges.filter((e) => e.label && !back.has(e.id) && laneOf.get(e.from) === laneOf.get(e.to) && col.get(e.to)! - col.get(e.from)! === 1); + const gutter = Math.min(MAX_GUTTER, Math.max(MIN_GUTTER, ...neighbourLabels.map((e) => labelWidth(e.label!) + 24))); + + const hasStart = nodes.some((n) => n.type === 'start' && col.get(n.id) === 0); + const x0 = PAD + LANE_HEAD + (hasStart ? START_MARK : 0); + const colX = (c: number) => x0 + c * (cardW + gutter); + const bandX = PAD; + const bandW = colX(cols - 1) + cardW + LANE_PAD_X - PAD; + + const top = PAD + (draft.phases?.length ? PHASE_BAND + PHASE_GAP : 0); + const rects: Record = {}; + const laneRects: Record = {}; + const groupRects: Record = {}; + let y = top; + for (const lane of lanes) { + const members = nodes.filter((n) => n.lane === lane.id); + const grouped = (draft.groups ?? []).some((g) => members.some((n) => n.group === g.id)); + const contentTop = y + LANE_PAD_TOP + (grouped ? GROUP_LABEL + GROUP_PAD : 0); + // Stack row k of every column shares one centre line, so neighbours in a lane line up. + const cells = new Map(); + for (const n of members) cells.set(col.get(n.id)!, [...(cells.get(col.get(n.id)!) ?? []), n]); + const depth = Math.max(0, ...[...cells.values()].map((c) => c.length)); + const rowH = Array.from({ length: depth }, (_, k) => Math.max(0, ...[...cells.values()].map((c) => (c[k] ? size.get(c[k].id)!.height : 0)))); + const rowCenter: number[] = []; + let cursor = contentTop; + for (let k = 0; k < depth; k++) { + rowCenter.push(cursor + rowH[k]! / 2); + cursor += rowH[k]! + STACK_GAP; + } + for (const [c, cell] of cells) { + cell.forEach((n, k) => { + const s = size.get(n.id)!; + rects[n.id] = roundRect({ x: colX(c) + (cardW - s.width) / 2, y: rowCenter[k]! - s.height / 2, width: s.width, height: s.height }); + }); + } + // The rail label may wrap: keep an empty or one-row lane tall enough for two lines. + const contentBottom = Math.max(depth ? cursor - STACK_GAP : contentTop + 60, y + 72 - LANE_PAD_BOTTOM); + const bottom = contentBottom + (grouped ? GROUP_PAD : 0) + LANE_PAD_BOTTOM; + laneRects[lane.id] = roundRect({ x: bandX, y, width: bandW, height: bottom - y }); + y = bottom + LANE_GAP; + } + + for (const g of draft.groups ?? []) { + const members = nodes.filter((n) => n.group === g.id).map((n) => rects[n.id]!); + if (!members.length) continue; + const x = Math.min(...members.map((r) => r.x)) - GROUP_PAD; + const yTop = Math.min(...members.map((r) => r.y)) - GROUP_PAD - GROUP_LABEL; + const right = Math.max(...members.map((r) => r.x + r.width)) + GROUP_PAD; + const bottom = Math.max(...members.map((r) => r.y + r.height)) + GROUP_PAD; + groupRects[g.id] = roundRect({ x, y: yTop, width: right - x, height: bottom - yTop }); + } + + const phaseRects: Record = {}; + for (const p of draft.phases ?? []) { + const cs = (p.nodes ?? []).filter((n) => col.has(n) && laneOf.has(n)).map((n) => col.get(n)!); + if (!cs.length) continue; + const left = colX(Math.min(...cs)) - gutter / 2 + 8; + const right = colX(Math.max(...cs)) + cardW + gutter / 2 - 8; + phaseRects[p.id] = roundRect({ x: Math.max(left, bandX + 8), y: PAD, width: Math.min(right, bandX + bandW - 8) - Math.max(left, bandX + 8), height: PHASE_BAND }); + } + + // Channels: gutter centres, the lane margins, and in every lane its label strip and bottom padding. + const xs = [x0 - (hasStart ? START_MARK : 0) - LANE_PAD_X / 2, bandX + bandW - LANE_PAD_X / 2]; + for (let c = 0; c < cols - 1; c++) xs.push(colX(c) + cardW + gutter / 2); + const ys: number[] = []; + for (const r of Object.values(laneRects)) ys.push(r.y + r.height - LANE_PAD_BOTTOM / 2, r.y - LANE_GAP / 2, r.y + r.height + LANE_GAP / 2); + // Lane rails, group labels and start markers are text and marks that edges must not run over. + const obstacles: Rect[] = []; + for (const lane of lanes) { + const r = laneRects[lane.id]!; + obstacles.push({ x: r.x, y: r.y, width: LANE_HEAD - 8, height: r.height }); + } + for (const g of draft.groups ?? []) { + const r = groupRects[g.id]; + if (r) obstacles.push({ x: r.x, y: r.y + 4, width: labelWidth(g.label) + 24, height: GROUP_LABEL - 8 }); + } + for (const n of nodes) { + if (n.type !== 'start') continue; + const r = rects[n.id]!; + obstacles.push({ x: r.x - START_MARK, y: r.y + r.height / 2 - 8, width: START_MARK, height: 16 }); + } + + const requests: RouteRequest[] = draft.edges + .filter((e) => rects[e.from] && rects[e.to] && e.from !== e.to) + .map((e) => { + const dLane = laneIndex.get(laneOf.get(e.to)!)! - laneIndex.get(laneOf.get(e.from)!)!; + const dCol = col.get(e.to)! - col.get(e.from)!; + const isBack = back.has(e.id) || dCol < 0; + const choice = portChoices(dLane === 0, dCol, dLane, isBack); + // A start state's left side holds its initial marker. + const blockLeft = (id: string) => draft.nodes.find((n) => n.id === id)?.type === 'start'; + return { + id: e.id, + from: e.from, + to: e.to, + tone: `${e.tone ?? ''}${e.kind === 'return' ? ':return' : ''}`, + sources: choice.sources.filter((c) => !(c.side === 'left' && blockLeft(e.from))), + targets: choice.targets.filter((c) => !(c.side === 'left' && blockLeft(e.to))), + order: (isBack ? 1e6 : 0) + Math.abs(dCol) * 1000 + Math.abs(dLane), + }; + }) + .sort((a, b) => a.order - b.order); + const ends = Object.fromEntries(draft.edges.map((e) => [e.id, { from: e.from, to: e.to }])); + const routed = nudge(routeEdges(rects, requests, { xs, ys }, obstacles), ends); + + const edges: Record = {}; + for (const e of draft.edges) { + if (routed[e.id]) edges[e.id] = routed[e.id]!.map((p) => ({ x: round(p.x), y: round(p.y) })); + else if (e.from === e.to && rects[e.from]) edges[e.id] = selfLoop(rects[e.from]!); + } + + const cards = Object.values(rects); + const taken: Rect[] = []; + const labels: Record = {}; + for (const e of draft.edges) if (e.label && edges[e.id]) labels[e.id] = placeLabel(edges[e.id]!, e.label, cards, taken); + + const height = y - LANE_GAP + PAD; + return { + draft, + nodes: rects, + groups: groupRects, + edges, + lanes: laneRects, + phases: phaseRects, + labels, + bounds: { width: round(bandX + bandW + PAD), height: round(height) }, + }; +} + +/** A self-transition: out of the top, round over the card, back into the top. */ +function selfLoop(r: Rect): Point[] { + const cx = r.x + r.width / 2; + return [ + { x: round(cx + 24), y: r.y }, + { x: round(cx + 24), y: round(r.y - STUB - 8) }, + { x: round(cx - 24), y: round(r.y - STUB - 8) }, + { x: round(cx - 24), y: r.y }, + ]; +} diff --git a/packages/layout/src/route.ts b/packages/layout/src/route.ts new file mode 100644 index 0000000..019aeda --- /dev/null +++ b/packages/layout/src/route.ts @@ -0,0 +1,429 @@ +import type { Point, Rect } from '@stackmap/core'; + +// Orthogonal connector routing for the lane layouts, where ELK doesn't place the nodes. A sparse visibility +// grid (the caller's channel lines plus every node's centre and port stubs), A* over (point, heading) with a +// bend cost, then nudging so unrelated edges sharing a channel run side by side instead of on top of each other. + +export type Side = 'left' | 'right' | 'top' | 'bottom'; +export interface PortChoice { + side: Side; + /** added to the route's cost when this side is used */ + cost: number; +} +export interface RouteRequest { + id: string; + from: string; + to: string; + /** edges of different tones don't share a port: a trunk would hide which colour goes where */ + tone?: string; + sources: PortChoice[]; + targets: PortChoice[]; +} + +/** Distance from a card edge to the first bend: arrowheads and handle dots need a straight run. */ +export const STUB = 20; +/** Clearance kept between a route and any card. */ +const MARGIN = 12; +const BEND = 48; +/** Extra cost per px for running along a channel another, unrelated edge already uses. */ +const SHARED = 1.5; +const TRACK = 8; +/** Cost of a side the request didn't ask for: a last resort, so every edge gets an orthogonal route. */ +const ANY_SIDE = 400; +/** Cost of leaving through a side an arrow already enters, or entering one an edge leaves: reads as two-way. */ +const OPPOSED = 600; +const SIDES: Side[] = ['right', 'bottom', 'left', 'top']; +/** Cost of sharing a port slot with an edge of another tone: another slot is almost always better. */ +const MIXED_TONE = 300; +/** Shortest straight run a nudge may leave at a port (arrowhead + gap). */ +const MIN_RUN = 12; + +type Dir = 0 | 1 | 2 | 3; // right, down, left, up +const DX = [1, 0, -1, 0]; +const DY = [0, 1, 0, -1]; +const SIDE_DIR: Record = { right: 0, bottom: 1, left: 2, top: 3 }; + +const round = (n: number) => Math.round(n * 100) / 100; + +/** Port slots per side: the midpoint, then one either side, so edges of different tones leave separately. */ +const SLOTS = [0, -1, 1] as const; +type Slot = (typeof SLOTS)[number]; +const SLOT_GAP = { horizontal: 28, vertical: 16 }; +/** Cost of an off-centre slot: used only when the midpoint is taken by an edge that can't share it. */ +const SLOT_COST = 24; + +export function portPoint(r: Rect, side: Side, slot: Slot = 0): Point { + const along = slot * (side === 'top' || side === 'bottom' ? SLOT_GAP.horizontal : SLOT_GAP.vertical); + switch (side) { + case 'left': + return { x: r.x, y: r.y + r.height / 2 + along }; + case 'right': + return { x: r.x + r.width, y: r.y + r.height / 2 + along }; + case 'top': + return { x: r.x + r.width / 2 + along, y: r.y }; + case 'bottom': + return { x: r.x + r.width / 2 + along, y: r.y + r.height }; + } +} +const stubPoint = (r: Rect, side: Side, slot: Slot = 0): Point => { + const p = portPoint(r, side, slot); + const d = SIDE_DIR[side]; + return { x: p.x + DX[d]! * STUB, y: p.y + DY[d]! * STUB }; +}; + +const inflate = (r: Rect, m: number): Rect => ({ x: r.x - m, y: r.y - m, width: r.width + 2 * m, height: r.height + 2 * m }); +const inside = (p: Point, r: Rect) => p.x > r.x && p.x < r.x + r.width && p.y > r.y && p.y < r.y + r.height; +/** Whether an axis-aligned segment passes through the rectangle's interior. */ +function crosses(a: Point, b: Point, r: Rect): boolean { + const x0 = Math.min(a.x, b.x); + const x1 = Math.max(a.x, b.x); + const y0 = Math.min(a.y, b.y); + const y1 = Math.max(a.y, b.y); + return x1 > r.x && x0 < r.x + r.width && y1 > r.y && y0 < r.y + r.height; +} + +class Heap { + private items: [number, number][] = []; + get size() { + return this.items.length; + } + push(cost: number, state: number) { + const a = this.items; + a.push([cost, state]); + let i = a.length - 1; + while (i > 0) { + const p = (i - 1) >> 1; + if (a[p]![0] <= a[i]![0]) break; + [a[p], a[i]] = [a[i]!, a[p]!]; + i = p; + } + } + pop(): [number, number] { + const a = this.items; + const top = a[0]!; + const last = a.pop()!; + if (a.length) { + a[0] = last; + let i = 0; + for (;;) { + const l = 2 * i + 1; + const r = l + 1; + let m = i; + if (l < a.length && a[l]![0] < a[m]![0]) m = l; + if (r < a.length && a[r]![0] < a[m]![0]) m = r; + if (m === i) break; + [a[m], a[i]] = [a[i]!, a[m]!]; + i = m; + } + } + return top; + } +} + +interface Grid { + pts: Point[]; + /** neighbour point index per direction, -1 when blocked */ + next: Int32Array; +} + +function buildGrid(xs: number[], ys: number[], blocked: Rect[]): Grid { + const X = [...new Set(xs.map(round))].sort((a, b) => a - b); + const Y = [...new Set(ys.map(round))].sort((a, b) => a - b); + const pts: Point[] = []; + const index = new Int32Array(X.length * Y.length).fill(-1); + for (let j = 0; j < Y.length; j++) { + for (let i = 0; i < X.length; i++) { + const p = { x: X[i]!, y: Y[j]! }; + if (blocked.some((r) => inside(p, r))) continue; + index[j * X.length + i] = pts.length; + pts.push(p); + } + } + const next = new Int32Array(pts.length * 4).fill(-1); + const link = (a: number, b: number, dir: Dir) => { + if (blocked.some((r) => crosses(pts[a]!, pts[b]!, r))) return; + next[a * 4 + dir] = b; + next[b * 4 + ((dir + 2) % 4)] = a; + }; + for (let j = 0; j < Y.length; j++) { + let prev = -1; + for (let i = 0; i < X.length; i++) { + const k = index[j * X.length + i]!; + if (k < 0) { + prev = -1; + continue; + } + if (prev >= 0) link(prev, k, 0); + prev = k; + } + } + for (let i = 0; i < X.length; i++) { + let prev = -1; + for (let j = 0; j < Y.length; j++) { + const k = index[j * X.length + i]!; + if (k < 0) { + prev = -1; + continue; + } + if (prev >= 0) link(prev, k, 1); + prev = k; + } + } + return { pts, next }; +} + +interface Used { + from: string; + to: string; +} +const stepKey = (a: number, b: number) => (a < b ? `${a}-${b}` : `${b}-${a}`); +/** Cost of touching a point another, unrelated edge already runs through (a crossing or a merge). */ +const CROSS = 40; + +interface Port { + side: Side; + slot: Slot; + cost: number; +} + +/** + * Routes every request around `nodes` (and extra `obstacles`, e.g. labels), using `xs`/`ys` as channel lines. + * Returns each edge's polyline from port to port, first point on the source card, last on the target card. + */ +export function routeEdges( + nodes: Record, + requests: RouteRequest[], + channels: { xs: number[]; ys: number[] }, + obstacles: Rect[] = [], +): Record { + const xs = [...channels.xs]; + const ys = [...channels.ys]; + for (const r of Object.values(nodes)) { + for (const slot of SLOTS) { + const top = stubPoint(r, 'top', slot); + const left = stubPoint(r, 'left', slot); + xs.push(top.x); + ys.push(left.y); + } + xs.push(r.x - STUB, r.x + r.width + STUB); + ys.push(r.y - STUB, r.y + r.height + STUB); + } + const blocked = [...Object.values(nodes).map((r) => inflate(r, MARGIN)), ...obstacles]; + const grid = buildGrid(xs, ys, blocked); + const at = new Map(grid.pts.map((p, i) => [`${p.x},${p.y}`, i])); + const find = (p: Point) => at.get(`${round(p.x)},${round(p.y)}`); + const usedSteps = new Map(); + const usedPoints = new Map(); + const out: Record = {}; + + // Who uses each port slot: edges may share one only in the same direction and tone (a trunk). + const slotUse = new Map }>(); + const ports = (choices: PortChoice[], node: string, role: 'in' | 'out', tone: string): Port[] => { + const listed = new Map(choices.map((c) => [c.side, c.cost])); + return SIDES.flatMap((side) => + SLOTS.map((slot) => { + const use = slotUse.get(`${node}:${side}:${slot}`); + const clash = !use ? 0 : use.role !== role ? OPPOSED : use.tones.has(tone) ? 0 : MIXED_TONE; + return { side, slot, cost: (listed.get(side) ?? ANY_SIDE) + (slot ? SLOT_COST : 0) + clash }; + }), + ); + }; + const claim = (node: string, port: { side: Side; slot: Slot }, role: 'in' | 'out', tone: string) => { + const key = `${node}:${port.side}:${port.slot}`; + const use = slotUse.get(key) ?? { role, tones: new Set() }; + use.tones.add(tone); + slotUse.set(key, use); + }; + + for (const req of requests) { + const src = nodes[req.from]!; + const dst = nodes[req.to]!; + const tone = req.tone ?? ''; + const starts = ports(req.sources, req.from, 'out', tone).flatMap((c) => { + const k = find(stubPoint(src, c.side, c.slot)); + return k === undefined ? [] : [{ ...c, k }]; + }); + const goals = new Map(); + for (const c of ports(req.targets, req.to, 'in', tone)) { + const k = find(stubPoint(dst, c.side, c.slot)); + if (k !== undefined) goals.set(k, [...(goals.get(k) ?? []), c]); + } + // Fan-out from one card and fan-in to one card share a trunk on purpose; only unrelated edges pay. + const unrelated = (list: Used[] | undefined) => !!list?.some((u) => u.from !== req.from && u.to !== req.to); + const path = search(grid, starts, goals, (a, b, len) => (unrelated(usedSteps.get(stepKey(a, b))) ? SHARED * len : 0) + (unrelated(usedPoints.get(b)) ? CROSS : 0)); + if (!path) { + // Boxed in (every stub blocked): an elbow, which may cross a card but is never diagonal. + const a = portPoint(src, 'right'); + const b = portPoint(dst, 'left'); + out[req.id] = [a, { x: b.x, y: a.y }, b]; + continue; + } + const { points: gridPath, src: from, dst: to } = path; + claim(req.from, from, 'out', tone); + claim(req.to, to, 'in', tone); + const me = { from: req.from, to: req.to }; + gridPath.forEach((k, i) => { + usedPoints.set(k, [...(usedPoints.get(k) ?? []), me]); + if (i) usedSteps.set(stepKey(gridPath[i - 1]!, k), [...(usedSteps.get(stepKey(gridPath[i - 1]!, k)) ?? []), me]); + }); + out[req.id] = [portPoint(src, from.side, from.slot), ...gridPath.map((k) => grid.pts[k]!), portPoint(dst, to.side, to.slot)]; + } + return out; +} + +function search( + grid: Grid, + starts: (Port & { k: number })[], + goals: Map, + extra: (a: number, b: number, len: number) => number, +): { points: number[]; src: Port; dst: Port } | null { + if (!starts.length || !goals.size) return null; + const n = grid.pts.length * 4; + const dist = new Float64Array(n).fill(Infinity); + const prev = new Int32Array(n).fill(-1); + const srcOf = new Map(); + const heap = new Heap(); + const goalPts = [...goals.keys()].map((k) => grid.pts[k]!); + const h = (k: number) => { + const p = grid.pts[k]!; + let best = Infinity; + for (const g of goalPts) best = Math.min(best, Math.abs(g.x - p.x) + Math.abs(g.y - p.y)); + return best; + }; + for (const s of starts) { + const state = s.k * 4 + SIDE_DIR[s.side]; + if (s.cost < dist[state]!) { + dist[state] = s.cost; + srcOf.set(state, s); + heap.push(s.cost + h(s.k), state); + } + } + let best: { state: number; cost: number; port: Port } | null = null; + while (heap.size) { + const [f, state] = heap.pop(); + if (best && f >= best.cost) break; + const k = state >> 2; + const dir = (state & 3) as Dir; + const g = dist[state]!; + if (f - h(k) > g + 1e-6) continue; + for (const c of goals.get(k) ?? []) { + // Arriving heading into the card costs nothing; any other heading needs one more bend. + const into = (SIDE_DIR[c.side] + 2) % 4; + const total = g + c.cost + (dir === into ? 0 : BEND); + if (!best || total < best.cost) best = { state, cost: total, port: c }; + } + for (let d = 0 as Dir; d < 4; d = (d + 1) as Dir) { + if (d === (dir + 2) % 4) continue; // no U-turns in place + const m = grid.next[k * 4 + d]!; + if (m < 0) continue; + const a = grid.pts[k]!; + const b = grid.pts[m]!; + const len = Math.abs(a.x - b.x) + Math.abs(a.y - b.y); + const cost = g + len + extra(k, m, len) + (d === dir ? 0 : BEND); + const next = m * 4 + d; + if (cost < dist[next]! - 1e-9) { + dist[next] = cost; + prev[next] = state; + heap.push(cost + h(m), next); + } + } + } + if (!best) return null; + const points: number[] = []; + let s = best.state; + let first = s; + while (s >= 0) { + points.push(s >> 2); + first = s; + s = prev[s]!; + } + points.reverse(); + return { points, src: srcOf.get(first)!, dst: best.port }; +} + +/** Drops repeated points and the middle of straight runs. */ +export function simplify(points: Point[]): Point[] { + const pts = points.filter((p, i) => i === 0 || p.x !== points[i - 1]!.x || p.y !== points[i - 1]!.y); + return pts.filter((p, i) => { + if (i === 0 || i === pts.length - 1) return true; + const a = pts[i - 1]!; + const b = pts[i + 1]!; + return !((a.x === p.x && p.x === b.x) || (a.y === p.y && p.y === b.y)); + }); +} + +interface Seg { + edge: string; + i: number; + /** the fixed coordinate: x for a vertical segment, y for a horizontal one */ + at: number; + lo: number; + hi: number; + src: string; + dst: string; +} + +/** + * Spreads unrelated edges that run along the same line over parallel tracks, TRACK px apart. Segments of + * edges that share a source or a target stay merged (a trunk). Segments on a port never move, and a move + * never shortens a port's straight run below MIN_RUN, so edges still meet their cards square. + */ +export function nudge(routes: Record, ends: Record): Record { + const out: Record = Object.fromEntries(Object.entries(routes).map(([k, v]) => [k, simplify(v).map((p) => ({ ...p }))])); + for (const vertical of [true, false]) { + const lines = new Map(); + for (const [edge, pts] of Object.entries(out)) { + for (let i = 1; i + 1 < pts.length - 1; i++) { + const a = pts[i]!; + const b = pts[i + 1]!; + if (vertical ? a.x !== b.x : a.y !== b.y) continue; + const at = vertical ? a.x : a.y; + const [lo, hi] = vertical ? [Math.min(a.y, b.y), Math.max(a.y, b.y)] : [Math.min(a.x, b.x), Math.max(a.x, b.x)]; + const seg = { edge, i, at, lo, hi, src: ends[edge]!.from, dst: ends[edge]!.to }; + lines.set(at, [...(lines.get(at) ?? []), seg]); + } + } + for (const segs of lines.values()) { + if (segs.length < 2) continue; + // Union segments that overlap and belong to one trunk; then give each cluster of overlapping groups tracks. + const group = segs.map((_, i) => i); + const root = (i: number): number => (group[i] === i ? i : (group[i] = root(group[i]!))); + const overlap = (a: Seg, b: Seg) => Math.min(a.hi, b.hi) - Math.max(a.lo, b.lo) > 0.5; + for (let a = 0; a < segs.length; a++) + for (let b = a + 1; b < segs.length; b++) { + const s = segs[a]!; + const t = segs[b]!; + if ((s.edge === t.edge || s.src === t.src || s.dst === t.dst) && overlap(s, t)) group[root(a)] = root(b); + } + const roots = [...new Set(segs.map((_, i) => root(i)))]; + const span = new Map(roots.map((r) => [r, { lo: Infinity, hi: -Infinity }])); + segs.forEach((s, i) => { + const g = span.get(root(i))!; + g.lo = Math.min(g.lo, s.lo); + g.hi = Math.max(g.hi, s.hi); + }); + const track = new Map(); + for (const r of roots) { + const me = span.get(r)!; + const taken = new Set( + roots.filter((o) => track.has(o) && Math.min(span.get(o)!.hi, me.hi) - Math.max(span.get(o)!.lo, me.lo) > 0.5).map((o) => track.get(o)!), + ); + let t = 0; + while (taken.has(t)) t++; + track.set(r, t); + } + segs.forEach((s, i) => { + const t = track.get(root(i))!; + if (!t) return; + const off = (t % 2 ? 1 : -1) * Math.ceil(t / 2) * TRACK; + const pts = out[s.edge]!; + const axis = vertical ? 'x' : 'y'; + // Moving a point next to a port stretches or shrinks that port's run; keep room for the arrowhead. + const run = (j: number, port: number) => Math.abs(pts[j]![axis] + off - pts[port]![axis]); + if ((s.i === 1 && run(1, 0) < MIN_RUN) || (s.i + 1 === pts.length - 2 && run(s.i + 1, pts.length - 1) < MIN_RUN)) return; + for (const j of [s.i, s.i + 1]) pts[j]![axis] = round(s.at + off); + }); + } + } + return Object.fromEntries(Object.entries(out).map(([k, v]) => [k, simplify(v)])); +} diff --git a/packages/layout/test/lanes.test.ts b/packages/layout/test/lanes.test.ts new file mode 100644 index 0000000..d08c57b --- /dev/null +++ b/packages/layout/test/lanes.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; +import type { DiagramDraft, LaidOutDiagram, Point, Rect } from '@stackmap/core'; +import { GALLERY } from '@stackmap/core/samples'; +import { assignColumns, backEdges } from '../src/lanes'; +import { layoutDiagram } from '../src/index'; + +const inside = (p: Point, r: Rect, pad = 0) => p.x > r.x + pad && p.x < r.x + r.width - pad && p.y > r.y + pad && p.y < r.y + r.height - pad; +const overlap = (a: Rect, b: Rect) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height; +const onBoundary = (p: Point, r: Rect) => + p.x >= r.x - 0.5 && p.x <= r.x + r.width + 0.5 && p.y >= r.y - 0.5 && p.y <= r.y + r.height + 0.5 && !inside(p, r, 0.5); +/** Whether an axis-aligned segment runs through the rectangle's interior. */ +const crosses = (a: Point, b: Point, r: Rect) => + Math.max(a.x, b.x) > r.x + 0.5 && Math.min(a.x, b.x) < r.x + r.width - 0.5 && Math.max(a.y, b.y) > r.y + 0.5 && Math.min(a.y, b.y) < r.y + r.height - 0.5; + +const laid = await Promise.all(Object.entries(GALLERY).map(async ([name, d]) => [name, d, await layoutDiagram(d)] as const)); + +describe.each(laid)('gallery sample %s', (_name, draft: DiagramDraft, out: LaidOutDiagram) => { + it('places no card on another', () => { + const rects = Object.entries(out.nodes); + for (let i = 0; i < rects.length; i++) + for (let j = i + 1; j < rects.length; j++) expect(overlap(rects[i]![1], rects[j]![1]), `${rects[i]![0]} × ${rects[j]![0]}`).toBe(false); + }); + + it('routes every edge orthogonally from its source card to its target card, through no card', () => { + for (const e of draft.edges) { + const pts = out.edges[e.id]!; + expect(pts.length, e.id).toBeGreaterThanOrEqual(2); + for (let i = 1; i < pts.length; i++) expect(pts[i]!.x === pts[i - 1]!.x || pts[i]!.y === pts[i - 1]!.y, `${e.id} segment ${i}`).toBe(true); + expect(onBoundary(pts[0]!, out.nodes[e.from]!), `${e.id} starts on ${e.from}`).toBe(true); + expect(onBoundary(pts.at(-1)!, out.nodes[e.to]!), `${e.id} ends on ${e.to}`).toBe(true); + for (const [id, r] of Object.entries(out.nodes)) { + // The first and last segments leave and enter their own cards; everything else stays outside every card. + for (let i = 1; i < pts.length; i++) { + const own = (i === 1 && id === e.from) || (i === pts.length - 1 && id === e.to); + if (!own) expect(crosses(pts[i - 1]!, pts[i]!, r), `${e.id} segment ${i} crosses ${id}`).toBe(false); + } + } + } + }); + + it('keeps edge labels off the cards', () => { + for (const [id, p] of Object.entries(out.labels ?? {})) for (const [n, r] of Object.entries(out.nodes)) expect(inside(p, r), `${id} label on ${n}`).toBe(false); + }); + + it('puts every lane node inside its lane, and lanes in draft order', () => { + if (!out.lanes) return; + for (const n of draft.nodes) { + const lane = out.lanes[n.lane!]!; + const r = out.nodes[n.id]!; + expect(r.y >= lane.y && r.y + r.height <= lane.y + lane.height, `${n.id} in ${n.lane}`).toBe(true); + } + const ys = draft.lanes!.map((l) => out.lanes![l.id]!.y); + expect([...ys].sort((a, b) => a - b)).toEqual(ys); + }); + + it('is deterministic', async () => { + expect(await layoutDiagram(draft)).toEqual(out); + }); +}); + +const lanes = (over: Partial): DiagramDraft => ({ + kind: 'workflow', + title: 't', + lanes: [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + { id: 'c', label: 'C' }, + ], + nodes: [], + edges: [], + ...over, +}); +const n = (id: string, lane: string) => ({ id, type: 'service' as const, lane, card: { title: id } }); + +describe('lane columns', () => { + it('steps right within a lane and drops straight down across lanes', () => { + const d = lanes({ + nodes: [n('x', 'a'), n('y', 'a'), n('z', 'b')], + edges: [ + { id: 'xy', from: 'x', to: 'y' }, + { id: 'yz', from: 'y', to: 'z' }, + ], + }); + expect(Object.fromEntries(assignColumns(d, backEdges(d)))).toEqual({ x: 0, y: 1, z: 1 }); + }); + + it('breaks cycles and ignores return edges', () => { + const d = lanes({ + nodes: [n('x', 'a'), n('y', 'a'), n('z', 'a')], + edges: [ + { id: 'xy', from: 'x', to: 'y' }, + { id: 'yz', from: 'y', to: 'z' }, + { id: 'zx', from: 'z', to: 'x' }, + { id: 'zy', from: 'z', to: 'y', kind: 'return' }, + ], + }); + expect([...backEdges(d)].sort()).toEqual(['zx', 'zy']); + expect(Object.fromEntries(assignColumns(d, backEdges(d)))).toEqual({ x: 0, y: 1, z: 2 }); + }); + + it('starts each phase after the previous one ends', () => { + const d = lanes({ + nodes: [n('x', 'a'), n('y', 'b'), n('z', 'c')], + phases: [ + { id: 'p', label: 'P', nodes: ['x'] }, + { id: 'q', label: 'Q', nodes: ['y', 'z'] }, + ], + edges: [{ id: 'xy', from: 'x', to: 'y' }], + }); + expect(Object.fromEntries(assignColumns(d, backEdges(d)))).toEqual({ x: 0, y: 1, z: 1 }); + }); + + it('steps a drop right when a card in a lane between would block it', () => { + const d = lanes({ + nodes: [n('x', 'a'), n('mid', 'b'), n('z', 'c'), n('w', 'a')], + edges: [ + { id: 'xw', from: 'x', to: 'w' }, + { id: 'xm', from: 'x', to: 'mid' }, + { id: 'xz', from: 'x', to: 'z' }, + ], + }); + // mid sits under x (column 0), so x → z steps into column 1, which w already opened. + expect(Object.fromEntries(assignColumns(d, backEdges(d)))).toEqual({ x: 0, mid: 0, z: 1, w: 1 }); + }); + + it('never splits a reply and its call onto one port (no two-way arrows)', async () => { + const d = lanes({ + nodes: [n('x', 'a'), n('y', 'b')], + edges: [ + { id: 'go', from: 'x', to: 'y' }, + { id: 'back', from: 'y', to: 'x', kind: 'return' }, + ], + }); + const out = await layoutDiagram(d); + const go = out.edges.go!; + const back = out.edges.back!; + expect(go[0]).not.toEqual(back.at(-1)); + expect(go.at(-1)).not.toEqual(back[0]); + }); +}); diff --git a/packages/viewer/src/samples/gallery.layout.ts b/packages/viewer/src/samples/gallery.layout.ts new file mode 100644 index 0000000..1d096ad --- /dev/null +++ b/packages/viewer/src/samples/gallery.layout.ts @@ -0,0 +1,4840 @@ +// Generated by packages/layout/scripts/layout-samples.ts — do not edit. +import type { LaidOutDiagram } from '@stackmap/core'; + +export const galleryLayouts: Record = { + "release-delivery": { + "draft": { + "kind": "workflow", + "title": "Release delivery", + "subtitle": "From commit to announced release", + "lanes": [ + { + "id": "dev", + "label": "Developer" + }, + { + "id": "ci", + "label": "Continuous integration" + }, + { + "id": "approval", + "label": "Release governance" + }, + { + "id": "environment", + "label": "Production environment" + }, + { + "id": "communication", + "label": "Release communication" + }, + { + "id": "exceptions", + "label": "Failure and rollback", + "tone": "exception" + } + ], + "phases": [ + { + "id": "change", + "label": "Change", + "nodes": [ + "commit", + "pull-request" + ] + }, + { + "id": "verify", + "label": "Build and verify", + "nodes": [ + "build", + "checks", + "failed" + ] + }, + { + "id": "promote", + "label": "Promote and observe", + "nodes": [ + "approve", + "deploy", + "verify-prod", + "announce", + "rollback" + ] + } + ], + "groups": [ + { + "id": "blocking-checks", + "label": "Blocking checks" + }, + { + "id": "recovery", + "label": "Recovery path", + "tone": "security" + } + ], + "nodes": [ + { + "id": "commit", + "type": "client", + "lane": "dev", + "card": { + "title": "Commit", + "subtitle": "Signed change" + } + }, + { + "id": "pull-request", + "type": "client", + "lane": "dev", + "card": { + "title": "Pull request", + "subtitle": "Reviewed diff" + } + }, + { + "id": "build", + "type": "service", + "lane": "ci", + "group": "blocking-checks", + "card": { + "title": "Build", + "subtitle": "Locked inputs", + "tag": "reproducible", + "brand": "githubactions" + } + }, + { + "id": "checks", + "type": "security", + "lane": "ci", + "group": "blocking-checks", + "card": { + "title": "Quality gates", + "subtitle": "Test and scan", + "tag": "blocking" + } + }, + { + "id": "approve", + "type": "security", + "lane": "approval", + "card": { + "title": "Approve", + "subtitle": "Release owner", + "tag": "human gate" + } + }, + { + "id": "deploy", + "type": "gateway", + "lane": "environment", + "card": { + "title": "Deploy", + "subtitle": "Canary 10%", + "tag": "production", + "brand": "kubernetes" + } + }, + { + "id": "verify-prod", + "type": "service", + "lane": "environment", + "card": { + "title": "Verify", + "subtitle": "Smoke and SLO" + } + }, + { + "id": "announce", + "type": "external", + "lane": "communication", + "card": { + "title": "Announce", + "subtitle": "Status and notes" + } + }, + { + "id": "failed", + "type": "security", + "lane": "exceptions", + "card": { + "title": "Stop release", + "subtitle": "Gate failed" + } + }, + { + "id": "rollback", + "type": "queue", + "lane": "exceptions", + "group": "recovery", + "card": { + "title": "Rollback", + "subtitle": "Last good image", + "tag": "owner: on-call" + } + } + ], + "edges": [ + { + "id": "e1", + "from": "commit", + "to": "pull-request", + "tone": "main" + }, + { + "id": "e2", + "from": "pull-request", + "to": "build", + "label": "merge", + "tone": "main" + }, + { + "id": "e3", + "from": "build", + "to": "checks", + "tone": "main" + }, + { + "id": "e4", + "from": "checks", + "to": "approve", + "tone": "main" + }, + { + "id": "e5", + "from": "approve", + "to": "deploy", + "tone": "main" + }, + { + "id": "e6", + "from": "deploy", + "to": "verify-prod", + "tone": "main" + }, + { + "id": "e7", + "from": "verify-prod", + "to": "announce", + "label": "healthy", + "tone": "main" + }, + { + "id": "e8", + "from": "checks", + "to": "failed", + "label": "red", + "tone": "error" + }, + { + "id": "e9", + "from": "verify-prod", + "to": "rollback", + "label": "unhealthy", + "tone": "error" + }, + { + "id": "e10", + "from": "rollback", + "to": "deploy", + "label": "restore", + "kind": "return" + } + ], + "views": [ + { + "id": "happy", + "label": "Happy path", + "caption": "Every change is reviewed, built once and verified in production before it is announced.", + "nodes": [ + "commit", + "pull-request", + "build", + "checks", + "approve", + "deploy", + "verify-prod", + "announce" + ] + }, + { + "id": "stops", + "label": "Stop conditions", + "caption": "A red gate stops promotion; an unhealthy release rolls back to the last good image.", + "nodes": [ + "checks", + "failed", + "verify-prod", + "rollback", + "deploy" + ] + } + ], + "notes": [ + { + "title": "One happy path", + "items": [ + "Every change is reviewed before a reproducible build", + "Blocking checks must be green before human approval", + "Production is complete only after smoke and SLO verification" + ] + }, + { + "title": "Stop conditions", + "items": [ + "Test or security failure stops promotion", + "Production health can reverse a release", + "Rollback ownership is visible before an incident" + ] + } + ] + }, + "nodes": { + "commit": { + "x": 184, + "y": 108, + "width": 176, + "height": 60 + }, + "pull-request": { + "x": 424, + "y": 108, + "width": 176, + "height": 60 + }, + "build": { + "x": 664, + "y": 272, + "width": 176, + "height": 84 + }, + "checks": { + "x": 904, + "y": 272, + "width": 176, + "height": 84 + }, + "approve": { + "x": 1144, + "y": 432, + "width": 176, + "height": 84 + }, + "deploy": { + "x": 1144, + "y": 580, + "width": 176, + "height": 84 + }, + "verify-prod": { + "x": 1384, + "y": 592, + "width": 176, + "height": 60 + }, + "announce": { + "x": 1384, + "y": 728, + "width": 176, + "height": 60 + }, + "failed": { + "x": 904, + "y": 904, + "width": 176, + "height": 60 + }, + "rollback": { + "x": 1384, + "y": 892, + "width": 176, + "height": 84 + } + }, + "groups": { + "blocking-checks": { + "x": 652, + "y": 232, + "width": 440, + "height": 136 + }, + "recovery": { + "x": 1372, + "y": 852, + "width": 200, + "height": 136 + } + }, + "edges": { + "e1": [ + { + "x": 360, + "y": 138 + }, + { + "x": 424, + "y": 138 + } + ], + "e2": [ + { + "x": 540, + "y": 168 + }, + { + "x": 540, + "y": 314 + }, + { + "x": 664, + "y": 314 + } + ], + "e3": [ + { + "x": 840, + "y": 314 + }, + { + "x": 904, + "y": 314 + } + ], + "e4": [ + { + "x": 1020, + "y": 356 + }, + { + "x": 1020, + "y": 474 + }, + { + "x": 1144, + "y": 474 + } + ], + "e5": [ + { + "x": 1232, + "y": 516 + }, + { + "x": 1232, + "y": 580 + } + ], + "e6": [ + { + "x": 1320, + "y": 622 + }, + { + "x": 1384, + "y": 622 + } + ], + "e7": [ + { + "x": 1472, + "y": 652 + }, + { + "x": 1472, + "y": 728 + } + ], + "e8": [ + { + "x": 992, + "y": 356 + }, + { + "x": 992, + "y": 904 + } + ], + "e9": [ + { + "x": 1500, + "y": 652 + }, + { + "x": 1500, + "y": 708 + }, + { + "x": 1576, + "y": 708 + }, + { + "x": 1576, + "y": 872 + }, + { + "x": 1500, + "y": 872 + }, + { + "x": 1500, + "y": 892 + } + ], + "e10": [ + { + "x": 1384, + "y": 934 + }, + { + "x": 1340, + "y": 934 + }, + { + "x": 1340, + "y": 638 + }, + { + "x": 1320, + "y": 638 + } + ] + }, + "lanes": { + "dev": { + "x": 40, + "y": 88, + "width": 1552, + "height": 108 + }, + "ci": { + "x": 40, + "y": 212, + "width": 1552, + "height": 184 + }, + "approval": { + "x": 40, + "y": 412, + "width": 1552, + "height": 132 + }, + "environment": { + "x": 40, + "y": 560, + "width": 1552, + "height": 132 + }, + "communication": { + "x": 40, + "y": 708, + "width": 1552, + "height": 108 + }, + "exceptions": { + "x": 40, + "y": 832, + "width": 1552, + "height": 184 + } + }, + "phases": { + "change": { + "x": 160, + "y": 40, + "width": 464, + "height": 40 + }, + "verify": { + "x": 640, + "y": 40, + "width": 464, + "height": 40 + }, + "promote": { + "x": 1120, + "y": 40, + "width": 464, + "height": 40 + } + }, + "labels": { + "e2": { + "x": 602, + "y": 314 + }, + "e7": { + "x": 1472, + "y": 690 + }, + "e8": { + "x": 992, + "y": 630 + }, + "e9": { + "x": 1576, + "y": 814.6 + }, + "e10": { + "x": 1340, + "y": 786 + } + }, + "bounds": { + "width": 1632, + "height": 1056 + } + }, + "incident-response": { + "draft": { + "kind": "workflow", + "title": "Incident response runbook", + "subtitle": "Detect, command, mitigate, verify", + "lanes": [ + { + "id": "signals", + "label": "Signals" + }, + { + "id": "command", + "label": "Incident command" + }, + { + "id": "mitigation", + "label": "Service mitigation" + }, + { + "id": "recovery", + "label": "Recovery evidence" + }, + { + "id": "communication", + "label": "Stakeholder communication" + }, + { + "id": "exceptions", + "label": "Escalation and rollback", + "tone": "exception" + } + ], + "phases": [ + { + "id": "detect", + "label": "Detect", + "nodes": [ + "alert", + "page" + ] + }, + { + "id": "triage", + "label": "Triage and mitigate", + "nodes": [ + "triage", + "declare", + "contain", + "update", + "escalate" + ] + }, + { + "id": "close", + "label": "Verify and close", + "nodes": [ + "recover", + "verify", + "resolve", + "rollback" + ] + } + ], + "groups": [ + { + "id": "owners", + "label": "Command owns the incident" + } + ], + "nodes": [ + { + "id": "alert", + "type": "queue", + "lane": "signals", + "card": { + "title": "SLO alert", + "subtitle": "Burn rate", + "brand": "pagerduty" + } + }, + { + "id": "page", + "type": "external", + "lane": "command", + "group": "owners", + "card": { + "title": "Page on-call", + "subtitle": "Acknowledge" + } + }, + { + "id": "triage", + "type": "service", + "lane": "command", + "group": "owners", + "card": { + "title": "Triage", + "subtitle": "Scope impact" + } + }, + { + "id": "declare", + "type": "security", + "lane": "command", + "group": "owners", + "card": { + "title": "Declare", + "subtitle": "Assign commander", + "tag": "SEV-1/2" + } + }, + { + "id": "contain", + "type": "service", + "lane": "mitigation", + "card": { + "title": "Contain", + "subtitle": "Stop growth" + } + }, + { + "id": "recover", + "type": "gateway", + "lane": "mitigation", + "card": { + "title": "Recover", + "subtitle": "Restore service" + } + }, + { + "id": "verify", + "type": "database", + "lane": "recovery", + "card": { + "title": "Verify", + "subtitle": "SLO and traces", + "tag": "15 min stable" + } + }, + { + "id": "update", + "type": "client", + "lane": "communication", + "card": { + "title": "Status update", + "subtitle": "Impact and ETA" + } + }, + { + "id": "resolve", + "type": "external", + "lane": "communication", + "card": { + "title": "Resolve", + "subtitle": "Final update" + } + }, + { + "id": "escalate", + "type": "security", + "lane": "exceptions", + "card": { + "title": "Escalate", + "subtitle": "Specialist" + } + }, + { + "id": "rollback", + "type": "queue", + "lane": "exceptions", + "card": { + "title": "Rollback", + "subtitle": "Last good" + } + } + ], + "edges": [ + { + "id": "e1", + "from": "alert", + "to": "page", + "label": "page", + "tone": "main" + }, + { + "id": "e2", + "from": "page", + "to": "triage", + "tone": "main" + }, + { + "id": "e3", + "from": "triage", + "to": "contain", + "tone": "main" + }, + { + "id": "e4", + "from": "contain", + "to": "recover", + "tone": "main" + }, + { + "id": "e5", + "from": "recover", + "to": "verify", + "tone": "main" + }, + { + "id": "e6", + "from": "verify", + "to": "resolve", + "tone": "main" + }, + { + "id": "e7", + "from": "triage", + "to": "declare", + "tone": "security" + }, + { + "id": "e8", + "from": "declare", + "to": "update", + "kind": "async" + }, + { + "id": "e9", + "from": "update", + "to": "escalate", + "label": "still impacted", + "tone": "security" + }, + { + "id": "e10", + "from": "verify", + "to": "rollback", + "label": "regressed", + "tone": "error" + } + ], + "views": [ + { + "id": "command", + "label": "Command", + "caption": "A page is not an incident until someone owns command.", + "nodes": [ + "alert", + "page", + "triage", + "declare", + "update" + ] + }, + { + "id": "recovery", + "label": "Recovery", + "caption": "Mitigation reduces impact; only a stable SLO window proves recovery.", + "nodes": [ + "contain", + "recover", + "verify", + "resolve", + "rollback" + ] + } + ], + "notes": [ + { + "title": "Ownership first", + "items": [ + "A page is not an incident until someone owns command", + "Severity and scope are explicit before mitigation spreads", + "Escalation names the missing expertise" + ] + }, + { + "title": "Recovery is evidence", + "items": [ + "Mitigation can reduce impact without proving recovery", + "SLOs and traces must stay healthy for a fixed window", + "The final update follows verification, not optimism" + ] + } + ] + }, + "nodes": { + "alert": { + "x": 184, + "y": 108, + "width": 176, + "height": 60 + }, + "page": { + "x": 184, + "y": 284, + "width": 176, + "height": 60 + }, + "triage": { + "x": 424, + "y": 284, + "width": 176, + "height": 60 + }, + "declare": { + "x": 664, + "y": 272, + "width": 176, + "height": 84 + }, + "contain": { + "x": 424, + "y": 432, + "width": 176, + "height": 60 + }, + "recover": { + "x": 904, + "y": 432, + "width": 176, + "height": 60 + }, + "verify": { + "x": 904, + "y": 556, + "width": 176, + "height": 84 + }, + "update": { + "x": 664, + "y": 704, + "width": 176, + "height": 60 + }, + "resolve": { + "x": 904, + "y": 704, + "width": 176, + "height": 60 + }, + "escalate": { + "x": 664, + "y": 828, + "width": 176, + "height": 60 + }, + "rollback": { + "x": 904, + "y": 828, + "width": 176, + "height": 60 + } + }, + "groups": { + "owners": { + "x": 172, + "y": 232, + "width": 680, + "height": 136 + } + }, + "edges": { + "e1": [ + { + "x": 300, + "y": 168 + }, + { + "x": 300, + "y": 204 + }, + { + "x": 380, + "y": 204 + }, + { + "x": 380, + "y": 264 + }, + { + "x": 300, + "y": 264 + }, + { + "x": 300, + "y": 284 + } + ], + "e2": [ + { + "x": 360, + "y": 314 + }, + { + "x": 424, + "y": 314 + } + ], + "e3": [ + { + "x": 512, + "y": 344 + }, + { + "x": 512, + "y": 432 + } + ], + "e4": [ + { + "x": 600, + "y": 462 + }, + { + "x": 904, + "y": 462 + } + ], + "e5": [ + { + "x": 992, + "y": 492 + }, + { + "x": 992, + "y": 556 + } + ], + "e6": [ + { + "x": 992, + "y": 640 + }, + { + "x": 992, + "y": 704 + } + ], + "e7": [ + { + "x": 600, + "y": 314 + }, + { + "x": 664, + "y": 314 + } + ], + "e8": [ + { + "x": 752, + "y": 356 + }, + { + "x": 752, + "y": 704 + } + ], + "e9": [ + { + "x": 752, + "y": 764 + }, + { + "x": 752, + "y": 828 + } + ], + "e10": [ + { + "x": 1020, + "y": 640 + }, + { + "x": 1020, + "y": 684 + }, + { + "x": 1096, + "y": 684 + }, + { + "x": 1096, + "y": 808 + }, + { + "x": 1020, + "y": 808 + }, + { + "x": 1020, + "y": 828 + } + ] + }, + "lanes": { + "signals": { + "x": 40, + "y": 88, + "width": 1072, + "height": 108 + }, + "command": { + "x": 40, + "y": 212, + "width": 1072, + "height": 184 + }, + "mitigation": { + "x": 40, + "y": 412, + "width": 1072, + "height": 108 + }, + "recovery": { + "x": 40, + "y": 536, + "width": 1072, + "height": 132 + }, + "communication": { + "x": 40, + "y": 684, + "width": 1072, + "height": 108 + }, + "exceptions": { + "x": 40, + "y": 808, + "width": 1072, + "height": 108 + } + }, + "phases": { + "detect": { + "x": 160, + "y": 40, + "width": 224, + "height": 40 + }, + "triage": { + "x": 400, + "y": 40, + "width": 464, + "height": 40 + }, + "close": { + "x": 880, + "y": 40, + "width": 224, + "height": 40 + } + }, + "labels": { + "e1": { + "x": 340, + "y": 204 + }, + "e9": { + "x": 752, + "y": 796 + }, + "e10": { + "x": 1096, + "y": 783.2 + } + }, + "bounds": { + "width": 1152, + "height": 956 + } + }, + "agent-tool-call": { + "draft": { + "kind": "workflow", + "title": "Agent tool call", + "subtitle": "Request, plan, approve, execute, reply", + "lanes": [ + { + "id": "ui", + "label": "User interface" + }, + { + "id": "agent", + "label": "Agent runtime" + }, + { + "id": "policy", + "label": "Policy and recovery", + "tone": "exception" + }, + { + "id": "tools", + "label": "Tool execution and evidence" + } + ], + "phases": [ + { + "id": "intake", + "label": "Intake", + "nodes": [ + "user", + "chat", + "store" + ] + }, + { + "id": "plan", + "label": "Plan and route", + "nodes": [ + "planner", + "router", + "approval" + ] + }, + { + "id": "execute", + "label": "Execute and report", + "nodes": [ + "tool", + "external", + "blocked", + "retry", + "final" + ] + } + ], + "groups": [ + { + "id": "loop", + "label": "Planning loop" + }, + { + "id": "stop", + "label": "Human or policy stop", + "tone": "security" + } + ], + "nodes": [ + { + "id": "user", + "type": "external", + "lane": "ui", + "card": { + "title": "User", + "subtitle": "Asks for work" + } + }, + { + "id": "chat", + "type": "client", + "lane": "ui", + "card": { + "title": "Chat surface", + "subtitle": "Thread and files" + } + }, + { + "id": "final", + "type": "service", + "lane": "ui", + "card": { + "title": "Final reply", + "subtitle": "Answer and changes" + } + }, + { + "id": "planner", + "type": "service", + "lane": "agent", + "group": "loop", + "card": { + "title": "Agent planner", + "subtitle": "Plan next step", + "tag": "context aware", + "brand": "anthropic" + } + }, + { + "id": "router", + "type": "service", + "lane": "agent", + "group": "loop", + "card": { + "title": "Tool router", + "subtitle": "Choose capability" + } + }, + { + "id": "approval", + "type": "security", + "lane": "policy", + "group": "stop", + "card": { + "title": "Approval gate", + "subtitle": "Scope and consent", + "tag": "blocks risky ops" + } + }, + { + "id": "blocked", + "type": "security", + "lane": "policy", + "group": "stop", + "card": { + "title": "Blocked", + "subtitle": "Wait or reject" + } + }, + { + "id": "retry", + "type": "queue", + "lane": "policy", + "group": "stop", + "card": { + "title": "Retry path", + "subtitle": "Revise request" + } + }, + { + "id": "tool", + "type": "queue", + "lane": "tools", + "card": { + "title": "Tool call", + "subtitle": "Shell, browser, MCP", + "tag": "structured result" + } + }, + { + "id": "external", + "type": "gateway", + "lane": "tools", + "card": { + "title": "External API", + "subtitle": "Network service" + } + }, + { + "id": "store", + "type": "database", + "lane": "tools", + "card": { + "title": "Context store", + "subtitle": "Repo and memory" + } + }, + { + "id": "trace", + "type": "database", + "lane": "tools", + "card": { + "title": "Trace log", + "subtitle": "Events and output" + } + } + ], + "edges": [ + { + "id": "e1", + "from": "user", + "to": "chat", + "tone": "main" + }, + { + "id": "e2", + "from": "chat", + "to": "planner", + "label": "plan", + "tone": "main" + }, + { + "id": "e3", + "from": "planner", + "to": "router", + "tone": "main" + }, + { + "id": "e4", + "from": "router", + "to": "approval", + "label": "needs approval?", + "tone": "security" + }, + { + "id": "e5", + "from": "approval", + "to": "tool", + "label": "approved", + "tone": "main" + }, + { + "id": "e6", + "from": "approval", + "to": "blocked", + "label": "denied", + "tone": "error" + }, + { + "id": "e7", + "from": "blocked", + "to": "retry", + "kind": "async" + }, + { + "id": "e8", + "from": "retry", + "to": "planner", + "label": "revise", + "kind": "return" + }, + { + "id": "e9", + "from": "tool", + "to": "external", + "tone": "main" + }, + { + "id": "e10", + "from": "external", + "to": "final", + "label": "result", + "kind": "return", + "tone": "main" + }, + { + "id": "e11", + "from": "external", + "to": "trace", + "label": "record", + "kind": "async" + }, + { + "id": "e12", + "from": "store", + "to": "trace", + "kind": "async" + }, + { + "id": "e13", + "from": "store", + "to": "planner", + "label": "context", + "kind": "async" + } + ], + "views": [ + { + "id": "gate", + "label": "Approval gate", + "caption": "Risky work waits for scope and consent before any tool runs.", + "nodes": [ + "router", + "approval", + "tool", + "blocked", + "retry" + ] + } + ], + "notes": [ + { + "title": "Runtime semantics", + "items": [ + "Approval gates risky work before tool execution", + "A denial loops back to planning instead of failing silently", + "Evidence returns through the trace log and memory" + ] + } + ] + }, + "nodes": { + "user": { + "x": 184, + "y": 108, + "width": 176, + "height": 60 + }, + "chat": { + "x": 436, + "y": 108, + "width": 176, + "height": 60 + }, + "final": { + "x": 1192, + "y": 108, + "width": 176, + "height": 60 + }, + "planner": { + "x": 688, + "y": 272, + "width": 176, + "height": 84 + }, + "router": { + "x": 940, + "y": 284, + "width": 176, + "height": 60 + }, + "approval": { + "x": 940, + "y": 472, + "width": 176, + "height": 84 + }, + "blocked": { + "x": 1192, + "y": 484, + "width": 176, + "height": 60 + }, + "retry": { + "x": 1444, + "y": 484, + "width": 176, + "height": 60 + }, + "tool": { + "x": 1192, + "y": 632, + "width": 176, + "height": 84 + }, + "external": { + "x": 1444, + "y": 644, + "width": 176, + "height": 60 + }, + "store": { + "x": 184, + "y": 644, + "width": 176, + "height": 60 + }, + "trace": { + "x": 1696, + "y": 644, + "width": 176, + "height": 60 + } + }, + "groups": { + "loop": { + "x": 676, + "y": 232, + "width": 452, + "height": 136 + }, + "stop": { + "x": 928, + "y": 432, + "width": 704, + "height": 136 + } + }, + "edges": { + "e1": [ + { + "x": 360, + "y": 138 + }, + { + "x": 436, + "y": 138 + } + ], + "e2": [ + { + "x": 552, + "y": 168 + }, + { + "x": 552, + "y": 314 + }, + { + "x": 688, + "y": 314 + } + ], + "e3": [ + { + "x": 864, + "y": 314 + }, + { + "x": 940, + "y": 314 + } + ], + "e4": [ + { + "x": 1056, + "y": 344 + }, + { + "x": 1056, + "y": 404 + }, + { + "x": 1136, + "y": 404 + }, + { + "x": 1136, + "y": 514 + }, + { + "x": 1116, + "y": 514 + } + ], + "e5": [ + { + "x": 1056, + "y": 556 + }, + { + "x": 1056, + "y": 674 + }, + { + "x": 1192, + "y": 674 + } + ], + "e6": [ + { + "x": 1116, + "y": 498 + }, + { + "x": 1192, + "y": 498 + } + ], + "e7": [ + { + "x": 1368, + "y": 514 + }, + { + "x": 1444, + "y": 514 + } + ], + "e8": [ + { + "x": 1504, + "y": 484 + }, + { + "x": 1504, + "y": 364 + }, + { + "x": 884, + "y": 364 + }, + { + "x": 884, + "y": 330 + }, + { + "x": 864, + "y": 330 + } + ], + "e9": [ + { + "x": 1368, + "y": 674 + }, + { + "x": 1444, + "y": 674 + } + ], + "e10": [ + { + "x": 1444, + "y": 658 + }, + { + "x": 1388, + "y": 658 + }, + { + "x": 1388, + "y": 138 + }, + { + "x": 1368, + "y": 138 + } + ], + "e11": [ + { + "x": 1620, + "y": 674 + }, + { + "x": 1696, + "y": 674 + } + ], + "e12": [ + { + "x": 360, + "y": 674 + }, + { + "x": 1028, + "y": 674 + }, + { + "x": 1028, + "y": 730 + }, + { + "x": 1676, + "y": 730 + }, + { + "x": 1676, + "y": 674 + }, + { + "x": 1696, + "y": 674 + } + ], + "e13": [ + { + "x": 360, + "y": 674 + }, + { + "x": 748, + "y": 674 + }, + { + "x": 748, + "y": 356 + } + ] + }, + "lanes": { + "ui": { + "x": 40, + "y": 88, + "width": 1864, + "height": 108 + }, + "agent": { + "x": 40, + "y": 212, + "width": 1864, + "height": 184 + }, + "policy": { + "x": 40, + "y": 412, + "width": 1864, + "height": 184 + }, + "tools": { + "x": 40, + "y": 612, + "width": 1864, + "height": 132 + } + }, + "phases": { + "intake": { + "x": 154, + "y": 40, + "width": 488, + "height": 40 + }, + "plan": { + "x": 658, + "y": 40, + "width": 488, + "height": 40 + }, + "execute": { + "x": 1162, + "y": 40, + "width": 488, + "height": 40 + } + }, + "labels": { + "e2": { + "x": 620, + "y": 314 + }, + "e4": { + "x": 1136, + "y": 459 + }, + "e5": { + "x": 1124, + "y": 674 + }, + "e6": { + "x": 1154, + "y": 498 + }, + "e8": { + "x": 1194, + "y": 364 + }, + "e10": { + "x": 1388, + "y": 398 + }, + "e11": { + "x": 1658, + "y": 674 + }, + "e13": { + "x": 554, + "y": 674 + } + }, + "bounds": { + "width": 1944, + "height": 784 + } + }, + "agent-run": { + "draft": { + "kind": "lifecycle", + "title": "Agent run lifecycle", + "subtitle": "States of one agent run", + "lanes": [ + { + "id": "main", + "label": "Lifecycle phases" + }, + { + "id": "waiting", + "label": "Interruptions" + }, + { + "id": "recovery", + "label": "Recovery loop", + "tone": "exception" + }, + { + "id": "terminal", + "label": "Terminal exits" + } + ], + "nodes": [ + { + "id": "queued", + "type": "start", + "lane": "main", + "card": { + "title": "Queued", + "subtitle": "Request accepted" + } + }, + { + "id": "planning", + "type": "active", + "lane": "main", + "card": { + "title": "Planning", + "subtitle": "Build task graph" + } + }, + { + "id": "executing", + "type": "active", + "lane": "main", + "card": { + "title": "Executing", + "subtitle": "Tool calls" + } + }, + { + "id": "reviewing", + "type": "decision", + "lane": "main", + "card": { + "title": "Reviewing", + "subtitle": "Quality gate" + } + }, + { + "id": "completed", + "type": "success", + "lane": "main", + "card": { + "title": "Completed", + "subtitle": "Final response" + } + }, + { + "id": "approval", + "type": "waiting", + "lane": "waiting", + "card": { + "title": "Needs approval", + "subtitle": "Human gate", + "tag": "pause" + } + }, + { + "id": "blocked", + "type": "waiting", + "lane": "waiting", + "card": { + "title": "Blocked", + "subtitle": "Missing input" + } + }, + { + "id": "failed", + "type": "failure", + "lane": "recovery", + "card": { + "title": "Failed", + "subtitle": "Recoverable error", + "tag": "retryable" + } + }, + { + "id": "cancelled", + "type": "failure", + "lane": "terminal", + "card": { + "title": "Cancelled", + "subtitle": "User stopped" + } + }, + { + "id": "expired", + "type": "failure", + "lane": "terminal", + "card": { + "title": "Expired", + "subtitle": "Timeout" + } + } + ], + "edges": [ + { + "id": "plan", + "from": "queued", + "to": "planning", + "tone": "main" + }, + { + "id": "execute", + "from": "planning", + "to": "executing", + "tone": "main" + }, + { + "id": "review", + "from": "executing", + "to": "reviewing", + "tone": "main" + }, + { + "id": "complete", + "from": "reviewing", + "to": "completed", + "tone": "main" + }, + { + "id": "needs-approval", + "from": "executing", + "to": "approval", + "label": "risky op", + "tone": "security" + }, + { + "id": "approved", + "from": "approval", + "to": "executing", + "label": "approved", + "kind": "return" + }, + { + "id": "review-blocked", + "from": "reviewing", + "to": "blocked", + "label": "missing input" + }, + { + "id": "execution-failed", + "from": "executing", + "to": "failed", + "label": "error", + "tone": "error" + }, + { + "id": "retry", + "from": "failed", + "to": "executing", + "label": "retry", + "kind": "return" + }, + { + "id": "approval-cancelled", + "from": "approval", + "to": "cancelled", + "label": "denied", + "tone": "error" + }, + { + "id": "block-expired", + "from": "blocked", + "to": "expired", + "label": "timeout", + "tone": "error" + } + ], + "notes": [ + { + "title": "Main path and waits", + "items": [ + "The run has five ordered phases from queue to completion", + "Approval and missing input pause the run without ending it" + ] + }, + { + "title": "Recovery and terminal exits", + "items": [ + "Failed loops back while retry budget remains", + "Cancelled and Expired are terminal exits with no return path" + ] + } + ] + }, + "nodes": { + "queued": { + "x": 212, + "y": 60, + "width": 176, + "height": 60 + }, + "planning": { + "x": 452, + "y": 60, + "width": 176, + "height": 60 + }, + "executing": { + "x": 692, + "y": 60, + "width": 176, + "height": 60 + }, + "reviewing": { + "x": 932, + "y": 60, + "width": 176, + "height": 60 + }, + "completed": { + "x": 1172, + "y": 60, + "width": 176, + "height": 60 + }, + "approval": { + "x": 692, + "y": 184, + "width": 176, + "height": 84 + }, + "blocked": { + "x": 932, + "y": 196, + "width": 176, + "height": 60 + }, + "failed": { + "x": 932, + "y": 332, + "width": 176, + "height": 84 + }, + "cancelled": { + "x": 692, + "y": 480, + "width": 176, + "height": 60 + }, + "expired": { + "x": 1172, + "y": 480, + "width": 176, + "height": 60 + } + }, + "groups": {}, + "edges": { + "plan": [ + { + "x": 388, + "y": 90 + }, + { + "x": 452, + "y": 90 + } + ], + "execute": [ + { + "x": 628, + "y": 90 + }, + { + "x": 692, + "y": 90 + } + ], + "review": [ + { + "x": 868, + "y": 90 + }, + { + "x": 932, + "y": 90 + } + ], + "complete": [ + { + "x": 1108, + "y": 90 + }, + { + "x": 1172, + "y": 90 + } + ], + "needs-approval": [ + { + "x": 780, + "y": 120 + }, + { + "x": 780, + "y": 184 + } + ], + "approved": [ + { + "x": 752, + "y": 184 + }, + { + "x": 752, + "y": 120 + } + ], + "review-blocked": [ + { + "x": 1020, + "y": 120 + }, + { + "x": 1020, + "y": 196 + } + ], + "execution-failed": [ + { + "x": 868, + "y": 106 + }, + { + "x": 912, + "y": 106 + }, + { + "x": 912, + "y": 374 + }, + { + "x": 932, + "y": 374 + } + ], + "retry": [ + { + "x": 932, + "y": 358 + }, + { + "x": 888, + "y": 358 + }, + { + "x": 888, + "y": 74 + }, + { + "x": 868, + "y": 74 + } + ], + "approval-cancelled": [ + { + "x": 780, + "y": 268 + }, + { + "x": 780, + "y": 480 + } + ], + "block-expired": [ + { + "x": 1108, + "y": 226 + }, + { + "x": 1232, + "y": 226 + }, + { + "x": 1232, + "y": 480 + } + ] + }, + "lanes": { + "main": { + "x": 40, + "y": 40, + "width": 1340, + "height": 108 + }, + "waiting": { + "x": 40, + "y": 164, + "width": 1340, + "height": 132 + }, + "recovery": { + "x": 40, + "y": 312, + "width": 1340, + "height": 132 + }, + "terminal": { + "x": 40, + "y": 460, + "width": 1340, + "height": 108 + } + }, + "phases": {}, + "labels": { + "needs-approval": { + "x": 780, + "y": 152 + }, + "approved": { + "x": 715, + "y": 152 + }, + "review-blocked": { + "x": 1020, + "y": 158 + }, + "execution-failed": { + "x": 912, + "y": 280.2 + }, + "retry": { + "x": 888, + "y": 173.4 + }, + "approval-cancelled": { + "x": 780, + "y": 374 + }, + "block-expired": { + "x": 1232, + "y": 353 + } + }, + "bounds": { + "width": 1420, + "height": 608 + } + }, + "deployment-release": { + "draft": { + "kind": "lifecycle", + "title": "Deployment release lifecycle", + "subtitle": "States of one release object", + "lanes": [ + { + "id": "main", + "label": "Release phases" + }, + { + "id": "waiting", + "label": "Approval and health wait" + }, + { + "id": "recovery", + "label": "Rollback controller", + "tone": "exception" + }, + { + "id": "terminal", + "label": "Terminal exits" + } + ], + "nodes": [ + { + "id": "queued", + "type": "start", + "lane": "main", + "card": { + "title": "Queued", + "subtitle": "Change accepted" + } + }, + { + "id": "building", + "type": "active", + "lane": "main", + "card": { + "title": "Building", + "subtitle": "Immutable image" + } + }, + { + "id": "verifying", + "type": "decision", + "lane": "main", + "card": { + "title": "Verifying", + "subtitle": "Tests and policy", + "tag": "gate" + } + }, + { + "id": "ready", + "type": "waiting", + "lane": "main", + "card": { + "title": "Ready", + "subtitle": "Promotion pending" + } + }, + { + "id": "live", + "type": "success", + "lane": "main", + "card": { + "title": "Live", + "subtitle": "Production healthy" + } + }, + { + "id": "approval", + "type": "waiting", + "lane": "waiting", + "card": { + "title": "Needs approval", + "subtitle": "Release owner", + "tag": "pause" + } + }, + { + "id": "paused", + "type": "waiting", + "lane": "waiting", + "card": { + "title": "Health paused", + "subtitle": "SLO regression" + } + }, + { + "id": "rolling-back", + "type": "active", + "lane": "recovery", + "card": { + "title": "Rolling back", + "subtitle": "Last good image", + "tag": "automatic" + } + }, + { + "id": "cancelled", + "type": "failure", + "lane": "terminal", + "card": { + "title": "Cancelled", + "subtitle": "Approval denied" + } + }, + { + "id": "failed", + "type": "failure", + "lane": "terminal", + "card": { + "title": "Failed", + "subtitle": "Rollback failed" + } + }, + { + "id": "rolled-back", + "type": "success", + "lane": "terminal", + "card": { + "title": "Rolled back", + "subtitle": "Service restored" + } + } + ], + "edges": [ + { + "id": "build", + "from": "queued", + "to": "building", + "tone": "main" + }, + { + "id": "verify", + "from": "building", + "to": "verifying", + "tone": "main" + }, + { + "id": "pass", + "from": "verifying", + "to": "ready", + "tone": "main" + }, + { + "id": "promote", + "from": "ready", + "to": "live", + "tone": "main" + }, + { + "id": "sign-off", + "from": "verifying", + "to": "approval", + "label": "needs sign-off", + "tone": "security" + }, + { + "id": "approved", + "from": "approval", + "to": "ready", + "label": "approved" + }, + { + "id": "denied", + "from": "approval", + "to": "cancelled", + "label": "denied", + "tone": "error" + }, + { + "id": "abort", + "from": "ready", + "to": "rolling-back", + "label": "abort", + "tone": "error" + }, + { + "id": "rollback-failed", + "from": "rolling-back", + "to": "failed", + "tone": "error" + }, + { + "id": "regress", + "from": "live", + "to": "paused", + "label": "SLO regression", + "kind": "async" + }, + { + "id": "rollback", + "from": "paused", + "to": "rolling-back", + "label": "roll back" + }, + { + "id": "restored", + "from": "rolling-back", + "to": "rolled-back", + "label": "restored", + "tone": "main" + } + ], + "notes": [ + { + "title": "Promotion rail", + "items": [ + "The release moves through five ordered phases", + "Verification and approval remain distinct states", + "Live means production health is currently proven" + ] + }, + { + "title": "Explicit endings", + "items": [ + "Denied approval ends as Cancelled", + "A failed rollback ends as Failed", + "A successful rollback is a terminal restored outcome" + ] + } + ] + }, + "nodes": { + "queued": { + "x": 212, + "y": 72, + "width": 176, + "height": 60 + }, + "building": { + "x": 452, + "y": 72, + "width": 176, + "height": 60 + }, + "verifying": { + "x": 692, + "y": 60, + "width": 176, + "height": 84 + }, + "ready": { + "x": 932, + "y": 72, + "width": 176, + "height": 60 + }, + "live": { + "x": 1172, + "y": 72, + "width": 176, + "height": 60 + }, + "approval": { + "x": 692, + "y": 208, + "width": 176, + "height": 84 + }, + "paused": { + "x": 1172, + "y": 220, + "width": 176, + "height": 60 + }, + "rolling-back": { + "x": 1172, + "y": 356, + "width": 176, + "height": 84 + }, + "cancelled": { + "x": 692, + "y": 504, + "width": 176, + "height": 60 + }, + "failed": { + "x": 1172, + "y": 504, + "width": 176, + "height": 60 + }, + "rolled-back": { + "x": 1172, + "y": 592, + "width": 176, + "height": 60 + } + }, + "groups": {}, + "edges": { + "build": [ + { + "x": 388, + "y": 102 + }, + { + "x": 452, + "y": 102 + } + ], + "verify": [ + { + "x": 628, + "y": 102 + }, + { + "x": 692, + "y": 102 + } + ], + "pass": [ + { + "x": 868, + "y": 102 + }, + { + "x": 932, + "y": 102 + } + ], + "promote": [ + { + "x": 1108, + "y": 102 + }, + { + "x": 1172, + "y": 102 + } + ], + "sign-off": [ + { + "x": 780, + "y": 144 + }, + { + "x": 780, + "y": 208 + } + ], + "approved": [ + { + "x": 868, + "y": 250 + }, + { + "x": 992, + "y": 250 + }, + { + "x": 992, + "y": 132 + } + ], + "denied": [ + { + "x": 780, + "y": 292 + }, + { + "x": 780, + "y": 504 + } + ], + "abort": [ + { + "x": 1048, + "y": 132 + }, + { + "x": 1048, + "y": 398 + }, + { + "x": 1172, + "y": 398 + } + ], + "rollback-failed": [ + { + "x": 1260, + "y": 440 + }, + { + "x": 1260, + "y": 504 + } + ], + "regress": [ + { + "x": 1260, + "y": 132 + }, + { + "x": 1260, + "y": 220 + } + ], + "rollback": [ + { + "x": 1260, + "y": 280 + }, + { + "x": 1260, + "y": 356 + } + ], + "restored": [ + { + "x": 1232, + "y": 440 + }, + { + "x": 1232, + "y": 484 + }, + { + "x": 1152, + "y": 484 + }, + { + "x": 1152, + "y": 622 + }, + { + "x": 1172, + "y": 622 + } + ] + }, + "lanes": { + "main": { + "x": 40, + "y": 40, + "width": 1340, + "height": 132 + }, + "waiting": { + "x": 40, + "y": 188, + "width": 1340, + "height": 132 + }, + "recovery": { + "x": 40, + "y": 336, + "width": 1340, + "height": 132 + }, + "terminal": { + "x": 40, + "y": 484, + "width": 1340, + "height": 196 + } + }, + "phases": {}, + "labels": { + "sign-off": { + "x": 780, + "y": 176 + }, + "approved": { + "x": 930, + "y": 250 + }, + "denied": { + "x": 780, + "y": 398 + }, + "abort": { + "x": 1048, + "y": 265 + }, + "regress": { + "x": 1260, + "y": 176 + }, + "rollback": { + "x": 1260, + "y": 318 + }, + "restored": { + "x": 1192, + "y": 484 + } + }, + "bounds": { + "width": 1420, + "height": 720 + } + }, + "production-deployment": { + "draft": { + "kind": "architecture", + "density": "compact", + "title": "Production deployment ownership", + "subtitle": "Who runs what, and where traffic crosses", + "direction": "RIGHT", + "groups": [ + { + "id": "us-east", + "label": "AWS us-east-1 · production" + }, + { + "id": "private", + "label": "Private application network", + "parent": "us-east", + "tone": "security" + }, + { + "id": "eu-west", + "label": "AWS eu-west-1 · disaster recovery" + } + ], + "nodes": [ + { + "id": "clients", + "type": "client", + "card": { + "title": "Customers", + "subtitle": "Web and mobile" + } + }, + { + "id": "edge", + "type": "gateway", + "group": "us-east", + "card": { + "title": "Global edge", + "subtitle": "CDN and WAF", + "brand": "cloudflare", + "tag": "Platform" + } + }, + { + "id": "gateway", + "type": "security", + "group": "us-east", + "card": { + "title": "API gateway", + "subtitle": "Public :443", + "tag": "Platform" + } + }, + { + "id": "api-a", + "type": "service", + "group": "private", + "card": { + "title": "API pods · AZ-a", + "subtitle": "Private subnet", + "brand": "kubernetes", + "tag": "Orders team" + } + }, + { + "id": "api-b", + "type": "service", + "group": "private", + "card": { + "title": "API pods · AZ-b", + "subtitle": "Private subnet", + "brand": "kubernetes", + "tag": "Orders team" + } + }, + { + "id": "redis", + "type": "cache", + "group": "private", + "card": { + "title": "Redis", + "subtitle": "Multi-AZ cache", + "brand": "redis", + "tag": "Platform" + } + }, + { + "id": "postgres", + "type": "database", + "group": "private", + "card": { + "title": "PostgreSQL", + "subtitle": "Primary, encrypted", + "brand": "postgresql", + "tag": "Data" + } + }, + { + "id": "events", + "type": "queue", + "group": "private", + "card": { + "title": "Event bus", + "subtitle": "orders.v1", + "tag": "Platform" + } + }, + { + "id": "workers", + "type": "service", + "group": "private", + "card": { + "title": "Workers", + "subtitle": "Private workload", + "tag": "Orders team" + } + }, + { + "id": "audit", + "type": "storage", + "group": "us-east", + "card": { + "title": "Audit archive", + "subtitle": "Immutable objects", + "tag": "Security" + } + }, + { + "id": "replica", + "type": "database", + "group": "eu-west", + "card": { + "title": "DR replica", + "subtitle": "eu-west-1", + "brand": "postgresql", + "tag": "Data" + } + }, + { + "id": "observability", + "type": "external", + "card": { + "title": "Observability", + "subtitle": "Metrics and traces", + "tag": "SRE" + } + } + ], + "edges": [ + { + "id": "e1", + "from": "clients", + "to": "edge", + "label": "HTTPS", + "tone": "main" + }, + { + "id": "e2", + "from": "edge", + "to": "gateway", + "label": "mTLS", + "tone": "security" + }, + { + "id": "e3", + "from": "gateway", + "to": "api-a", + "label": "VPC route", + "tone": "main" + }, + { + "id": "e4", + "from": "gateway", + "to": "api-b", + "label": "VPC route", + "tone": "main" + }, + { + "id": "e5", + "from": "api-a", + "to": "redis", + "label": "cache" + }, + { + "id": "e6", + "from": "api-b", + "to": "postgres", + "label": "SQL" + }, + { + "id": "e7", + "from": "api-a", + "to": "events", + "label": "publish", + "kind": "async" + }, + { + "id": "e8", + "from": "api-b", + "to": "events", + "kind": "async" + }, + { + "id": "e9", + "from": "events", + "to": "workers", + "tone": "main" + }, + { + "id": "e10", + "from": "postgres", + "to": "replica", + "label": "cross-region WAL", + "tone": "security" + }, + { + "id": "e11", + "from": "workers", + "to": "audit", + "label": "evidence", + "kind": "async" + }, + { + "id": "e12", + "from": "workers", + "to": "observability", + "label": "OTLP", + "kind": "async" + } + ], + "views": [ + { + "id": "crossings", + "label": "Crossings", + "caption": "Public HTTPS ends at the edge; mTLS crosses into the private network; WAL crosses regions.", + "nodes": [ + "clients", + "edge", + "gateway", + "postgres", + "replica" + ] + }, + { + "id": "async", + "label": "Async work", + "caption": "API pods publish to the event bus; workers drain it and leave evidence.", + "nodes": [ + "api-a", + "api-b", + "events", + "workers", + "audit", + "observability" + ] + } + ], + "notes": [ + { + "title": "Runtime ownership", + "items": [ + "Platform owns the edge, gateway, cache and event bus", + "Application teams own API pods and workers", + "Data owns primary and disaster-recovery state" + ] + }, + { + "title": "Named crossings", + "items": [ + "Public HTTPS terminates at the managed edge", + "mTLS crosses into the application network", + "Cross-region WAL is explicit and encrypted" + ] + } + ] + }, + "nodes": { + "api-a": { + "x": 858, + "y": 240, + "width": 176, + "height": 84 + }, + "api-b": { + "x": 858, + "y": 136, + "width": 176, + "height": 84 + }, + "redis": { + "x": 1114, + "y": 344, + "width": 176, + "height": 84 + }, + "postgres": { + "x": 1114, + "y": 136, + "width": 176, + "height": 84 + }, + "events": { + "x": 1114, + "y": 240, + "width": 176, + "height": 84 + }, + "workers": { + "x": 1370, + "y": 240, + "width": 176, + "height": 84 + }, + "edge": { + "x": 317, + "y": 240, + "width": 176, + "height": 84 + }, + "gateway": { + "x": 573, + "y": 240, + "width": 176, + "height": 84 + }, + "audit": { + "x": 1655, + "y": 240, + "width": 176, + "height": 84 + }, + "replica": { + "x": 1961, + "y": 136, + "width": 176, + "height": 84 + }, + "clients": { + "x": 40, + "y": 252, + "width": 176, + "height": 60 + }, + "observability": { + "x": 1937, + "y": 302, + "width": 176, + "height": 84 + } + }, + "groups": { + "us-east": { + "x": 293, + "y": 40, + "width": 1562, + "height": 436 + }, + "private": { + "x": 834, + "y": 88, + "width": 736, + "height": 364 + }, + "eu-west": { + "x": 1937, + "y": 88, + "width": 224, + "height": 156 + } + }, + "edges": { + "e1": [ + { + "x": 216, + "y": 282 + }, + { + "x": 317, + "y": 282 + } + ], + "e2": [ + { + "x": 493, + "y": 282 + }, + { + "x": 573, + "y": 282 + } + ], + "e3": [ + { + "x": 749, + "y": 282 + }, + { + "x": 858, + "y": 282 + } + ], + "e4": [ + { + "x": 749, + "y": 282 + }, + { + "x": 759, + "y": 282 + }, + { + "x": 759, + "y": 178 + }, + { + "x": 858, + "y": 178 + } + ], + "e5": [ + { + "x": 1034, + "y": 282 + }, + { + "x": 1044, + "y": 282 + }, + { + "x": 1044, + "y": 386 + }, + { + "x": 1114, + "y": 386 + } + ], + "e6": [ + { + "x": 1034, + "y": 178 + }, + { + "x": 1114, + "y": 178 + } + ], + "e7": [ + { + "x": 1034, + "y": 282 + }, + { + "x": 1114, + "y": 282 + } + ], + "e8": [ + { + "x": 1034, + "y": 178 + }, + { + "x": 1044, + "y": 178 + }, + { + "x": 1044, + "y": 282 + }, + { + "x": 1114, + "y": 282 + } + ], + "e9": [ + { + "x": 1290, + "y": 282 + }, + { + "x": 1370, + "y": 282 + } + ], + "e10": [ + { + "x": 1290, + "y": 178 + }, + { + "x": 1961, + "y": 178 + } + ], + "e11": [ + { + "x": 1546, + "y": 282 + }, + { + "x": 1655, + "y": 282 + } + ], + "e12": [ + { + "x": 1546, + "y": 282 + }, + { + "x": 1585, + "y": 282 + }, + { + "x": 1585, + "y": 344 + }, + { + "x": 1937, + "y": 344 + } + ] + }, + "bounds": { + "width": 2201, + "height": 516 + }, + "labels": { + "e1": { + "x": 266.5, + "y": 282 + }, + "e2": { + "x": 533, + "y": 282 + }, + "e3": { + "x": 803.5, + "y": 282 + }, + "e4": { + "x": 808.5, + "y": 178 + }, + "e5": { + "x": 1079, + "y": 386 + }, + "e6": { + "x": 1074, + "y": 178 + }, + "e7": { + "x": 1074, + "y": 282 + }, + "e10": { + "x": 1625.5, + "y": 178 + }, + "e11": { + "x": 1600.5, + "y": 282 + }, + "e12": { + "x": 1761, + "y": 344 + } + } + }, + "web-app": { + "draft": { + "kind": "architecture", + "density": "compact", + "title": "Sample web app", + "subtitle": "Three tiers on AWS", + "direction": "RIGHT", + "groups": [ + { + "id": "region", + "label": "AWS us-west-2" + }, + { + "id": "sg-api", + "label": "sg-api · :443 / :8000", + "parent": "region", + "tone": "security" + } + ], + "nodes": [ + { + "id": "users", + "type": "client", + "card": { + "title": "Users", + "subtitle": "Browser and mobile" + } + }, + { + "id": "auth", + "type": "security", + "card": { + "title": "Auth provider", + "subtitle": "OAuth 2.0" + } + }, + { + "id": "cdn", + "type": "gateway", + "group": "region", + "card": { + "title": "CloudFront", + "subtitle": "CDN" + } + }, + { + "id": "lb", + "type": "gateway", + "group": "sg-api", + "card": { + "title": "Load balancer", + "subtitle": "HTTPS", + "tag": ":443" + } + }, + { + "id": "api", + "type": "service", + "group": "sg-api", + "card": { + "title": "API server", + "subtitle": "FastAPI", + "brand": "fastapi", + "tag": ":8000" + } + }, + { + "id": "cache", + "type": "cache", + "group": "region", + "card": { + "title": "Redis", + "subtitle": "Read-through cache", + "brand": "redis", + "tag": ":6379" + } + }, + { + "id": "db", + "type": "database", + "group": "region", + "card": { + "title": "PostgreSQL", + "subtitle": "Primary", + "brand": "postgresql", + "tag": ":5432" + } + }, + { + "id": "s3", + "type": "storage", + "group": "region", + "card": { + "title": "S3", + "subtitle": "Static assets" + } + }, + { + "id": "queue", + "type": "queue", + "group": "region", + "card": { + "title": "SQS", + "subtitle": "Job queue" + } + }, + { + "id": "worker", + "type": "service", + "group": "region", + "card": { + "title": "Worker", + "subtitle": "Async jobs" + } + } + ], + "edges": [ + { + "id": "e1", + "from": "users", + "to": "cdn", + "label": "HTTPS", + "tone": "main" + }, + { + "id": "e2", + "from": "cdn", + "to": "lb", + "tone": "main" + }, + { + "id": "e3", + "from": "cdn", + "to": "s3", + "label": "static", + "kind": "async" + }, + { + "id": "e4", + "from": "lb", + "to": "api", + "tone": "main" + }, + { + "id": "e5", + "from": "auth", + "to": "api", + "label": "verify JWT", + "tone": "security" + }, + { + "id": "e6", + "from": "api", + "to": "cache", + "label": "read-through" + }, + { + "id": "e7", + "from": "api", + "to": "db", + "label": "SQL", + "tone": "main" + }, + { + "id": "e8", + "from": "api", + "to": "queue", + "label": "enqueue", + "kind": "async" + }, + { + "id": "e9", + "from": "queue", + "to": "worker" + } + ], + "notes": [ + { + "title": "Edge", + "items": [ + "CloudFront fronts all traffic", + "S3 serves static assets" + ] + }, + { + "title": "Security", + "items": [ + "OAuth 2.0 with JWT and PKCE", + "API and load balancer isolated in a security group" + ] + } + ] + }, + "nodes": { + "lb": { + "x": 602, + "y": 216, + "width": 176, + "height": 84 + }, + "api": { + "x": 858, + "y": 216, + "width": 176, + "height": 84 + }, + "cdn": { + "x": 317, + "y": 88, + "width": 176, + "height": 60 + }, + "cache": { + "x": 1143, + "y": 124, + "width": 176, + "height": 84 + }, + "db": { + "x": 1143, + "y": 308, + "width": 176, + "height": 84 + }, + "s3": { + "x": 578, + "y": 88, + "width": 176, + "height": 60 + }, + "queue": { + "x": 1143, + "y": 228, + "width": 176, + "height": 60 + }, + "worker": { + "x": 1399, + "y": 228, + "width": 176, + "height": 60 + }, + "users": { + "x": 40, + "y": 88, + "width": 176, + "height": 60 + }, + "auth": { + "x": 40, + "y": 290, + "width": 176, + "height": 60 + } + }, + "groups": { + "region": { + "x": 293, + "y": 40, + "width": 1306, + "height": 376 + }, + "sg-api": { + "x": 578, + "y": 168, + "width": 480, + "height": 177 + } + }, + "edges": { + "e1": [ + { + "x": 216, + "y": 118 + }, + { + "x": 317, + "y": 118 + } + ], + "e2": [ + { + "x": 493, + "y": 118 + }, + { + "x": 503, + "y": 118 + }, + { + "x": 503, + "y": 258 + }, + { + "x": 602, + "y": 258 + } + ], + "e3": [ + { + "x": 493, + "y": 118 + }, + { + "x": 578, + "y": 118 + } + ], + "e4": [ + { + "x": 778, + "y": 258 + }, + { + "x": 858, + "y": 258 + } + ], + "e5": [ + { + "x": 216, + "y": 320 + }, + { + "x": 788, + "y": 320 + }, + { + "x": 788, + "y": 258 + }, + { + "x": 858, + "y": 258 + } + ], + "e6": [ + { + "x": 1034, + "y": 258 + }, + { + "x": 1073, + "y": 258 + }, + { + "x": 1073, + "y": 166 + }, + { + "x": 1143, + "y": 166 + } + ], + "e7": [ + { + "x": 1034, + "y": 258 + }, + { + "x": 1073, + "y": 258 + }, + { + "x": 1073, + "y": 350 + }, + { + "x": 1143, + "y": 350 + } + ], + "e8": [ + { + "x": 1034, + "y": 258 + }, + { + "x": 1143, + "y": 258 + } + ], + "e9": [ + { + "x": 1319, + "y": 258 + }, + { + "x": 1399, + "y": 258 + } + ] + }, + "bounds": { + "width": 1639, + "height": 456 + }, + "labels": { + "e1": { + "x": 266.5, + "y": 118 + }, + "e3": { + "x": 535.5, + "y": 118 + }, + "e5": { + "x": 502, + "y": 320 + }, + "e6": { + "x": 1073, + "y": 198.2 + }, + "e7": { + "x": 1108, + "y": 350 + }, + "e8": { + "x": 1088.5, + "y": 258 + } + } + }, + "product-analytics": { + "draft": { + "kind": "dataflow", + "density": "compact", + "title": "Product analytics", + "subtitle": "From SDK events to dashboards and models", + "direction": "RIGHT", + "phases": [ + { + "id": "sources", + "label": "Sources", + "nodes": [ + "web", + "mobile" + ] + }, + { + "id": "ingest", + "label": "Ingest", + "nodes": [ + "edge" + ] + }, + { + "id": "process", + "label": "Process", + "nodes": [ + "consent", + "stream" + ] + }, + { + "id": "store", + "label": "Store", + "nodes": [ + "pii", + "warehouse", + "features" + ] + }, + { + "id": "consume", + "label": "Consume", + "nodes": [ + "dashboards", + "model" + ] + } + ], + "nodes": [ + { + "id": "web", + "type": "client", + "card": { + "title": "Web app", + "subtitle": "Browser SDK" + } + }, + { + "id": "mobile", + "type": "client", + "card": { + "title": "Mobile", + "subtitle": "iOS and Android" + } + }, + { + "id": "edge", + "type": "gateway", + "card": { + "title": "Edge API", + "subtitle": "Collector" + } + }, + { + "id": "consent", + "type": "security", + "card": { + "title": "Consent gate", + "subtitle": "Policy filter" + } + }, + { + "id": "stream", + "type": "queue", + "card": { + "title": "Event stream", + "subtitle": "Kafka topic" + } + }, + { + "id": "pii", + "type": "security", + "card": { + "title": "PII vault", + "subtitle": "Encrypted" + } + }, + { + "id": "warehouse", + "type": "database", + "card": { + "title": "Warehouse", + "subtitle": "Analytics tables", + "brand": "snowflake" + } + }, + { + "id": "features", + "type": "database", + "card": { + "title": "Feature store", + "subtitle": "Daily batch" + } + }, + { + "id": "dashboards", + "type": "service", + "card": { + "title": "Dashboards", + "subtitle": "Product metrics" + } + }, + { + "id": "model", + "type": "service", + "card": { + "title": "ML model", + "subtitle": "Ranking job" + } + } + ], + "edges": [ + { + "id": "e1", + "from": "web", + "to": "edge", + "label": "clickstream", + "tone": "main" + }, + { + "id": "e2", + "from": "mobile", + "to": "edge", + "label": "app events" + }, + { + "id": "e3", + "from": "edge", + "to": "consent", + "label": "identity", + "tone": "security" + }, + { + "id": "e4", + "from": "edge", + "to": "stream", + "label": "accepted events", + "tone": "main" + }, + { + "id": "e5", + "from": "consent", + "to": "pii", + "label": "identity map", + "tone": "security" + }, + { + "id": "e6", + "from": "stream", + "to": "warehouse", + "label": "normalized facts", + "tone": "main" + }, + { + "id": "e7", + "from": "warehouse", + "to": "features", + "label": "daily aggregates", + "kind": "async" + }, + { + "id": "e8", + "from": "warehouse", + "to": "dashboards", + "label": "metrics SQL" + }, + { + "id": "e9", + "from": "features", + "to": "model", + "label": "feature vectors", + "kind": "async" + }, + { + "id": "e10", + "from": "pii", + "to": "dashboards", + "label": "restricted join", + "tone": "security" + } + ], + "notes": [ + { + "title": "Sensitive boundary", + "items": [ + "Consent and PII paths are security flows", + "PII lands in a restricted vault, apart from the warehouse", + "Restricted joins are visible without implying default access" + ] + } + ] + }, + "nodes": { + "web": { + "x": 40, + "y": 84, + "width": 176, + "height": 60 + }, + "mobile": { + "x": 40, + "y": 184, + "width": 176, + "height": 60 + }, + "edge": { + "x": 344, + "y": 84, + "width": 176, + "height": 60 + }, + "consent": { + "x": 648, + "y": 84, + "width": 176, + "height": 60 + }, + "stream": { + "x": 648, + "y": 184, + "width": 176, + "height": 60 + }, + "pii": { + "x": 952, + "y": 84, + "width": 176, + "height": 60 + }, + "warehouse": { + "x": 952, + "y": 184, + "width": 176, + "height": 60 + }, + "features": { + "x": 1256, + "y": 184, + "width": 176, + "height": 60 + }, + "dashboards": { + "x": 1560, + "y": 84, + "width": 176, + "height": 60 + }, + "model": { + "x": 1560, + "y": 184, + "width": 176, + "height": 60 + } + }, + "groups": {}, + "edges": { + "e1": [ + { + "x": 216, + "y": 114 + }, + { + "x": 344, + "y": 114 + } + ], + "e2": [ + { + "x": 216, + "y": 214 + }, + { + "x": 240, + "y": 214 + }, + { + "x": 240, + "y": 114 + }, + { + "x": 344, + "y": 114 + } + ], + "e3": [ + { + "x": 520, + "y": 114 + }, + { + "x": 648, + "y": 114 + } + ], + "e4": [ + { + "x": 520, + "y": 114 + }, + { + "x": 544, + "y": 114 + }, + { + "x": 544, + "y": 214 + }, + { + "x": 648, + "y": 214 + } + ], + "e5": [ + { + "x": 824, + "y": 114 + }, + { + "x": 952, + "y": 114 + } + ], + "e6": [ + { + "x": 824, + "y": 214 + }, + { + "x": 952, + "y": 214 + } + ], + "e7": [ + { + "x": 1128, + "y": 214 + }, + { + "x": 1256, + "y": 214 + } + ], + "e8": [ + { + "x": 1128, + "y": 214 + }, + { + "x": 1152, + "y": 214 + }, + { + "x": 1152, + "y": 114 + }, + { + "x": 1560, + "y": 114 + } + ], + "e9": [ + { + "x": 1432, + "y": 214 + }, + { + "x": 1560, + "y": 214 + } + ], + "e10": [ + { + "x": 1128, + "y": 114 + }, + { + "x": 1560, + "y": 114 + } + ] + }, + "bounds": { + "width": 1776, + "height": 284 + }, + "phases": { + "sources": { + "x": 20, + "y": 20, + "width": 216, + "height": 244 + }, + "ingest": { + "x": 324, + "y": 20, + "width": 216, + "height": 244 + }, + "process": { + "x": 628, + "y": 20, + "width": 216, + "height": 244 + }, + "store": { + "x": 932, + "y": 20, + "width": 520, + "height": 244 + }, + "consume": { + "x": 1540, + "y": 20, + "width": 216, + "height": 244 + } + }, + "labels": { + "e1": { + "x": 280, + "y": 114 + }, + "e2": { + "x": 240, + "y": 164 + }, + "e3": { + "x": 584, + "y": 114 + }, + "e4": { + "x": 544, + "y": 164 + }, + "e5": { + "x": 888, + "y": 114 + }, + "e6": { + "x": 888, + "y": 214 + }, + "e7": { + "x": 1192, + "y": 214 + }, + "e8": { + "x": 1356, + "y": 114 + }, + "e9": { + "x": 1496, + "y": 214 + }, + "e10": { + "x": 1214.4, + "y": 114 + } + } + }, + "event-stream": { + "draft": { + "kind": "dataflow", + "density": "compact", + "title": "Order event-stream topology", + "subtitle": "Topics, consumer groups and the dead-letter path", + "direction": "RIGHT", + "phases": [ + { + "id": "producers", + "label": "Producers", + "nodes": [ + "checkout", + "billing" + ] + }, + { + "id": "transit", + "label": "Transit", + "nodes": [ + "orders", + "payments" + ] + }, + { + "id": "processors", + "label": "Processors", + "nodes": [ + "validate", + "enrich" + ] + }, + { + "id": "state", + "label": "State and recovery", + "nodes": [ + "order-state", + "dlq" + ] + }, + { + "id": "consumers", + "label": "Consumers", + "nodes": [ + "fulfillment", + "analytics", + "replay", + "ops" + ] + } + ], + "nodes": [ + { + "id": "checkout", + "type": "service", + "card": { + "title": "Checkout API", + "subtitle": "Order producer" + } + }, + { + "id": "billing", + "type": "service", + "card": { + "title": "Billing API", + "subtitle": "Payment producer", + "brand": "stripe" + } + }, + { + "id": "orders", + "type": "queue", + "card": { + "title": "orders.v1", + "subtitle": "Topic", + "tag": "12 partitions" + } + }, + { + "id": "payments", + "type": "queue", + "card": { + "title": "payments.v2", + "subtitle": "Topic", + "tag": "8 partitions" + } + }, + { + "id": "validate", + "type": "service", + "card": { + "title": "Order validate", + "subtitle": "Group fulfillment" + } + }, + { + "id": "enrich", + "type": "service", + "card": { + "title": "Payment enrich", + "subtitle": "Group analytics" + } + }, + { + "id": "order-state", + "type": "database", + "card": { + "title": "Order state", + "subtitle": "Materialized view" + } + }, + { + "id": "dlq", + "type": "queue", + "card": { + "title": "events.dlq", + "subtitle": "Poison events" + } + }, + { + "id": "fulfillment", + "type": "service", + "card": { + "title": "Fulfillment", + "subtitle": "Shipping workflow" + } + }, + { + "id": "analytics", + "type": "database", + "card": { + "title": "Analytics", + "subtitle": "Streaming facts" + } + }, + { + "id": "replay", + "type": "security", + "card": { + "title": "Replay tool", + "subtitle": "Approved batch" + } + }, + { + "id": "ops", + "type": "external", + "card": { + "title": "On-call", + "subtitle": "DLQ owner", + "brand": "pagerduty" + } + } + ], + "edges": [ + { + "id": "e1", + "from": "checkout", + "to": "orders", + "label": "OrderPlaced", + "tone": "main" + }, + { + "id": "e2", + "from": "billing", + "to": "payments", + "label": "PaymentCaptured", + "tone": "main" + }, + { + "id": "e3", + "from": "orders", + "to": "validate", + "label": "ordered", + "tone": "main" + }, + { + "id": "e4", + "from": "payments", + "to": "enrich", + "label": "payment facts", + "tone": "main" + }, + { + "id": "e5", + "from": "validate", + "to": "order-state", + "label": "valid order", + "tone": "main" + }, + { + "id": "e6", + "from": "enrich", + "to": "order-state", + "label": "enriched" + }, + { + "id": "e7", + "from": "order-state", + "to": "fulfillment", + "label": "ready orders", + "tone": "main" + }, + { + "id": "e8", + "from": "order-state", + "to": "analytics", + "label": "order facts" + }, + { + "id": "e9", + "from": "validate", + "to": "dlq", + "label": "invalid", + "tone": "error" + }, + { + "id": "e10", + "from": "enrich", + "to": "dlq", + "label": "poison", + "tone": "error" + }, + { + "id": "e11", + "from": "dlq", + "to": "ops", + "label": "sample", + "tone": "error" + }, + { + "id": "e12", + "from": "dlq", + "to": "replay", + "label": "approved replay", + "kind": "async" + } + ], + "notes": [ + { + "title": "Failure ownership", + "items": [ + "Poison events land in a retained dead-letter topic", + "On-call inspects samples before replay", + "Replay is gated, batched and auditable" + ] + } + ] + }, + "nodes": { + "checkout": { + "x": 40, + "y": 184, + "width": 176, + "height": 60 + }, + "billing": { + "x": 40, + "y": 308, + "width": 176, + "height": 60 + }, + "orders": { + "x": 350, + "y": 172, + "width": 176, + "height": 84 + }, + "payments": { + "x": 350, + "y": 296, + "width": 176, + "height": 84 + }, + "validate": { + "x": 660, + "y": 184, + "width": 176, + "height": 60 + }, + "enrich": { + "x": 660, + "y": 284, + "width": 176, + "height": 60 + }, + "order-state": { + "x": 970, + "y": 184, + "width": 176, + "height": 60 + }, + "dlq": { + "x": 970, + "y": 284, + "width": 176, + "height": 60 + }, + "fulfillment": { + "x": 1280, + "y": 84, + "width": 176, + "height": 60 + }, + "analytics": { + "x": 1280, + "y": 184, + "width": 176, + "height": 60 + }, + "replay": { + "x": 1280, + "y": 284, + "width": 176, + "height": 60 + }, + "ops": { + "x": 1280, + "y": 384, + "width": 176, + "height": 60 + } + }, + "groups": {}, + "edges": { + "e1": [ + { + "x": 216, + "y": 214 + }, + { + "x": 350, + "y": 214 + } + ], + "e2": [ + { + "x": 216, + "y": 338 + }, + { + "x": 350, + "y": 338 + } + ], + "e3": [ + { + "x": 526, + "y": 214 + }, + { + "x": 660, + "y": 214 + } + ], + "e4": [ + { + "x": 526, + "y": 338 + }, + { + "x": 550, + "y": 338 + }, + { + "x": 550, + "y": 314 + }, + { + "x": 660, + "y": 314 + } + ], + "e5": [ + { + "x": 836, + "y": 214 + }, + { + "x": 970, + "y": 214 + } + ], + "e6": [ + { + "x": 836, + "y": 314 + }, + { + "x": 860, + "y": 314 + }, + { + "x": 860, + "y": 214 + }, + { + "x": 970, + "y": 214 + } + ], + "e7": [ + { + "x": 1146, + "y": 214 + }, + { + "x": 1170, + "y": 214 + }, + { + "x": 1170, + "y": 114 + }, + { + "x": 1280, + "y": 114 + } + ], + "e8": [ + { + "x": 1146, + "y": 214 + }, + { + "x": 1280, + "y": 214 + } + ], + "e9": [ + { + "x": 836, + "y": 214 + }, + { + "x": 860, + "y": 214 + }, + { + "x": 860, + "y": 314 + }, + { + "x": 970, + "y": 314 + } + ], + "e10": [ + { + "x": 836, + "y": 314 + }, + { + "x": 970, + "y": 314 + } + ], + "e11": [ + { + "x": 1146, + "y": 314 + }, + { + "x": 1170, + "y": 314 + }, + { + "x": 1170, + "y": 414 + }, + { + "x": 1280, + "y": 414 + } + ], + "e12": [ + { + "x": 1146, + "y": 314 + }, + { + "x": 1280, + "y": 314 + } + ] + }, + "bounds": { + "width": 1496, + "height": 484 + }, + "phases": { + "producers": { + "x": 20, + "y": 20, + "width": 216, + "height": 444 + }, + "transit": { + "x": 330, + "y": 20, + "width": 216, + "height": 444 + }, + "processors": { + "x": 640, + "y": 20, + "width": 216, + "height": 444 + }, + "state": { + "x": 950, + "y": 20, + "width": 216, + "height": 444 + }, + "consumers": { + "x": 1260, + "y": 20, + "width": 216, + "height": 444 + } + }, + "labels": { + "e1": { + "x": 283, + "y": 214 + }, + "e2": { + "x": 283, + "y": 338 + }, + "e3": { + "x": 593, + "y": 214 + }, + "e4": { + "x": 605, + "y": 314 + }, + "e5": { + "x": 903, + "y": 214 + }, + "e6": { + "x": 860, + "y": 264 + }, + "e7": { + "x": 1225, + "y": 114 + }, + "e8": { + "x": 1213, + "y": 214 + }, + "e9": { + "x": 915, + "y": 314 + }, + "e10": { + "x": 862.8, + "y": 314 + }, + "e11": { + "x": 1225, + "y": 414 + }, + "e12": { + "x": 1213, + "y": 314 + } + } + } +}; From 0a7d3f054ee04fbe58aa400681de2c44ef6b59c4 Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Wed, 30 Sep 2026 00:23:55 +0800 Subject: [PATCH 04/14] feat(viewer): draw lanes, phases, compact cards and edge tones --- packages/viewer/e2e/lanes.spec.ts | 40 ++++++ packages/viewer/e2e/metrics.spec.ts | 16 ++- packages/viewer/e2e/visual.spec.ts | 9 +- packages/viewer/src/App.tsx | 3 +- packages/viewer/src/canvas/ArrowMarker.tsx | 50 ++++--- packages/viewer/src/canvas/Minimap.tsx | 3 + packages/viewer/src/canvas/SceneLayers.tsx | 140 +++++++++++++++++--- packages/viewer/src/canvas/scene.test.ts | 25 ++++ packages/viewer/src/canvas/scene.ts | 102 +++++++++++++- packages/viewer/src/card/StepCard.test.tsx | 29 ++++ packages/viewer/src/card/StepCard.tsx | 57 ++++++++ packages/viewer/src/chrome/Inspector.tsx | 81 +++++++++-- packages/viewer/src/chrome/ViewerShell.tsx | 5 +- packages/viewer/src/icons/TypeIcon.tsx | 14 ++ packages/viewer/src/pages/GalleryPage.tsx | 16 ++- packages/viewer/src/pages/boundary-nodes.ts | 12 +- packages/viewer/src/pages/gallery-nodes.ts | 20 ++- packages/viewer/src/styles.css | 6 +- 18 files changed, 552 insertions(+), 76 deletions(-) create mode 100644 packages/viewer/e2e/lanes.spec.ts create mode 100644 packages/viewer/src/card/StepCard.test.tsx create mode 100644 packages/viewer/src/card/StepCard.tsx diff --git a/packages/viewer/e2e/lanes.spec.ts b/packages/viewer/e2e/lanes.spec.ts new file mode 100644 index 0000000..c6f6921 --- /dev/null +++ b/packages/viewer/e2e/lanes.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; + +test('a workflow draws its lanes, phase headers, groups and compact cards', async ({ page }) => { + await page.goto('/?page=release-delivery'); + await expect(page.locator('[data-lane-id]')).toHaveCount(6); + await expect(page.locator('[data-lane-id="exceptions"]')).toHaveAttribute('data-tone', 'exception'); + await expect(page.locator('[data-phase-id]')).toHaveCount(3); + await expect(page.locator('[data-frame-id="recovery"]')).toHaveAttribute('data-tone', 'security'); + await expect(page.locator('[data-testid="step-card"]')).toHaveCount(10); + await expect(page.locator('[data-testid="node-card"]')).toHaveCount(0); + await expect(page.getByText('Workflow', { exact: true })).toBeVisible(); + // Notes and the connection styles the diagram uses. + const inspector = page.getByRole('complementary', { name: 'Inspector' }); + await expect(inspector.getByRole('heading', { name: 'Notes' })).toBeVisible(); + await expect(inspector.getByRole('list', { name: 'Connection styles' })).toContainText('Main pathFailure pathReply or return'); +}); + +test('edge tones and kinds reach the SVG, and a selection tints over them', async ({ page }) => { + await page.goto('/?page=release-delivery'); + await expect(page.locator('path[data-edge-id="e8"]')).toHaveAttribute('data-tone', 'error'); + await expect(page.locator('path[data-edge-id="e10"]')).toHaveAttribute('data-kind', 'return'); + await page.locator('.sm-card[data-card-id="rollback"]').click(); + await expect(page.locator('path[data-edge-id="e10"]')).toHaveAttribute('data-tint', 'queue'); + // Dots sit where routes meet cards, and dim with their card: "failed" is neither up- nor downstream of rollback. + await page.getByRole('button', { name: /Trace/ }).click(); + await expect(page.locator('[data-handle-of="failed"][data-dim]').first()).toBeAttached(); +}); + +test('a lifecycle marks its start state and its end states', async ({ page }) => { + await page.goto('/?page=agent-run'); + await expect(page.locator('[data-start-mark="queued"]')).toBeAttached(); + await expect(page.locator('[data-testid="step-card"][data-final]')).toHaveCount(3); + await expect(page.getByText('Lifecycle', { exact: true })).toBeVisible(); +}); + +test('a staged dataflow draws its stages as bands', async ({ page }) => { + await page.goto('/?page=product-analytics'); + await expect(page.locator('[data-phase-id]')).toHaveCount(5); + await expect(page.locator('[data-testid="step-card"]')).toHaveCount(10); +}); diff --git a/packages/viewer/e2e/metrics.spec.ts b/packages/viewer/e2e/metrics.spec.ts index 56166da..c485244 100644 --- a/packages/viewer/e2e/metrics.spec.ts +++ b/packages/viewer/e2e/metrics.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import { cardTextSlots, measureText, type FontFace } from '@stackmap/core'; -import { boundaryNodes } from '../src/pages/boundary-nodes'; -import { GALLERY_SECTIONS } from '../src/pages/gallery-nodes'; +import { boundaryNodes, compactBoundaryNodes } from '../src/pages/boundary-nodes'; +import { COMPACT_SECTIONS, GALLERY_SECTIONS } from '../src/pages/gallery-nodes'; // Browser ground truth for the headless card-fit measure (M1). The measure is the wider of the macOS/ // Windows layout (fractional, kerned) and the Linux one (whole-pixel advances, no kerning), so on any @@ -63,7 +63,7 @@ test('headless text measure matches the browser within a conservative margin', a test('a card slot never truncates text the measure said fits, and the measure stays close', async ({ page }) => { await page.goto('/?page=gallery'); await page.evaluate(() => document.fonts.ready); - const rendered = await page.$$eval('[data-testid="node-card"]', (cards) => + const rendered = await page.$$eval('[data-testid="node-card"], [data-testid="step-card"]', (cards) => Object.fromEntries( cards.map((card) => [ card.getAttribute('data-node-id')!, @@ -77,9 +77,13 @@ test('a card slot never truncates text the measure said fits, and the measure st ); const problems: string[] = []; let checked = 0; - for (const [, nodes] of [...GALLERY_SECTIONS, ['Boundary', boundaryNodes()] as const]) { + const sets = [ + ...[...GALLERY_SECTIONS, ['Boundary', boundaryNodes()] as const].map(([, nodes]) => ({ nodes, variant: 'full' as const })), + ...[...COMPACT_SECTIONS, ['Compact boundary', compactBoundaryNodes()] as const].map(([, nodes]) => ({ nodes, variant: 'compact' as const })), + ]; + for (const { nodes, variant } of sets) { for (const node of nodes) { - const slots = cardTextSlots(node.card); + const slots = cardTextSlots(node.card, variant); const dom = rendered[node.id]!; expect(dom.map((d) => d.text), node.id).toEqual(slots.map((s) => s.text)); slots.forEach((slot, i) => { @@ -92,7 +96,7 @@ test('a card slot never truncates text the measure said fits, and the measure st if (!dom[i]!.truncated && width > TOO_WIDE(dom[i]!.width)) problems.push(`${at}, real ${dom[i]!.width.toFixed(1)}: too conservative`); // Linux renders the snapped model to within ±2px (hinting), so text measured clearly over budget // must truncate there; this is what catches a budget that is set too small. - if (process.platform === 'linux' && node.id === 'boundary-over' && width > slot.maxWidth + 4 && !dom[i]!.truncated) + if (process.platform === 'linux' && node.id.endsWith('-over') && width > slot.maxWidth + 4 && !dom[i]!.truncated) problems.push(`${at}, but not truncated on Linux`); }); } diff --git a/packages/viewer/e2e/visual.spec.ts b/packages/viewer/e2e/visual.spec.ts index 832590f..5fb87bd 100644 --- a/packages/viewer/e2e/visual.spec.ts +++ b/packages/viewer/e2e/visual.spec.ts @@ -4,7 +4,14 @@ import { expect, test } from '@playwright/test'; // Linux only (CI, or `bun run visual:update`, which runs Playwright's Linux image in Docker). test.skip(process.platform !== 'linux' && !process.env.VISUAL, 'visual baselines are Linux-only'); -const PAGES = { sample: '/?page=sample', grouped: '/?page=grouped', gallery: '/?page=gallery' } as const; +const PAGES = { + sample: '/?page=sample', + grouped: '/?page=grouped', + gallery: '/?page=gallery', + workflow: '/?page=release-delivery', + lifecycle: '/?page=agent-run', + stages: '/?page=product-analytics', +} as const; for (const theme of ['light', 'dark'] as const) { for (const [name, url] of Object.entries(PAGES)) { diff --git a/packages/viewer/src/App.tsx b/packages/viewer/src/App.tsx index 2ca5a6d..de66298 100644 --- a/packages/viewer/src/App.tsx +++ b/packages/viewer/src/App.tsx @@ -4,6 +4,7 @@ import { ViewerShell } from './chrome/ViewerShell'; import { readEmbeddedDiagram } from './data'; import { GalleryPage } from './pages/GalleryPage'; import { commerceApiLayout } from './samples/commerce-api.layout'; +import { galleryLayouts } from './samples/gallery.layout'; import { groupedPlatformLayout } from './samples/grouped-platform.layout'; import { useTheme } from './theme/theme'; @@ -33,7 +34,7 @@ function Notice({ children }: { children: string }) { function DevPages({ theme, toggle }: ReturnType) { const page = new URLSearchParams(location.search).get('page'); if (page === 'gallery') return ; - const diagram = page === 'grouped' ? groupedPlatformLayout : commerceApiLayout; + const diagram = page === 'grouped' ? groupedPlatformLayout : ((page ? galleryLayouts[page] : undefined) ?? commerceApiLayout); return ; } diff --git a/packages/viewer/src/canvas/ArrowMarker.tsx b/packages/viewer/src/canvas/ArrowMarker.tsx index 4848e64..fd42245 100644 --- a/packages/viewer/src/canvas/ArrowMarker.tsx +++ b/packages/viewer/src/canvas/ArrowMarker.tsx @@ -1,36 +1,44 @@ import { NODE_TYPES, type NodeType } from '@stackmap/core'; export const ARROW_MARKER_ID = 'sm-arrow'; -/** Marker for an edge tinted by its source type (selection / trace, Q26). */ -export const arrowMarkerId = (tint: NodeType | null) => (tint ? `${ARROW_MARKER_ID}-${tint}` : ARROW_MARKER_ID); + +/** An edge's colour: the default grey, ink for the happy path, or a node-type tint (trace, selection, tones). */ +export type EdgeColor = NodeType | 'ink' | null; +export const edgeStroke = (color: EdgeColor) => (color === null ? 'var(--sm-edge)' : color === 'ink' ? 'var(--sm-text)' : `var(--sm-${color}-accent)`); + +/** Marker for an edge of that colour; `open` is the reply arrowhead (a chevron, not a filled triangle). */ +export const arrowMarkerId = (color: EdgeColor, open = false) => `${ARROW_MARKER_ID}${color ? `-${color}` : ''}${open ? '-open' : ''}`; const LENGTH = 7; const HEIGHT = 6; // Handle dots are 7px and paint above edges; the tip stops 4px short of the port so the dot can't hide it. const TIP_GAP = 4; +const COLORS: EdgeColor[] = [null, 'ink', ...NODE_TYPES]; -// One marker per colour: `context-stroke` would be neater but isn't reliable in every engine yet. +// One marker per colour and shape: `context-stroke` would be neater but isn't reliable in every engine yet. export function ArrowMarkerDefs() { return ( - {[null, ...NODE_TYPES].map((tint) => ( - - - - ))} + {COLORS.flatMap((color) => + [false, true].map((open) => ( + + + + )), + )} ); } diff --git a/packages/viewer/src/canvas/Minimap.tsx b/packages/viewer/src/canvas/Minimap.tsx index 77eb416..6b1e690 100644 --- a/packages/viewer/src/canvas/Minimap.tsx +++ b/packages/viewer/src/canvas/Minimap.tsx @@ -60,6 +60,9 @@ export function Minimap({ scene }: { scene: Scene }) { onPointerUp={stop} onPointerCancel={stop} > + {[...scene.lanes, ...(scene.phaseStyle === 'band' ? scene.phases : [])].map((l) => ( + + ))} {scene.frames.map((f) => ( = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' }; @@ -30,25 +32,91 @@ function handleStyle(side: 'in' | 'out', horizontal: boolean): CSSProperties { : { left: '50%', bottom: 0, transform: 'translate(-50%, 50%)' }; } -function Frame({ frame }: { frame: SceneFrame }) { - // Q18: groups aren't in the refs — thin dashed container, faint fill, sentence-case label in the 48px band. +function Frame({ frame, compact }: { frame: SceneFrame; compact: boolean }) { + // Q18: groups aren't in the refs — thin dashed container, faint fill, sentence-case label in the label band. + // A trust boundary (`tone: security`) takes the security tint and a shield. + const secure = frame.tone === 'security'; return (
-
+
+ {secure &&
); } +/** A swimlane: a full-width band with its label in the left rail (layout's LANE_HEAD). Exception lanes are dashed. */ +function Lane({ lane }: { lane: SceneLane }) { + const exception = lane.tone === 'exception'; + return ( +
+
+ {exception &&
+
+ ); +} + +/** A phase header over the columns it spans (label and hairline), or a stage band around its nodes. */ +function Phase({ phase, style, direction }: { phase: ScenePhase; style: Scene['phaseStyle']; direction: Scene['direction'] }) { + if (style === 'band') { + return ( +
+
{phase.label}
+
+ ); + } + return ( +
+ {phase.label} +
+ ); +} + +// What an edge looks like at rest: tones pick the colour and weight, kinds the dash (Q26 + gallery parity). +function edgeLook(e: SceneEdge, tint: EdgeColor): { color: EdgeColor; width: number; dash?: string } { + const color: EdgeColor = tint ?? (e.tone === 'main' ? 'ink' : e.tone === 'security' ? 'security' : e.tone === 'error' ? 'failure' : null); + const width = tint ? 1.75 : e.tone === 'main' ? 1.75 : e.tone ? 1.5 : 1.25; + const dash = e.kind === 'async' ? '5 4' : e.kind === 'return' ? '1 4' : undefined; + return { color, width, dash }; +} + // Memoised with stable props: an explorer change re-renders only the cards whose emphasis changed. const Card = memo(function Card({ card, horizontal, + compact, + handles, state, rects, tabbable, @@ -56,13 +124,16 @@ const Card = memo(function Card({ }: { card: SceneCard; horizontal: boolean; + compact: boolean; + /** false: the scene draws dots at the route ends instead (lane layouts) */ + handles: boolean; state: NodeEmphasis; rects: Record; /** roving tabindex: one card is the canvas's tab stop, arrows move between the rest */ tabbable: boolean; onFocused: (id: string) => void; }) { - const { node, rect, hasIn, hasOut } = card; + const { node, rect, hasIn, hasOut, final } = card; const dispatch = useExploreDispatch(); const camera = useCamera(); const accent = { '--sm-handle': `var(--sm-${node.type}-accent)` } as CSSProperties; @@ -103,9 +174,9 @@ const Card = memo(function Card({ }} onKeyDown={onKeyDown} > - - {hasIn &&
); }); @@ -123,13 +194,20 @@ export const SceneLayers = memo(function SceneLayers({ selected: string | null; }) { const horizontal = scene.direction === 'RIGHT'; + const typeOf = useMemo(() => new Map(scene.cards.map((c) => [c.node.id, c.node.type])), [scene]); const rects = useMemo(() => Object.fromEntries(scene.cards.map((c) => [c.node.id, c.rect])), [scene]); const [lastFocused, setLastFocused] = useState(null); const tabStop = selected ?? (lastFocused && rects[lastFocused] ? lastFocused : scene.cards[0]?.node.id); return ( <> + {scene.lanes.map((l) => ( + + ))} + {scene.phases.map((p) => ( + + ))} {scene.frames.map((f) => ( - + ))} {scene.edges.map((e) => { const { dim, tint } = emphasis.edges.get(e.id) ?? { dim: false, tint: null }; + const look = edgeLook(e, tint); return ( ); })} + {/* Lifecycle: a start state's initial marker, a filled dot with a stub into the card (UML). */} + {scene.kind === 'lifecycle' && + scene.cards + .filter((c) => c.node.type === 'start') + .map((c) => { + const y = c.rect.y + c.rect.height / 2; + const dim = emphasis.nodes.get(c.node.id) === 'dim' || undefined; + return ( + + + + + ); + })} {scene.cards.map((c) => ( ))} + {scene.handles?.map((h) => ( +