# Pipeline Editor UI Specification

**Status:** v1.29 (revised — see Change Log)
**Owner:** datapipelines.co core
**Depends on:** [Pipeline Contract](/docs/pipeline-contract.md), [REST API + SSE](/docs/rest-api.md), [Type System](/docs/type-system.md), [Enums](/docs/enums.md), [Auth](/docs/auth.md), [Configuration](/docs/configuration.md), [@acme/design-tokens Design System](https://github.com/msabir/design-system-starter)
**Last updated:** 2026-10-19

---

## 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

1. **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.
2. **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 and `dependsOn` — 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".
3. **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.
4. **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.
5. **One page, the workspace composition (#349).** Six tabs — Flow (the graph + the bottom dock, the default), Overview, Parameters, Runs, Usage, Versions. Node Details lives IN the bottom dock beside Results, Errors and Events; there is no right inspector and no settings sidebar (its content is Overview's and the Parameters tab's).
6. **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.
7. **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.
8. **Readable identifiers everywhere.** Node IDs in the graph match node IDs in the pipeline JSON (`fetch_orders`, not `node_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}` (the read workspace, `PipelineWorkspaceController`); `GET /pipelines/{id}/editor` is `PipelineEditorController`'s compatibility redirect. |

### 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. |

> `EventSource` is deliberately **not** in this stack. Every SSE consumer in the editor is `fetch` + `ReadableStream` (§7.3).

The editor's scripts load through the shell's ONE page-script loader, `page-assets.mjs`
(#494; [UI Screens §3.2](/docs/ui-screens.md#32-boosted-navigation-the-app-shell-076)): the
vendors and modules ride in an inert `<template id="pe-runtime-scripts"
data-page-assets="on-mount">` catalog, which the loader fetches while a navigation
prepares and executes once the page is in place — in order, Alpine LAST so the modules own
the `alpine:init` registration, once per document across every page that shares a source
(dagre, Cytoscape, the tab core, the history listener). The catalog's last entry,
`pipeline-editor/runtime.js`, is the page's ONE initializer and a `data-page-mount`: the
loader calls it once per catalog node — the first arrival, a boosted swap and every cached
history restore — after every entry before it loaded and after the page's own bare tags a
swap or restore re-created have executed (a full load's order: a replayed script never acts
on an activated root). The `.pe-root` is served, and
cached, with `x-ignore`, and the runtime removes it inside its single `mutateDom`
activation (destroy-before-bind, the context read first). A cached history restore
replays no script at all: the layout's `hx-history-elt` scopes the history cache to the
workspace region, where only the inert catalogs and the parser-blocking `splitter.js`
live. `runtime-activation.test.mjs` owns the activation, `page-assets.test.mjs` the
loading (order, once per document, the `/js` and `/vendor` allow-list, the Alpine
singleton gate).

### 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, all `prefers-reduced-motion` aware.

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](/docs/ui-screens.md#411-user-settings)), otherwise the deployment default from [Configuration §3.10](/docs/configuration.md#310-ui) (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):

```html
<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 -->
<link rel="stylesheet" href="/css/data-table.css">         <!-- 282: the data table, right after app.css -->
<!-- …then the page sheets that build on both: template-tree.css, schedules.css -->
```

`app.css` loads after the design system so its rules win equal-specificity ties by document order (since 282 the data table's sheet, `data-table.css`, follows it directly — it folded app.css's `.ds-table` block and must win over what is left — and the head-loaded page sheets follow both; [UI Screens §3.7](/docs/ui-screens.md#37-the-data-table-282-normative)): 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.css` or theme files (they are vendored, not forked).
- App-specific semantic tokens (e.g., `--node-running-accent`) are defined in `app.css` and 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:

```javascript
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}?version=N&tab=flow|overview|parameters|runs|usage|versions   (canonical, #348)
GET /pipelines/{id}/editor                                       (compatibility redirect)
```

The canonical page is the version-explicit READ workspace: `version` is optional and, when
supplied, must be a positive integer — the named version's body renders exactly, or the house
404 (an absent, foreign or lens-hidden version is one answer); invalid syntax is the house 400.
With no `version`, the page resolves the ACTUAL current pointer first (a development-posture
current draft shows as the draft it is), then an accessible draft, then a choose-a-version
state over the admitted history — never "latest release". `tab` is the closed set above;
anything else resolves to `flow`, and `runs` resolves to `flow` for a caller without the
execution read. Selecting a version is read navigation (D5): it never moves the current
pointer, and since #349 it never reloads the document either — the selector and the
Versions tab apply the version IN PAGE (§10.8), so an active run keeps its stream and
identity. The old `/editor` URL redirects here — preserving a valid explicit `version` and a
supported `tab` — so every historical link keeps working; its floor is `read` (see below).

**How a reader arrives (#350).** From the global sidebar's **Pipelines tree** (ui-screens §3.4 —
a leaf is a persistent main-content link to `/pipelines/{id}`, no version, so the current-first rule above
resolves it) or from a row of the `/pipelines` **catalog** (ui-screens §4.3, the same link).
Both preserve the sidebar through the shell's prepared main-content navigation (#460). The
pipeline runtime retains sole initialization ownership; outgoing observation detaches without
cancelling execution, and cached history mounts one observer on return. Fresh sidebar initialization
loads only its root and never auto-reveals this leaf; an already mounted tree retains its loaded rows.

Authentication: session cookie carrying the internal JWT (browser flow). See [Auth §6](/docs/auth.md#6-session-tokens-internal-jwt). Required scope per the authoritative matrix in [Auth §7.6](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative): `read` to view (`pipeline.read` — every admitted role, the promoter through the lens), `execute` to run, `execute` to cancel.

### 4.2 Server-rendered HTML structure

```html
<!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:

- `PipelineEditorController` sets `model.addAttribute("fullBleed", true)`. **No other
  page sets it**, and `th:classappend` adds 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-container` cap.** 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>` carries `app-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-lg` gutter. 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 | — | withdrawn by #349 (the owner's workspace ruling): the graph owns the available width; the description and the declared parameters live in the Overview and Parameters tabs |
| 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 | a tab strip (`--pe-dock-tabs-h`, the shell's `--app-tab-h` since #565) over a 232px pane (`--pe-dock-pane-h`); the pane row is `minmax(0, …)` and gives way before the canvas goes under `--pe-dock-canvas-floor` (180px, #615) | `.pe-dock` |
| Dock, at start | collapsed when the canvas with the dock open would be under `--pe-dock-auto-collapse-below` (320px) and no pane height is remembered; open otherwise. The default re-decides as `.pe-panes` resizes until anything moves the dock (#615, owner ruling 2026-10-19; `dock.js` `defaultState`) | `init.js` `applyDockDefault`, `watchDockDefault` |
| 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 workspace keeps every action reachable and
grid track goes to 0, and the Details pane's two columns stack. Below `--breakpoint-md`
(768px) the minimap hides and the execution identity strip wraps (§10.9).

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:

```javascript
// 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 `.caller` class as its visual marker, not a fixed position ([Pipeline Contract §9](/docs/pipeline-contract.md#9-the-caller-node-result-node)). 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-elk` with `elk.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:

1. **The head** — an icon **tile** (34px rounded square) washed in the node type's accent pair (`--type` on `--type-bg`, set inline per card), the node **id** (semibold, one line, right-edge ellipsis), and the **type eyebrow** (11px uppercase, `.06em` tracking, in the type colour). The tile's glyph is the card's **ONE** glyph (059b's rule survives): `db` / `table` / `boxes` / `workflow` / `file` from the vendored sprite — generic, never a vendor logo; the engine's identity is the source fact's TEXT.
2. **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 `#db` would duplicate the DQL tile): source · dialect / template `@ v` / output for SQL types; `kind → context_key` and the input names for a CALCULATOR; child `@ v` and 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).
3. **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 carries `duration_ms` and `rows_out` only (SseEventProjection) — there is no `rows_in`, so the mock's `in → out` collapses 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 from `node_progress` ([REST API §6.4.9](/docs/rest-api.md#649-node_progress)) through `node-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` — tagged `pe-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`, else `12,000 fetched`). Both slots stay one line (`nowrap` + ellipsis on `.pe-card-state` too). 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 shows `Running…` 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.

```javascript
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.

```javascript
// 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](/docs/rest-api.md#64-event-types); 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](/docs/rest-api.md#65-event-ordering-guarantee)); the editor treats stream close without a terminal event as connection loss (§15.1).

### 6.4 Implementation: state update on SSE event

```javascript
// 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

1. User fills in parameter form (if pipeline has parameters).
2. Clicks **Execute**.
3. Execute button becomes disabled (`aria-busy="true"`), label changes to "Executing..."; the **Cancel** button appears (§15.2).
4. Values are coerced to their declared wire types (§7.2) and posted as typed JSON, WITH the viewed version pinned (`{"parameters": …, "version": N}` — the version the page resolved, released or draft, always sent; #348). Page opens the SSE stream via `fetch` (POST to `/api/v1/pipelines/{id}/execute` with `Accept: text/event-stream`).
5. SSE events flow in → graph, node list and live region update in real-time.
6. On a terminal event: button re-enables, Cancel disappears, and — result panel (`data_ready`, §10), success banner (`pipeline_completed` with no caller node), error modal (`pipeline_failed`, §9), or aborted banner (`execution_aborted`, §15).
7. If the stream ends **without** a terminal event, that is connection loss, not completion (§15.1).
8. **Navigation during a run never cancels it (#358).** Leaving the page (a boosted swap) DETACHES the stream — `dispose()` aborts the reader and disarms every timer without sending a cancellation (cancel is a verb with its own permission; navigation is not it) — and records the run on `window.__peLiveExecution`. A restored or re-entered page whose component binds while that record is still armed RE-ATTACHES through the replay stream (`GET /api/v1/executions/{id}/events`, the §10.3 log): each replay rebuilds the run's timeline, and once the terminal event is IN the log it is delivered — and toasted — exactly once. A replay that ends before the terminal re-arms within a budget; a spent budget says what a lost connection says. The record clears at the terminal, so a finished run is never re-announced and a fresh document never re-attaches. `sse-disposal.test.mjs` owns disposal and the re-attach loop.

### 7.2 Implementation

```javascript
// 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',
            // #348: the VIEWED version rides every execute POST — released included.
            // A page whose version context (#pipeline-workspace) is missing or malformed
            // refuses locally with a visible error and sends NOTHING (zero POSTs): it
            // never falls back to the server's execute-default, which can be a different
            // body than the one the person is looking at.
            body: JSON.stringify({ parameters, version: workspace.viewedVersion }),   // typed JSON — 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](/docs/pipeline-contract.md#63-wire-encoding-of-input-parameter-values) 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.

```javascript
// 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: `ParameterError` is 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) maps `INTEGER`→`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

```javascript
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](/docs/rest-api.md#66-heartbeat-keepalive)) 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 Node 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 = value`
  with 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, from `node_completed`'s
  `child_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 →" — and the SQL section's `name @ vN` reference (§8.3) — navigates to the template's canonical workspace `/templates/{name}` ([UI Screens §4.7](/docs/ui-screens.md#47-the-template-workspace)): the name's segments are the path, each segment encoded, never `/templates/editor?name=` (since #398 a 302 kept only for old bookmarks). The pinned version is not part of the route; the workspace opens the template's current resolution, not the version the node pins (adding the pin would change which version a reader lands on — an open decision, not this link's). 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](/docs/pipeline-contract.md) 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>[&version=N]
```

The optional `version` (#348) names the pipeline version whose body resolves the node — the
workspace always sends the version it is viewing, so the panel's SQL is the viewed body's even
when the current pointer names another one. Omitted (a legacy caller), it keeps the resolver's
working-version default (the draft when one exists, else the current release) — the agent
node-run default, unchanged. An explicit version that is absent, or hidden under a narrowing
lens, is the same 404 an unknown pipeline id is.

The WORKSPACE page (#348-b) is not a legacy caller: when its validated version context cannot
produce a pin — the block missing, unparsable, or failing the identity/body/positive-bounded-
integer validation — the panel refuses visibly and sends NOTHING, never a working-version
preview. The page's context has ONE validated initialization path (`workspace.js`) shared by
the full document load, a boosted swap and a cached history restoration (the restore re-reads
the restored block and clears stale refusal flags).

- **Scope:** `READ_RESOURCES`, matching every other read partial. The existing `POST /partials/templates/{id}/versions/{version}/render` is deliberately NOT reused: it requires `MUTATE_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 `parameters` query value is a JSON document in [contract §6.3](/docs/pipeline-contract.md) wire form, built client-side by the page's own `coerceValue` — the same function the execute path uses (§7.2). One coercion path for both surfaces; `ParameterCoercion` is 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, a `TemplateRenderException`, and an unknown node id each get their own `.ds-empty` state. `Node.template` is never null server-side (`Node.fromJson` binds `template ?: TemplateRef()`), so the "no template" branch keys on the node type / a blank template id — a null check would never fire.
- **Loading:** `htmx.ajax` **on 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-spinner` indicator 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's `flex: 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 a `grid-template-rows: auto 1fr` and the `<pre>` takes the `1fr`.
- **Highlighting and copy.** After the swap, `sql-highlight.js` re-highlights the `<code>` block — a zero-dependency, single-pass tokenizer (keywords, strings, comments, numbers, `${param}`/`:param` parameters), 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 its `data-sql` attribute (or the `<code>` element's `textContent`) — never from the highlighted `innerHTML`, 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](/docs/rest-api.md#644-node_failed);
redaction mechanism in [Observability](/docs/observability.md)). 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_failed` **keeps the error modal** (§9.2) for its one-line summary, and puts
  the record in the Errors tab (§9.1).
- `pipeline_completed` and `execution_aborted` report as **toasts** via `DpToast.show`
  (Shape D, [UI Screens §5.1](/docs/ui-screens.md)): 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's `reason` in its body.
- All three also call `announceStatus`, as does the first failure of a run ("Errors (1)")
  — through `a11y.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: Node 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.

#349 adds two rules. **The tab strip is Node Details | Results | Errors | Events, and the
three execution tabs render only for a caller with the execution read** — a promoter keeps
Node Details alone, and the dock stays the landing surface for node inspection. **The
execution tabs belong to ONE captured run**, named by the identity strip (§10.9); Node
Details follows the VIEWED version, and its run-derived rows (Last run, the measured
operation, the calculator Context resolutions, the child-execution link) attach only when
the run's pipeline AND version match the viewed body (§10.8). The dock lives inside the
Flow tab's pane — the graph and its run visualization are one surface — and the
workspace's own tab strip stays reachable while the dock is collapsed.

- `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 success
- `dock.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](/docs/rest-api.md#7-result-delivery)). 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](/docs/rest-api.md#647-data_ready)):

```
┌──────────────────────────────────────────────────┐
│  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.rows` from the event directly — no fetch needed for the first page. When `has_more` is `false` (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 house data table ([UI Screens §3.7](/docs/ui-screens.md#37-the-data-table-282-normative), 282) — a `.dt-frame` FIXED by the pane (`dt-fill`) whose one viewport scrolls both ways: the header holds (the scrollbar starts under it — the cap), the first column holds (`dt-freeze`), one line per row (`dt-nowrap`), and the columns sort (this page — `data-dt-paged`) and resize. The editor's bespoke `.pe-result-table` styles are gone; `.pe-result-table-container` keeps only the dock's inset and flex placement.
- 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_ready` event. 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](/docs/rest-api.md#72-cursor-endpoint)):

```
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()` with `hasPrev`/`hasNext`, `limit`-authoritative and `total_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 + `read` scope + ownership — `result_url` is **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_at` and counts down.
- After expiry the endpoint returns `410` with `result.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) and `result.execution_not_found` (404) surface through the same error path (§9). The full endpoint error list is [REST API §7.6](/docs/rest-api.md#76-endpoint-errors).
- A result exceeding `datapipelines.result.max-size-bytes` never reaches this panel — the execution itself fails with `result.too_large` and the error modal explains that large datasets belong in `output.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.
080's fix (the rescue destroying the stale tree before re-binding) is itself superseded:
since #358 the editor's scripts ride in an inert catalog (the shell's page-script loader
executes it and mounts `runtime.js` since #494, §3.2), the restored root keeps `x-ignore` until the runtime's single `mutateDom`
activation removes it, and init.js wires NO `afterSettle` initializer at all — one
Alpine instance per document, one activation per root. `editor-toast-once.test.mjs`
pins the wiring shape (the boost pair stays teardown-only, singular across a second
execution of the file) and one-stream-one-toast; `runtime-activation.test.mjs` owns the
activation discipline; `PipelineWorkspaceHistoryBrowserTest` reads the counts (panes,
`_x_dataStack`, POSTs) on the real app after real cache-hit restores.

---

### 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).

### 10.8 In-page version switching and the state keys (#349, spec §4.3)

The page/view state key is **workspace + pipeline + viewed version + request generation**;
the execution state key is **workspace + execution id**, carrying the submitted
pipeline/version/parameter snapshot. Switching the viewed version — the header selector or
the Versions tab's Open — fetches the version's body through the admitted REST read
(`GET /api/v1/pipelines/{id}/versions/{n}`, the same lens the page resolved with) and
applies it as ONE view transition: the graph rebuilds, the parameter schema and the
per-version overrides swap, the checks URL re-stamps and re-issues, the selector and
Versions-tab marks move, and the canonical URL moves with a pushed history entry of the
workspace's own (#402: `static/js/workspace/history.js`, state `{dpWorkspace: {family,
version, tab}}` — no parameter value; htmx's popstate ignores it). Back/Forward REPLAY an
entry on the live instance — the tab through the tab machine's admission, the version
through this same lensed read (`applyVersion(n, {history: false})`, which never pushes; a
refused read is the house banner) — without a reload and without touching the run below.
An entry that pops while another screen is showing is handed to htmx's own restore. The
workspace block keeps its composition facts across a switch (only the pin fields and the
rows' viewed mark are re-stated) and the root's `data-active-tab` follows the tab, because
both are what a cached history restore re-reads.

- **The active run is untouched.** The stream, the run facts and the result panel are
  execution-owned; the version switch moves the view under them. Every graph-paint
  decision (`sse.js` `paintsGraph`) compares the run's own version (the pin the execute
  POST carried, confirmed by `execution_started`'s `pipeline_version`) with the page's
  VIEWED version — a v1 run's progress never wears the v2 body, and returning to v1
  (`View run's version`, §10.9) replays the recorded states onto its own graph.
- **Stale never paints.** Each in-page read (node SQL, checks, runs, usage) carries the
  generation-token it was issued under as the `DP-PE-Sink-Token` request header; the
  `htmx:beforeSwap` guard compares it against the component's current token and cancels a
  stale response — success or refusal — before it can paint (A8, A→B→A included). The
  token rides the REQUEST because a newer request re-stamps any sink-carried copy. Reads
  issue from the one `hx-sync="this:replace"` requester: htmx's default DROPS a request
  on a target with one in flight, and this build's `abort` mode aborts without issuing —
  only `replace` aborts the old AND issues the new (verified in the vendored 2.0.10).
- **Run input drafts are per pipeline/version within the page.** Switching versions saves
  this version's overrides and loads the other version's own bag filtered to its schema —
  an incompatible field never leaks; revisit restores the compatible overrides. Overrides
  never enter localStorage, navigation URLs or history.
- **A refused switch moves nothing.** An absent, foreign or lens-hidden version answers
  with a visible banner refusal; the page stays on the version it resolved (never a
  silent fallback, never a clamp).
- **Leaving the page** (boosted navigation, history save) disposes the stream without
  cancelling (§7.3's rule); the restored or re-entered page re-attaches through the
  live-run record — which names the run's VERSION too, so a page restored at another
  version adopts the strip, never the paint.

### 10.9 The execution identity strip (#349, spec §4.2)

The Results/Errors/Events tabs show ONE captured run; the strip above them names it —
**Run vN · status · execution id · the effective submitted parameters** — armed by the
run's own `execution_started` (the pinned version, the parameters as bound) and settled
by the terminal event. The parameters shown are the pipeline's DECLARED keys as the run
resolved them, never the whole Context (the org/platform tiers ride the same payload and
stay off the strip). When the viewed version differs from the run's, the strip says so
and offers **View run's version** — the same in-page switch (§10.8), which replays the
run's states onto its own graph. The strip is not rendered for a caller without the
execution read: no execution tabs, no strip.

## 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:
1. **LLMs via MCP** — the primary authoring path for this product ([MCP Server](/docs/mcp-server.md)).
2. **Direct JSON editing** via the REST API (developer path).
3. **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](/docs/pipeline-contract.md#2-design-principles)) — 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](/docs/roadmap.md). 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](https://github.com/msabiransari/datapipelines/blob/main/docs/SPEC-REVIEW-2026-08.md#part-3--out-of-scope-for-this-campaign)).

---

## 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](https://github.com/msabiransari/datapipelines/blob/main/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:

1. Download the specific version from npm CDN (unpkg/jsdelivr) **once** during development.
2. Compute SHA-256 hash.
3. Commit the file + an entry in `static/vendor/design-system/vendor-manifest.json` (the single manifest for every vendored asset — see §12.1) recording:
   ```json
   {
     "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://registry.npmjs.org/@alpinejs/csp/-/alpinejs-csp-3.14.1.tgz (the CSP build's dist/cdn.min.js — 195: the editor's expressions are pure property paths)",
      "license": "MIT"
    }
   }
   ```
4. 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:

1. Build the design system: `cd ../design-system-starter && npm run build` → produces `dist/`.
2. Copy the needed files from `dist/` into `modules/web/src/main/resources/static/vendor/design-system/`.
3. Record the version (from `design-system-starter/package.json`) in `static/vendor/design-system/vendor-manifest.json`.
4. 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:
1. Download the new version.
2. Compute new SHA-256.
3. Update the file + manifest.
4. Run the editor test suite.
5. 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):

```html
<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"` with `aria-label` = a one-sentence graph summary (`"Pipeline DAG: 6 nodes, 7 dependencies, left to right"`) and `aria-describedby` pointing 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>`'s `data-state` attribute (`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 from `PipelineGraph.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 use `aria-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-surface` background, `--node-label-text` label): 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-*-accent` on 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](/docs/rest-api.md#68-client-disconnect) 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:

1. The `fetch` reader throws, **or** the stream closes without a terminal event (`pipeline_completed` / `pipeline_failed` / `execution_aborted`). Both are the same condition — `handleConnectionLoss()`.
2. A non-blocking banner appears immediately: **"Connection lost — this execution will be cancelled shortly. Checking final status…"** No retry, no countdown that implies recovery.
3. The editor polls `GET /api/v1/executions/{execution_id}` ([REST API §10.2](/docs/rest-api.md#102-get-execution-metadata)) **once**, after a short delay, and **at most once more** — enough to catch the abort landing, not a polling loop.
4. 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.
5. 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.

```javascript
// 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](/docs/rest-api.md#104-cancel-execution); 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](/docs/auth.md#84-api-endpoints-auth-via-api-key-or-jwt)).

- The `204` acknowledges the *request*, not completion. The UI shows "Cancelling…" and waits for the `execution_aborted` event on the still-open stream, which is what actually finalizes the graph (§6.3).
- **The delete's outcome is stated (#336).** An ACCEPTED cancel arms the 5-second abort fallback — the only case it exists for (a cancel the server took whose terminal event never arrives). A `409` is the documented quiet case: the execution already reached a terminal state and that is what renders. Any OTHER refusal, or a network failure, toasts the server's catalogued `error.message` (fixed copy when there is no body) and leaves the stream open — **the run is never presented as cancelled**, and the fallback never arms.
- 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 `204` means the nodes have stopped.
- Cancelling an execution that already reached a terminal state returns `409` with `pipeline.execution.not_running` — the editor swallows this quietly and just renders the terminal state it already has.

**The lifecycle block refuses the run (#336).** `draft.js` reads the page's `pipeline-lifecycle` JSON to pin the run to the draft it shows (versioning §8). A block that cannot be parsed is NOT "no draft" — silently running the RELEASED version while the person edits a draft runs the wrong body. The read records the refusal and **Execute stops with the error modal** (fixed copy: the editor cannot choose which version to run; reload), before any execute request leaves the page.

### 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 via `GET /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 (not `cy.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}`, `idle` on every node, `caller` on exactly the node resolving to `output.target: "caller"` (declared or omitted), and on **no** node for a zero-caller pipeline.
  - `PipelineGraph.setNodeStatus()` — class transitions are exclusive (previous status class removed, including `idle`).
  - `abortUnfinishedNodes()` — running nodes are aborted too; `success`/`failed` are preserved.
  - `parseSseEvent()` — correct SSE wire format parsing; heartbeat comment frames yield `null`.
  - `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 "`Esc` is a no-op" and "a minimised dock keeps its badge", plus a check that **no** transition in the module can return the dock to `hidden` (`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 065 `inspector.js` and its suite are deleted — the overlay is gone.)
  - `EditorLayoutRenderTest` — the editor's `<main>` renders with `app-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}`, version-and-tab query contract, §4.1 — the
  `/editor` URL remains a redirect into it; #348).
- 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_aborted` as 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](/docs/roadmap.md):

- **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 `parameters` declaration (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-link` swap 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

```css
: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:

1. **Indirection enables restyling.** If we later decide "running nodes should be teal, not indigo," we change `--node-running-accent` in one place, not every Cytoscape selector.
2. **Semantic clarity.** `--node-running-accent` is self-documenting. `--accent-primary` requires the reader to know what accent-primary means in the context of a graph node.
3. **Theme portability.** Some design system themes may have unusual accent colors (e.g., `minimal` theme 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:

1. The user selects a theme on the profile screen (global nav, not the editor itself). That choice is **persisted to `users.theme_preference`** via `PATCH /partials/profile/theme` ([UI Screens §4.11](/docs/ui-screens.md#411-user-settings)) — never held in session state — and becomes the left-hand side of the §3.4 resolution on every subsequent request.
2. The server's response swaps the `#theme-link` stylesheet 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'`).
3. All CSS custom properties update instantly across the page.
4. The editor's `PipelineGraph` listens 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:
   ```javascript
   window.addEventListener('theme-change', () => {
       const newTokens = readDesignTokens();
       this.cy.style().fromJson(buildGraphStyle(newTokens)).update();
   });
   ```
5. 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](/docs/configuration.md#310-ui); this paragraph describes their character, not their validity.

---

## Appendix B: Change Log


| Date | Version | Author | Change |
|---|---|---|---|
| 2026-10-19 | v1.29 | #615 editor canvas on a short window | **§4.3 Layout dimensions**: the open dock's pane row gives way to the canvas floor (180px, from 160); a new **Dock, at start** row — collapsed under 320px of canvas unless a pane height is remembered, and a start-up collapse opens on a run's first results. ui-screens §4.4 v1.168 carries the reasoning. |
| 2026-10-15 | v1.28 | #433 the template link names the workspace | **§8.2**: "Open template →" and the SQL section's reference link the canonical workspace `/templates/{name}` directly (segments as path, each encoded) instead of the `/templates/editor?name=` compatibility 302; the dangling "§9.6" pointer (this document has no §9.6) now points at UI Screens §4.7. No version pin — unchanged behaviour, the link opens the current resolution. The 302 stays for old bookmarks with its tests. Tests: `PipelineNodeSqlRenderTest`, `TemplateTreeRenderTest`, `template-ref-text.test.mjs` (the `selectedTemplateHref` getter). |
| 2026-10-11 | v1.27 | #494 one page-script loader | **§3.2**: the editor's catalog becomes `<template id="pe-runtime-scripts" data-page-assets="on-mount">` and loads through the shell's ONE loader, `page-assets.mjs` (fetched while a navigation prepares, executed once the page is in place, once per document across pages that share a source); `runtime.js` is no longer a loader or a bare tag but the catalog's last entry and `data-page-mount`, called once per catalog node (arrival, swap, cached restore) after the page's re-created bare tags have executed, keeping its activation, generation counter, before-save cleanup and `PEPipelineRuntime` identity. A cached restore now replays no editor script. Tests: `page-assets.test.mjs` (order, once per document, allow-list, singleton gate moved from `runtime-activation.test.mjs`), `PersistentChartNavigationBrowserTest` (per-source evaluation counts across all six pages). |
| 2026-10-06 | v1.26 | #460 reusable REST navigation | §4.1: persistent artifact entry, closed-root initialization, rail continuity and observer ownership through cached restoration. **Supersedes the #149 full-document editor-entry rule** (ui-screens v1.85): the workspace now arrives through prepared main-content navigation, and `pipeline-editor/runtime.js` loads the editor's modules in order and activates the component after the swap (`data-pe-runtime-epoch` is the readiness signal the browser witnesses wait on). |
| 2026-10-03 | v1.25 | #402 Back/Forward across the in-page switches | **§4.1**: in-page version/tab switches push entries and Back/Forward replays them (no longer `replaceState`). **§10.8**: the canonical URL moves with a pushed entry of the workspace's own (`workspace/history.js`, `{dpWorkspace: {family, version, tab}}`); a replay goes through the same lensed read with `{history: false}` and never pushes; an entry under another screen is handed to htmx; the workspace block and the root's tab attribute stay truthful for a cached restore. |
| 2026-10-02 | v1.24 | #350 the sidebar tree | **§4.1**: how a reader arrives — the global sidebar's Pipelines tree leaf or a `/pipelines` catalog row, both full-document links to the canonical `/pipelines/{id}` (no version: the current-first rule); the page's `data-nav-current` hook (id + path) lets the rail mark and reveal its leaf; the explorer's detail pane and "Open" are gone (ui-screens §4.3); Back/Forward across in-page switches is #402. The workspace header's lifecycle verbs and the Versions tab's Switch/Discard/Restore redirect with a flash since #395 (ui-screens §4.3d). |
| 2026-10-01 | v1.23 | #349 — the workspace composition. **§5** principle 5: six tabs (Flow the graph + dock, Overview, Parameters, Runs, Usage, Versions); the settings sidebar withdrawn, the graph owns the width (§4.3's row updated; the 1024px drawer and the sidebar resize contract die with the pane). **§4.1**: the selector is IN-PAGE read navigation — a version switch never reloads the document (an active run keeps its stream; §10.8). **§10**: the dock is Node Details beside Results/Errors/Events; the execution tabs render only with the execution read, belong to ONE captured run named by the identity strip (§10.9), and the Details pane's run-derived rows attach only when run version = viewed version. **§10.8** (new): the state keys, the generation/token discipline over every in-page read (the token rides the request; stale never paints; the `hx-sync="this:replace"` requester supersedes an in-flight read — the default drops it and `abort` aborts without issuing, both verified in the vendored 2.0.10), per-version run-input drafts (never persisted), refused switches move nothing. No route, permission or role changed. |
| 2026-10-01 | v1.22 | #358 the restore lifecycle, by lane 348-c | **§3.2**: the editor's vendors and modules load through `pipeline-editor/runtime.js` — an inert `<template id="pe-runtime-scripts">` catalog, Alpine LAST — and the `.pe-root` is served and cached with `x-ignore`; the runtime's single `mutateDom` activation (destroy-before-bind, context read first) is the ONE initializer a restored or swapped-in root can get, so a cached history restore cannot stack Alpine components (the 080 afterSettle rescue is GONE — init.js wires no afterSettle initializer; §10.6's exactly-once toast contract unchanged, its mechanism superseded). **§7.1 step 8**: navigating during a run DETACHES the stream without cancelling (`dispose()` — no DELETE; the run continues server-side) and a restored page re-attaches through the §10.3 replay stream — the terminal event is delivered, and toasted, exactly once; the live-run record clears at the terminal. Layout-scoped history (`hx-history-elt` on `#app-main`) and the old-cache shape guard are UI Screens v1.94's. Tests: `runtime-activation.test.mjs`, `sse-disposal.test.mjs`, `PipelineWorkspaceHistoryBrowserTest` (real cache-hit restores: one component, one pane, one version-pinned POST, zero CSP violations), `editor-teardown`/`editor-toast-once` rewritten to the ownership shape. |
| 2026-09-30 | v1.21 | 348-b (#348) the workspace's version-context corrections | **§8.3**: the workspace page is not a legacy caller — a page whose validated version context cannot produce a pin refuses the preview visibly with ZERO requests (the working-version default stays with callers that omit the parameter). The page's context has ONE validated initialization path (`workspace.js`: positive bounded integer, body presence, pipeline identity) shared by full load, boost and cached history restoration — the restore re-reads the restored block and clears stale refusal flags. **§4.1's projection**: the page's script JSON states only the LENS-VISIBLE current pointer (a development-posture current draft's number cannot leak into `current_version` for a promoter); PipelineResponses' REST shape untouched. |
| 2026-09-30 | v1.20 | 348 (#348) the version-explicit workspace | **§4.1** is the canonical read page `GET /pipelines/{id}?version=N&tab=…` at the `pipeline.read` floor — explicit version or 404, never clamped; current-pointer-first default; choose-a-version/empty states; `/editor` is a compatibility redirect (§4.1's `/versions/{version}/editor` line named a route that never shipped; both are superseded). **§7.1/§7.2**: every execute POST pins the VIEWED version (`body.version`, released included); a missing or malformed version context refuses with a visible error and ZERO POSTs (the `#pipeline-workspace` block and `workspace.js` replace the lifecycle draft pin and `draft.js`). **§8.3**: the node-SQL GET takes an optional `version` — the workspace always sends it; the omission keeps the resolver's working-version default. Layout, dock and table behavior are #349's. |
| 2026-09-30 | v1.19 | lane 336 (#336 D8) | §15.2: the cancel DELETE's outcome is stated — accepted arms the 5 s fallback, 409 stays the quiet case, any other refusal or a network failure toasts the catalogued message and leaves the stream open (the run is never presented as cancelled); the malformed-lifecycle refusal (§7.1's draft pin: an unreadable `pipeline-lifecycle` block stops Execute at the error modal instead of silently running the RELEASED version). |
| 2026-09-27 | v1.18 | 282 (#282) the data table | §3.4's load order: `data-table.css` loads right after `app.css` (it folds app.css's old `.ds-table` block) and before the page sheets; the result dock's grid (§10) is the house data table — [UI Screens §3.7](/docs/ui-screens.md#37-the-data-table-282-normative). |
| 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 | Errors, three states, no close.** `.pe-result-panel`'s × set `resultPanel.visible = false` with no way back short of re-running; the owner asked for **minimise**. The full transition table is §10.1, implemented in a new PURE `dock.js` (state only, no DOM) and driven row-by-row by `dock.test.mjs`, including "`Esc` is a no-op", "a minimised dock keeps its badge" and "no transition can return the dock to `hidden`". The dock is the last FLEX CHILD of `.pe-root`, not a fixed overlay — that is the mechanism by which the canvas actually reclaims the space (a fixed overlay can cover the canvas but never give it back). Open height is today's 40vh; no drag handle (not asked for, YAGNI). **§9 rewritten: the Errors tab is the home of a failure record.** The 057 record used to render INSIDE the results panel, gated on `resultPanel.failure` — two different objects with different lifetimes in one pane, both lost when the pane closed. Three surfaces now: Errors tab (per RUN), inspector Failure section (per NODE, kept), error modal (one-line summary only). §9.1's old "error modal shows the technical details" wording is DEAD and replaced, not appended. `pipeline_failed`'s execution-level record joins the same list, deduped on node+code+message. **§8/§5.4 rewritten: the inspector opens FROM THE CARD, large.** Tapping a card now SELECTS and nothing else; every `.pe-card` carries a `.pe-card-open` button (`ds-icon-sm`, `stopPropagation`, one DELEGATED listener because the html-label re-renders its template on every `data`/`style` event), with `Enter`/`Space` on the node-list row as its keyboard twin. The fixed 320px drawer and the `has-details` grid shift are GONE: the panel is an overlay inside `.pe-layout` at `clamp(720px, 60%, 1200px)`, full layout height, scrim over the CANVAS only (the sidebar is context, not chrome). Close is ×/`Esc`/scrim; focus moves in on open and returns to the opening control on close — captured as an ELEMENT reference, never a selector, because the card button is re-drawn constantly. Opening from a second card replaces in place with no intermediate closed state (`inspector.js`, pure; `inspector.test.mjs` asserts it against a recorded transition log — a post-hoc `open === true` cannot see a close that already happened). The SQL section is the point: full panel width, `white-space: pre`, scroll in its own box, floored at 40% of the panel via a percentage FLEX-BASIS — a `min-height: 40%` would have resolved against a containing block with no definite height and silently computed to `auto`. The partial now loads on OPEN, not on select (one request per opening, not one per click through the graph). §14.1's Escape ladder loses its middle rung (modal → inspector → nothing); §14.2 records that the dock adds NO second live region; §12.1 gains `dock.js`/`inspector.js`. `graph-card.test.mjs`'s 059b "exactly one svg per card" assertion is deliberately revised to "one GLYPH plus the button's icon" — the one-glyph rule is unchanged, the card simply also has a control now. |
| 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](https://github.com/msabiransari/datapipelines/blob/main/docs/SPEC-REVIEW-2026-08.md) §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. |
