Pipeline Editor UI Specification
Status: v1.17 (revised — see Change Log) Owner: datapipelines.co core Depends on: Pipeline Contract, REST API + SSE, Type System, Enums, Auth, Configuration, @acme/design-tokens Design System Last updated: 2026-09-17
1. Purpose
The Pipeline Editor is the primary human-facing screen of datapipelines.co. It renders a pipeline as an interactive DAG visualization (nodes + edges), shows its metadata (settings, parameters, datasources), lets the user execute it, and shows real-time execution progress via SSE — highlighting each node as it runs, succeeds, or fails.
This spec defines:
- The page architecture (server-rendered shell + client-side interactivity).
- Technology stack (Thymeleaf + Alpine.js + Cytoscape.js + @acme/design-tokens +
fetch). - Graph rendering (Cytoscape.js with dagre layout).
- Node states and visual styles (idle, running, success, failed, aborted).
- SSE event handling → graph updates, including connection loss (§15).
- Execute button flow.
- Error display and result delivery.
- JavaScript vendoring strategy (no CDN, no build step).
- Accessibility and keyboard navigation (canvas graph + parallel DOM node list).
Graph authoring is out of scope: v1 pipelines are authored by LLMs via MCP or by direct JSON editing. The editor is a visualization + execution surface (§11).
2. Design Principles
- Hybrid rendering. Thymeleaf renders the page shell + initial data on the server. Client-side JS (Alpine.js + vanilla) handles interactivity. No SPA framework, no build step.
- Graceful degradation, stated honestly. The editor requires JavaScript for its core function. Without JS the server-rendered shell still shows pipeline metadata (name, version, settings, parameters) and a plain
<ul>of node ids with their declared type, source, template anddependsOn— the same list §14 renders for assistive technology. There is no graph (Cytoscape draws to a<canvas>), no execution, no result panel, no error modal. A<noscript>block states this. We do not claim the page "works without JavaScript". - No CDN, no build step. All JS libraries (Cytoscape.js, cytoscape-dagre, Alpine.js) and the design system CSS vendored as static files under
/vendor/. Per the project's no-CDN rule. - Design system is the styling foundation. All colors, spacing, typography, shadows, and radii come from
@acme/design-tokens. No hardcoded hex values anywhere — not in CSS, not in Cytoscape styles, not in Thymeleaf templates. The design system's semantic tokens (--surface-*,--text-*,--accent-*, etc.) are the single source of truth. See §3.4. - One page, three panels. Left sidebar (settings + parameters), center (graph), right panel (node details — slides in on node click).
- SSE drives the graph. When the user clicks Execute, the page opens an SSE connection. Every event updates the graph in real-time. No polling.
- Cytoscape's class-based styling is the status mechanism. Adding/removing CSS classes on nodes (
running,success,failed,aborted) drives all visual state changes. No manual style manipulation. - Readable identifiers everywhere. Node IDs in the graph match node IDs in the pipeline JSON (
fetch_orders, notnode_1). Users can correlate graph ↔ JSON ↔ logs.
3. Technology Stack
3.1 Server-side
| Technology | Role |
|---|---|
| Thymeleaf | Renders the page shell, pipeline metadata, settings/parameters forms, error fragments. |
| Spring MVC | Controller serving GET /pipelines/{id}/editor. |
3.2 Client-side
| Technology | Version (pin at impl time) | Role |
|---|---|---|
| Alpine.js | 3.x | Reactive data binding for UI state (execute button state, modal visibility, parameter form values, error display). |
| Cytoscape.js | 3.34.0 | Graph rendering + interaction (pan, zoom, click, node selection). |
| cytoscape-dagre | matching extension version | DAG auto-layout (left-to-right, levels by topology). |
Native fetch API + ReadableStream |
browser-built-in | REST calls (execute, fetch details, result cursor) and SSE consumption — the execute endpoint is a POST, which EventSource cannot issue, so the stream is parsed manually from the response body. See §7.3. |
EventSourceis deliberately not in this stack. Every SSE consumer in the editor isfetch+ReadableStream(§7.3).
3.3 What we explicitly do NOT use
- No React, Vue, or Svelte. The editor is one page; Alpine.js is sufficient. Adding a SPA framework would require a build step (Vite/Webpack), npm dependency tree, and bundle management — friction that doesn't pay for itself on a single page.
- No htmx for the editor. htmx is great for the rest of the UI (datasource list, template browser, execution history) where server-rendered partials swap in. The editor's core interactivity (graph updates from SSE) is client-side, not HTML-swap-driven. fetch + direct Cytoscape API calls are the right tool.
- No CDN. All libraries and the design system vendored locally (per the project's hard rule).
- No npm/build step. All libraries used via their UMD/browser builds, included via
<script>tags. The design system is pure CSS — no build step either.
3.4 Design System (@acme/design-tokens)
The entire UI — not just the editor — uses @acme/design-tokens (v0.2.0+) as the styling foundation. This is a framework-agnostic, CSS-custom-property-based design system with:
- Semantic tokens (
tokens.css): the contract. Variables like--surface-default,--text-primary,--accent-primary,--radius-base,--space-4. Never contains concrete values. - 9 swappable themes (
themes/*.css): concrete values per theme. Switch one file → re-skin the entire app at runtime. - Primitives (
primitives.css): ~80 ready-to-use component classes (.ds-button,.ds-card,.ds-input,.ds-badge,.ds-table,.ds-modal, etc.). - Base reset (
base.css): global typography + reset. - Motion (
motion.css): animations + keyframes, allprefers-reduced-motionaware.
The active theme is resolved per request, not fixed at deployment: ${activeTheme} = users.theme_preference ?: datapipelines.ui.theme — the authenticated user's stored preference when set (UI Screens §4.11), otherwise the deployment default from Configuration §3.10 (name, default and valid theme list defined once there, not restated here). The controller passes the resolved value to the template as ${activeTheme} (§4.2); both sources are validated against the vendored theme list (config at startup, preference on write), so the editor may assume it names a vendored theme file. The runtime theme-swap mechanism (Appendix A.5's readDesignTokens() re-read) is unaffected by which source won.
Design system CSS load order (in every page's <head>, before app CSS):
<link rel="stylesheet" href="/vendor/design-system/tokens.css">
<link rel="stylesheet" href="/vendor/design-system/themes/saas.css" id="theme-link">
<link rel="stylesheet" href="/vendor/design-system/base.css">
<link rel="stylesheet" href="/vendor/design-system/motion.css">
<link rel="stylesheet" href="/vendor/design-system/primitives.css">
<link rel="stylesheet" href="/css/app.css"> <!-- app-specific, LAST -->
app.css loads LAST so its rules win equal-specificity ties by document order: its body rule re-asserts background-color: var(--surface-page); color: var(--text-primary) (born as the guard against Bootstrap's reboot painting <body> white under every theme, 024 T40 — Bootstrap itself was removed in 076 §C, and the element defaults its reboot silently provided are now owned by base.css plus the "076 §C" section at the end of app.css). Every theme file, dark.css included, opens on :root: the swap loads exactly one file, so a theme takes effect by being loaded — no data-theme attribute is involved anywhere.
Theme switching at runtime: swap the href of #theme-link. All tokens cascade instantly — no page reload.
Design system rules we follow strictly:
- Never use hardcoded hex values in CSS, Thymeleaf templates, or Cytoscape styles. Always reference tokens.
- Never edit
tokens.cssor theme files (they are vendored, not forked). - App-specific semantic tokens (e.g.,
--node-running-accent) are defined inapp.cssand derive from design system tokens. - Use
.ds-*primitives wherever they fit (buttons, inputs, cards, badges, tables, modals). Override or extend only when the primitive doesn't fit.
Bridging design system tokens → Cytoscape styles:
Cytoscape uses its own style format (not CSS). We bridge by reading computed CSS custom properties at init time:
function readDesignTokens() {
const cs = getComputedStyle(document.documentElement);
// Every key falls back to a hard hex so a stale theme file cannot blank the graph
// (fallbacks elided here). The keys are exactly those §5.3 references.
return {
// Node card (§5.3)
nodeSurface: cs.getPropertyValue('--node-surface').trim(),
nodeBorder: cs.getPropertyValue('--node-border').trim(),
nodeLabelText: cs.getPropertyValue('--node-label-text').trim(),
// Selection (§5.3)
nodeSelectedRing: cs.getPropertyValue('--node-selected-ring').trim(),
nodeSelectedHalo: cs.getPropertyValue('--node-selected-halo').trim(),
// State accents (§6.2) — success/failed/aborted fall back to the banner's
// --node-*-bg tokens so a theme overriding those re-themes both surfaces
nodeRunningAccent: cs.getPropertyValue('--node-running-accent').trim(),
nodeSuccessAccent: cs.getPropertyValue('--node-success-accent').trim(),
nodeFailedAccent: cs.getPropertyValue('--node-failed-accent').trim(),
nodeAbortedAccent: cs.getPropertyValue('--node-aborted-accent').trim(),
// Edges
edgeIdleStroke: cs.getPropertyValue('--edge-idle-stroke').trim(),
edgeActiveStroke: cs.getPropertyValue('--edge-active-stroke').trim(),
};
}
When the theme changes at runtime, the graph re-reads tokens and re-applies the Cytoscape stylesheet. See Appendix A.5.
4. Page Architecture
4.1 URL
GET /pipelines/{id}/editor
GET /pipelines/{id}/versions/{version}/editor (specific version)
Authentication: session cookie carrying the internal JWT (browser flow). See Auth §6. Required scope per the authoritative matrix in Auth §7.6: read to view, execute to run, execute to cancel.
4.2 Server-rendered HTML structure
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<title>Pipeline Editor — <span th:text="${pipeline.displayName}">Name</span></title>
<!-- Design System (load order per @acme/design-tokens spec) -->
<link rel="stylesheet" href="/vendor/design-system/tokens.css">
<link rel="stylesheet" th:href="@{/vendor/design-system/themes/{theme}.css(theme=${activeTheme})}"
id="theme-link">
<link rel="stylesheet" href="/vendor/design-system/base.css">
<link rel="stylesheet" href="/vendor/design-system/motion.css">
<link rel="stylesheet" href="/vendor/design-system/primitives.css">
<!-- App CSS (uses design system tokens; defines app-specific semantic tokens) -->
<link rel="stylesheet" href="/css/app.css">
<link rel="stylesheet" href="/css/pipeline-editor.css">
</head>
<body>
<!-- Top bar: pipeline name, version selector, actions -->
<header class="ds-header editor-topbar">
<h1 class="ds-h2" th:text="${pipeline.displayName}">Pipeline Name</h1>
<span class="ds-badge ds-badge--neutral" th:text="'v' + ${pipeline.version}">v3</span>
<select id="version-selector" class="ds-select ds-select--sm" x-data x-model="selectedVersion"
@change="window.location.href = `/pipelines/${pipelineId}/versions/${selectedVersion}/editor`">
<option th:each="v : ${versions}" th:value="${v.version}"
th:text="'v' + ${v.version}"
th:selected="${v.version == pipeline.version}"></option>
</select>
<button id="execute-btn" class="ds-button ds-button--primary"
x-data="{ running: false }"
@click="executePipeline()"
:disabled="running"
:aria-busy="running"
x-text="running ? 'Executing...' : 'Execute'">Execute</button>
<!-- Cancel: DELETE /executions/{id} (§15.2). Shown only while a stream is open. -->
<button id="cancel-btn" class="ds-button ds-button--secondary"
x-data="{ running: false, cancelling: false }"
x-show="running" @click="cancelExecution()"
:disabled="cancelling"
x-text="cancelling ? 'Cancelling...' : 'Cancel'">Cancel</button>
</header>
<!-- Main layout: sidebar | graph | details -->
<main class="editor-layout">
<!-- Left sidebar: settings + parameters -->
<aside class="ds-sidebar editor-sidebar">
<section class="ds-section settings-panel">
<h2 class="ds-h4">Settings</h2>
<div th:replace="~{fragments/settings :: body(${pipeline.settings})}"></div>
</section>
<section class="ds-section parameters-panel">
<h2 class="ds-h4">Parameters</h2>
<form id="parameter-form" class="ds-form">
<div class="ds-field" th:each="param : ${pipeline.parameters}">
<label class="ds-label" th:for="${param.key}"
th:text="${param.key + (param.required ? ' *' : '')}">
param_name
</label>
<input class="ds-input" th:type="${inputType(param.value.type)}"
th:id="${param.key}"
th:name="${param.key}"
th:required="${param.required}"
th:value="${param.value.default}">
</div>
</form>
</section>
</aside>
<!-- Center: graph -->
<section class="editor-graph">
<!-- Cytoscape draws into a <canvas> inside #cy: no per-node DOM exists. -->
<div id="cy" role="img" th:attr="aria-label=${graphSummary}"
aria-describedby="node-list">Pipeline graph</div>
<!-- Parallel accessible node list (§14). Server-rendered, so it is also the
no-JS fallback. Visually hidden until focused; statuses updated by the
same SSE handler that styles the canvas. -->
<ul id="node-list" class="visually-hidden-until-focus" role="listbox"
aria-label="Pipeline nodes" tabindex="0">
<li th:each="n : ${pipeline.nodes}" role="option"
th:id="'node-item-' + ${n.id}"
th:attr="data-node-id=${n.id},aria-selected=false"
th:text="${n.id} + ' — ' + ${n.type} + ' — idle'">node_id — DQL — idle</li>
</ul>
<!-- Status announcements for AT; SSE handler writes one sentence per transition. -->
<div id="graph-status" class="visually-hidden" role="status" aria-live="polite"></div>
<noscript>
<p class="ds-text">This editor needs JavaScript for the graph, execution and
results. Without it you can read the pipeline metadata and the node list above.</p>
</noscript>
<div class="graph-controls">
<button class="ds-button ds-button--ghost ds-button--sm" onclick="window.editor.fit()">Fit</button>
<button class="ds-button ds-button--ghost ds-button--sm" onclick="window.editor.zoomIn()">+</button>
<button class="ds-button ds-button--ghost ds-button--sm" onclick="window.editor.zoomOut()">−</button>
</div>
</section>
<!-- Right: node details (hidden by default, slides in on node click) -->
<aside class="ds-card editor-details" id="node-details"
x-data="{ visible: false }" x-show="visible" x-transition>
<!-- Populated dynamically on node click -->
</aside>
</main>
<!-- Error modal (uses .ds-modal primitive).
The window-level `show-error` listener is what makes §9.1's dispatch do anything —
ErrorModal.show() dispatches on `window`, Alpine catches it here. -->
<div class="ds-modal-backdrop" id="error-modal"
x-data="{ visible: false, error: null }"
x-on:show-error.window="error = $event.detail; visible = true"
x-on:keydown.escape.window="visible = false"
x-show="visible" x-transition.opacity>
<div class="ds-modal" role="alertdialog" aria-modal="true"
aria-labelledby="error-modal-title" @click.away="visible = false">
<div class="ds-modal__header">
<h2 class="ds-h4 ds-text--danger" id="error-modal-title">Execution Failed</h2>
<button class="ds-button ds-button--ghost ds-button--sm" @click="visible = false">×</button>
</div>
<div class="ds-modal__body">
<p class="ds-text" x-text="error?.userMessage"></p>
<pre class="ds-code-block" x-text="error?.details"></pre>
<a x-show="error?.docUrl" :href="error?.docUrl" class="ds-link" target="_blank">View documentation</a>
</div>
<div class="ds-modal__footer">
<button class="ds-button ds-button--secondary" @click="visible = false">Close</button>
</div>
</div>
</div>
<!-- Result preview panel (§10) — populated by result.js on data_ready -->
<aside class="ds-card editor-result" id="result-panel"
role="region" aria-label="Execution result"
x-data="{ visible: false }" x-show="visible" x-transition></aside>
<!-- Connection-loss banner (§15.1) — non-blocking, above the graph -->
<div class="ds-banner ds-banner--warning" id="connection-banner"
role="status" x-data="{ visible: false, text: '' }" x-show="visible"
x-on:connection-lost.window="text = $event.detail.text; visible = true"
x-text="text"></div>
<!-- Pipeline JSON embedded for client-side consumption -->
<script type="application/json" id="pipeline-data" th:utext="${pipelineJson}">
{}
</script>
<!-- Vendored libraries -->
<script src="/vendor/cytoscape/cytoscape.min.js"></script>
<script src="/vendor/cytoscape/cytoscape-dagre.js"></script>
<script src="/vendor/alpinejs/alpine.min.js" defer></script>
<!-- Editor logic -->
<script src="/js/pipeline-editor/node-ops.js"></script>
<script src="/js/pipeline-editor/graph.js"></script>
<script src="/js/pipeline-editor/sse.js"></script>
<script src="/js/pipeline-editor/execute.js"></script>
<script src="/js/pipeline-editor/details.js"></script>
<script src="/js/pipeline-editor/error.js"></script>
<script src="/js/pipeline-editor/result.js"></script>
<script src="/js/pipeline-editor/a11y.js"></script>
<script src="/js/pipeline-editor/init.js"></script>
</body>
</html>
The page also loads /js/pipeline-editor/sql-highlight.js (§8.3) and issues one additional partial request at runtime: GET /partials/pipelines/{id}/nodes/{nodeId}/sql (scope READ_RESOURCES) fills the details panel's SQL section via htmx.ajax on node selection — route, wire format and states in §8.3.
4.3 Layout dimensions
The editor is full-bleed (065 §A). layouts/default.html wraps every page in
<main class="app-container app-main"> and app.css caps .app-container at
--app-content-max: 1600px. That measure is right for prose — lists, settings, docs —
and wrong for the one surface whose value scales with width: on a ~2000px viewport the
editor occupied ~1450px between two dead margins, while the graph was the thing paying
for the cap. A page opts out with a single model attribute:
PipelineEditorControllersetsmodel.addAttribute("fullBleed", true). No other page sets it, andth:classappendadds nothing on a null condition, so every other screen keeps the 1600px cap by default.layouts/default.html:<main class="app-container app-main" th:classappend="${fullBleed} ? 'app-main-bleed' : null">.app.css:.app-main-bleed { max-width: none; padding-inline: var(--gap-md); }— tokens only.- The nav bar keeps its own
.app-containercap. A nav wider than its content reads as broken, and a full-bleed nav was never asked for. - Pinned by
EditorLayoutRenderTest: the editor's<main>carriesapp-main-bleed, the pipelines list's does not. - Prior art, not unified: the template explorer un-caps itself from its own stylesheet
(
template-tree.css:.app-container:has(> .tplx-page) { max-width: none; }, 059 §C′) and keeps the--gap-lggutter. The model attribute is the mechanism to reuse — it is declared by the controller, visible in the rendered<main>, and testable without a stylesheet.
┌──────────────────────────────────────────────────────────────────────────────┐
│ Toolbar: ← Back | Name | [draft badge] [Release] [Discard] [Execute] │ 48px
├──────────┬───────────────────────────────────────────────────────────────────┤
│ │ ┌────────┴──┐
│ Settings │ │ Node │
│ │ Graph (Cytoscape) │ details │
│ Parameters│ │ (overlay, │
│ │ [#cy-canvas, fills the pane] │ scrim on │
│ │ │ canvas │
│ 280px │ 1fr │ only) │
│ │ │ clamp(720,│
│ │ │ 60%,1200)│
├──────────┴──────────────────────────────────────────────────────────┴─────────┤
│ [Results] [Errors ②] [▁] │ header
│ …body… (open: ≤ 40vh; minimized: the header row alone) │
└───────────────────────────────────────────────────────────────────────────────┘
The three panes' dimensions, all in one place:
| Surface | Dimension | Source |
|---|---|---|
| Page | no max-width; padding-inline: var(--gap-md) |
.app-main-bleed (§4.3 above) |
.pe-root height |
calc(100dvh - var(--header-height) - var(--space-px) - (var(--gap-lg) * 2)), floor 540px |
unchanged since 041/059 |
| Left sidebar | --pe-sidebar-width: 280px |
unchanged |
| Canvas | 1fr — the layout is a two-track grid (sidebar + stage); the inspector's third track died with the 065 overlay, and the overlay itself died with 080 |
.pe-body |
| Dock, open | the mock's geometry: a 38px tab strip over a 232px pane (--pe-dock-tabs-h / --pe-dock-pane-h) |
.pe-dock |
| Dock, collapsed | the tab strip's own height — one row, tokens only | .pe-dock-collapsed |
| Node card | --pe-card-w: 236px wide; --pe-card-h: 148px is a min-height, not a height (082 addendum) — the card grows to its content and syncCardHeights() hands each measured height to Cytoscape. Both step to 272 / 170 with the card type scale +1px once the STAGE is ≥ 1200px wide (082 §A) |
§5.3 |
The dock is the last flex child of .pe-root, not a position: fixed overlay. That
is the whole mechanism behind "the canvas reclaims the rest": .pe-body is flex: 1,
so whatever the dock stops using, the graph gets. A fixed overlay can only ever cover the
canvas — it can never give the space back.
Responsive: below --breakpoint-lg (1024px) the sidebar collapses to a drawer and its
grid track goes to 0, and the Details pane's two columns stack. Below --breakpoint-md
(768px) the sidebar drawer is full-width and the minimap hides.
Layout dimensions come from design system tokens wherever a token exists. The pane
heights and the card box are the geometry itself, declared once as custom properties —
the same standing as --app-content-max (Appendix A).
5. Graph Rendering (Cytoscape.js)
5.1 Initialization
On page load, init.js reads the embedded pipeline JSON and initializes the graph:
// init.js
document.addEventListener('DOMContentLoaded', () => {
const pipelineData = JSON.parse(document.getElementById('pipeline-data').textContent);
window.editor = new PipelineGraph(pipelineData);
window.editor.render();
});
// graph.js
class PipelineGraph {
constructor(pipeline) {
this.pipeline = pipeline;
this.cy = null;
}
render() {
const elements = this.buildElements(this.pipeline);
const layout = this.layoutOptions();
this.cy = cytoscape({
container: document.getElementById('cy'),
elements: elements,
style: PIPELINE_GRAPH_STYLE, // §5.3
layout: layout,
wheelSensitivity: 0.2,
minZoom: 0.2,
maxZoom: 3.0,
});
this.wireEventHandlers(); // §5.4
}
buildElements(pipeline) {
// The caller node is the node resolving to output.target === 'caller' — declared, or
// by an omitted `output` block. At most one exists; zero is legal (pure ETL).
// See Pipeline Contract §9. NOT topology-derived.
const isCaller = n => n.type === 'DQL' && (!n.output || n.output.target === 'caller');
const nodes = pipeline.nodes.map(n => ({
data: {
id: n.id,
label: n.id,
nodeType: n.type, // DQL / DML / DDL
description: n.description,
source: n.source,
template: n.template,
output: n.output,
dependsOn: n.dependsOn,
isCaller: isCaller(n),
status: 'idle', // §6
},
// Classes MUST be set here — the §5.3 stylesheet selects on them and nothing
// else adds them later. `idle` is applied at build time so setNodeStatus()'s
// removeClass('idle running success failed aborted') stays symmetric (§6.4).
classes: [
'idle', // explicit — §6.2 symmetry
`type-${n.type.toLowerCase()}`, // type-dql | type-dml | type-ddl
// (PIPELINE nodes get `pipeline-node` instead)
...(isCaller(n) ? ['caller'] : []),
].join(' '),
}));
const edges = [];
pipeline.nodes.forEach(n => {
n.dependsOn.forEach(dep => {
edges.push({
data: {
id: `${dep}->${n.id}`,
source: dep,
target: n.id,
}
});
});
});
return [...nodes, ...edges];
}
// The layout options actually passed (v1.6, retuned for cards — 059 §B): a 264px
// card needs wider ranks and more breathing room than the 120×44 box ever did.
// fit is FALSE — fitToView() owns the fit so it can apply padding AND the
// readable-minimum floor ("three nodes should fill the pane"). marginX/marginY
// are NOT cytoscape-dagre options (they belong to grid/cose and are silently
// ignored); padding is the edge clearance.
layoutOptions() {
return {
name: 'dagre',
rankDir: 'LR', // left-to-right
nodeSep: 64, // vertical spacing between nodes at same rank
rankSep: 176, // horizontal spacing between ranks (card + curve)
edgeSep: 12,
padding: 40,
fit: false, // fitToView() applies the min-zoom floor
nodeDimensionsIncludeLabels: true, // the card box IS the node's whole box
};
}
}
5.2 Dagre layout choice
cytoscape-dagre with rankDir: 'LR' produces a left-to-right DAG layout where:
- Source nodes (no dependencies) are on the left.
- Sinks (nodes nothing depends on) are on the right. The caller node is wherever topology puts it — it carries the
.callerclass as its visual marker, not a fixed position (Pipeline Contract §9). A pipeline may have no caller node at all. - Topological levels are visually distinct columns.
- Edges flow left-to-right with arrowheads.
Alternatives if dagre doesn't fit a specific pipeline's shape:
rankDir: 'TB'— top-to-bottom (better for tall, narrow pipelines).cytoscape-elkwithelk.algorithm: 'layered'— more sophisticated layout for complex graphs.
v1 ships with dagre LR. The layout choice is configurable per pipeline in a future version.
5.3 Graph stylesheet — the node CARD (080, the v2 canvas)
The Cytoscape stylesheet reads design tokens at init time via readDesignTokens() (§3.4) and uses them throughout. No hardcoded hex values in the JS — every value resolves through a custom property with a hex fallback. When the theme changes, updateTheme() re-reads the tokens and re-applies the stylesheet without a page reload.
The 080 canvas (owner-approved mock 2026-09-05-editor-canvas-v2.html): the stage is a dotted grid (radial-gradient on --grid-dot, 22px, drawn on the stage so it stays put while the world pans over it), every node is the mock's card, edges are beziers with three states, and the chrome is the mock's: controls bottom-right, a minimap beside them, legend chips bottom-left, a keyboard hint top-left. The canvas tokens live in ONE block at the top of app.css (/* app tokens (080): node-type accents */): the five node-type accent pairs (--type-dql/-bg, --type-dml/-bg, --type-ddl/-bg, --type-pipeline/-bg, --type-calc/-bg), --brand, --brand-soft, --border-faint, --grid-dot, --edge, --edge-active, --edge-done. Tokens the mock derived from the design system BRIDGE to it (and match its light and dark hexes exactly); the rest are color-mix bridges, so all nine themes re-skin the canvas, not just light/dark. 079's shell v2 is told the block exists and must not redeclare it.
Every node is a 236px rectangular card — 272px on a wide stage (082 §A, below) — and as TALL as its content, --pe-card-h being a floor rather than a height (082 addendum, below). Inside it, top to bottom:
- The head — an icon tile (34px rounded square) washed in the node type's accent pair (
--typeon--type-bg, set inline per card), the node id (semibold, one line, right-edge ellipsis), and the type eyebrow (11px uppercase,.06emtracking, in the type colour). The tile's glyph is the card's ONE glyph (059b's rule survives):db/table/boxes/workflow/filefrom the vendored sprite — generic, never a vendor logo; the engine's identity is the source fact's TEXT. - Up to three fact lines in mono (11.5px, a muted CSS bullet for a marker — the mock's small kind icons have no glyph in the fenced sprite, and reusing
#dbwould duplicate the DQL tile): source · dialect / template@ v/ output for SQL types;kind → context_keyand the input names for a CALCULATOR; child@ vand the parameter names for a PIPELINE. The dialect is resolved client-side from the workspace's datasource listing (the body is portable across environments, contract §11.1) and upgrades in place when the listing lands. Template paths truncate from the LEFT (the leaf identifies). - The footer — state dot + label (
Pending/Running…/Done/Failed/Aborted) on the left, the last run's numbers on the right (5 rows · 37 ms, tabular; a CALCULATOR counts what it wrote —1 key · 1 ms,2 keys · 2 ms— and never inlines the values, which are the Details pane's and the Events tab's: inlined, a two-key window wrapped the footer to three lines and floated the state dot mid-card, T251 2026-09-13). The run numbers are one line by CSS too (nowrap+ ellipsis on.pe-card-rt). The wire carriesduration_msandrows_outonly (SseEventProjection) — there is norows_in, so the mock'sin → outcollapses to the out count. Before any run the numbers are absent, never a placeholder. While the node runs (149) the footer is the MEASURED operation fromnode_progress(REST API §6.4.9) throughnode-ops.js(§10.7): the state word on the left —Connecting to source,Querying(Querying and materializing (one statement)for a tempdb CTAS,Child execution running),Fetching,Waiting for tempdb/Waiting for <datasource>,Writing,Finalizing/Committing— taggedpe-card-op-<state>so the dot is amber and still while waiting for an output connection and green while writing; the live cumulative count on the right (11,000 written, else12,000 fetched). Both slots stay one line (nowrap+ ellipsis on.pe-card-statetoo). The destination is not repeated in the footer — it is the card's own output fact line. A terminal node drops the live line and shows the plain word and the run numbers; a node whose operation was never sampled showsRunning…exactly as before. No percentage anywhere: no operation knows its total.
§5.3a The output port (151, #127). Where the output fact line used to be, a node with a configured output carries ONE port row: a short stub ending in the port dot, the destination in the card's mono voice (tempdb.stg, warehouse.facts, caller), the operation's kind word once measured, and the write's state on the right. outputPortFor(node) mirrors the server's own node-shape → destination mapping (NodeOperations.operationFor) so the idle port and the measured destination name the same thing: a DQL's output block (omitted = caller, contract §4.7) — stage from a source, one statement for a tempdb CTAS whose write is inside the statement, write-back to a datasource, result for the caller; a DML statement writes INTO its source and names no table (no SQL lineage is inferred); a PIPELINE node's port is the output it declared for the child's caller rows (child result). DDL, an output-less PIPELINE and a CALCULATOR have no port — nothing they do is a row write; a calculator's context keys are its fact line. The states are 149's reducer through describe().port (node-ops.js), never estimated from the node's status: idle (destination only) → pending while the source is queried or fetched (the footer says Querying/Fetching; the port says nothing until a batch lands, then N written) → waiting for tempdb connection (amber, still — waiting_output) → writing · N written (the success accent and the only moving indicator on the canvas, a gradient sliding along the stub — .pe-port-flow, CSS, stopped by prefers-reduced-motion) → committing / finalizing (a soft ring pulse, no flow) → committed · N rows (0 rows included), or N written · commit not observed when the terminal sample carried no commit evidence or a node_completed closed the operation without one, or failed · rolled back / failed · N written · commit not observed / aborted · …; a confirmed commit survives an aborted node (R149-1). A done port plays ONE settle ring (pe-port-settle, 0.7 s) so a half-second write still registers after the fact. A lost stream (markStreamLost) marks every card stale: the last words stand, "— stream lost" is appended, nothing moves, no outcome is invented; the recovery poll then settles End from the polled status and closes every open operation as not observed (settlePolledOutcome). The port's aria-label is its whole meaning in words (Output to tempdb.stg: writing, 11,000 written); the a11y node list keeps 149's operation description; the Details pane gains Depends on (each ordering with its source's state) and Required by rows, and tapping an arrow announces by_a depends on stg — ordering only; stg is done and selects the waiting node. The legend's .pe-legend-links chips — Depends on and Output write — are not aria-hidden: they are the graph's key. graph-output-port.test.mjs pins the mapping, the states and the card; PipelineEditorArrowClarityBrowserTest proves one port with committed · 900,000 rows against two unlabelled dependencies on a real run; PipelineEditorLeaseWaitBrowserTest makes a real wait happen (one staging connection, two writers) and reads it off the port.
§5.3b The execution boundaries (150, redesigned by 151 / #144). Start and End are derived, view-only elements (kind: "boundary", unselectable, never in editor.nodeStates, never authored) — Start connects to every root, every leaf to End — rendered by buildMarkerHtml as SHAPES: Start a 56px disc (64 on the wide stage) with the sprite's play, End a 56px rounded square with square, the word under the shape in the card's small type, inside a transparent 72px Cytoscape box (node.boundary, BOUNDARY_W) whose connectors leave the disc's right edge and enter the square's left edge (edge.boundary-start / edge.boundary-end). Start is the run trigger when the viewer may execute: canExecute is the same server-rendered flag that renders the toolbar's Execute, stamped on .pe-root as data-can-execute and read by init() — the graph never derives permissions. With it the disc is role="button", tabindex="0", aria-label="Start execution", filled in --accent-primary (the run button's own fill, --accent-primary-text glyph, --shadow-md), and wireMarkerActivation — one delegated click + keydown (Enter/Space) pair on the graph container, resolving the live component like the cards' open button — calls the SAME executePipeline() the toolbar calls while idle — and, while the run is active, the SAME cancelExecution() the toolbar's Cancel calls (159 / #148): the disc is then the Cancel control (.pe-marker-cancel, aria-label="Cancel execution", the word Cancel, the square glyph, the toolbar's --accent-danger fill under the running pulse, never aria-disabled), and it is Start again on any terminal state — one button, two verbs, the mirror of the toolbar's Execute → Running… + Cancel. Without the right (a render with canExecute=false) the disc is an outlined marker, role="img", no affordance, reading Running… while the run is active; in a browser session every workspace member may execute (D-R3), so viewers get the button. The press guard (159 / #148): the disc lives in the html-label layer inside the Cytoscape container, so a pointer press on it also reached Cytoscape's mousedown binding, which activate()d the marker node → a style event → cytoscape-node-html-label re-parsed the disc a setTimeout(0) later; a hand holds the button 50–150 ms, so the mouseup landed on a NEW element and the browser dispatched no click at all — the owner's "Start does nothing" (automation releases in ~2 ms and never saw it). wireMarkerActivation therefore stops mousedown / pointerdown / touchstart at the container in the CAPTURE phase for .pe-marker-run targets only (nothing prevented, so focus still lands on the disc; a tap on a marker selected nothing anyway). Focus survives the re-render: every state change re-parses the disc and the browser drops focus to <body> when the focused element is removed; keepMarkerFocus remembers the focused disc (focusin), watches the canvas while it has focus, and hands focus to the replacement — only when focus fell to <body>, never when it moved on purpose (a focusout is classified one tick later: the disc gone by then was re-rendered, one still connected was a real blur). End is never a trigger: a muted outline while idle, then the authoritative outcome's fill and word — Finished / Failed / Stopped on pipeline_completed / pipeline_failed / execution_aborted (never a node event) — with the run clock's elapsed under it (.pe-marker-elapsed, durationText, from runStatus.startedAt; no clock, no claim) and aria-label="Execution end — finished in 2m 14s". The minimap paints the two as a disc and a square of their own box. #cy-canvas is role="group", not img: an image role makes its children presentational and would hide the Start button and the cards' open buttons from assistive tech. graph-markers.test.mjs pins the start/end × state × canExecute table (Start / Cancel while running / plain for a viewer who may not execute), the activation (execute while idle, cancel while running, each exactly once, red with the wiring removed), the press guard, the focus keeper, the elapsed plumbing and the minimap; PipelineEditorStartMarkerBrowserTest clicks the disc as a member, presses it the way a HAND does (down, a 150 ms hold, up — the input that reproduced #148; red without the press guard) to start and then to cancel a run (the execution recorded ABORTED, disc and toolbar agreeing at every step, focus kept on the disc), repeats the held press after a boosted arrival, and presses Enter on it as a viewer.
State encoding (the mock's). Running: brand border (canvas), a pulsing state dot and an indeterminate progress line (both pure CSS on the card, so prefers-reduced-motion stops them with one media query — the 059 JS border pulse is retired). Done: success dot, and the out-port turns --edge-done. Failed: danger border and footer. Hover: translateY(-2px) + --shadow-lg lift and the hover-only expand affordance — both CSS, both driven by a JS-toggled .pe-card-hover class because the label container is pointer-events: none and CSS :hover never fires (Cytoscape's own node mouseover/mouseout stand in). Selection: brand border over a full-opacity --brand-soft underlay padded 3px — the mock's 0 0 0 3px var(--brand-soft) ring; Cytoscape has no box-shadow, and the underlay paints BEHIND the card so nothing dims.
Edges — the mock's bezier, its three states, and the backward-edge detour (105). unbundled-bezier with per-edge control points computed after layout AND after every node drag (applyEdgeCurves, re-run on dragfree). Which curve an edge gets is edgeRouteFor's ruling, a pure function of the two card boxes: a port-to-port dx ≥ 60 takes the mock's bezier — offset max(60, dx/2), leaving the source port horizontally and entering the target port horizontally; a dx < 60, including EVERY backward edge, takes an orthogonal detour BELOW both cards — out right of the source port, down past the lower card's bottom edge, across at the detour depth (44px + 24px per additional backward edge into the same target, so parallel detours stagger), up, and in to the target port from its left. The detour exists because nodes are draggable and a user can put a dependent left of its source (the owner did, 2026-09-08): on a backward edge the forward formula loops the line over the cards and the arrowhead ends up behind one of them — and because the (weight, distance) control points are RELATIVE to the source→target vector, a drag without a recompute kept the old curve re-projected onto the reversed vector, which is the photographed arc. graph-edges.test.mjs owns the regimes; PipelineEditorEdgesBrowserTest owns the live guarantees (direction, endpoints ±4px, the detour under both cards, the arrow clear).
An edge is an ORDERING, never a transfer (151, #127). Every depends_on entry is one edge of kind: "dependency" (class dependency): "the target waits for the source to finish" — which is also what a dependency on a DDL node means, with no rows involved at all. Its states are STATIC facts about the two nodes it joins, set in setNodeState(): --edge at rest (2px, round caps); .active while the TARGET runs — --edge-active, dashed [6 8], still (the consumer is running and reading its inputs; nothing travels along the line); .satisfied once the SOURCE completed — --edge-done, arrow included (the ordering is met; the dependent may start); .unmet once the SOURCE failed or was aborted — the failed accent, short dashes [2 6] at 0.6 opacity (it cannot be met this run; legible without colour by its pattern). Nothing on the canvas moves along an edge: the pre-151 rAF loop that stepped line-dash-offset on active edges, and the .done (target ran) state, are retired — motion along a dependency read as rows moving along it, and one staged table feeding two consumers looked like two writes. No count ever rides an edge: setNodeStats used to copy the producer's rows_out onto every outgoing edge (edge.rows / rowLabel), so a node that wrote 10,000 rows once showed "10,000 rows" on each arrow out of it. The count stays on the producer — its footer and its output port (§5.3a), scoped to the operation that wrote it — and a consumer's count stays unknown until the consumer itself reports one. The mock's blurred glow path has no Cytoscape counterpart (no canvas filters); the wider 2.5px active stroke carries the emphasis instead. graph-dependency-edges.test.mjs falsifies both retired readings (a completing producer with two dependents labels neither edge; a starting consumer schedules no animation frame and touches no writing state on its producer).
The arrowhead is ≥ 8px at zoom 1 (105). Cytoscape's triangle scales at 4.35 model px per unit of arrow-scale (measured live on this stack's editor, 2026-09-09: 3.91px at 0.9, 8.70px at 2.0). The 080 value of 0.9 put the head at 3.9px — under the floor and near-invisible at a 0.27 fit zoom, which is the owner's "the line just disappears behind the node without giving any hint if it's connected to input (left) or output (right)". arrow-scale: 2 (the exported ARROW_SCALE) clears the floor and keeps the mock's head-to-card proportion.
Fit never zooms IN past 1.0. The 059 floor stays (FIT_MIN_ZOOM 0.75 — three nodes fill the pane); 080 adds the ceiling (FIT_MAX_ZOOM 1.0) — a one-node pipeline used to fit to 3×, which is the owner's "default zoom too big". clampFitZoom is pure; graph-stylesheet.test.mjs pins both ends.
The card scales with the stage, because the zoom may not (082 §A). The ceiling above is the
reason "at 2560 the cards read small" cannot be answered by fit: a wider window can only ever show
the same card with more empty canvas around it. So the CARD steps up instead. .pe-stage is a
container-type: inline-size query container; at a stage inline size of 1200px or more the
step-up lands on #cy-canvas — --pe-card-w: 272px, --pe-card-h: 170px and every
--pe-card-fs-* a pixel larger. It lands on the canvas rather than on the stage because a
container cannot be styled by its OWN query, and because readDesignTokens(containerId) reads the
geometry back from exactly that element: the Cytoscape node box and the HTML overlay step together
or not at all (reading documentElement, as it did before 082, would have returned the 236px base
for ever — a container query is invisible there). The threshold is on the STAGE, not the viewport,
because the same 2560px window has a different stage depending on the rail and the settings
sidebar. A resize that crosses the threshold while the page is open is caught by a ResizeObserver
on the stage: refreshCardMetrics() re-reads the tokens and, only if the box actually moved,
re-applies the stylesheet and re-runs the layout — dagre's separation is tuned to the card width.
card-scale.test.mjs pins the container read, the :root colour read that must NOT move, and the
only-when-changed contract.
Cytoscape is handed COLOURS, never token text (082 addendum P1). getComputedStyle(root) .getPropertyValue('--edge') returns a custom property's token stream VERBATIM, and four of the
canvas tokens above are color-mix() bridges (--brand-soft, --border-faint, --grid-dot,
--edge). Cytoscape parses colours itself, cannot read color-mix(in srgb, …), logs The style
property line-color: color-mix(…) is invalid and falls back — so the first paint was already
degraded and the re-render on a theme switch collapsed the edges into thick grey bands. Every
colour token is therefore resolved through a PROBE before the stylesheet is built: an
aria-hidden, zero-size span is appended to <body>, color: var(--token) is assigned to it, and
getComputedStyle(probe).color gives back the rgb(…) the browser itself computed — var()
substitution, the mix, and the theme in force, all done by the engine that owns the rules. One
probe per read, removed before the function returns. The fallback contract is unchanged: an
UNDECLARED token still yields the mock's light hex, so a stale theme file degrades rather than
blanking the graph.
And what the browser answers is not always rgb(). Measured on Chrome 148 against the live
editor (2026-09-07): a color-mix() token computes to CSS Color 4's color(srgb 0.412745 0.436078 0.480392), which Cytoscape parses no better than the color-mix it came from. The
first cut of this fix accepted only strings beginning rgb, fell back to the raw token, and left
the defect in place with a green unit test. toLegacyRgb normalises the color(srgb …) form; a
1x1 canvas sample stands behind it for any syntax it does not know, and the raw token remains the
last resort.
The theme swap needed a second fix, and it is the one the owner actually saw. updateTheme()
re-applied with cy.style(array) — which does NOT replace a live graph's stylesheet. It resets the
whole style to Cytoscape's DEFAULTS. Measured after one call: line-color #999, edge width
30px, node background-color #999 — "the re-render on a theme switch collapses edges into thick
grey bands", exactly. The re-apply is cy.style().fromJson(sheet).update()
(applyStylesheet()), whose one condition is that the sheet be JSON: fromJson silently drops
every function value, which is why the node height in the stylesheet is a plain token and the
measured per-node height is an element BYPASS (below). A guard in card-height.test.mjs fails the
build if any stylesheet entry becomes a function again. 080's wheelSensitivity: 0.3 is gone with
it — Cytoscape warns on every init that a custom sensitivity is unsupported, and the ± controls are
the supported way to change the feel. graph-colour-tokens.test.mjs pins the resolution, the
fallback and the absent sensitivity; a browser test switches the theme on a live editor and demands
zero complaints plus an edge colour that both parses and CHANGED.
The card sizes to its content, and the node box follows it (082 addendum P1). --pe-card-h was
a fixed height, and a card with three fact lines overflowed it: the footer painted below the
node's own border. .pe-card takes it as a min-height now. That alone would only move the defect
— the Cytoscape node underneath paints the chrome (surface, border, selection ring) and is the
anchor whose vertical centre the edge ports sit at, so a card taller than its node comes apart from
its own frame. syncCardHeights() therefore measures each rendered card after layout and writes
the height onto its node twice: as cardH DATA (what the minimap and the model read) and as an
element style BYPASS (what Cytoscape paints). The stylesheet's height stays the plain token — the
floor — because a function value there does not survive fromJson(), and a height that holds until
the first theme switch and then collapses every node to a 30px box is worse than none. offsetHeight is deliberate — the
html-label container carries the pan/zoom transform, so getBoundingClientRect() would feed the
zoom back into the model. Because dagre's rank separation is tuned to the card box, a changed height
re-runs the layout (runLayout), bounded at two passes: the second measures the same content and
finds nothing to change. The minimap reads the same per-node height. card-height.test.mjs owns the
stylesheet function, the measurement, the no-op second call, the zero-height guard and the CSS floor.
A card that grows DURING a run is measured again, per node (159 addendum, #151): every
height-changing write (setNodeState, setNodeStats, setNodeOperation — the port block's lines —
setMarkerState's elapsed line, resetAll) queues one deferred, per-node measureCard that runs
behind the html-label's own re-render and writes the same cardH + bypass, without a full
runLayout while the run is in flight (a mid-run relayout moves cards under the reader); the layout
is re-run only when the grown box would overlap a neighbour (cardOverlaps, CARD_GAP_MIN) or, once,
when End takes its terminal state (settleCardHeights, the _heightPasses bound reset). Before it,
the two CTAS cards' one statement / committed lines grew the label past a node box measured once at
render. graph-card-height.test.mjs and PipelineEditorStartMarkerBrowserTest's card-heights arm.
The minimap (no Cytoscape equivalent — plain DOM, painted by renderMinimap() after layout): nodes as small bars carrying the state colour (updated on every setNodeState), the viewport rectangle in --brand re-read from cy.extent() on pan/zoom. It is pointer-events: none — orientation, not navigation.
function buildStylesheet(t) { // t = readDesignTokens() output
const cardW = t.cardW || 236, cardH = t.cardH || 148;
return [
{ selector: 'node', style: {
'background-color': t.nodeSurface, // --surface-raised
'width': cardW, 'height': cardH, // --pe-card-w / --pe-card-h
'shape': 'round-rectangle',
'border-width': 1, 'border-color': t.nodeBorder, // --border-subtle
'corner-radius': t.cardRadius, // --radius-lg
} },
{ selector: 'node.running', style: { 'border-color': t.brand, 'border-width': 2 } },
{ selector: 'node.success', style: { 'border-color': t.nodeSuccess, 'border-width': 2 } },
{ selector: 'node.failed', style: { 'border-color': t.nodeFailed, 'border-width': 2 } },
{ selector: 'node.aborted', style: { 'border-color': t.nodeAborted, 'border-width': 2, 'opacity': 0.5 } },
// SELECTION — brand border over the brand-soft ring (underlay, BEHIND the card).
{ selector: 'node:selected', style: {
'border-width': 2, 'border-color': t.brand,
'underlay-color': t.brandSoft, 'underlay-opacity': 1, 'underlay-padding': 3,
'underlay-shape': 'round-rectangle',
} },
// Edges — unbundled-bezier, control offset max(60, dx/2); three states.
{ selector: 'edge', style: {
'width': 2, 'line-cap': 'round', 'line-color': t.edgeIdle,
'target-arrow-color': t.edgeIdle, 'target-arrow-shape': 'triangle', 'arrow-scale': 0.9,
'curve-style': 'unbundled-bezier',
'source-endpoint': (cardW / 2) + 'px 0px', 'target-endpoint': -(cardW / 2) + 'px 0px',
} },
{ selector: 'edge.active', style: { // the TARGET is running — still
'width': 2.5, 'line-color': t.edgeActive, 'target-arrow-color': t.edgeActive,
'line-style': 'dashed', 'line-dash-pattern': [6, 8],
} },
{ selector: 'edge.satisfied', style: { // the SOURCE completed
'line-color': t.edgeDone, 'target-arrow-color': t.edgeDone,
} },
{ selector: 'edge.unmet', style: { // the SOURCE failed / was aborted
'line-color': t.nodeFailed, 'target-arrow-color': t.nodeFailed,
'line-style': 'dashed', 'line-dash-pattern': [2, 6], 'opacity': 0.6,
} },
// 151/#144 — the boundary markers: a compact transparent box (the shape is HTML),
// and connectors anchored at the marker's own half width, not the card's.
{ selector: 'node.boundary', style: { 'width': BOUNDARY_W, 'background-opacity': 0, 'border-width': 0, 'underlay-opacity': 0 } },
{ selector: 'edge.boundary-start', style: { 'source-endpoint': (BOUNDARY_W / 2) + 'px 0px' } },
{ selector: 'edge.boundary-end', style: { 'target-endpoint': -(BOUNDARY_W / 2) + 'px 0px' } },
{ selector: 'edge.secondary', style: { 'line-style': 'dashed' } }, // reserved, unused
];
}
readDesignTokens() returns exactly the keys referenced above — brand, brandSoft, edgeIdle, edgeActive, edgeDone, nodeSurface, nodeBorder, nodeSuccess, nodeFailed, nodeAborted, cardW, cardH, cardRadius (151 retired edgeLabelText/edgeLabelBg with the edge label) — each read from the custom property with a hard hex fallback (the mock's light values), so a stale theme file cannot blank the graph. The retired --node-selected-ring/--edge-*-stroke tokens in app.css remain for the banner and node-list accents; the canvas no longer reads them.
5.4 Event handlers
Tapping a card SELECTS it and fills the dock's Details tab (080 §B — the 065
split between select-only and open died with the inspector overlay: there is no second
pane to keep closed). selectNodeById(id) sets selectedNode, calls cyNode.select()
(the node:selected pseudo-class the §5.3 stylesheet keys on), mirrors
aria-selected onto the §14 DOM list, and calls dock.selectNode(id) — the mock's
select(), which fills Details and surfaces that tab. Tapping the canvas background
clears the selection (dock.clearSelection()), not the tab.
// init.js
this.cy.on('tap', 'node', (evt) => this.selectNodeById(evt.target.data().id));
this.cy.on('tap', (evt) => { if (evt.target === this.cy) { this.selectedNode = null; this.dock.clearSelection(); } });
| Gesture | Effect |
|---|---|
| Tap a card / click a node-list row | Select + fill Details — highlight, selectedNode, aria-selected, the dock on the Details tab |
Click a card's .pe-card-open expand button |
The same — the explicit route in (what the 065 inspector button became) |
Enter / Space on the focused node-list row |
The keyboard twin of the button |
| Tap the canvas background | Clear the selection |
F (no modifier, not in an input) |
Fit the graph — the hint pill's shortcut |
The expand button is part of the html label, so it rides the pan/zoom transform with its
card. The label container is pointer-events: none (that is what keeps the canvas's
pan, drag and tap working under it); .pe-card-open re-enables pointer events for
itself alone, and its handler calls stopPropagation() so the graph's tap-to-select
does not also fire. One delegated listener on the graph container serves every card
(the html-label re-renders its template on each data/style event, so per-button
listeners would leak), guarded by a flag on the container so the history-restore
re-render (§7.4) cannot stack a second one.
6. Node States and Visual Mapping
6.1 State machine
┌─────────┐
│ idle │ (initial render)
└────┬────┘
│ node_started
▼
┌─────────┐
┌────── │ running │ ──────┐
│ └─────────┘ │
│ node_completed │ node_failed
▼ ▼
┌───────────────┐ ┌───────────────┐
│ success │ │ failed │
└───────────────┘ └───────────────┘
Nodes that never reach a terminal state of their own:
┌─────────┐
│ aborted │
└─────────┘
Reached when (a) an upstream node failed and this one never ran, or
(b) the execution was cancelled (execution_aborted) — in which case a
node still `running` also becomes `aborted`, because the server has
interrupted its statement.
6.2 CSS class → visual mapping
All colors derive from the 080 canvas tokens (app.css's node-type accent block) and the design system. See Appendix A for the full token mapping.
State is an accent border on the neutral card plus the footer's dot, never a background fill — colour carries STATE and never competes with the tile for TYPE or the ring for SELECTION. The card's surface and label are constant across all five states.
| State | CSS class | Accent token | Animation | Meaning |
|---|---|---|---|---|
idle |
.idle |
— (neutral card: --border-subtle) |
none | Initial state. Applied in buildElements() so all five statuses are symmetric classes. |
running |
.running |
--brand |
state-dot pulse + indeterminate progress line (CSS on the card); incoming edges .active (static dashed brand) |
Node is currently executing |
success |
.success |
--accent-success |
none; the out-port turns --edge-done and every outgoing dependency is .satisfied |
Node completed successfully |
failed |
.failed |
--accent-danger |
none | Node failed; pipeline aborted |
aborted |
.aborted |
--accent-warning, 0.5 opacity |
none | Node never ran (dependency failed), or was interrupted by cancellation |
Every animation the canvas chrome owns is CSS (pulse, progress slide, status-dot pulse,
the output port's write flow — §5.3a), so one prefers-reduced-motion: reduce media query
stops them all — the 059 JS border pulse and the 080 JS edge flow (a rAF loop stepping
line-dash-offset) are both retired. Nothing moves on the <canvas> itself; under reduced
motion the accents carry every state alone.
Colors automatically adapt to the active design system theme. No hardcoded hex values.
6.3 State transitions via SSE events
Event payloads are defined in REST API §6.4; the table below is only the graph's reaction to them.
| SSE event | Graph action |
|---|---|
execution_started |
Reset all nodes to idle, edges to rest. Events log resets; the top bar's clock starts. Disable Execute button; the Start disc becomes the Cancel control for a viewer who may execute (§5.3b, 159). |
node_started |
Node → running; incoming edges → .active (static; inside setNodeState). |
node_progress |
Reduced into nodeOps (§10.7); the card's footer takes the state word and the live count (setNodeOperation), the output port takes its measured state (§5.3a), the node's a11y row takes the operation as its description, the Events tab formats the row. No edge changes: a sample is a fact about the node's own write, never about an arrow. |
node_completed |
Node → success; incoming edges clear .active; outgoing edges → .satisfied; the node's run line fills (523 rows · 634 ms; 0 rows included: it ran and emitted nothing; 2 keys for a CALCULATOR). No edge takes a count (151): the footer and the output port hold what this node wrote. A CALCULATOR's context_value/context_values reach the Details pane, the Events tab and the Context; the footer counts them. A PIPELINE node's child_execution_id reaches the Details pane. |
node_failed |
Node → failed; incoming edges clear .active; outgoing edges → .unmet. The failure record joins the dock's Errors tab. All pending nodes → aborted (their outgoing edges .unmet too). |
pipeline_completed |
Terminal. Every node is success or aborted. The ONE success toast; the top bar's status takes its final text; End reads Finished with the run clock's elapsed, Start rests (§5.3b); every open operation closes as not observed (§10.7). |
pipeline_failed |
Terminal. Show error modal (§9); the record joins the Errors tab. Unfinished nodes are swept aborted (#135); End reads Failed. |
data_ready |
Show the result in the dock's Results tab (§10) — emitted after pipeline_completed and only when the pipeline has a caller node. |
execution_aborted |
Terminal. Every node not already success/failed → aborted; banner "Execution aborted ({reason})" using the event's reason (client_disconnect | cancelled | shutdown); Execute button re-enabled; End reads Stopped, Start rests (the Cancel disc is Start again — as on every terminal row, §5.3b). See §15. |
| stream lost / recovery poll (151) | handleConnectionLoss freezes every indicator (markStreamLost: cards and markers stale, ports "— stream lost", no active edge) without rewriting a state; a poll that learns the outcome runs settlePolledOutcome: End from the polled status, open operations closed unobserved, the failed/aborted sweep as live. A polled SUCCESS paints no node Done — no node_completed was observed. |
Every event, of every kind, also lands in the dock's Events tab in arrival order
(§10.6) — sse.js's dispatch calls editor.logEvent first, before the switch above.
The toast only ever announces the terminal events.
The stream closes after exactly one terminal event (REST API §6.5); the editor treats stream close without a terminal event as connection loss (§15.1).
6.4 Implementation: state update on SSE event
// sse.js
class SseHandler {
constructor(graph) {
this.graph = graph;
}
onEvent(eventType, data) {
this.lastEventType = eventType;
switch (eventType) {
case 'execution_started':
this.executionId = data.execution_id; // needed by §15.1 status poll
this.graph.resetAllNodes();
break;
case 'node_started':
this.graph.setNodeStatus(data.node_id, 'running');
break;
case 'node_completed':
this.graph.setNodeStatus(data.node_id, 'success');
break;
case 'node_failed':
this.graph.setNodeStatus(data.node_id, 'failed');
this.graph.abortPendingNodes(data.node_id);
break;
case 'pipeline_completed':
// All nodes should already be success/aborted
break;
case 'pipeline_failed':
window.errorModal.show(data.error);
break;
case 'data_ready':
window.resultPanel.show(data);
break;
case 'execution_aborted':
// Terminal event for every cancellation path (rest-api §6.4.8):
// client disconnect beyond grace, explicit DELETE, server shutdown.
this.graph.abortUnfinishedNodes();
window.banner.warn(`Execution aborted (${data.reason}).`);
break;
}
window.a11y.announce(eventType, data); // §14.2 live-region announcement
}
isTerminal(eventType) {
return ['pipeline_completed', 'pipeline_failed', 'execution_aborted'].includes(eventType);
}
}
// graph.js (methods on PipelineGraph)
// The five status classes are mutually exclusive and `idle` is one of them (applied at
// build time in §5.1), so every transition is remove-all-then-add-one.
const STATUS_CLASSES = 'idle running success failed aborted';
setNodeStatus(nodeId, status) {
const node = this.cy.getElementById(nodeId);
node.removeClass(STATUS_CLASSES);
node.addClass(status);
node.data('status', status);
window.a11y.syncStatus(nodeId, status); // §14 DOM list stays in lockstep
}
abortPendingNodes(failedNodeId) {
// A node failed: nodes that never started can no longer run.
this.cy.nodes()
.filter(n => n.hasClass('idle'))
.forEach(n => this.setNodeStatus(n.id(), 'aborted'));
}
abortUnfinishedNodes() {
// Execution cancelled (execution_aborted): anything not already terminal is aborted —
// including nodes still `running`, whose statements the server has interrupted.
this.cy.nodes()
.filter(n => !n.hasClass('success') && !n.hasClass('failed'))
.forEach(n => this.setNodeStatus(n.id(), 'aborted'));
this.cy.edges().removeClass('active');
}
resetAllNodes() {
this.cy.edges().removeClass('active');
this.cy.nodes().forEach(n => this.setNodeStatus(n.id(), 'idle'));
}
7. Execute Button Flow
7.1 User action
- User fills in parameter form (if pipeline has parameters).
- Clicks Execute.
- Execute button becomes disabled (
aria-busy="true"), label changes to "Executing..."; the Cancel button appears (§15.2). - Values are coerced to their declared wire types (§7.2) and posted as typed JSON. Page opens the SSE stream via
fetch(POST to/api/v1/pipelines/{id}/executewithAccept: text/event-stream). - SSE events flow in → graph, node list and live region update in real-time.
- On a terminal event: button re-enables, Cancel disappears, and — result panel (
data_ready, §10), success banner (pipeline_completedwith no caller node), error modal (pipeline_failed, §9), or aborted banner (execution_aborted, §15). - If the stream ends without a terminal event, that is connection loss, not completion (§15.1).
7.2 Implementation
// execute.js
async function executePipeline() {
const pipelineId = window.pipelineId;
const parameters = collectParameters();
const executeBtn = document.getElementById('execute-btn');
executeBtn.disabled = true;
executeBtn.textContent = 'Executing...';
try {
const response = await fetch(`/api/v1/pipelines/${pipelineId}/execute`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
// Cookie-authenticated state-changing call → CSRF token from the `dp_csrf`
// cookie (Auth §8.4). Custom headers use the DP- prefix throughout.
'DP-CSRF-Token': readCookie('dp_csrf'),
},
credentials: 'same-origin',
body: JSON.stringify({ parameters }), // typed JSON, never FormData — see collectParameters()
});
if (!response.ok) {
const error = await response.json();
window.errorModal.show(error.error);
return;
}
// Consume SSE stream
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split('\n\n');
buffer = events.pop(); // incomplete event stays in buffer
for (const eventStr of events) {
const event = parseSseEvent(eventStr);
if (event) {
window.sseHandler.onEvent(event.type, event.data);
}
}
}
// Stream ended. If no terminal event arrived, the connection dropped (§15.1).
if (!window.sseHandler.isTerminal(window.sseHandler.lastEventType)) {
await handleConnectionLoss(window.sseHandler.executionId);
}
} catch (error) {
// A thrown reader is the same condition as an early close: the stream is gone
// and the server will cancel after the grace period (§15.1).
await handleConnectionLoss(window.sseHandler.executionId);
} finally {
executeBtn.disabled = false;
executeBtn.textContent = 'Execute';
}
}
Parameter coercion
collectParameters() MUST NOT post raw form strings. Pipeline Contract §6.3 makes coercion strict: the server rejects a JSON string where a number/boolean is declared and vice versa (pipeline.execution.invalid_parameter_type). Since every <input> yields a string, the editor converts to the declared wire type before serializing.
// execute.js — declared types come from the embedded pipeline JSON (`parameters` map).
function collectParameters() {
const form = document.getElementById('parameter-form');
const declared = window.editor.pipeline.parameters || {};
const params = {};
new FormData(form).forEach((raw, key) => {
const decl = declared[key];
if (!decl) return; // not a declared parameter
const value = typeof raw === 'string' ? raw.trim() : raw;
if (value === '' && !decl.required) return; // omit → server applies the default
params[key] = coerceParameter(value, decl.type, key);
});
return params;
}
function coerceParameter(value, type, key) {
switch (type) {
// Number-on-wire types → JSON numbers, never strings.
case 'INTEGER':
case 'SMALLINT':
case 'BIGINT':
case 'DECIMAL': // precision ≤ 15 (Type System §3.1)
case 'FLOAT':
case 'DOUBLE': {
const n = Number(value);
if (value === '' || Number.isNaN(n)) throw new ParameterError(key, type, value);
return n;
}
// String-on-wire numerics: precision must survive, so they stay strings.
// Sending them as JSON numbers is an explicit server-side rejection.
case 'BIGINTEGER':
case 'BIGDECIMAL':
return String(value);
// Boolean-on-wire → JSON true/false, never "true"/"on".
case 'BOOLEAN':
return value === true || value === 'true' || value === 'on';
// TIMESTAMP requires an explicit offset or Z — the server never guesses a zone.
// <input type="datetime-local"> yields a zone-less string, so we attach the
// browser's offset explicitly rather than sending it bare.
case 'TIMESTAMP':
return withExplicitOffset(value); // "2026-08-07T14:30" → "2026-08-07T14:30:00+02:00"
case 'DATE': // exact ISO 8601 YYYY-MM-DD
case 'TIME': // exact ISO 8601 HH:MM:SS[.ffffff]
case 'STRING':
default:
return String(value);
}
}
- Unparseable input never leaves the browser:
ParameterErroris caught by the Execute handler, which marks the offending field invalid (aria-invalid="true"+.ds-field--error) and aborts the POST. - Type-appropriate input widgets reduce, but do not remove, the need for coercion —
inputType(param.value.type)(§4.2) mapsINTEGER→number,BOOLEAN→checkbox,DATE→date,TIMESTAMP→datetime-local, everything else →text. - Client-side coercion is a UX convenience, not a validation boundary — the server re-validates every parameter regardless.
SSE frame parsing
function parseSseEvent(raw) {
let type = null;
let data = null;
for (const line of raw.split('\n')) {
if (line.startsWith('event: ')) type = line.slice(7).trim();
else if (line.startsWith('data: ')) data = JSON.parse(line.slice(6));
}
return type && data ? { type, data } : null;
}
Heartbeat frames (REST API §6.6) are SSE comment lines (: heartbeat) with no event:/data: field, so this parser returns null for them and they are skipped — they exist only to keep the connection open through proxy idle timeouts.
7.3 Why fetch + ReadableStream, not EventSource
The native EventSource API only supports GET requests. Our execute endpoint is POST /pipelines/{id}/execute (parameters in the body). So we use fetch with Accept: text/event-stream and manually parse the SSE stream from the response body via ReadableStream. This is a well-known pattern; works in all modern browsers.
8. The dock's Details tab (was: the node inspector overlay)
Details is a TAB of the bottom dock (080 §B, owner ruling 2026-09-05: "move that
pane in the bottom along with Result and Errors, minimizable"). The 065 overlay —
scrim, dialog role, focus trap and all — is deleted: inspector.js, its DOM and its
CSS are gone, and so is the focus contract they existed for (there is no panel to focus
into or out of). What survived of its state machine is the re-target-in-place rule:
selecting a second node while Details is showing replaces the content, never flashes an
empty pane.
Open. Selecting a node fills Details and surfaces the tab (§5.4) — the card's
expand affordance and Enter/Space on a focused node-list row are the same route,
not a second one. The SQL partial loads on selection for SQL-backed types (the tab
is the only SQL surface left; the 065 "one request per opening" became "one per
selection", debounced with parameter edits exactly as before).
Layout. Two columns inside the pane: the meta column (300px) — the type tile + id, then the key/value facts — and the statement column, headed by the section label and the "Open template →" link.
Content, per node type (built by detailsMeta() / definitionHtml() in init.js,
driven per-type by details-pane.test.mjs):
- DQL / DML / DDL — Source (datasource, or
tempdb (engine, per-execution)), Template (path @ v), Output (the §8.1 wording — an omitted block on a DQL is "returns result to caller (default)"), Parameters (the pipeline's declared keys), and the rendered SQL (§8.3) in the statement column. - CALCULATOR — Kind, Inputs (each body's expression beside what the last run
resolved it to, from the §7.2 Context), Writes (the context key), Value (the last
run's), and the evaluation in the statement column (
kind(inputs) → key = valuewith the resolved inputs as comments) — no fetch: a calculator has no SQL. - PIPELINE — Child (
path @ v, linked), Parameters (the mapping passed down), Output, Execution (the last child execution id, fromnode_completed'schild_execution_id), and the child mapping in the statement column.
The per-node Failure section the inspector carried is deliberately NOT reproduced here: the dock's Errors tab (§9.1) renders the same record for the whole run, and the mock's Details has no failure block. The record lost nothing — it has one home instead of two.
8.1 Fields displayed### 8.1 Fields displayed
| Field | Source | Notes |
|---|---|---|
| Node ID | node.id |
Meta header, beside the type tile |
| Type | node.type |
The eyebrow in the type accent |
| SQL | GET /partials/pipelines/{id}/nodes/{nodeId}/sql |
The node's rendered SQL — see §8.3 (SQL-backed types only) |
| Source | node.source |
Datasource name or tempdb (engine) |
| Template | node.template |
{id, version} — link to the template editor (§8.2) |
| Output | node.output |
For DQL: target + table/mode; an omitted output renders as "returns result to caller (default)" (§9.1). For DML/DDL: "side effect" |
| Parameters | the pipeline's declared keys | The binds the SQL references |
| Calculator | node.kind, node.inputs, node.context_key |
Kind / Inputs (expression → resolved) / Writes / Value |
| Child | node.pipeline, node.parameters |
Child @ v, the mapping passed down, the child execution id |
| Last run | nodeStates[node.id] |
Present only after a run touched the node |
| Operation · Progress · Rows · Time in · Commit · Child execution | nodeOps.get(node.id) via PENodeOps.describe (§10.7) |
149: stage → tempdb.trips; Writing · 5.3 s / Completed · 6.1 s; 12,000 fetched · 11,000 written; connect 12 ms · query 410 ms · fetch 3.4 s · wait 22 ms · write 1.5 s; Committed · 12,453 rows (also on a failed node whose commit was confirmed) / Not committed · rolled back (only a confirmed undo) / Commit not observed (a node or execution event closed the operation before its terminal sample, or the terminal sample carried no commit evidence); rows absent when nothing was measured |
Long values wrap via overflow-wrap: anywhere rather than widening the pane; every
value also rides on its element's title (the §9.4 rule — the truncated text is never
the only copy).
8.2 Template link
The statement column's "Open template →" navigates to /templates/editor?name={id} — the route that exists (§9.6). The pinned version is not part of the route; the template editor always opens the current version. A PIPELINE node's head instead links "Open child →" to the explorer filtered on the child.
8.3 The SQL section (rendered, resolved server-side)
SQL does not live in pipeline nodes — Pipeline Contract §2 principle 3: "SQL/FTL lives in template entities, not inline in the Pipeline." So "show the SQL for a node" is a resolution problem: the server resolves the node's pinned template: {id, version} (never the latest), assembles the pipeline's own parameter context, and renders.
GET /partials/pipelines/{id}/nodes/{nodeId}/sql?parameters=<url-encoded JSON>
- Scope:
READ_RESOURCES, matching every other read partial. The existingPOST /partials/templates/{id}/versions/{version}/renderis deliberately NOT reused: it requiresMUTATE_PIPELINES_TEMPLATES(an author scope, so a read-only viewer would be refused) and takes a free-form context that bypasses the pipeline's own parameter declarations. - Wire format: the
parametersquery value is a JSON document in contract §6.3 wire form, built client-side by the page's owncoerceValue— the same function the execute path uses (§7.2). One coercion path for both surfaces;ParameterCoercionis strict, so raw form strings would be rejected by design. GET, not POST: it is a read, needs no CSRF token, and matches the/partials/**GET idiom. - Three context outcomes, not one. Bound — every parameter supplied or defaulted; renders with the bound context. Sampled — binding rejected only on unsupplied REQUIRED parameters; renders with
ParameterBinder.sampleContext()(the §12.6 dry-render context: defaults where present, type-appropriate sample values otherwise) and the panel labels which parameters were sampled. Rejected — a supplied override failed §6.3 coercion; the partial names the parameter and renders no SQL — SQL built from a value the executor would refuse is worse than no SQL. - The non-render states. A PIPELINE node has no template by contract (§4.6) — the partial shows the child-pipeline state (name @ version, linked), not an empty SQL block. A pinned
{id, version}absent from the workspace registry, aTemplateRenderException, and an unknown node id each get their own.ds-emptystate.Node.templateis never null server-side (Node.fromJsonbindstemplate ?: TemplateRef()), so the "no template" branch keys on the node type / a blank template id — a null check would never fire. - Loading:
htmx.ajaxon selection (080 §B — the Details tab is the only SQL surface left, so selection and opening are the same act), and again (debounced ~300ms, the list-screen search delay) when a parameter override changes. CALCULATOR and PIPELINE nodes skip the fetch — their statement column is the client-built evaluation / child mapping. The response swaps into#pe-node-sql; a.ds-spinnerindicator rides the request. - The box (080). The statement spans the statement column's full width,
white-space: pre(no wrapping — wrapping destroys the indentation that carries the query's shape), mono 12.5px on--surface-inset, and scrolls inside the pane in both directions. The 065 overlay'sflex: 1 0 40%floor died with the overlay: the dock's pane has a definite height (232px), so no floor arithmetic is needed — the column is agrid-template-rows: auto 1frand the<pre>takes the1fr. - Highlighting and copy. After the swap,
sql-highlight.jsre-highlights the<code>block — a zero-dependency, single-pass tokenizer (keywords, strings, comments, numbers,${param}/:paramparameters), escaping each token's text as it is emitted (tokenize the RAW SQL, never the escaped string; token colours are--pe-sql-*custom properties resolving to design-system accents). The copy button reads the SQL from itsdata-sqlattribute (or the<code>element'stextContent) — never from the highlightedinnerHTML, which carries<span>markup. The confirmation is the live region plus a 1.5s label swap on the button, deliberately not a toast: copy is high-frequency and self-evident, and a 6s notification per copy trains the user to ignore the stack the §9 terminal events need.
9. Error Display
9.1 Where a failure lives: the dock's Errors tab
The Errors tab is the home of a failure record (065 §B). Before 065 the 057 record
rendered inside the results panel, gated on resultPanel.failure — results and
failures are different objects with different lifetimes sharing one pane, and closing the
pane took both away. The record now has its own tab beside Results, and the two are
independently reachable at all times.
Two surfaces, two jobs, one record (080 §B removed the third — the inspector's per-node Failure section — because the Errors tab renders the same record for the whole run and the mock's Details has no failure block):
| Surface | Scope | What it shows |
|---|---|---|
| Errors tab (§10, the dock) | per run | one entry per failed node of the current-or-last run, newest last |
| Error modal (§9.2) | the run's terminal event | a one-line summary — a failure detail is not a dialog |
PEErrorDetails.build(record) is the one view-model both render; details.js owns
it and sse-node-failure.test.mjs pins it. Nothing about the wire changed: the
node_failed / pipeline_failed error object, its caused_by chain (outermost-first
on the wire, reversed to root-cause-first for humans) and the redaction rules are
exactly as 057 left them. This round re-homed what was already on the page.
An Errors entry is the summary line node id · code, then the message, the node line,
the correlation id, any details JSON, the rendered SQL, the collapsible Exception
section with the chain root-cause-first, and one Copy failure detail button for the
whole record. Under error-detail=structured the SQL and Exception sections are simply
absent — no empty panel, no apology. Empty state: "No failures in this run."
pipeline_failed's execution-level record joins the same list (it is the record 057
built the failure view for, and moving the block out of Results without giving it the
Errors tab would have deleted 057's point from the page). Records matching on node, code
and message dedupe, so a node failure and the pipeline failure it caused list once.
9.2 The error modal — a one-line summary, and only that
pipeline_failed still raises the modal, and the modal still says one sentence:
┌──────────────────────────────────────────────────┐
│ Execution Failed [×] │
├──────────────────────────────────────────────────┤
│ We couldn't reach the 'pg-prod' database. │
│ │
│ [Dismiss] │
└──────────────────────────────────────────────────┘
It carries error.user_message (falling back to error.message). The detail —
code, correlation id, node line, SQL, exception chain — is in the Errors tab, on the page
the engineer is already looking at, and stays there after the modal is dismissed. The
modal is role="alertdialog"; Esc closes it, and it is the topmost rung of the §14.1
Escape ladder.
The details object is rendered verbatim wherever it appears, so it must never contain
connection secrets: the server's node_failed payload carries datasource_name and
underlying_error, never jdbc_url or credentials (REST API §6.4.4;
redaction mechanism in Observability). The editor does no redaction
of its own — it has nothing to redact with.
The graph also shows the failed node in red (§6.2), and its card's open button leads straight to that node's Failure section.
9.3 Terminal events: modal for failure, toasts for the rest
The three terminal SSE events report differently:
pipeline_failedkeeps the error modal (§9.2) for its one-line summary, and puts the record in the Errors tab (§9.1).pipeline_completedandexecution_abortedreport as toasts viaDpToast.show(Shape D, UI Screens §5.1): a stream-borne event has no HTTP response to attach an OOB swap to, and this is the one client-side toast builder that exists. The abort toast carries the event'sreasonin its body.- All three also call
announceStatus, as does the first failure of a run ("Errors (1)") — througha11y.js's single live region (#pe-live-region), never a second one.
The running-progress banner stays at the toolbar for the running state.
10. The bottom dock: Details | Results | Errors | Events
One dock, four tabs, two states, no close (080 §B; the 065 rule stands — the old
panel's × set resultPanel.visible = false and left no way back short of re-running
the pipeline). The inspector overlay moved IN as the Details tab (§8), so the dock is
always present: the 065 hidden state has no page left to live on, and minimized
is renamed collapsed (the mock's chevron).
dock.js holds the state and the transitions and nothing else — no DOM, no Alpine, no
fetch — so node --test drives every row of the table below (dock.test.mjs). The
events log is a second pure module (events.js, §10.6). The template binds the fields
directly (state, tab, errors.length, resultsRows, detailsNodeId); there are no
derived getters to drift from what the browser renders.
dock.state ∈ {open, collapsed}dock.tab ∈ {details, results, errors, events}dock.errors: FailureRecord[]— the 057 record, one per failed node of the current or last run (§9.1)dock.resultsRows— the Results tab badge: the caller result's row count on successdock.detailsNodeId— the node the Details tab is showing (§8)
10.1 Transitions — the whole table, no others
| Event | From | To |
|---|---|---|
| page load | — | open, tab details (nothing selected) |
| node selected | any | detailsNodeId set; tab details; state open |
| canvas background tap | any | detailsNodeId cleared; state and tab unchanged |
| execute started | any | errors cleared; state unchanged; the Results tab shows "previous run" while its page is from an earlier run |
data_ready |
any | resultsRows set; tab results unless errors.length > 0; state unchanged |
node_failed (first of this run) |
any | open, tab errors |
node_failed (subsequent) |
any | record appended; badge count updates; state and tab unchanged |
| user clicks the chevron | open ↔ collapsed |
the other state |
| user clicks a tab | collapsed |
open, that tab |
| user clicks a tab | open |
that tab |
user presses Esc |
any | no change — a tab has nothing to close |
Two readings the table leaves open, resolved in the code and pinned by tests: a first failure arriving while the user is on another tab still takes the tab (the failure outranks the resting tab only once), and an unknown tab name is inert.
There is no close. collapsed renders the tab strip only — tabs, badges, and the
chevron flipped to restore — and the canvas reclaims the rest, which works because the
dock is the last flex child of .pe-root (§4.3). The open height is the mock's
232px pane under a 38px tab strip. There is no drag handle: it was not asked for.
10.2 The four tabs
Details — §8. Results — the body §10.4 describes, unchanged. Errors — the
list described in §9.1, with the count badge turning danger when errors.length > 0.
Events — §10.6.
Badges: Results shows the row count on success (the success wash), Errors the danger
count, Events the live event count. The first failure of a run announces
"Errors (1)" through a11y.js's existing live region; the dock header carries no
aria-live of its own (§14.2).
10.3 Result delivery (unchanged)
Delivery is uniform — there is no inline-vs-claim-check split (REST API §7). Every caller result is materialized in Redis before data_ready is emitted, and every data_ready carries the same fields: full schema, the inline first page, total_rows, has_more, result_url, expires_at.
10.4 On data_ready
result.js renders one body shape for every result size, inside the dock's Results tab (REST API §6.4.7):
┌──────────────────────────────────────────────────┐
│ Execution Result [×] │
├──────────────────────────────────────────────────┤
│ 4,480 rows • 2377 ms • 4 nodes succeeded │
│ Available until 14:35:02 (expires in 4m 58s) │
│ │
│ customer_id │ customer_name │ total_amount │
│ ─────────────┼───────────────┼────────────────── │
│ 1 │ Acme Corp │ "12345.67" │
│ 2 │ Globex │ "67890.12" │
│ 3 │ Initech │ "1234.56" │
│ ... │
│ showing 1–1,000 of 4,480 │
│ [Load next page] │
│ │
│ [Download JSON] [Download CSV] [Download Arrow] │
└──────────────────────────────────────────────────┘
- The panel renders
data.rowsfrom the event directly — no fetch needed for the first page. Whenhas_moreisfalse(the common case) the first page IS the whole result and no cursor call is ever made. - The table renders the first page as delivered. Page size is the server's
datapipelines.result.page-size-rows, not a client constant — the editor never assumes 100 or 1000 rows. - The grid is the shared
.ds-tablecomponent (UI Screens §5) — the editor's bespoke.pe-result-tablestyles are gone; the container supplies scroll only. - BIGDECIMAL / BIGINTEGER values arrive as strings (Type System wire rules) and are rendered as-is — the editor never parses them into JS numbers.
- Nothing is shown for a pure-ETL pipeline: with no caller node there is no
data_readyevent. The completion banner shows execution stats only.
10.5 Paging and download via the result cursor
Both "Load next page" and the download buttons use the same cursor endpoint (REST API §7.2):
GET /api/v1/executions/{execution_id}/result?offset=&limit=&format={json|arrow|csv}
- Paging is by
offset/limit; ordering is stable because the result was fully materialized before the cursor existed. - The editor's Prev/Next row is client-side cursor paging over the stored result —
resultPanel.prevPage()/nextPage()withhasPrev/hasNext,limit-authoritative andtotal_rows-denominated (027b). That arithmetic is frozen; the execute-page redesign restyled the row (ghost buttons on tokens) without rewiring it. - The cursor requires normal session auth +
readscope + ownership —result_urlis not a capability URL, so the editor sends its credentials like any other API call. - TTL is fixed. Reading pages does not extend it. The panel shows the remaining time from
expires_atand counts down. - After expiry the endpoint returns
410withresult.expired; the panel replaces the table with "Result expired — re-run the pipeline" and a re-execute button. It does not retry. result.execution_failed(410) andresult.execution_not_found(404) surface through the same error path (§9). The full endpoint error list is REST API §7.6.- A result exceeding
datapipelines.result.max-size-bytesnever reaches this panel — the execution itself fails withresult.too_largeand the error modal explains that large datasets belong inoutput.target: "datasource"write-back, not in caller results.
10.6 The Events tab — every event, in arrival order
The owner: "put all the events in the list". Every SSE event the stream emits lands in
the Events tab as one timeline row — the toast only ever announces the terminal one
(§9.3). The row is the mock's anatomy: +t.tttS (t0 = the run's start), the marker
coloured by kind, the kind in mono, node — text, the duration. Text per kind:
execution_started names the pipeline @ version, the short execution id, the node and
parameter counts; node_started says rendered template, executing on source /
evaluating kind / spawning child @v; node_completed says rows → output /
key = "value" / child id completed; node_failed leads with the error code;
data_ready reports rows · columns; the terminal events carry the summary and the
total duration; node_progress (149) says state → destination · N fetched · M written
(waiting for tempdb connection, querying and materializing (one statement) → tempdb.t)
and, on the terminal sample, committed / not committed, rolled back.
The tab carries a live count badge. The list auto-scrolls while running and yields the moment the user scrolls up (the pin releases when they return to the bottom) — a run should never yank the row the user is reading out of view, and a finished run should never leave the list scrolled off its own tail.
events.js is pure: formatEvent(kind, payload, ctx) builds the text (defensively — a
field the wire omits degrades the sentence, never throws) and createEventsLog() holds
the arrival-order list, t0, the count and the pin, so events.test.mjs owns every row.
init.js does the row rendering (escaped by construction) and the scrolling.
One toast per execution. The pre-080 defect ("many success toasts on completion")
was NOT in the toast path — sse.js fires exactly one on pipeline_completed. It was
the 076 boost lifecycle: a history restore brings the editor's DOM back with the
previous component's Alpine state and its @click listeners still attached, and the
afterSettle rescue ran a bare Alpine.initTree(root), which Alpine does not guard
against (its re-init marker is only set by Alpine.clone). Each restore stacked
another component on the same root; one Execute click then fired executePipeline()
once per stacked component, and N executions produced N terminal events and N toasts.
The fix is at the source: the rescue destroys the stale tree (Alpine.destroyTree)
before re-binding. editor-toast-once.test.mjs is the falsifier — two lifecycle passes
and one stream produce exactly one toast.
10.7 The node-operation reducer (149) — the one view model of what a node is doing
node-ops.js (window.PENodeOps, pure, node --test: node-ops.test.mjs) reduces EVERY
lifecycle event and every node_progress sample into one operation view per node —
createNodeOps() → { reduce(kind, payload), get(nodeId), all(), reset() }. sse.js feeds it
before its own switch so a node's terminal event closes its operation before the card
re-renders. The cards (§5.3), the Details pane (§8.1), the Events tab (§10.6) and the a11y
node list (§14) all read it; 151's output connector reads editor.nodeOps.get(nodeId)
and must not estimate a phase from a node's status on its own.
The honesty rules the wire imposes, kept here: samples apply in sequence order and a late,
lower sequence is ignored; the terminal sample (completed/failed/aborted) seals the
operation; a node_completed/node_failed arriving WITHOUT a terminal sample closes it with
committed: null — "not observed", never "committed"; an execution terminal event
(pipeline_completed/pipeline_failed/execution_aborted) aborts every open operation with
observed: false and the SAME committed: null — the client saw no commit evidence and claims
none (never "Not committed"); an operation whose terminal sample was observed is untouched by
it; a terminal sample without a committed key reads as "Commit not observed" too, and one
with committed: true on a failed state reads "Committed" — the node failed after its
write became durable; there is no percentage. describe(op) renders the honest
labels — stateLabel, destinationText, countsText, phaseText (the per-state share),
commitText (only what the terminal sample said), elapsedText, the card's cardLine and
cardCounts, and a11yText ("Writing to tempdb.trips, 12,000 fetched · 11,000 written, elapsed
5.3 s"). Since 151 this view model is also what the output port renders (describe().port,
§5.3a) — the dependency arrows carry no count and nothing moves along them (#127); the Start/End
markers (§5.3b) read the run status and the lifecycle events, never this reducer (#126, #144).
11. Editing Scope
11.1 The editor is read-only in v1
The editor is a visualization + execution surface, not an authoring surface. There is no edit mode, no save action, and no ?edit=true parameter — the page has exactly one mode. The user can:
- View the pipeline (graph, settings, parameters, node details).
- Execute it and watch progress.
- Cancel a running execution.
- Inspect and page through results.
- Switch versions.
The editor issues no POST/PUT/DELETE against /pipelines — its only state-changing calls are execute (§7) and cancel (§15).
11.2 Authoring is LLM/MCP-first
Pipelines are authored, in order of intended use:
- LLMs via MCP — the primary authoring path for this product (MCP Server).
- Direct JSON editing via the REST API (developer path).
- The template editor for the SQL templates a pipeline references (separate spec).
A pipeline authored by any of these routes is validated at save time — no invalid pipeline reaches the database (Pipeline Contract §2) — so the editor can render whatever it loads without defensive validation.
11.3 UI edit mode is a ROADMAP item
In-UI authoring — editable node metadata, "save as new version", and eventually drag-and-drop topology editing (node palette, cytoscape-edgehandles, live validation) — is out of v1 scope and tracked in ROADMAP. It is deferred deliberately, not merely unbuilt: the authoring surface has to re-implement the save-time validation rules the server already owns, and the MCP path delivers the same outcome today. Design work on it is explicitly out of scope for the 2026-08 consistency campaign (SPEC-REVIEW-2026-08 Part 3).
12. JavaScript Organization
12.1 File structure
modules/web/src/main/resources/static/
├── vendor/
│ ├── design-system/ ← @acme/design-tokens (vendored CSS)
│ │ ├── vendor-manifest.json ← versions + SHA-256 for ALL vendored assets
│ │ ├── tokens.css
│ │ ├── base.css
│ │ ├── motion.css
│ │ ├── primitives.css
│ │ ├── themes/
│ │ │ ├── saas.css ← default theme
│ │ │ ├── light.css
│ │ │ ├── dark.css
│ │ │ ├── professional.css
│ │ │ └── ... ← other themes
│ │ └── icons.css
│ ├── cytoscape/
│ │ ├── cytoscape.min.js (v3.34.0, vendored)
│ │ ├── cytoscape-dagre.js (extension, vendored)
│ │ └── style.css (cytoscape base styles)
│ ├── alpinejs/
│ │ └── alpine.min.js (v3.x, vendored)
│ └── dagre/
│ └── dagre.min.js (dagre layout engine, dependency of cytoscape-dagre)
├── css/
│ ├── app.css ← app-specific semantic tokens (derive from design system)
│ └── pipeline-editor.css ← editor-specific styles (uses design system tokens)
└── js/
└── pipeline-editor/
├── init.js (page load → graph init)
├── graph.js (PipelineGraph class)
├── sse.js (SseHandler class)
├── execute.js (executePipeline, collectParameters, coercion)
├── details.js (DetailsPanel class + PEErrorDetails — §9.1)
├── dock.js (bottom dock state machine, PURE — §10)
├── events.js (Events tab log + per-kind text, PURE — §10.6)
├── node-ops.js (node-operation reducer + describe(), PURE — §10.7)
├── error.js (ErrorModal class)
├── result.js (ResultPanel class — §10)
├── sql-highlight.js (zero-dependency SQL tokenizer + highlighter — §8.3)
└── a11y.js (DOM node list ↔ canvas sync, live region — §14)
vendor-manifest.json lives at static/vendor/design-system/vendor-manifest.json — one manifest for all vendored assets (design system CSS, Cytoscape, dagre, Alpine), written there by scripts/sync-design-system.sh (DEVELOPMENT.md). It is not under css/.
12.2 No build step, no modules
All JS files use plain <script> tags. No ES modules, no bundler, no transpiler. This is intentional:
- Simpler deployment (static files served by Spring Boot).
- No npm dependency tree to manage.
- No CI build step for frontend.
- Faster iteration (edit JS, refresh page).
Trade-off: no tree-shaking, no minification of our own code. For a page with ~7 JS files totaling < 50KB, this doesn't matter.
12.3 Global namespace
All editor code lives under window.editor or window.*Panel globals. Not ideal for a large app, but fine for a single page with a known, bounded scope. If the editor grows significantly, this can be refactored to ES modules without changing the public behavior.
13. Vendoring Strategy
13.1 How libraries get vendored
Each library is downloaded as a pre-built UMD bundle and committed to modules/web/src/main/resources/static/vendor/. The process:
- Download the specific version from npm CDN (unpkg/jsdelivr) once during development.
- Compute SHA-256 hash.
- Commit the file + an entry in
static/vendor/design-system/vendor-manifest.json(the single manifest for every vendored asset — see §12.1) recording:{ "design-system": { "package": "@acme/design-tokens", "version": "0.2.0", "source": "../design-system-starter/dist/", "files": [ "tokens.css", "base.css", "motion.css", "primitives.css", "themes/saas.css", "themes/light.css", "themes/dark.css", "themes/professional.css", "icons.css" ], "license": "MIT", "note": "Vendored from local sibling project. Run 'npm run build' in design-system-starter before copying dist/." }, "cytoscape": { "version": "3.34.0", "file": "vendor/cytoscape/cytoscape.min.js", "sha256": "...", "source": "https://unpkg.com/cytoscape@3.34.0/dist/cytoscape.min.js", "license": "MIT" }, "cytoscape-dagre": { "version": "2.5.0", "file": "vendor/cytoscape/cytoscape-dagre.js", "sha256": "...", "source": "https://unpkg.com/cytoscape-dagre@2.5.0/cytoscape-dagre.js", "license": "MIT" }, "dagre": { "version": "0.8.5", "file": "vendor/dagre/dagre.min.js", "sha256": "...", "source": "https://unpkg.com/dagre@0.8.5/dist/dagre.min.js", "license": "MIT" }, "alpinejs": { "version": "3.14.1", "file": "vendor/alpinejs/alpine.min.js", "sha256": "...", "source": "https://unpkg.com/alpinejs@3.14.1/dist/cdn.min.js", "license": "MIT" } } - A CI check verifies the SHA-256 hashes match the manifest.
13.2 Design system vendoring specifics
The design system (@acme/design-tokens) is a sibling project at ../design-system-starter. It is not installed via npm into the datapipelines project. Instead:
- Build the design system:
cd ../design-system-starter && npm run build→ producesdist/. - Copy the needed files from
dist/intomodules/web/src/main/resources/static/vendor/design-system/. - Record the version (from
design-system-starter/package.json) instatic/vendor/design-system/vendor-manifest.json. - A CI script (
scripts/sync-design-system.sh) automates this: builds the design system, copies files, writes the manifest, verifies version match.
This keeps the design system as an independent project that can evolve separately, while datapipelines vendors a specific build for reproducibility.
13.3 Version upgrades
When upgrading a vendored library:
- Download the new version.
- Compute new SHA-256.
- Update the file + manifest.
- Run the editor test suite.
- Commit.
No npm install in CI, no package-lock.json, no transitive dependency resolution. Fully deterministic.
14. Accessibility
The operation as an accessible description (149). a11y.js's a11yNodeOperation(nodeId, text) writes describe(op).a11yText onto the node row as aria-description (and data-operation), so the measured state a sighted user reads off the card's footer and dot — "Writing to tempdb.trips, 12,000 fetched · 11,000 written, elapsed 5.3 s", later "Completed … Committed · 12,453 rows" — is available without colour or animation. Cleared for a new run; nothing is announced per sample (the live region stays for node lifecycle).
The constraint that shapes this whole section: Cytoscape.js renders the graph into a single <canvas> element. There are no per-node DOM elements, no SVG shapes, nothing for a screen reader or the browser's focus engine to reach. Per-node ARIA (role="button" on a node, a per-node aria-label, tabbing "into" the graph) is not implementable on this renderer — v1.1 of this spec required it and was wrong.
The accessible surface is therefore a parallel DOM structure mirroring the graph, not annotations on the graph. A server-rendered list sits next to the canvas (markup in §4.2):
<div id="cy" role="img" aria-label="{graphSummary}" aria-describedby="node-list"></div>
<ul id="node-list" role="listbox" aria-label="Pipeline nodes" tabindex="0">
<li role="option" id="node-item-fetch_orders" data-node-id="fetch_orders"
aria-selected="false">fetch_orders — DQL — idle</li>
...
</ul>
- One
<li role="option">per node, in topological order (the same order dagre ranks them), so the list reads as a sequence of execution stages. - Each option's accessible name is
"{node_id} — {type} — {status}", plus", returns result to caller"on the caller node and", depends on: a, b"when it has dependencies. That sentence is what a screen-reader user hears; it must carry everything the visual node encodes (identity, kind, state, caller marker, edges). - The list is visually hidden until focused (
.visually-hidden-until-focus), then rendered as a normal panel — sighted keyboard users get the same affordance, which is also how the styling stays testable. - It is server-rendered, so it doubles as the no-JS fallback (§2 principle 2).
- The canvas itself is
role="img"witharia-label= a one-sentence graph summary ("Pipeline DAG: 6 nodes, 7 dependencies, left to right") andaria-describedbypointing at the list.role="application"is not used — it would suppress the browsing mode the list depends on.
14.1 Keyboard navigation
| Key | Context | Action |
|---|---|---|
Tab / Shift+Tab |
page | Execute → version dropdown → parameter inputs → node list → graph controls → result panel |
Enter |
Execute button | Execute pipeline |
↑ / ↓ |
node list | Move focus between options (roving tabindex — exactly one <li> is tabbable) |
Home / End |
node list | First / last node |
Enter / Space |
node list | Open the focused node's Details tab (the keyboard twin of the card's expand button, §5.4); a plain click on the row only selects |
F |
anywhere but an input | Fit the graph (080 §A — the hint pill's shortcut; never with a modifier, never while typing) |
Escape |
anywhere | Error modal only — the inspector rung died with the overlay (080 §B). Never the dock — it has no close, and losing the results to the key that dismisses the surface above them is the defect 065 removed |
The +/−/R graph-control shortcuts this table once listed were removed (034 F1) because the controls did not exist. The controls exist since 059 §B — Fit / Reset / Zoom in / Zoom out as REAL buttons in a role="toolbar" at the canvas's bottom-right (.pe-graph-controls), keyboard-reachable by Tab + Enter — since 059b .ds-icon-md (20px) glyph buttons, sized by the icons.css link the page carries. F returned with 080 because the mock's hint pill advertises it; it is guarded (no modifier, never in an input) and the buttons remain the honest surface.
Selection is bidirectional and single-sourced: a click on a list item calls selectNodeById() (§5.4), which calls cyNode.select() on the canvas node — the node:selected pseudo-class the §5.3 stylesheet styles — and sets aria-selected="true" and tabindex="0" on the matching <li> (roving tabindex). Tapping a node on the canvas runs the same path in reverse. The two representations cannot drift because only one function mutates selection.
Focus follows the KEYBOARD only (082 §B). The list is visually clipped and revealed by :focus-within, so .focus() on a row is what paints it. Every POINTER route — the canvas tap, the card's expand button — therefore selects with moveFocus = false: the ring, the selection and the roving tabindex all still move, only the focus call is withheld. Before 082 a plain mouse click floated the picker into the stage's bottom-left corner, on top of the legend, on every click (visible in 080's dark Details screenshot). The overlap itself is now structurally impossible as well: the picker and the legend share ONE bottom-anchored column (.pe-stage-bl), picker first, so a revealed picker stacks ABOVE the legend instead of over it. node-picker-focus.test.mjs owns the argument, EditorLayoutRenderTest the nesting, and PipelineEditorDetailsBrowserTest the geometry — after a real pointer click, and again with the picker open.
The graph canvas is not in the tab order (tabindex="-1") — focusing an image the user cannot interact with is a trap, and every graph action is reachable from the list or the controls.
14.2 ARIA and status announcements
a11y.js owns two jobs, both driven by the same SSE handler that styles the canvas (§6.4) — there is no second source of truth for status:
a11yNodeState(nodeId, state)sets the matching<li>'sdata-stateattribute (idle/running/success/failed/aborted), styled with the same accent tokens as the graph's state border — a keyboard user sees execution state without the canvas. It is called fromPipelineGraph.setNodeState()/resetAll(), the same functions that style the canvas, so there is no second source of truth for status.announce(eventType, data)writes one sentence into#graph-status(role="status",aria-live="polite"):"fetch_orders running","fetch_orders failed: could not reach pg-prod","Pipeline completed, 4480 rows","Execution aborted (cancelled)". Terminal and failure events usearia-live="assertive"via a second region; per-node progress stays polite so a 20-node pipeline does not flood the buffer.- Rapid node transitions are coalesced (max one announcement per 500 ms, latest wins) — parallel branches otherwise emit faster than speech synthesis can consume.
- The Execute button carries
aria-busy="true"for the duration of the stream.
Other regions: the dock aria-label="Node details, execution results, errors and events" with a role="tablist" of four role="tab" buttons over four role="tabpanel" bodies (the inspector's role="dialog" died with the overlay — a tab needs no dialog role, §8); the error modal role="alertdialog" with focus moved to it on open. The dock carries no aria-live of its own — "Errors (1)" is announced through the single #pe-live-region this section owns; a second live region on the same page is what this rule exists to prevent.
14.3 Color contrast
All node colors derive from design system tokens, which are audited for WCAG AA compliance by the design system's own contrast audit (npm run audit:contrast in the design system project). The token mappings in Appendix A ensure:
- The card is constant across states (
--node-surfacebackground,--node-label-textlabel): one pairing to audit, and it is the same surface/text pairing the design system uses for raised cards. - State accents are borders, not fills (
--node-*-accenton a 2px border): they carry state in addition to the shape/type and ring/selection channels, so even a hypothetical low-contrast accent never hides information — the §14 node list repeats every state in words and in its own accent stripe. - Selection (
--node-selected-ring): the design system's primary/focus accent, AA-compliant as an indicator against both light and dark surfaces.
If a custom theme introduces a contrast issue, the design system's contrast audit catches it at build time. We run npm run audit:contrast in the design system project as part of our CI when syncing themes.
For colorblind users, status is also indicated by:
- Shape: DML nodes are diamonds, DDL are tags, DQL are rectangles (PIPELINE nodes are hexagons).
- Opacity: aborted nodes render at 0.5 opacity — a non-color channel.
- Text: the details panel and the §14 node list always show the status in words — no state is conveyed by color alone.
15. Connection Loss and Cancellation
15.1 Connection drop — the execution is cancelled, not resumed
There is no reconnection. REST API §6.8 is explicit: a disconnected SSE client cancels its own execution. When the stream drops the server starts a grace timer (datapipelines.sse.disconnect-grace-seconds) and, if no terminal event has been reached when it elapses, interrupts the in-flight statements and finishes the execution as ABORTED. Last-Event-Id is ignored; there is no resume endpoint and no GET variant of execute to reconnect to. Any UI that promises "reconnecting…" is lying to the user.
What the editor does instead:
- The
fetchreader throws, or the stream closes without a terminal event (pipeline_completed/pipeline_failed/execution_aborted). Both are the same condition —handleConnectionLoss(). - A non-blocking banner appears immediately: "Connection lost — this execution will be cancelled shortly. Checking final status…" No retry, no countdown that implies recovery.
- The editor polls
GET /api/v1/executions/{execution_id}(REST API §10.2) once, after a short delay, and at most once more — enough to catch the abort landing, not a polling loop. - On a terminal status the banner is replaced by the real outcome and the graph is finalized:
ABORTED→abortUnfinishedNodes(), banner "Execution aborted — the connection was lost."SUCCESS→ the execution had already finished when the stream died; the result is in Redis for its TTL, so the dock's Results tab is populated from the cursor (§10.5).FAILED→ the error modal (§9), populated from the execution record.
- If both polls still report
RUNNING, the editor stops and shows "Execution still running — it will be cancelled within {grace} seconds. Reload to see the final status." It does not keep polling.
// execute.js
async function handleConnectionLoss(executionId) {
window.banner.warn('Connection lost — this execution will be cancelled shortly. Checking final status…');
if (!executionId) return; // stream died before execution_started
for (const delayMs of [2000, 8000]) { // two probes, then stop
await sleep(delayMs);
const res = await fetch(`/api/v1/executions/${executionId}`, { credentials: 'same-origin' });
if (!res.ok) continue;
const { data } = await res.json();
if (data.status !== 'RUNNING') return finalizeFromRecord(data);
}
window.banner.warn('Execution still running — it will be cancelled shortly. Reload to see the final status.');
}
15.2 Explicit cancellation
While an execution is running, the Execute button is replaced by Cancel, which issues DELETE /api/v1/executions/{execution_id} (REST API §10.4; scope execute + ownership) — a cookie-authenticated state-changing call, so it carries the same DP-CSRF-Token double-submit header as execute (§7.2, Auth §8.4).
- The
204acknowledges the request, not completion. The UI shows "Cancelling…" and waits for theexecution_abortedevent on the still-open stream, which is what actually finalizes the graph (§6.3). - Cancellation works from any server instance (it travels via a Redis flag), so completion can lag by up to one heartbeat interval. The UI must not assume the
204means the nodes have stopped. - Cancelling an execution that already reached a terminal state returns
409withpipeline.execution.not_running— the editor swallows this quietly and just renders the terminal state it already has.
15.3 What is NOT offered
- No reconnection or stream resumption — deliberately removed; see §15.1.
- No fire-and-forget execution. Closing the tab cancels the run. A user who needs the pipeline to finish unattended should trigger it via the REST/MCP surface, not the editor.
- Past executions are still inspectable after the fact: metadata via
GET /executions/{id}, the event stream replayable for 1 hour viaGET /executions/{id}/events, and the result within its TTL via the cursor (§10.5).
16. Performance
16.1 Expected graph sizes
Typical pipelines have 3–20 nodes. Large pipelines might have 50–100. Cytoscape.js handles hundreds of nodes smoothly on canvas.
16.2 SSE event throughput
Execution events arrive at human-observable rates (one per node start/complete, ~1-5 seconds apart for real pipelines). No performance concern.
16.3 Result preview rendering
The result table renders one page at a time (datapipelines.result.page-size-rows); further pages come from the cursor on demand (§10.5) and replace the visible page rather than appending indefinitely. DOM stays small regardless of total_rows.
16.4 Cytoscape performance tips
- Disable box selection (we don't need it):
boxSelectionEnabled: false. - Disable node dragging (read-only graph in v1):
autoungrabify: true. - Use
elements: [...]batch initialization (notcy.add()per element) — we already do this.
17. Testing
17.1 Manual test matrix
| Scenario | Steps | Expected |
|---|---|---|
| View pipeline | Navigate to editor URL | Graph renders with all nodes in dagre LR layout |
| Execute simple pipeline | Click Execute | Nodes turn blue → green sequentially; result panel shows |
| Execute with failure | Click Execute on pipeline with unreachable datasource | Failed node turns red; error modal shows; pending nodes turn gray |
| Switch versions | Use version dropdown | Page reloads with selected version's graph |
| Node details | Click node | Details panel slides in with node metadata |
| Caller node marker | Open a pipeline with a caller node, then one without | .caller double border on exactly one node; none on the pure-ETL pipeline |
| Zero-caller pipeline | Execute a pure write-back pipeline | Completes with stats banner; no data_ready, no result panel |
| Connection loss | Kill the network mid-execution | Banner "connection lost — will be cancelled"; no reconnect attempt; status poll resolves to ABORTED; nodes go gray |
| Explicit cancel | Click Cancel mid-execution | 204; execution_aborted arrives; running + pending nodes → aborted; banner names the reason |
| Multi-page result | Execute a pipeline returning more rows than one page | First page inline from data_ready; "Load next page" hits the cursor; order stable |
| Expired result | Wait past the result TTL, then page | "Result expired — re-run" (no retry loop) |
| Parameter coercion | Submit a BOOLEAN + INTEGER + TIMESTAMP parameter set | Request body carries true/42/offset-bearing timestamp — not "true"/"42"/zone-less |
| Keyboard nav | Tab through page, arrow through node list | Every action reachable; graph canvas itself is skipped; node list announces status changes |
| No-JS | Load with JS disabled | Pipeline metadata + node list render; <noscript> explains the graph is unavailable |
17.2 Automated tests
- Unit tests (JS, run in headless browser or jsdom):
PipelineGraph.buildElements()— correct node/edge construction and classes:nodeType{DQL|DML|DDL},idleon every node,calleron exactly the node resolving tooutput.target: "caller"(declared or omitted), and on no node for a zero-caller pipeline.PipelineGraph.setNodeStatus()— class transitions are exclusive (previous status class removed, includingidle).abortUnfinishedNodes()— running nodes are aborted too;success/failedare preserved.parseSseEvent()— correct SSE wire format parsing; heartbeat comment frames yieldnull.coerceParameter()— per declared type:BOOLEAN→true,INTEGER→number,BIGDECIMAL→string (never a number),TIMESTAMP→offset-bearing string, bad input throws.a11y.syncStatus()— the<li>text and the canvas class agree after every transition.dock.js— every row of the §10.1 transition table, plus "Escis a no-op" and "a minimised dock keeps its badge", plus a check that no transition in the module can return the dock tohidden(dock.test.mjs).events.js— per-kind text, the arrival-order log, the t0 offsets and the auto-scroll pin (events.test.mjs); the Details pane's per-type content (details-pane.test.mjs); the exactly-once toast under a duplicated lifecycle (editor-toast-once.test.mjs). (The 065inspector.jsand its suite are deleted — the overlay is gone.)EditorLayoutRenderTest— the editor's<main>renders withapp-main-bleed, the pipelines list's without it (§4.3).
- Integration tests (Playwright or Cypress):
- Full execute flow: render → execute → SSE events → graph updates → the dock's Results tab with the inline first page.
- Failure flow: execute → node fails → the dock raises its Errors tab with the record → error modal shows the one-line summary → graph retains state.
- Dock flow: results open → minimise → the canvas grows → a tab click restores. There is no path that loses the pane.
- Abort flow: execute → Cancel →
execution_aborted→ all unfinished nodes aborted, banner shown. - Connection-loss flow: stream killed mid-execution → banner, no reconnect request issued, status poll fires at most twice.
- Cursor paging: multi-page result → next page → stable row order, TTL countdown, expiry message.
- Version switch: dropdown changes → page reloads with correct version.
- Accessibility: axe scan clean; arrow-key traversal of the node list drives canvas selection; live region announces each status change.
18. Stability Promise
18.1 Frozen in v1
- The page URL structure (
/pipelines/{id}/editor). - The three-panel layout (sidebar + graph + details).
- The Cytoscape.js + dagre LR rendering.
- The 5 node states (idle/running/success/failed/aborted) and their class names.
- The SSE event → graph update mapping, including
execution_abortedas a terminal event. - The parallel accessible node list as the AT/keyboard surface for the canvas graph.
- The vendoring strategy (no CDN, no build step).
- The error modal and result panel shapes.
- Read-only: the editor never writes pipeline definitions in v1.
18.2 Not frozen
- Specific color values (may be themed in future).
- JS file organization (may be refactored to modules).
- Addition of an authoring/edit mode, including drag-and-drop topology editing (ROADMAP, §11.3).
- Layout algorithm choice (may offer per-pipeline config).
19. Open Questions / Future Additions
Out of scope for v1, tracked in ROADMAP:
- UI edit mode — editable node metadata + "save as new version" (§11.3). Deferred with drag-and-drop, not ahead of it.
- Drag-and-drop graph editing — add nodes, draw edges, edit in place. Requires node palette, edge-drawing interaction, live validation. v2.
- Async / detached execution — today closing the tab cancels the run (§15.3). Webhook-notified background execution is the ROADMAP answer, not SSE resumption.
- Live parameter form from JSON Schema — auto-generate the parameter form from the pipeline's
parametersdeclaration (currently Thymeleaf-rendered, which works but isn't dynamic when the pipeline JSON changes client-side). - Mini-map — Cytoscape supports an extension (
cytoscape-navigator) for a mini-map overview on large graphs. - Undo/redo for edits — if graph editing lands.
- Template inline preview — click a node and see its template body rendered (read-only) without leaving the editor.
- Multi-pipeline view — show multiple related pipelines on one canvas (for cross-pipeline dependency awareness).
- Mobile touch support — Cytoscape supports touch, but the three-panel layout doesn't work on phones. Needs a separate mobile layout.
Dark mode is NOT a future item — it's built into the design system (
themes/dark.css,themes/auto.css). Runtime theme switching works out of the box via the#theme-linkswap mechanism described in Appendix A.5.
Appendix A: Token Mapping (Design System Integration)
The pipeline editor defines app-specific semantic tokens in app.css that derive from the @acme/design-tokens design system. No hardcoded hex values anywhere in the editor CSS or Cytoscape styles.
A.1 app.css — app-specific tokens deriving from design system
:root {
/* ============================================================
Graph node cards (§5.3) — neutral card; TYPE is the icon badge,
STATE is an accent border, SELECTION is the ring
============================================================ */
--node-surface: var(--surface-raised);
--node-border: var(--border-default);
--node-label-text: var(--text-primary);
--node-selected-ring: var(--accent-primary);
--node-selected-halo: var(--accent-primary);
--node-running-accent: var(--accent-primary);
--node-success-accent: var(--accent-success);
--node-failed-accent: var(--accent-danger);
--node-aborted-accent: var(--accent-warning);
/* Card geometry (059 §A): one source for the canvas box and the HTML
overlay — readDesignTokens() parses these for the Cytoscape style;
pipeline-editor.css uses them for the overlay div. */
--pe-card-w: 264px;
--pe-card-h: 164px;
/* ============================================================
Banner state fills (pipeline-editor.css .pe-banner) — the
graph's success/failed/aborted accents fall back to these,
so a theme override re-themes banner and graph together
============================================================ */
--node-success-bg: var(--accent-success);
--node-success-text: var(--accent-primary-text);
--node-failed-bg: var(--accent-danger);
--node-failed-text: var(--accent-danger-text);
--node-aborted-bg: var(--accent-warning);
--node-aborted-text: var(--accent-primary-text);
/* ============================================================
Edge colors
============================================================ */
--edge-idle-stroke: var(--text-secondary);
--edge-active-stroke: var(--accent-primary);
/* ============================================================
Editor layout — uses design system layout tokens
============================================================ */
--editor-sidebar-width: var(--sidebar-width); /* 280px from design system */
--editor-details-width: 320px; /* app-specific; not in design system */
--editor-topbar-height: var(--header-height); /* 60px from design system */
}
A.2 Why app-specific tokens, not direct design system references
We don't reference --accent-primary directly in Cytoscape styles because:
- Indirection enables restyling. If we later decide "running nodes should be teal, not indigo," we change
--node-running-accentin one place, not every Cytoscape selector. - Semantic clarity.
--node-running-accentis self-documenting.--accent-primaryrequires the reader to know what accent-primary means in the context of a graph node. - Theme portability. Some design system themes may have unusual accent colors (e.g.,
minimaltheme uses black + white). The app-specific tokens let us remap for edge cases without touching Cytoscape code.
A.3 Design system tokens used directly (no app wrapper)
These design system tokens are used directly in the editor CSS and HTML because they're already semantic enough:
| Design system token | Used for |
|---|---|
--surface-page |
Editor page background |
--surface-raised |
Sidebar, details panel background |
--surface-overlay |
Modal backdrop |
--text-primary |
All primary text |
--text-secondary |
Node descriptions, secondary labels |
--text-muted |
Placeholder text, disabled state |
--border-default |
Panel borders, dividers |
--border-focus |
Selected node border |
--accent-primary |
Execute button, links, active edge |
--accent-danger |
Error modal header, failed node |
--radius-base |
Node border radius, input fields |
--shadow-md |
Details panel shadow, modal shadow |
--space-4, --space-6 |
Spacing in sidebar, panels |
--font-sans |
All text (Cytoscape font-family reads this) |
--font-mono |
Code blocks in error details, result data |
--header-height |
Topbar height |
--sidebar-width |
Sidebar width |
--breakpoint-md, --breakpoint-lg |
Responsive breakpoints |
A.4 Primitives used in the editor
| Design system primitive | Where in editor |
|---|---|
.ds-button (.ds-button--primary, --ghost, --sm) |
Execute button, graph controls, modal buttons |
.ds-card |
Details panel container, result preview container |
.ds-input, .ds-label, .ds-field |
Parameter form |
.ds-select |
Version selector dropdown |
.ds-badge (.ds-badge--neutral) |
Pipeline version badge |
.ds-modal, .ds-modal-backdrop |
Error modal |
.ds-code-block |
Error details JSON, SQL preview |
.ds-link |
Documentation links in error modal, template links |
.ds-text, .ds-text--danger |
Error messages |
.ds-h2, .ds-h4 |
Section headers |
A.5 Theme switching
The design system supports runtime theme switching by swapping the #theme-link href. The editor supports this:
- The user selects a theme on the profile screen (global nav, not the editor itself). That choice is persisted to
users.theme_preferenceviaPATCH /partials/profile/theme(UI Screens §4.11) — never held in session state — and becomes the left-hand side of the §3.4 resolution on every subsequent request. - The server's response swaps the
#theme-linkstylesheet element out-of-band, so the new theme applies without a page reload (equivalently:document.getElementById('theme-link').href = '/vendor/design-system/themes/' + themeName + '.css'). - All CSS custom properties update instantly across the page.
- The editor's
PipelineGraphlistens for theme changes and re-reads tokens — this mechanism is indifferent to whether the active theme came from the user row or the deployment default:window.addEventListener('theme-change', () => { const newTokens = readDesignTokens(); this.cy.style().fromJson(buildGraphStyle(newTokens)).update(); }); - Graph re-styles without re-rendering (Cytoscape's
.style().update()is incremental).
Themes shipped by the design system — saas (modern indigo, devtool-oriented), light (clean neutral), dark, auto (follows OS prefers-color-scheme), professional (navy/enterprise), plus healthcare, minimal, forest, ocean. The authoritative valid-value list and the deployment default live in Configuration §3.10; this paragraph describes their character, not their validity.
Appendix B: Change Log
| Date | Version | Author | Change |
|---|---|---|---|
| 2026-09-18 | v1.17 | 159 addendum / #151 cards re-measure when they grow mid-run | §5.3: measureCard per height-changing write (state, stats, the port lines, the markers' elapsed line, the reset), deferred behind the html-label's re-render, no relayout in flight except on overlap; settleCardHeights re-runs a stale layout once at End's terminal state; the oscillation bound never reaches this path. |
| 2026-09-17 | v1.16 | 159 / #148 the Start disc runs on a human press and cancels while running | §5.3b: the press guard — the cause of "Start does nothing" on the live editor was Cytoscape's mousedown activate() on the marker node re-rendering the disc (cytoscape-node-html-label re-parses on every style event, setTimeout(0)) under a held button, so no click was ever dispatched; wireMarkerActivation now stops mousedown/pointerdown/touchstart for .pe-marker-run targets in the capture phase. Cancel from the marker: while isExecuting the disc is .pe-marker-cancel (Cancel execution, the word Cancel, the square glyph, the danger fill, never aria-disabled) and its activation calls the toolbar's own cancelExecution(); Start again on any terminal state; a viewer who may not execute keeps the plain marker. Focus survives the re-render (keepMarkerFocus). §6.3: execution_started / execution_aborted rows name the disc's two faces. |
| 2026-09-17 | v1.15 | 151 addendum / #144 boundary redesign | New §5.3b the execution boundaries: Start a disc and End a rounded square in a transparent 72px box (node.boundary, BOUNDARY_W; connectors anchored to the marker's half width); Start is the run trigger when canExecute (the toolbar's own flag, stamped as data-can-execute, read by init()) — role="button", Enter/Space via wireMarkerActivation, the same executePipeline(), aria-disabled + pulse while Running…; End's fill and word are the outcome, with the run clock's elapsed (durationText); minimap silhouettes; #cy-canvas role="group". §6.3: End on the terminal rows. §10.7: the closing sentence that said the arrows were untouched and no marker existed is replaced — the reducer now feeds the port, and the markers read the run status. |
| 2026-09-17 | v1.14 | 151 / #127 arrows vs. writes | §5.3 edges rewritten: a depends_on edge is an ORDERING (kind: "dependency"; active still / satisfied / unmet; done, rows/rowLabel and the rAF dash flow retired — no count rides an edge, nothing moves along one). New §5.3a the output port: one row per configured output (outputPortFor mirrors NodeOperations.operationFor), states from describe().port (waiting amber and still, writing the only flow, honest terminal words, CTAS one statement), stale on stream loss, settlePolledOutcome on the recovery poll; Details Depends on / Required by; arrow tap announces its meaning; legend Depends on / Output write. §6.2/§6.3 tables updated; edgeLabelText/edgeLabelBg retired from readDesignTokens. |
| 2026-09-17 | v1.13 | 149 correction / #125 review R149-2 | §10.7: the execution-terminal fallback closes open operations with committed: null ("Commit not observed"), never false; observed terminals untouched; §8.1 Commit row wording. |
| 2026-09-16 | v1.12 | 149 / #125 measured node operations | New §10.7 the node-operation reducer (node-ops.js, pure): one view model per node from node_progress and the lifecycle events — sequence-ordered, sealed by the terminal sample, closed by node/execution terminal events with committed UNKNOWN rather than invented; the interface 151's output connector consumes. §5.3: while a node runs the footer is the measured operation (state word left, live count right, pe-card-op-<state> dot colours through tokens; one line, T251 kept). §6.2: the node_progress row. §8.1: Operation / Progress / Rows / Time in / Commit / Child execution rows. §10.6: the Events tab sentence. §12.1: node-ops.js. §14: the node row's accessible description carries the operation. No edge, arrow, row-label or Start/End change (#127, #126). |
| 2026-09-13 | v1.11 | T251 calculator card footer | §5.3 footer + §6.2 node_completed: a calculator's footer and edge label COUNT what it wrote (2 keys · 2 ms, edge 2 keys); the values stay in the Details pane and the Events tab. The owner's screenshot: the multi-output window calculator's footer inlined = last_quarter_end="2024-12-31", last_quarter_start="2024-10-01" · 2 ms, wrapped to three lines in a footer laid out for one, floated the Done dot mid-card, and its edge read 0 rows. graph.js: keysWritten/countLabel, edgeLabelFor (a calculator's edge never says rows); .pe-card-rt is nowrap + ellipsis as the belt. Pinned in graph-card.test.mjs (footer count forms, edge label per kind) and in the browser on a real calculator run. |
| 2026-09-06 | v1.10 | wide-stage card scale and the stray node list (082) | §5.3/§4.3: the card steps up on a wide stage. "At 2560 the cards read small" cannot be a zoom change — the owner's ruling caps fit at 1.0 and forbids zooming IN — so .pe-stage became a container-type: inline-size container and a stage ≥ 1200px wide gets --pe-card-w: 272px, --pe-card-h: 170px and the card type scale (now tokenised as --pe-card-fs-*) a pixel larger. The declarations land on #cy-canvas because a container cannot style itself and because readDesignTokens(containerId) now reads the geometry from that element — reading documentElement would keep the Cytoscape node box at 236px while the HTML card grew. A ResizeObserver on the stage re-runs layout when the threshold is crossed live. §14.1: the node picker is a KEYBOARD surface again — every pointer route selects with moveFocus = false, and the picker now shares one bottom-anchored column with the legend (.pe-stage-bl) instead of being anchored to the same corner, which is what put it on top of the legend in 080's dark Details shot. New guards: card-scale.test.mjs, node-picker-focus.test.mjs, an EditorLayoutRenderTest nesting assertion and a PipelineEditorDetailsBrowserTest geometry assertion. No wire contract moved. Addendum (owner's first walk of the merged UI), two P1s: the dark theme broke the canvas — TWO causes, both measured live rather than assumed. (1) readDesignTokens handed Cytoscape the raw custom-property text, and four canvas tokens are color-mix() bridges it cannot parse; every colour is now resolved through a probe element and normalised to rgb(…) — Chrome answers color(srgb …), not rgb(), which the first cut of the fix rejected. (2) updateTheme() re-applied with cy.style(array), which RESETS a live graph to Cytoscape's defaults (#999 lines, 30px edges, #999 fills — the owner's grey bands); the re-apply is cy.style().fromJson(sheet).update(), and because fromJson drops function values the per-node card height became an element bypass. 080's wheelSensitivity (warned on every init) is gone. The card footer painted outside the card — --pe-card-h was a fixed height that three fact lines overflow; it is a min-height now and syncCardHeights() hands each measured height to Cytoscape so the node box, the ports and the minimap follow the real card. Guards: graph-colour-tokens.test.mjs, card-height.test.mjs, and a live theme-switch browser test demanding zero Cytoscape complaints. |
| 2026-09-05 | v1.9 | editor v2 canvas and dock (080) | The owner-approved mock (2026-09-05-editor-canvas-v2.html) becomes the editor. §5.3 rewritten: the v2 canvas — dotted-grid stage, the mock's card anatomy (type-accent icon tile, id + type eyebrow, up to three mono facts, footer with state dot + run numbers, ports, hover lift + hover-only expand, running progress line), bezier edges with three states (--edge rest / --edge-active dashed + JS-stepped flow while the target runs / --edge-done after), row counts riding the edge behind the rows class, controls + minimap bottom-right, legend bottom-left, keyboard hint top-left, and the fit ceiling: fit never zooms IN past 1.0 (the 059 0.75 floor stays). Canvas tokens are ONE new block in app.css (the five node-type accent pairs + --brand(-soft) + --border-faint + --grid-dot + --edge*), bridged to the design system so all nine themes re-skin the canvas. The 065 inspector overlay is DELETED — §8 rewritten: Details is a tab of the dock (owner ruling 2026-09-05), with per-type meta and the rendered SQL / calculator evaluation / child mapping; the per-node Failure section merged into the per-run Errors tab. §10 rewritten: the dock is Details | Results | Errors | Events, always present (hidden died with the overlay), collapsed the only contraction, still no close; §10.6 new: the Events tab — every SSE event in arrival order with a live count badge and a yielding auto-scroll. The exactly-once toast: "many success toasts" was the 076 afterSettle rescue stacking Alpine components on a history-restored root (one click → N executions → N terminal toasts); the rescue now destroys the stale tree before re-binding (editor-toast-once.test.mjs falsifies the old shape). §4.2/§4.3/§5.4/§6.2/§6.3/§9.1/§12.1/§14 updated to match; events.js in, inspector.js out. Top bar gains the mock's crumb (folder muted + name bold), version chip and run-status clock (§4.2). No wire contract moved. |
| 2026-09-04 | v1.8 | editor real estate and panes (065) | Four layout/behaviour changes on a read-only editor; no wire contract moved — the SSE events, the 027b result cursor, the SQL partial, the 057 failure record and the 059 card facts are exactly as they were. This round re-homed what was already on the page. §4.3 rewritten: the editor is FULL-BLEED. layouts/default.html capped every page at --app-content-max: 1600px; on the owner's ~2000px viewport the editor took ~1450px between two dead margins, and the graph — the one surface that scales with width — was paying for a measure chosen for prose ("use all the real estate on screen", twice). One model attribute (fullBleed, set by PipelineEditorController and no other page), one th:classappend, one .app-main-bleed rule (tokens only); the nav keeps its own cap. Pinned by EditorLayoutRenderTest in both directions. §4.3 also gains the one table of every pane's dimension. **§10 rewritten: the result panel becomes a DOCK — Results |
| 2026-09-03 | v1.7 | icon sizing (059b) | The 059 screenshots showed every icon at canvas scale: toolbar glyphs 300×150, card glyphs ~190px, the database glyph drawn TWICE per card. Two causes, both fixed at the source. (1) icons.css was never loaded — the .ds-icon size classes on every emitted <svg> were inert, and an svg with no size is the 300×150 replaced-element default. The editor page now links /vendor/design-system/icons.css (pinned by PipelineEditorRenderTest and the 027b harness); §5.3 item 2 records the rule: every svg the editor emits carries the class pair, never bare. The toolbar is a row of .ds-icon-md buttons at the canvas's top-right corner (§14.1 note updated). (2) The card drew #db twice — the type glyph (line 2) and the engine glyph (line 3, added in 059 beyond the five-line spec) are the same database drawing on every db-backed card. The engine glyph is RETIRED: the card's one glyph is the type glyph, and the engine's identity is the source line's text (POSTGRES, SQLITE, …) — the "engine glyphs by dialect" sentence leaves §5.3 with it. iconForDialect deleted; the sprite keeps its 12 recorded glyphs (file unused, harmless). Gates: the live DOM check (every .pe-graph svg/toolbar svg ≤ 24×24, exactly one glyph svg per card) red on 5187efd (toolbar 4×300×150, cards 2 svgs ~190px), green after; measured on the demo stack. |
| 2026-09-03 | v1.6 | graph node cards (059) | §5.3 rewritten for the CARD, reversing the 2026-08-31 label-below contract (the operator reviewed the 031 result on the live product, 2026-09-02: an empty box with a caption — "I want to display total node execution time, dialect, template name and datasource name. It should be INSIDE the box"). The five lines are specified: name (two-line clamp, title carries the full), type badge + vendored Lucide glyph (the per-type SHAPES are RETIRED — the icon badge carries TYPE), datasource · dialect (resolved client-side from GET /api/v1/datasources, the body is portable and carries only names; tempdb · H2 from settings; a PIPELINE card names the child pipeline), template@version LEFT-truncated so the leaf survives (043), and the run line from node_completed's FLAT duration_ms/rows_out — absent, not a placeholder, before any execution. Rendering: route 1 decided — cytoscape-node-html-label 1.2.2 vendored (pointer-events:none container, pan/zoom transform — verified against its source) paints the content OVER a canvas that still paints the chrome (state accents §6.2, caller double border, selection ring); state dots and run lines arrive as data.state/data.run writes the overlay re-renders on. Corner status dot (✓/✕/spinner/–) and edge PORTS specified. Card geometry is one token source (--pe-card-w/h). §B: the canvas fills the main pane (041 height math), fitToView() fits with padding then enforces a readable minimum zoom, dagre retuned (nodeSep 64, rankSep 176, fit: false — §5.1 updated), Fit/Reset/Zoom buttons keyboard-reachable (§14.1 note updated: controls exist, single-key shortcuts did not return). Edges: unbundled-bezier, endpoints on the card's right/left edges, per-edge horizontal control points computed post-layout (no control-point-positions in Cytoscape 3.34); a DASHED edge.secondary style is defined and deliberately unused (future template-import links). §6.2 unchanged. Appendix A gains the card geometry tokens. |
| 2026-08-31 | v1.5 | recurrence defect round (034) | §14.1 reconciled with the code: Home/End (first/last option, roving tabindex) and Escape (topmost-first close — error modal, result panel, details panel; one surface per press, unconsumed when nothing is open) are now IMPLEMENTED in a11y.js and pinned by a11y.test.mjs; the +/−, F, R rows were REMOVED — the graph zoom/fit controls they name do not exist, so they were spec requirements the code ignored. They return with the round that builds those controls. |
| 2026-08-31 | v1.4 | execute page redesign (032) | §8 rewritten around the SQL section. New §8.3: "show the SQL for a node" is a resolution problem, not a display problem — SQL lives in template entities (contract §2.3), so the new GET /partials/pipelines/{id}/nodes/{nodeId}/sql (scope READ_RESOURCES; the author-scoped free-form template render endpoint deliberately NOT reused) resolves the node's PINNED {id, version} and renders against the pipeline's own parameter context. Wire format is §6.3 JSON built by the page's own coerceValue — one coercion path for execute and preview. Three context outcomes documented: bound / sampled (sampleContext(), labelled) / rejected (named parameter, NO SQL — SQL from a value the executor would refuse is worse than none). PIPELINE nodes show the child-pipeline state, not an empty block. §8.1: SQL row added; the last-execution-stats and per-node error rows marked not implemented in v1 (they previously read as shipped); long-value wrapping specified. §8.2's route fixed to /templates/{id}/editor — the spec previously named /templates/{id}/versions/{version}/editor, which does not exist. §8 panel regrouped into Identity / SQL / Configuration / Runtime sections; template is now a real link; Output renders "returns result to caller (default)" instead of undefined. §9.3 new: terminal SSE events split — pipeline_failed keeps the modal; pipeline_completed / execution_aborted report as toasts via DpToast.show (Shape D — the one client-side builder, for events with no HTTP response); all three gain the announceStatus call they lacked (an addition, not a preservation). §10: the result grid moved onto the shared .ds-table (the bespoke .pe-result-table styles deleted); §10.2 records that the 027b paging arithmetic is frozen — restyled, not rewired. §4.2/§12.1: the new partial and sql-highlight.js join the page structure. The SQL copy confirmation is deliberately NOT a toast (live region + 1.5s label swap) — §8.3 says why. |
| 2026-08-31 | v1.3 | graph design (031) | §5.3 rewritten to the shipped stylesheet: node cards with the label BELOW the shape (was text-valign: center inside an 80×40 box, truncated at 20 chars — genuine change, operator contract), per-TYPE shapes (type-dml round-diamond, type-ddl round-tag, pipeline-node hexagon; classes renamed from the never-implemented nodeTypeDQL form), the node.caller marker (double border, contract §9) now actually emitted and styled, and SELECTION as the node:selected PSEUDO-CLASS with ring + underlay halo (was a .selected class the code never had; §5.4 corrected to match — selection is cyNode.select(), driven by init.js/a11y.js). State becomes an accent border via new --node-*-accent tokens (§6.2), superseding the --node-*-bg/text fill pairs — the second genuine change; the fill pairs remain only for the .pe-banner fills. §6.2: the unimplemented failed "brief flash" requirement WITHDRAWN; the running pulse specified honestly as a JS-driven ele.animate loop gated on window.matchMedia("(prefers-reduced-motion: reduce)") (canvas — CSS media queries cannot reach it). §5.1: layout options recorded as shipped — edgeSep, padding, and nodeDimensionsIncludeLabels: true (mandatory once labels sit below shapes); marginX/marginY noted as non-dagre options. §14.1/§14.2: node list corrected to roving tabindex (the previous markup gave <li>s no tabindex — the keyboard path was dead) and the data-state execution-state mirror. §3.4 bridge listing and Appendix A token map updated to the shipped token names. |
| 2026-08-05 | v1.0 | initial draft | Initial pipeline editor UI spec: Thymeleaf + Alpine.js + Cytoscape.js 3.34.0 + cytoscape-dagre. Three-panel layout, 5 node states, SSE-driven graph updates, vendoring strategy, accessibility, keyboard nav. |
| 2026-08-07 | v1.2 | consistency campaign | [D7] §15 rewritten: no SSE reconnection and no Last-Event-Id — a dropped stream cancels the execution after the grace period; the editor warns, polls GET /executions/{id} at most twice for the final status, and renders ABORTED. execution_aborted (rest-api §6.4.8) wired into §6.3/§6.4 as a terminal event; explicit Cancel (DELETE /executions/{id}) added (§15.2). [D9] §10 rewritten for uniform result delivery: one panel shape, inline first page from data_ready, cursor paging/downloads within a fixed TTL, result.expired handling; inline-vs-claim-check split deleted. [D1] "terminal node" → caller node throughout; .terminal Cytoscape class renamed .caller and actually applied in buildElements(); zero-caller pipelines documented (no data_ready). [D8] Theme resolution corrected to ${activeTheme} = users.theme_preference ?: datapipelines.ui.theme (per-user override, deployment value as default — ui-screens.md v1.1 §4.11); the config key, its default and the valid theme list are referenced from configuration.md §3.10 instead of restated (§3.4, Appendix A.5). [M] "Native EventSource" removed from the §3.2 stack (contradicted §7.3). Styling wiring fixed: nodeType* and idle classes added at build time, .selected managed in selectNode()/clearSelection(). §4.2 adds result.js, a11y.js and the x-on:show-error.window listener that gives §9.1's dispatch a consumer. [M] §7.2 collectParameters() coerces values to declared wire types (pipeline-contract §6.3) instead of posting FormData strings. [M] §14 accessibility rewritten honestly for canvas rendering: per-node role="button" is impossible, replaced by a parallel visually-hidden <ul role="listbox">, role="img" canvas, and an aria-live status region. [M] Progressive-enhancement overclaim removed (no-JS = metadata + node list, no graph). [M] §11 edit mode removed from v1 scope (?edit=true deleted) — authoring is LLM/MCP-first, UI edit mode is a ROADMAP item. [M] vendor-manifest.json unified to static/vendor/design-system/. [M] Duplicate ### 13.2 renumbered (version upgrades → §13.3). Anchor fixes: auth §6, dag-executor/rest-api cross-links; jdbc_url removed from the §9.2 error mockup. See SPEC-REVIEW-2026-08 §2.13. |
| 2026-08-05 | v1.1 | design system integration | Integrated @acme/design-tokens design system as the styling foundation. All hardcoded colors replaced with design system tokens (--surface-*, --text-*, --accent-*). HTML uses .ds-* primitives (.ds-button, .ds-card, .ds-modal, etc.). Cytoscape stylesheet reads tokens via readDesignTokens() bridge. App-specific node-state tokens (--node-*-bg/text) derive from design system accent tokens. Theme switching (9 themes including dark mode) works at runtime without page reload. Replaced Appendix A entirely. Updated vendoring to include design system CSS. |