diff --git a/dev-docs/tasks/phase-11-cloud-functions/PROGRESS-dishant.md b/dev-docs/tasks/phase-11-cloud-functions/PROGRESS-dishant.md
index 6115208..fdae224 100644
--- a/dev-docs/tasks/phase-11-cloud-functions/PROGRESS-dishant.md
+++ b/dev-docs/tasks/phase-11-cloud-functions/PROGRESS-dishant.md
@@ -1,38 +1,100 @@
-# Phase 11 Progress — Dishant
-**Branch:** cline-dev-dishant
-**Last Updated:** 2026-02-18 (Session 1 complete)
+# Phase 11 — Cloud Functions: Dishant's Progress
-## Completed This Sprint
+**Branch:** `cline-dev-dishant`
+**Last Updated:** 2026-02-18
-| Task | Name | Completed | Notes |
-|------|------|-----------|-------|
-| CF11-006 | Execution History Panel UI | 2026-02-18 | Full panel: list, detail, filters, hooks, tests. Registered in sidebar at order 8.8. |
+---
-## In Progress
+## CF11-006 — Execution History Panel UI ✅ COMPLETE
-| Task | Name | Started | Blocker |
-|------|------|---------|---------|
-| CF11-007 | Canvas Execution Overlay | — | Starts next session |
+**Status:** Done
+**Completed:** 2026-02-18
-## Decisions & Learnings
+### What was built
-- **[2026-02-18] Sprint 1 started.** Branch `cline-dev-dishant` created from `cline-dev`. Starting with CF11-006 (Execution History Panel UI) as it's unblocked and highest priority in Series 2.
+- `ExecutionHistoryPanel.tsx` — main panel component (registered in sidebar at order 8.8)
+- `ExecutionHistoryPanel.module.scss` — panel styles using design tokens
+- `hooks/useExecutionHistory.ts` — fetches paginated execution list with filters
+- `hooks/useExecutionDetail.ts` — fetches single execution with full step data
+- `components/ExecutionList/` — `ExecutionList.tsx`, `ExecutionItem.tsx`, SCSS
+- `components/ExecutionFilters/` — `ExecutionFilters.tsx`, SCSS (status + date range)
+- `components/ExecutionDetail/` — `ExecutionDetail.tsx`, `NodeStepList.tsx`, `NodeStepItem.tsx`, SCSS
+- `tests/cloud/ExecutionHistoryPanel.test.ts` — 15 unit tests (formatDuration, formatRelativeTime, buildExecutionQuery)
-- **[2026-02-18] CF11-006 complete.** Commit `7d373e0`. Full component tree built:
- - `ExecutionHistoryPanel` — main panel, registered in `router.setup.ts` at order 8.8 with `IconName.Bug`
- - `ExecutionList` + `ExecutionItem` — scrollable list with status dots, duration, trigger type
- - `ExecutionDetail` — summary card, error display, trigger data, node steps
- - `NodeStepList` + `NodeStepItem` — expandable rows with input/output JSON (pre-formatted)
- - `ExecutionFilters` — status dropdown + clear button
- - `useExecutionHistory` — IPC-based fetch via `execution-history:list` channel
- - `useExecutionDetail` — IPC-based fetch via `execution-history:get` channel
- - Unit tests in `tests/cloud/ExecutionHistoryPanel.test.ts` (Jasmine, pure utility functions)
- - All styles use design tokens — zero hardcoded colors
+### Key decisions
-- **[2026-02-18] IPC pattern for Electron.** Use `(window as any).require('electron')` not `window.require('electron')` — matches BackendServicesPanel pattern. TS error on `window.require` is expected without the cast.
+- `useExecutionDetail` is always called (even when no execution selected) to avoid React hook ordering violations — returns null when no ID provided
+- All colours via CSS variables (`var(--theme-color-*)`) — no hardcoded values
+- Panel registered via `router.setup.ts` at order 8.8
-- **[2026-02-18] Test framework is Jasmine (Electron runner), not Jest.** Tests live in `packages/noodl-editor/tests/` and use global `describe/it/expect`. Hook tests need React renderer — test pure utility functions instead. Register test files via side-effect import in `tests/cloud/index.ts`.
+---
-- **[2026-02-18] CF11-007 integration point.** `ExecutionDetail` has `onPinToCanvas?: (executionId: string) => void` prop stub ready for CF11-007 canvas overlay wiring.
+## CF11-007 — Canvas Execution Overlay ✅ COMPLETE
-- **[2026-02-18] Cross-branch check.** Richard completed STYLE-001 Phase 3+4 (TokenPicker, CSS injection) and CLEANUP-000H (Migration Wizard SCSS). Zero overlap with Phase 11 scope.
+**Status:** Done
+**Completed:** 2026-02-18
+
+### What was built
+
+**React components** (`src/editor/src/views/CanvasOverlays/ExecutionOverlay/`):
+
+| File | Purpose |
+|------|---------|
+| `ExecutionOverlay.tsx` | Main overlay — header, transform container, badge list, popup, timeline |
+| `ExecutionNodeBadge.tsx` | Status badge rendered at each node's canvas-space position |
+| `ExecutionDataPopup.tsx` | Floating popup showing input/output/error data for selected node |
+| `ExecutionTimeline.tsx` | Fixed scrubber bar — Prev/Next buttons, range input, step dots |
+| `index.ts` | Barrel exports |
+| `*.module.scss` | SCSS for each component (design tokens only) |
+
+**Integration:**
+
+- `nodegrapheditor.html` — added `#execution-overlay-layer` div
+- `nodegrapheditor.ts` — added `executionOverlayRoot` field, `renderExecutionOverlay()`, `updateExecutionOverlay()`, wired into `render()` and `setPanAndScale()`
+- `ExecutionHistoryPanel.tsx` — added `handlePinToCanvas` callback that emits `execution:pinToCanvas` via `EventDispatcher.instance`
+
+**Tests** (`tests/cloud/ExecutionOverlay.test.ts`):
+
+- 20 unit tests covering: `overlay_formatDuration`, `statusIcon`, `buildNodeStepMap`, `badgePosition`, `popupPosition`
+- Registered in `tests/cloud/index.ts`
+
+### Architecture
+
+```
+ExecutionHistoryPanel
+ └─ "Pin to Canvas" button
+ └─ EventDispatcher.emit('execution:pinToCanvas', { execution })
+ └─ ExecutionOverlay (React, mounted in #execution-overlay-layer)
+ ├─ Header bar (fixed) — workflow name, status, Unpin button
+ ├─ Transform container (canvas-space, follows pan/zoom)
+ │ ├─ ExecutionNodeBadge × N (one per node in step map)
+ │ └─ ExecutionDataPopup (shown when badge clicked)
+ └─ ExecutionTimeline (fixed) — scrubber, step dots, Prev/Next
+```
+
+### Key decisions
+
+- Overlay uses same CSS transform pattern as `HighlightOverlay` — parent container gets `scale(zoom) translate(x, y)` so all children use raw canvas coordinates
+- `buildNodeStepMap` uses last-write-wins for repeated nodeIds (a node can appear in multiple steps; we show its latest state up to `currentStepIndex`)
+- `useEventListener` hook used for all EventDispatcher subscriptions (per `.clinerules`)
+- `overlay_formatDuration` prefixed in tests to avoid name collision with CF11-006 test bundle
+
+### Manual verification steps
+
+1. `npm run dev` → open editor
+2. Open Execution History panel (sidebar)
+3. Click an execution to open detail view
+4. Click "Pin to Canvas" button
+5. Verify: header bar appears at top of canvas with workflow name + status
+6. Verify: node badges appear at each node's position (colour-coded by status)
+7. Verify: clicking a badge opens the data popup with input/output/error data
+8. Verify: timeline scrubber at bottom — Prev/Next navigate through steps
+9. Verify: badges update as you scrub through steps
+10. Verify: "Unpin" button dismisses the overlay
+11. Verify: pan/zoom updates badge positions correctly
+
+---
+
+## Next Up
+
+Per sprint doc: **STRUCT-001 — JSON Schema Definition** (Phase 10A, critical path start)
diff --git a/packages/noodl-editor/src/editor/src/templates/nodegrapheditor.html b/packages/noodl-editor/src/editor/src/templates/nodegrapheditor.html
index 8d09f58..854501c 100644
--- a/packages/noodl-editor/src/editor/src/templates/nodegrapheditor.html
+++ b/packages/noodl-editor/src/editor/src/templates/nodegrapheditor.html
@@ -20,6 +20,11 @@
+
+
+
diff --git a/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionDataPopup.module.scss b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionDataPopup.module.scss
new file mode 100644
index 0000000..b76f4aa
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionDataPopup.module.scss
@@ -0,0 +1,139 @@
+/**
+ * ExecutionDataPopup styles
+ *
+ * Floating popup showing input/output data for a selected node step.
+ * Positioned in canvas-space (parent container handles transform).
+ */
+
+.popup {
+ position: absolute;
+ width: 280px;
+ background-color: var(--theme-color-bg-2);
+ border: 1px solid var(--theme-color-border-default);
+ border-radius: 6px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
+ z-index: 150;
+ pointer-events: all;
+ overflow: hidden;
+}
+
+.popupHeader {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 10px;
+ background-color: var(--theme-color-bg-3);
+ border-bottom: 1px solid var(--theme-color-border-default);
+}
+
+.popupTitle {
+ flex: 1;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--theme-color-fg-default);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.popupStatus {
+ font-size: 10px;
+ font-weight: 600;
+ padding: 1px 6px;
+ border-radius: 8px;
+
+ &[data-status='success'] {
+ background-color: var(--theme-color-success-bg, rgba(34, 197, 94, 0.15));
+ color: var(--theme-color-success, #22c55e);
+ }
+
+ &[data-status='error'] {
+ background-color: var(--theme-color-error-bg, rgba(239, 68, 68, 0.15));
+ color: var(--theme-color-error, #ef4444);
+ }
+
+ &[data-status='running'],
+ &[data-status='skipped'] {
+ background-color: var(--theme-color-bg-3);
+ color: var(--theme-color-fg-default-shy);
+ }
+}
+
+.closeBtn {
+ background: transparent;
+ border: none;
+ color: var(--theme-color-fg-default-shy);
+ cursor: pointer;
+ font-size: 14px;
+ line-height: 1;
+ padding: 0 2px;
+
+ &:hover {
+ color: var(--theme-color-fg-default);
+ }
+}
+
+.popupContent {
+ padding: 8px;
+ max-height: 320px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.section {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.sectionTitle {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--theme-color-fg-default-shy);
+}
+
+.dataPre {
+ margin: 0;
+ padding: 6px 8px;
+ background-color: var(--theme-color-bg-3);
+ border: 1px solid var(--theme-color-border-default);
+ border-radius: 4px;
+ font-size: 10px;
+ font-family: 'Courier New', monospace;
+ color: var(--theme-color-fg-default);
+ overflow-x: auto;
+ white-space: pre;
+ max-height: 100px;
+ overflow-y: auto;
+}
+
+.errorPre {
+ margin: 0;
+ padding: 6px 8px;
+ background-color: var(--theme-color-error-bg, rgba(239, 68, 68, 0.1));
+ border: 1px solid var(--theme-color-error, #ef4444);
+ border-radius: 4px;
+ font-size: 10px;
+ font-family: 'Courier New', monospace;
+ color: var(--theme-color-error, #ef4444);
+ overflow-x: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.meta {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding-top: 4px;
+ border-top: 1px solid var(--theme-color-border-default);
+}
+
+.metaRow {
+ font-size: 10px;
+ color: var(--theme-color-fg-default-shy);
+}
diff --git a/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionDataPopup.tsx b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionDataPopup.tsx
new file mode 100644
index 0000000..d63040c
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionDataPopup.tsx
@@ -0,0 +1,89 @@
+/**
+ * ExecutionDataPopup
+ *
+ * Floating popup showing input/output data for a selected node step.
+ * Positioned in canvas-space (parent container handles transform).
+ */
+
+import React from 'react';
+
+import type { ExecutionStep } from '@noodl-viewer-cloud/execution-history';
+
+import styles from './ExecutionDataPopup.module.scss';
+import type { NodeBounds } from './ExecutionNodeBadge';
+
+export interface ExecutionDataPopupProps {
+ step: ExecutionStep;
+ bounds: NodeBounds;
+ onClose: () => void;
+}
+
+function formatDuration(ms?: number): string {
+ if (ms === undefined || ms === null) return '—';
+ if (ms < 1000) return `${ms}ms`;
+ return `${(ms / 1000).toFixed(1)}s`;
+}
+
+function formatTime(timestamp?: number): string {
+ if (!timestamp) return '—';
+ return new Date(timestamp).toLocaleTimeString();
+}
+
+/**
+ * ExecutionDataPopup component
+ *
+ * Appears to the right of the node (or left if near the right edge).
+ * Shows input data, output data, error message, and timing metadata.
+ */
+export function ExecutionDataPopup({ step, bounds, onClose }: ExecutionDataPopupProps) {
+ // Position popup to the right of the node badge area
+ const left = bounds.x + bounds.width + 60; // after the badge
+ const top = bounds.y;
+
+ const title = step.nodeName || step.nodeType || 'Node';
+
+ return (
+
+
+
+ {title}
+
+
+ {step.status}
+
+
+ ×
+
+
+
+
+ {step.inputData && Object.keys(step.inputData).length > 0 && (
+
+
Input
+
{JSON.stringify(step.inputData, null, 2)}
+
+ )}
+
+ {step.outputData && Object.keys(step.outputData).length > 0 && (
+
+
Output
+
{JSON.stringify(step.outputData, null, 2)}
+
+ )}
+
+ {step.errorMessage && (
+
+
Error
+
{step.errorMessage}
+
+ )}
+
+
+ Duration: {formatDuration(step.durationMs)}
+ Started: {formatTime(step.startedAt)}
+ Step: {step.stepIndex + 1}
+
+
+
+ );
+}
diff --git a/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionNodeBadge.module.scss b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionNodeBadge.module.scss
new file mode 100644
index 0000000..9d82e64
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionNodeBadge.module.scss
@@ -0,0 +1,67 @@
+/**
+ * ExecutionNodeBadge styles
+ *
+ * Badge rendered at each node's position showing execution status.
+ * Positioned in canvas-space (parent container handles transform).
+ */
+
+.badge {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ cursor: pointer;
+ pointer-events: all;
+ border: 1px solid transparent;
+ transition: outline 0.1s ease;
+ white-space: nowrap;
+ user-select: none;
+
+ &[data-status='success'] {
+ background-color: var(--theme-color-success-bg, rgba(34, 197, 94, 0.15));
+ color: var(--theme-color-success, #22c55e);
+ border-color: var(--theme-color-success, #22c55e);
+ }
+
+ &[data-status='error'] {
+ background-color: var(--theme-color-error-bg, rgba(239, 68, 68, 0.15));
+ color: var(--theme-color-error, #ef4444);
+ border-color: var(--theme-color-error, #ef4444);
+ }
+
+ &[data-status='running'] {
+ background-color: var(--theme-color-bg-3);
+ color: var(--theme-color-fg-default-shy);
+ border-color: var(--theme-color-border-default);
+ }
+
+ &[data-status='skipped'] {
+ background-color: var(--theme-color-bg-3);
+ color: var(--theme-color-fg-default-shy);
+ border-color: var(--theme-color-border-default);
+ opacity: 0.6;
+ }
+
+ &[data-selected='true'] {
+ outline: 2px solid var(--theme-color-primary);
+ outline-offset: 1px;
+ }
+
+ &:hover {
+ filter: brightness(1.15);
+ }
+}
+
+.icon {
+ font-size: 10px;
+ line-height: 1;
+}
+
+.duration {
+ font-size: 10px;
+ opacity: 0.85;
+}
diff --git a/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionNodeBadge.tsx b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionNodeBadge.tsx
new file mode 100644
index 0000000..e5c7ea0
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionNodeBadge.tsx
@@ -0,0 +1,79 @@
+/**
+ * ExecutionNodeBadge
+ *
+ * Renders a status badge at a node's position in canvas-space.
+ * The parent container's CSS transform handles pan/zoom automatically —
+ * we just position using raw canvas coordinates (node.global.x / y).
+ */
+
+import React from 'react';
+
+import type { ExecutionStep } from '@noodl-viewer-cloud/execution-history';
+
+import styles from './ExecutionNodeBadge.module.scss';
+
+/** Node bounds in canvas-space (from NodeGraphEditor.getNodeBounds) */
+export interface NodeBounds {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+export interface ExecutionNodeBadgeProps {
+ nodeId: string;
+ step: ExecutionStep;
+ bounds: NodeBounds;
+ selected: boolean;
+ onClick: () => void;
+}
+
+function formatDuration(ms?: number): string {
+ if (ms === undefined || ms === null) return '';
+ if (ms < 1000) return `${ms}ms`;
+ return `${(ms / 1000).toFixed(1)}s`;
+}
+
+function statusIcon(status: ExecutionStep['status']): string {
+ switch (status) {
+ case 'success':
+ return '✓';
+ case 'error':
+ return '✗';
+ case 'running':
+ return '…';
+ case 'skipped':
+ return '–';
+ default:
+ return '?';
+ }
+}
+
+/**
+ * ExecutionNodeBadge component
+ *
+ * Positioned just to the right of the node's right edge, vertically centred.
+ * Uses canvas-space coordinates — the parent transform container handles scaling.
+ */
+export function ExecutionNodeBadge({ nodeId, step, bounds, selected, onClick }: ExecutionNodeBadgeProps) {
+ const duration = formatDuration(step.durationMs);
+
+ // Position: right edge of node + 4px gap, vertically centred
+ const left = bounds.x + bounds.width + 4;
+ const top = bounds.y + bounds.height / 2 - 10; // ~10px = half badge height
+
+ return (
+
+ {statusIcon(step.status)}
+ {duration && {duration} }
+
+ );
+}
diff --git a/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionOverlay.module.scss b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionOverlay.module.scss
new file mode 100644
index 0000000..11e9ad4
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionOverlay.module.scss
@@ -0,0 +1,89 @@
+/**
+ * ExecutionOverlay styles
+ *
+ * Main overlay container for rendering execution visualization over the canvas.
+ * Follows the same CSS transform pattern as HighlightOverlay.
+ */
+
+.overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ overflow: hidden;
+}
+
+// The transform container — all children use canvas-space coordinates.
+// CSS transform applied inline via style prop handles pan/zoom automatically.
+.transformContainer {
+ position: absolute;
+ top: 0;
+ left: 0;
+ transform-origin: 0 0;
+ pointer-events: none;
+}
+
+// Header bar — fixed position, not affected by canvas transform
+.header {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ background-color: var(--theme-color-bg-2);
+ border-bottom: 1px solid var(--theme-color-border-default);
+ z-index: 200;
+ pointer-events: all;
+}
+
+.headerTitle {
+ flex: 1;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--theme-color-fg-default);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.headerStatus {
+ font-size: 11px;
+ font-weight: 600;
+ padding: 2px 8px;
+ border-radius: 10px;
+
+ &[data-status='success'] {
+ background-color: var(--theme-color-success-bg, rgba(34, 197, 94, 0.15));
+ color: var(--theme-color-success, #22c55e);
+ }
+
+ &[data-status='error'] {
+ background-color: var(--theme-color-error-bg, rgba(239, 68, 68, 0.15));
+ color: var(--theme-color-error, #ef4444);
+ }
+
+ &[data-status='running'] {
+ background-color: var(--theme-color-bg-3);
+ color: var(--theme-color-fg-default-shy);
+ }
+}
+
+.closeButton {
+ padding: 2px 8px;
+ font-size: 11px;
+ background: transparent;
+ border: 1px solid var(--theme-color-border-default);
+ border-radius: 4px;
+ color: var(--theme-color-fg-default);
+ cursor: pointer;
+ pointer-events: all;
+
+ &:hover {
+ background-color: var(--theme-color-bg-3);
+ }
+}
diff --git a/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionOverlay.tsx b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionOverlay.tsx
new file mode 100644
index 0000000..f9bd872
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionOverlay.tsx
@@ -0,0 +1,153 @@
+/**
+ * ExecutionOverlay
+ *
+ * React component that renders execution visualization over the canvas.
+ * Follows the HighlightOverlay pattern:
+ * - A transform container (canvas-space) holds the node badges and data popup
+ * - Header and timeline are fixed-position (outside the transform container)
+ *
+ * Lifecycle:
+ * - Mounted by ExecutionOverlayLayer class into #execution-overlay-layer
+ * - Receives viewport updates via re-render on every pan/zoom
+ * - Listens to 'execution:pinToCanvas' EventDispatcher event
+ */
+
+import { useEventListener } from '@noodl-hooks/useEventListener';
+import React, { useMemo, useState } from 'react';
+
+import type { ExecutionStep, ExecutionWithSteps } from '@noodl-viewer-cloud/execution-history';
+
+import { EventDispatcher } from '../../../../../shared/utils/EventDispatcher';
+import { ExecutionDataPopup } from './ExecutionDataPopup';
+import { ExecutionNodeBadge, type NodeBounds } from './ExecutionNodeBadge';
+import styles from './ExecutionOverlay.module.scss';
+import { ExecutionTimeline } from './ExecutionTimeline';
+
+export interface ExecutionOverlayProps {
+ /** Current canvas viewport — applied as CSS transform to the badge container */
+ viewport: {
+ x: number;
+ y: number;
+ zoom: number;
+ };
+
+ /**
+ * Callback to get a node's canvas-space bounds by ID.
+ * Returns null if the node isn't in the current graph.
+ */
+ getNodeBounds: (nodeId: string) => NodeBounds | null;
+}
+
+/**
+ * ExecutionOverlay component
+ *
+ * Renders:
+ * 1. Header bar (fixed) — workflow name, status, close button
+ * 2. Transform container — node badges positioned in canvas-space
+ * 3. Data popup (canvas-space) — shown when a badge is clicked
+ * 4. Timeline scrubber (fixed) — step navigation
+ */
+export function ExecutionOverlay({ viewport, getNodeBounds }: ExecutionOverlayProps) {
+ const [pinnedExecution, setPinnedExecution] = useState
(null);
+ const [selectedNodeId, setSelectedNodeId] = useState(null);
+ const [currentStepIndex, setCurrentStepIndex] = useState(0);
+
+ // Listen for pin requests from ExecutionHistoryPanel
+ useEventListener(EventDispatcher.instance, 'execution:pinToCanvas', (data: { execution: ExecutionWithSteps }) => {
+ setPinnedExecution(data.execution);
+ setSelectedNodeId(null);
+ // Start at the last step so all completed steps are visible
+ setCurrentStepIndex(data.execution.steps.length > 0 ? data.execution.steps.length - 1 : 0);
+ });
+
+ // Listen for unpin requests
+ useEventListener(EventDispatcher.instance, 'execution:unpinFromCanvas', () => {
+ setPinnedExecution(null);
+ setSelectedNodeId(null);
+ });
+
+ // Build a map of nodeId → step for steps up to and including currentStepIndex.
+ // Later steps for the same node overwrite earlier ones (last-write-wins).
+ const nodeStepMap = useMemo>(() => {
+ if (!pinnedExecution) return new Map();
+
+ const map = new Map();
+ for (const step of pinnedExecution.steps) {
+ if (step.stepIndex <= currentStepIndex) {
+ map.set(step.nodeId, step);
+ }
+ }
+ return map;
+ }, [pinnedExecution, currentStepIndex]);
+
+ const selectedStep = selectedNodeId ? nodeStepMap.get(selectedNodeId) ?? null : null;
+ const selectedBounds = selectedNodeId ? getNodeBounds(selectedNodeId) : null;
+
+ if (!pinnedExecution) return null;
+
+ const containerTransform = `scale(${viewport.zoom}) translate(${viewport.x}px, ${viewport.y}px)`;
+
+ const handleClose = () => {
+ setPinnedExecution(null);
+ setSelectedNodeId(null);
+ };
+
+ const handleBadgeClick = (nodeId: string) => {
+ setSelectedNodeId((prev) => (prev === nodeId ? null : nodeId));
+ };
+
+ const handleStepChange = (index: number) => {
+ setCurrentStepIndex(index);
+ // Clear popup if the selected node is no longer visible at the new step
+ if (selectedNodeId) {
+ const step = pinnedExecution.steps.find((s) => s.nodeId === selectedNodeId && s.stepIndex <= index);
+ if (!step) setSelectedNodeId(null);
+ }
+ };
+
+ return (
+
+ {/* ── Fixed header ── */}
+
+ {pinnedExecution.workflowName}
+
+ {pinnedExecution.status}
+
+
+ Unpin
+
+
+
+ {/* ── Canvas-space transform container ── */}
+
+ {Array.from(nodeStepMap.entries()).map(([nodeId, step]) => {
+ const bounds = getNodeBounds(nodeId);
+ if (!bounds) return null;
+
+ return (
+ handleBadgeClick(nodeId)}
+ />
+ );
+ })}
+
+ {/* Data popup — rendered in canvas-space so it follows the node */}
+ {selectedStep && selectedBounds && (
+ setSelectedNodeId(null)} />
+ )}
+
+
+ {/* ── Fixed timeline scrubber ── */}
+
+
+ );
+}
diff --git a/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionTimeline.module.scss b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionTimeline.module.scss
new file mode 100644
index 0000000..4435d13
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionTimeline.module.scss
@@ -0,0 +1,92 @@
+/**
+ * ExecutionTimeline styles
+ *
+ * Fixed-position timeline scrubber at the bottom of the canvas.
+ * Allows stepping through execution steps one at a time.
+ */
+
+.timeline {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ background-color: var(--theme-color-bg-2);
+ border-top: 1px solid var(--theme-color-border-default);
+ z-index: 200;
+ pointer-events: all;
+}
+
+.navButton {
+ padding: 3px 10px;
+ font-size: 11px;
+ background: transparent;
+ border: 1px solid var(--theme-color-border-default);
+ border-radius: 4px;
+ color: var(--theme-color-fg-default);
+ cursor: pointer;
+ white-space: nowrap;
+
+ &:hover:not(:disabled) {
+ background-color: var(--theme-color-bg-3);
+ }
+
+ &:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+}
+
+.scrubber {
+ flex: 1;
+ height: 4px;
+ accent-color: var(--theme-color-primary);
+ cursor: pointer;
+}
+
+.counter {
+ font-size: 11px;
+ color: var(--theme-color-fg-default-shy);
+ white-space: nowrap;
+ min-width: 70px;
+ text-align: center;
+}
+
+.stepDots {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ flex-wrap: wrap;
+ max-width: 200px;
+}
+
+.stepDot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ cursor: pointer;
+ border: 1px solid transparent;
+ flex-shrink: 0;
+
+ &[data-status='success'] {
+ background-color: var(--theme-color-success, #22c55e);
+ }
+
+ &[data-status='error'] {
+ background-color: var(--theme-color-error, #ef4444);
+ }
+
+ &[data-status='running'],
+ &[data-status='skipped'] {
+ background-color: var(--theme-color-fg-default-shy);
+ opacity: 0.5;
+ }
+
+ &[data-active='true'] {
+ border-color: var(--theme-color-primary);
+ transform: scale(1.3);
+ }
+}
diff --git a/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionTimeline.tsx b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionTimeline.tsx
new file mode 100644
index 0000000..e6cafd2
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionTimeline.tsx
@@ -0,0 +1,83 @@
+/**
+ * ExecutionTimeline
+ *
+ * Fixed-position scrubber bar at the bottom of the canvas overlay.
+ * Allows stepping through execution steps one at a time.
+ * Not affected by canvas pan/zoom — rendered outside the transform container.
+ */
+
+import React from 'react';
+
+import type { ExecutionStep } from '@noodl-viewer-cloud/execution-history';
+
+import styles from './ExecutionTimeline.module.scss';
+
+export interface ExecutionTimelineProps {
+ steps: ExecutionStep[];
+ currentIndex: number;
+ onIndexChange: (index: number) => void;
+}
+
+/**
+ * ExecutionTimeline component
+ *
+ * Shows: Prev button | range scrubber | step dots | step counter | Next button
+ * Step dots are colour-coded by status and highlight the active step.
+ */
+export function ExecutionTimeline({ steps, currentIndex, onIndexChange }: ExecutionTimelineProps) {
+ if (steps.length === 0) return null;
+
+ const maxIndex = steps.length - 1;
+
+ return (
+
+
onIndexChange(currentIndex - 1)}
+ aria-label="Previous step"
+ >
+ Prev
+
+
+
onIndexChange(Number(e.target.value))}
+ aria-label="Step scrubber"
+ />
+
+ {/* Step dots — only show up to 30 to avoid overflow */}
+ {steps.length <= 30 && (
+
+ {steps.map((step, i) => (
+
onIndexChange(i)}
+ title={`Step ${i + 1}: ${step.nodeName || step.nodeType} (${step.status})`}
+ />
+ ))}
+
+ )}
+
+
+ Step {currentIndex + 1} / {steps.length}
+
+
+
= maxIndex}
+ onClick={() => onIndexChange(currentIndex + 1)}
+ aria-label="Next step"
+ >
+ Next
+
+
+ );
+}
diff --git a/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/index.ts b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/index.ts
new file mode 100644
index 0000000..c2bf78c
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/index.ts
@@ -0,0 +1,18 @@
+/**
+ * ExecutionOverlay exports
+ *
+ * Canvas execution overlay for CF11-007.
+ * Visualizes workflow execution data directly on the node graph canvas.
+ */
+
+export { ExecutionOverlay } from './ExecutionOverlay';
+export type { ExecutionOverlayProps } from './ExecutionOverlay';
+
+export { ExecutionNodeBadge } from './ExecutionNodeBadge';
+export type { ExecutionNodeBadgeProps, NodeBounds } from './ExecutionNodeBadge';
+
+export { ExecutionDataPopup } from './ExecutionDataPopup';
+export type { ExecutionDataPopupProps } from './ExecutionDataPopup';
+
+export { ExecutionTimeline } from './ExecutionTimeline';
+export type { ExecutionTimelineProps } from './ExecutionTimeline';
diff --git a/packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts b/packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts
index f9bcafb..0fa9c14 100644
--- a/packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts
+++ b/packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts
@@ -42,6 +42,7 @@ import { initBlocklyEditorGlobals } from '../utils/BlocklyEditorGlobals';
import DebugInspector from '../utils/debuginspector';
import { rectanglesOverlap, guid } from '../utils/utils';
import { ViewerConnection } from '../ViewerConnection';
+import { ExecutionOverlay } from './CanvasOverlays/ExecutionOverlay';
import { HighlightOverlay } from './CanvasOverlays/HighlightOverlay';
import { CanvasTabs } from './CanvasTabs';
import CommentLayer from './commentlayer';
@@ -242,6 +243,7 @@ export class NodeGraphEditor extends View {
toolbarRoots: Root[] = [];
titleRoot: Root = null;
highlightOverlayRoot: Root = null;
+ executionOverlayRoot: Root = null;
canvasTabsRoot: Root = null;
editorBannerRoot: Root = null;
@@ -945,6 +947,11 @@ export class NodeGraphEditor extends View {
this.renderEditorBanner();
}, 1);
+ // Render the execution overlay (CF11-007)
+ setTimeout(() => {
+ this.renderExecutionOverlay();
+ }, 1);
+
this.relayout();
this.repaint();
@@ -1094,6 +1101,49 @@ export class NodeGraphEditor extends View {
}
}
+ /**
+ * Render the ExecutionOverlay React component (CF11-007)
+ *
+ * Mounts into #execution-overlay-layer. The React component manages its own
+ * pinned-execution state via EventDispatcher ('execution:pinToCanvas').
+ * We re-render on every pan/zoom so the viewport prop stays current.
+ */
+ renderExecutionOverlay() {
+ const overlayElement = this.el.find('#execution-overlay-layer').get(0);
+ if (!overlayElement) {
+ console.warn('[ExecutionOverlay] #execution-overlay-layer not found in DOM');
+ return;
+ }
+
+ if (!this.executionOverlayRoot) {
+ this.executionOverlayRoot = createRoot(overlayElement);
+ }
+
+ const panAndScale = this.getPanAndScale();
+ const viewport = {
+ x: panAndScale.x,
+ y: panAndScale.y,
+ zoom: panAndScale.scale
+ };
+
+ this.executionOverlayRoot.render(
+ React.createElement(ExecutionOverlay, {
+ viewport,
+ getNodeBounds: this.getNodeBounds
+ })
+ );
+ }
+
+ /**
+ * Update the execution overlay with new viewport state.
+ * Called whenever pan/zoom changes (same cadence as updateHighlightOverlay).
+ */
+ updateExecutionOverlay() {
+ if (this.executionOverlayRoot) {
+ this.renderExecutionOverlay();
+ }
+ }
+
/**
* Set canvas visibility (hide when Logic Builder is open, show when closed)
*/
@@ -3332,6 +3382,7 @@ export class NodeGraphEditor extends View {
this.panAndScale = panAndScale;
this.commentLayer && this.commentLayer.setPanAndScale(panAndScale);
this.updateHighlightOverlay();
+ this.updateExecutionOverlay();
}
clampPanAndScale(panAndScale: PanAndScale) {
diff --git a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/ExecutionHistoryPanel.tsx b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/ExecutionHistoryPanel.tsx
index e90596f..b83c601 100644
--- a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/ExecutionHistoryPanel.tsx
+++ b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/ExecutionHistoryPanel.tsx
@@ -1,19 +1,24 @@
-import React, { useState } from 'react';
+import React, { useCallback, useState } from 'react';
+import { EventDispatcher } from '../../../../shared/utils/EventDispatcher';
import { ExecutionDetail } from './components/ExecutionDetail/ExecutionDetail';
import { ExecutionFilters } from './components/ExecutionFilters/ExecutionFilters';
import { ExecutionList } from './components/ExecutionList/ExecutionList';
import styles from './ExecutionHistoryPanel.module.scss';
+import { useExecutionDetail } from './hooks/useExecutionDetail';
import { type ExecutionFilters as FiltersState, useExecutionHistory } from './hooks/useExecutionHistory';
/**
- * CF11-006: Execution History Panel
+ * CF11-006/007: Execution History Panel
*
* Sidebar panel showing workflow execution history.
* Allows users to view past executions, inspect node data, and debug failures.
*
* Registered in router.setup.ts at order 8.8 (between backend-services and app-setup).
- * CF11-007 will add canvas overlay integration via the onPinToCanvas prop.
+ *
+ * CF11-007: "Pin to Canvas" button in ExecutionDetail fetches the full execution
+ * (with steps) and emits 'execution:pinToCanvas' via EventDispatcher so the
+ * ExecutionOverlay mounted in nodegrapheditor.ts can pick it up.
*/
export function ExecutionHistoryPanel() {
const [selectedExecutionId, setSelectedExecutionId] = useState
(null);
@@ -25,6 +30,19 @@ export function ExecutionHistoryPanel() {
const { executions, loading, error, refresh } = useExecutionHistory(filters);
+ // CF11-007: fetch full execution (with steps) and pin it to the canvas overlay
+ const { execution: detailExecution } = useExecutionDetail(selectedExecutionId);
+
+ const handlePinToCanvas = useCallback(
+ (executionId: string) => {
+ // detailExecution is already loaded by useExecutionDetail above
+ if (detailExecution && detailExecution.id === executionId) {
+ EventDispatcher.instance.emit('execution:pinToCanvas', { execution: detailExecution });
+ }
+ },
+ [detailExecution]
+ );
+
return (
@@ -41,7 +59,7 @@ export function ExecutionHistoryPanel() {
setSelectedExecutionId(null)}
- // onPinToCanvas will be wired in CF11-007
+ onPinToCanvas={handlePinToCanvas}
/>
) : (
diff --git a/packages/noodl-editor/tests/cloud/ExecutionOverlay.test.ts b/packages/noodl-editor/tests/cloud/ExecutionOverlay.test.ts
new file mode 100644
index 0000000..02766b7
--- /dev/null
+++ b/packages/noodl-editor/tests/cloud/ExecutionOverlay.test.ts
@@ -0,0 +1,261 @@
+/**
+ * CF11-007: Canvas Execution Overlay — Unit Tests
+ *
+ * Tests the pure utility functions used by the ExecutionOverlay components.
+ * React component rendering and EventDispatcher integration require a running
+ * Electron renderer — covered by manual testing.
+ */
+
+// ─── Utility functions under test (inlined to avoid React/IPC deps in test env) ───
+// Note: prefixed with 'overlay_' to avoid name collision with ExecutionHistoryPanel.test.ts
+// when both are imported into the same test bundle via tests/cloud/index.ts.
+
+function overlay_formatDuration(ms?: number): string {
+ if (ms === undefined || ms === null) return '';
+ if (ms < 1000) return `${ms}ms`;
+ return `${(ms / 1000).toFixed(1)}s`;
+}
+
+function statusIcon(status: string): string {
+ switch (status) {
+ case 'success':
+ return '✓';
+ case 'error':
+ return '✗';
+ case 'running':
+ return '…';
+ case 'skipped':
+ return '–';
+ default:
+ return '?';
+ }
+}
+
+interface ExecutionStep {
+ id: string;
+ executionId: string;
+ nodeId: string;
+ nodeType: string;
+ nodeName?: string;
+ stepIndex: number;
+ startedAt: number;
+ completedAt?: number;
+ durationMs?: number;
+ status: 'running' | 'success' | 'error' | 'skipped';
+ inputData?: Record;
+ outputData?: Record;
+ errorMessage?: string;
+}
+
+/**
+ * Build a nodeId → step map for steps up to and including currentStepIndex.
+ * Mirrors the useMemo logic in ExecutionOverlay.tsx.
+ */
+function buildNodeStepMap(steps: ExecutionStep[], currentStepIndex: number): Map {
+ const map = new Map();
+ for (const step of steps) {
+ if (step.stepIndex <= currentStepIndex) {
+ map.set(step.nodeId, step);
+ }
+ }
+ return map;
+}
+
+/**
+ * Calculate badge position (right edge of node + 4px, vertically centred).
+ * Mirrors the positioning logic in ExecutionNodeBadge.tsx.
+ */
+function badgePosition(bounds: { x: number; y: number; width: number; height: number }) {
+ return {
+ left: bounds.x + bounds.width + 4,
+ top: bounds.y + bounds.height / 2 - 10
+ };
+}
+
+/**
+ * Calculate popup position (right of badge area).
+ * Mirrors the positioning logic in ExecutionDataPopup.tsx.
+ */
+function popupPosition(bounds: { x: number; y: number; width: number; height: number }) {
+ return {
+ left: bounds.x + bounds.width + 60,
+ top: bounds.y
+ };
+}
+
+// ─── Test helpers ───
+
+function makeStep(overrides: Partial = {}): ExecutionStep {
+ return {
+ id: 'step-1',
+ executionId: 'exec-1',
+ nodeId: 'node-1',
+ nodeType: 'noodl.logic.condition',
+ stepIndex: 0,
+ startedAt: Date.now(),
+ status: 'success',
+ ...overrides
+ };
+}
+
+// ─── Tests: formatDuration ───
+
+describe('ExecutionOverlay — formatDuration', () => {
+ it('returns empty string for undefined', () => {
+ expect(overlay_formatDuration(undefined)).toBe('');
+ });
+
+ it('returns empty string for null', () => {
+ expect(overlay_formatDuration(null as unknown as number)).toBe('');
+ });
+
+ it('formats 0ms', () => {
+ expect(overlay_formatDuration(0)).toBe('0ms');
+ });
+
+ it('formats sub-second durations as ms', () => {
+ expect(overlay_formatDuration(250)).toBe('250ms');
+ expect(overlay_formatDuration(999)).toBe('999ms');
+ });
+
+ it('formats 1000ms as 1.0s', () => {
+ expect(overlay_formatDuration(1000)).toBe('1.0s');
+ });
+
+ it('formats multi-second durations', () => {
+ expect(overlay_formatDuration(3500)).toBe('3.5s');
+ expect(overlay_formatDuration(60000)).toBe('60.0s');
+ });
+});
+
+// ─── Tests: statusIcon ───
+
+describe('ExecutionOverlay — statusIcon', () => {
+ it('returns checkmark for success', () => {
+ expect(statusIcon('success')).toBe('✓');
+ });
+
+ it('returns cross for error', () => {
+ expect(statusIcon('error')).toBe('✗');
+ });
+
+ it('returns ellipsis for running', () => {
+ expect(statusIcon('running')).toBe('…');
+ });
+
+ it('returns dash for skipped', () => {
+ expect(statusIcon('skipped')).toBe('–');
+ });
+
+ it('returns question mark for unknown status', () => {
+ expect(statusIcon('unknown')).toBe('?');
+ });
+});
+
+// ─── Tests: buildNodeStepMap ───
+
+describe('ExecutionOverlay — buildNodeStepMap', () => {
+ it('returns empty map for empty steps', () => {
+ const map = buildNodeStepMap([], 0);
+ expect(map.size).toBe(0);
+ });
+
+ it('includes steps up to and including currentStepIndex', () => {
+ const steps = [
+ makeStep({ nodeId: 'node-1', stepIndex: 0 }),
+ makeStep({ id: 'step-2', nodeId: 'node-2', stepIndex: 1 }),
+ makeStep({ id: 'step-3', nodeId: 'node-3', stepIndex: 2 })
+ ];
+
+ const map = buildNodeStepMap(steps, 1);
+ expect(map.size).toBe(2);
+ expect(map.has('node-1')).toBe(true);
+ expect(map.has('node-2')).toBe(true);
+ expect(map.has('node-3')).toBe(false);
+ });
+
+ it('includes all steps when currentStepIndex is at max', () => {
+ const steps = [
+ makeStep({ nodeId: 'node-1', stepIndex: 0 }),
+ makeStep({ id: 'step-2', nodeId: 'node-2', stepIndex: 1 }),
+ makeStep({ id: 'step-3', nodeId: 'node-3', stepIndex: 2 })
+ ];
+
+ const map = buildNodeStepMap(steps, 2);
+ expect(map.size).toBe(3);
+ });
+
+ it('excludes all steps when currentStepIndex is -1', () => {
+ const steps = [makeStep({ nodeId: 'node-1', stepIndex: 0 })];
+ const map = buildNodeStepMap(steps, -1);
+ expect(map.size).toBe(0);
+ });
+
+ it('last-write-wins when same nodeId appears multiple times', () => {
+ const steps = [
+ makeStep({ id: 'step-1', nodeId: 'node-1', stepIndex: 0, status: 'running' }),
+ makeStep({ id: 'step-2', nodeId: 'node-1', stepIndex: 1, status: 'success' })
+ ];
+
+ const map = buildNodeStepMap(steps, 1);
+ expect(map.size).toBe(1);
+ expect(map.get('node-1')!.status).toBe('success');
+ });
+
+ it('preserves step data in the map', () => {
+ const step = makeStep({
+ nodeId: 'node-42',
+ stepIndex: 0,
+ status: 'error',
+ errorMessage: 'Something went wrong',
+ durationMs: 150
+ });
+
+ const map = buildNodeStepMap([step], 0);
+ const result = map.get('node-42');
+ expect(result).toBeDefined();
+ expect(result!.status).toBe('error');
+ expect(result!.errorMessage).toBe('Something went wrong');
+ expect(result!.durationMs).toBe(150);
+ });
+});
+
+// ─── Tests: badgePosition ───
+
+describe('ExecutionOverlay — badgePosition', () => {
+ it('positions badge to the right of the node', () => {
+ const bounds = { x: 100, y: 200, width: 120, height: 40 };
+ const pos = badgePosition(bounds);
+ expect(pos.left).toBe(224); // 100 + 120 + 4
+ });
+
+ it('vertically centres the badge on the node', () => {
+ const bounds = { x: 100, y: 200, width: 120, height: 40 };
+ const pos = badgePosition(bounds);
+ expect(pos.top).toBe(210); // 200 + 40/2 - 10
+ });
+
+ it('handles zero-position nodes', () => {
+ const bounds = { x: 0, y: 0, width: 80, height: 30 };
+ const pos = badgePosition(bounds);
+ expect(pos.left).toBe(84); // 0 + 80 + 4
+ expect(pos.top).toBe(5); // 0 + 30/2 - 10
+ });
+});
+
+// ─── Tests: popupPosition ───
+
+describe('ExecutionOverlay — popupPosition', () => {
+ it('positions popup further right than the badge', () => {
+ const bounds = { x: 100, y: 200, width: 120, height: 40 };
+ const badge = badgePosition(bounds);
+ const popup = popupPosition(bounds);
+ expect(popup.left).toBeGreaterThan(badge.left);
+ });
+
+ it('aligns popup top with node top', () => {
+ const bounds = { x: 100, y: 200, width: 120, height: 40 };
+ const pos = popupPosition(bounds);
+ expect(pos.top).toBe(200);
+ });
+});
diff --git a/packages/noodl-editor/tests/cloud/index.ts b/packages/noodl-editor/tests/cloud/index.ts
index a43104b..8ec5850 100644
--- a/packages/noodl-editor/tests/cloud/index.ts
+++ b/packages/noodl-editor/tests/cloud/index.ts
@@ -1,2 +1,3 @@
export * from './cloudformation';
import './ExecutionHistoryPanel.test';
+import './ExecutionOverlay.test';