@conduction/nextcloud-vue 2.72.0 → 2.73.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +29 -0
  2. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  4. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +18 -0
  5. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  6. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +9 -6
  7. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  8. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +16 -1
  9. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  10. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +5 -4
  11. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  12. package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue2.js +25 -0
  13. package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue2.js.map +1 -1
  14. package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue3.js +3 -1
  15. package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue3.js.map +1 -1
  16. package/dist/esm/components/CnGraphCanvas/CnFlowNode.vue2.js +36 -1
  17. package/dist/esm/components/CnGraphCanvas/CnFlowNode.vue2.js.map +1 -1
  18. package/dist/esm/components/CnGraphCanvas/CnFlowNode.vue3.js.map +1 -1
  19. package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue2.js +37 -0
  20. package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue2.js.map +1 -1
  21. package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue3.js +4 -2
  22. package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue3.js.map +1 -1
  23. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +16 -1
  24. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  25. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +6 -6
  26. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  27. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue2.js +53 -5
  28. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue2.js.map +1 -1
  29. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue3.js +1 -1
  30. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue3.js.map +1 -1
  31. package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue2.js +133 -0
  32. package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue2.js.map +1 -1
  33. package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue3.js +22 -14
  34. package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue3.js.map +1 -1
  35. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +15 -1
  36. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  37. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +3 -2
  38. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  39. package/dist/esm/components/CnWidgetGrid/CnWidgetGrid.vue2.js +15 -1
  40. package/dist/esm/components/CnWidgetGrid/CnWidgetGrid.vue2.js.map +1 -1
  41. package/dist/esm/components/CnWidgetGrid/CnWidgetGrid.vue3.js +2 -2
  42. package/dist/esm/components/CnWidgetGrid/CnWidgetGrid.vue3.js.map +1 -1
  43. package/dist/esm/index.js +1 -0
  44. package/dist/esm/index.js.map +1 -1
  45. package/dist/esm/utils/notificationRuleLabel.js +132 -0
  46. package/dist/esm/utils/notificationRuleLabel.js.map +1 -0
  47. package/dist/nextcloud-vue.cjs.js +578 -47
  48. package/dist/nextcloud-vue.cjs.js.map +2 -2
  49. package/package.json +1 -1
  50. package/scripts/openregister-reliance.js +206 -0
  51. package/src/components/CnAppRoot/CnAppRoot.md +1 -0
  52. package/src/components/CnAppRoot/CnAppRoot.vue +29 -0
  53. package/src/components/CnDetailPage/CnDetailPage.vue +24 -3
  54. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +22 -4
  55. package/src/components/CnFlowDetail/CnFlowDetail.vue +27 -0
  56. package/src/components/CnGraphCanvas/CnFlowNode.vue +36 -1
  57. package/src/components/CnGraphCanvas/CnGraphCanvas.vue +40 -1
  58. package/src/components/CnIndexPage/CnIndexPage.vue +17 -2
  59. package/src/components/CnNotificationPreferences/CnNotificationPreferences.vue +54 -6
  60. package/src/components/CnPageRenderer/CnPageRenderer.vue +147 -6
  61. package/src/components/CnTabsWidget/CnTabsWidget.vue +17 -2
  62. package/src/components/CnWidgetGrid/CnWidgetGrid.vue +19 -3
  63. package/src/index.js +2 -0
  64. package/src/utils/notificationRuleLabel.js +129 -0
  65. package/webpack/index.d.ts +16 -0
  66. package/webpack/index.js +70 -0
@@ -204,6 +204,8 @@ function render(_ctx, _cache, $props, $setup, $data, $options) {
204
204
  maxZoom: $data.maxZoom,
205
205
  onZoomChange: _cache[8] || (_cache[8] = $event => ($data.zoom = $event)),
206
206
  onNodeSelect: $options.onNodeSelect,
207
+ onNodeActivate: $options.onNodeActivate,
208
+ onNodeMenu: $options.onNodeMenu,
207
209
  onEdgeSelect: $options.onEdgeSelect,
208
210
  onEdgeLabelClick: $options.onEdgeLabelClick,
209
211
  onEdgeLabelContext: $options.onEdgeLabelContext,
@@ -249,7 +251,7 @@ function render(_ctx, _cache, $props, $setup, $data, $options) {
249
251
  : createCommentVNode("v-if", true)
250
252
  ]),
251
253
  _: 1 /* STABLE */
252
- }, 8 /* PROPS */, ["nodes", "edges", "minZoom", "maxZoom", "onNodeSelect", "onEdgeSelect", "onEdgeLabelClick", "onEdgeLabelContext", "onEdgeLabelMove", "onCanvasClick", "onNodesChange", "onCanvasDrop"]),
254
+ }, 8 /* PROPS */, ["nodes", "edges", "minZoom", "maxZoom", "onNodeSelect", "onNodeActivate", "onNodeMenu", "onEdgeSelect", "onEdgeLabelClick", "onEdgeLabelContext", "onEdgeLabelMove", "onCanvasClick", "onNodesChange", "onCanvasDrop"]),
253
255
  createVNode(_component_CnContextMenu, {
254
256
  open: $setup.nodeMenuOpen,
255
257
  "onUpdate:open": _cache[11] || (_cache[11] = $event => (($setup.nodeMenuOpen) = $event)),
@@ -1 +1 @@
1
- {"version":3,"file":"CnFlowDetail.vue3.js","sources":["../../../../src/components/CnFlowDetail/CnFlowDetail.vue?vue&type=template&id=65800ae2&lang.js"],"sourcesContent":["<!--\n CnFlowDetail — the canvas half of the flow editor.\n\n Geometry and interaction (pan, zoom, drag, drag-to-connect) come from\n CnGraphCanvas; this component supplies typed node cards, directional edge\n routing, and the editor toolbar (Save / Run / Check / arrange / zoom) — the\n actions that concern the graph live ON the graph. The palette, node config\n and flow settings live in CnFlowSidebar. The two halves render in different\n parts of the tree (page body vs Nextcloud's app sidebar), so they share\n `useFlowStore` rather than passing props. Save and Run are EMITTED, not\n handled: only the host knows whether a freshly minted id needs a route swap.\n\n Ported from hermiq's GraphBuilder, with ONE behavioural fix carried through\n the port: every label and config pane keys on the CATALOGUE id\n (`openregister.set-fields`, `hermiq.agent-step`), never a bare id. hermiq's\n builder fed its palette from the catalogue but matched bare ids everywhere\n else, so a node placed from the palette had no config pane and was skipped at\n run time — with the run reporting success.\n\n SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>\n SPDX-License-Identifier: EUPL-1.2\n-->\n<template>\n\t<div class=\"cn-flow-detail\">\n\t\t<!-- The editor's controls, ON the canvas: the actions that concern the\n\t\t graph live with the graph, the way every flow tool draws it. -->\n\t\t<div class=\"cn-flow-detail__toolbar\" role=\"toolbar\" :aria-label=\"toolbarLabel\">\n\t\t\t<!--\n\t\t\t\t🔴 NOT OFFERED WHILE A RUN IS BEING OPENED.\n\n\t\t\t\tA visitor arriving on `/flows/{id}?run={uuid}` was given the whole\n\t\t\t\teditor for the length of three requests: Add a step, Save, Run and\n\t\t\t\tCheck, on a page the URL had already said was a run. Every one of\n\t\t\t\tthem was about to become read-only, and Save was the dangerous one:\n\t\t\t\t`save()` picks PUT from `flow.id`, and the graph underneath was\n\t\t\t\tabout to be replaced by a historic snapshot.\n\n\t\t\t\tHidden rather than disabled, and only for this window. A row of\n\t\t\t\tgreyed buttons is a claim that the actions exist and are refused;\n\t\t\t\twhat is true here is that the page is not the editor yet. Once the\n\t\t\t\trun is open the toolbar comes back exactly as it was, with the\n\t\t\t\tsnapshot lock doing the refusing, and that part was already right.\n\t\t\t-->\n\t\t\t<template v-if=\"!openingRun\">\n\t\t\t\t<!--\n\t\t\t\t\tFIRST, because it is the first thing a new flow needs and the\n\t\t\t\t\tpalette it replaced was three clicks away in a sidebar tab.\n\t\t\t\t-->\n\t\t\t\t<!--\n\t\t\t\t\t⚠️ NOT DISABLED ON A LOCKED GRAPH, and that is deliberate. A\n\t\t\t\t\tpublished version cannot be changed, and the obvious move is to\n\t\t\t\t\tgrey this out — but the refusal on the canvas is the thing that\n\t\t\t\t\tTELLS the author so, and offers \"Create draft version\" next to\n\t\t\t\t\tit. A silent grey button says only that something is wrong.\n\n\t\t\t\t\tDifferent from Run, which IS disabled: pressing Run on a flow\n\t\t\t\t\twith no manual start produced a slow, confusing engine error\n\t\t\t\t\tseveral seconds later. Pressing this produces an immediate\n\t\t\t\t\tmessage beside the graph it is about.\n\t\t\t\t-->\n\t\t\t\t<NcButton data-testid=\"flow-add-step\"\n\t\t\t\t\t@click=\"stepPickerOpen = true\">\n\t\t\t\t\t<template #icon>\n\t\t\t\t\t\t<Plus :size=\"20\" />\n\t\t\t\t\t</template>\n\t\t\t\t\t{{ t('nextcloud-vue', 'Add a step') }}\n\t\t\t\t</NcButton>\n\t\t\t\t<!-- Disabled on a locked graph, which is a published or deprecated\n\t\t\t\t flow and now also a version snapshot. It was enabled on both\n\t\t\t\t before: pressing it on a published flow spent a request to be\n\t\t\t\t told no, and pressing it on a snapshot would have written the\n\t\t\t\t snapshot over the live flow. The store refuses the snapshot case\n\t\t\t\t outright; this is the half that says so before the click. -->\n\t\t\t\t<NcButton variant=\"primary\"\n\t\t\t\t\t:disabled=\"store.saving || !store.flow.name || store.graphLocked\"\n\t\t\t\t\t:title=\"saveDisabledReason\"\n\t\t\t\t\tdata-testid=\"flow-save-button\"\n\t\t\t\t\t@click=\"onSaveClick\">\n\t\t\t\t\t<template #icon>\n\t\t\t\t\t\t<NcLoadingIcon v-if=\"store.saving\" :size=\"20\" />\n\t\t\t\t\t\t<ContentSave v-else :size=\"20\" />\n\t\t\t\t\t</template>\n\t\t\t\t\t{{ t('nextcloud-vue', 'Save') }}\n\t\t\t\t</NcButton>\n\t\t\t\t<NcButton :disabled=\"store.running || !store.flow.id || !hasManualStart\"\n\t\t\t\t\t:title=\"runDisabledReason\"\n\t\t\t\t\tdata-testid=\"flow-run-button\"\n\t\t\t\t\t@click=\"onRunClick\">\n\t\t\t\t\t<template #icon>\n\t\t\t\t\t\t<Play :size=\"20\" />\n\t\t\t\t\t</template>\n\t\t\t\t\t{{ t('nextcloud-vue', 'Run') }}\n\t\t\t\t</NcButton>\n\t\t\t\t<NcButton variant=\"tertiary\"\n\t\t\t\t\t:disabled=\"store.checking || !store.nodes.length\"\n\t\t\t\t\t@click=\"store.check()\">\n\t\t\t\t\t<template #icon>\n\t\t\t\t\t\t<NcLoadingIcon v-if=\"store.checking\" :size=\"20\" />\n\t\t\t\t\t\t<CheckDecagram v-else :size=\"20\" />\n\t\t\t\t\t</template>\n\t\t\t\t\t{{ t('nextcloud-vue', 'Check') }}\n\t\t\t\t</NcButton>\n\t\t\t\t<NcButton variant=\"tertiary\"\n\t\t\t\t\t:disabled=\"!store.nodes.length\"\n\t\t\t\t\t:aria-label=\"t('nextcloud-vue', 'Arrange steps automatically')\"\n\t\t\t\t\t:title=\"t('nextcloud-vue', 'Arrange steps automatically')\"\n\t\t\t\t\t@click=\"store.autoSort()\">\n\t\t\t\t\t<template #icon>\n\t\t\t\t\t\t<SortVariant :size=\"20\" />\n\t\t\t\t\t</template>\n\t\t\t\t</NcButton>\n\t\t\t\t<!-- Undo has a BUTTON as well as Ctrl+Z. A shortcut nobody is told\n\t\t\t\t about is a feature only its author has: the affordance is what\n\t\t\t\t tells a user the canvas is safe to experiment on. -->\n\t\t\t\t<NcButton variant=\"tertiary\"\n\t\t\t\t\t:disabled=\"!store.canUndo\"\n\t\t\t\t\t:aria-label=\"t('nextcloud-vue', 'Undo the last change')\"\n\t\t\t\t\t:title=\"t('nextcloud-vue', 'Undo the last change')\"\n\t\t\t\t\t@click=\"store.undo()\">\n\t\t\t\t\t<template #icon>\n\t\t\t\t\t\t<UndoVariant :size=\"20\" />\n\t\t\t\t\t</template>\n\t\t\t\t</NcButton>\n\t\t\t</template>\n\n\t\t\t<!-- What the toolbar says instead: a run is being opened. Silence\n\t\t\t here would leave the strip empty apart from the zoom controls,\n\t\t\t and an empty toolbar reads as a broken page rather than as a\n\t\t\t page mid-load. -->\n\t\t\t<span v-else class=\"cn-flow-detail__toolbar-status\" data-testid=\"flow-toolbar-opening-run\">\n\t\t\t\t<NcLoadingIcon :size=\"20\" />\n\t\t\t\t{{ t('nextcloud-vue', 'Opening the run') }}\n\t\t\t</span>\n\n\t\t\t<div class=\"cn-flow-detail__toolbar-group\">\n\t\t\t\t<NcButton variant=\"tertiary\"\n\t\t\t\t\t:disabled=\"zoom <= minZoom\"\n\t\t\t\t\t:aria-label=\"t('nextcloud-vue', 'Zoom out')\"\n\t\t\t\t\t@click=\"zoomBy(-0.1)\">\n\t\t\t\t\t<template #icon>\n\t\t\t\t\t\t<Minus :size=\"20\" />\n\t\t\t\t\t</template>\n\t\t\t\t</NcButton>\n\t\t\t\t<NcButton variant=\"tertiary\"\n\t\t\t\t\t:aria-label=\"t('nextcloud-vue', 'Reset zoom')\"\n\t\t\t\t\t@click=\"zoomTo(1)\">\n\t\t\t\t\t{{ Math.round(zoom * 100) }}%\n\t\t\t\t</NcButton>\n\t\t\t\t<NcButton variant=\"tertiary\"\n\t\t\t\t\t:disabled=\"zoom >= maxZoom\"\n\t\t\t\t\t:aria-label=\"t('nextcloud-vue', 'Zoom in')\"\n\t\t\t\t\t@click=\"zoomBy(0.1)\">\n\t\t\t\t\t<template #icon>\n\t\t\t\t\t\t<Plus :size=\"20\" />\n\t\t\t\t\t</template>\n\t\t\t\t</NcButton>\n\t\t\t</div>\n\n\t\t\t<!-- The way back to a closed sidebar has to live OUTSIDE it. -->\n\t\t\t<NcButton v-if=\"!store.sidebarOpen\"\n\t\t\t\tvariant=\"tertiary\"\n\t\t\t\t:aria-label=\"t('nextcloud-vue', 'Show the flow controls')\"\n\t\t\t\t:title=\"t('nextcloud-vue', 'Show the flow controls')\"\n\t\t\t\t@click=\"store.sidebarOpen = true\">\n\t\t\t\t<template #icon>\n\t\t\t\t\t<DockRight :size=\"20\" />\n\t\t\t\t</template>\n\t\t\t</NcButton>\n\t\t</div>\n\n\t\t<!-- A node type whose configuration IS another product surface gets\n\t\t that surface as its editor (the registry); everything else gets\n\t\t the generic form. Same draft contract either way. -->\n\t\t<component :is=\"nodeEditorComponent\" v-if=\"store.editingNodeId !== null\" />\n\n\t\t<!-- EVERYTHING THE EDITOR HAS TO SAY, IN ONE PLACE, ON THE CANVAS.\n\n\t\t Refusals and standing conditions both. They used to be spread over\n\t\t two sidebar tabs and two cards here, and the one an author most\n\t\t needed — \"this version is published, your step was not added\" —\n\t\t was the furthest away: top of the Steps tab, above a scrolling\n\t\t palette, on the other side of the screen from the click. -->\n\t\t<CnFlowCanvasMessages :messages=\"canvasMessages\"\n\t\t\t@dismiss=\"dismissCanvasMessage\"\n\t\t\t@action=\"runCanvasMessageAction\" />\n\n\t\t<!-- Edges are Vue Flow's now. The hand-drawn `#edge` slot and its\n\t\t orthogonal `edgePath()` are gone: Vue Flow routes and arrows edges\n\t\t itself, and it measures the rendered node instead of being told a\n\t\t `nodeWidth`/`nodeHeight` to guess the centre from. -->\n\t\t<!-- Run state travels ON the edge records, through the canvas's\n\t\t existing `edges` prop: Vue Flow applies an edge's `class` to the\n\t\t line it draws, so the canvas itself stays a geometry-only renderer\n\t\t with no status concept (ADR-065). -->\n\t\t<CnGraphCanvas\n\t\t\tref=\"canvas\"\n\t\t\t:nodes=\"canvasNodes\"\n\t\t\t:edges=\"canvasEdgesWithRunState\"\n\t\t\t:minZoom=\"minZoom\"\n\t\t\t:maxZoom=\"maxZoom\"\n\t\t\t@zoomChange=\"zoom = $event\"\n\t\t\t@nodeSelect=\"onNodeSelect\"\n\t\t\t@edgeSelect=\"onEdgeSelect\"\n\t\t\t@edgeLabelClick=\"onEdgeLabelClick\"\n\t\t\t@edgeLabelContext=\"onEdgeLabelContext\"\n\t\t\t@edgeLabelMove=\"onEdgeLabelMove\"\n\t\t\t@canvasClick=\"onCanvasClick\"\n\t\t\t@nodesChange=\"onNodesChange\"\n\t\t\t@nodeRemove=\"store.removeNode($event)\"\n\t\t\t@connect=\"store.connect($event)\"\n\t\t\t@canvasDrop=\"onCanvasDrop\">\n\t\t\t<!-- The step's own chrome. `node.data` carries the flow node, because\n\t\t\t Vue Flow's `type` selects a COMPONENT while the flow's own type\n\t\t\t is domain data — conflating the two would make every new step\n\t\t\t type need a registered component before it could render at all. -->\n\t\t\t<template #node=\"{ node }\">\n\t\t\t\t<div\n\t\t\t\t\tclass=\"cn-flow-detail__node\"\n\t\t\t\t\t:class=\"[\n\t\t\t\t\t\t`cn-flow-detail__node--${typeSlug(node.data.stepType)}`,\n\t\t\t\t\t\t`cn-flow-detail__node--role-${store.roleOfNodeType(node.data.stepType)}`,\n\t\t\t\t\t\t{ 'cn-flow-detail__node--unknown': isUnknown(node.data.stepType) },\n\t\t\t\t\t\trunStateOf(node.id) ? `cn-flow-detail__node--run-${runStateOf(node.id)}` : null,\n\t\t\t\t\t]\"\n\t\t\t\t\t@dblclick.stop=\"store.editingNodeId = node.id\">\n\t\t\t\t\t<span class=\"cn-flow-detail__node-type\">{{ typeLabel(node.data.stepType) }}</span>\n\t\t\t\t\t<span class=\"cn-flow-detail__node-label\">{{ node.data.label }}</span>\n\t\t\t\t\t<span\n\t\t\t\t\t\tv-if=\"isUnknown(node.data.stepType)\"\n\t\t\t\t\t\tclass=\"cn-flow-detail__node-warning\"\n\t\t\t\t\t\t:title=\"t('nextcloud-vue', 'The engine does not know this node type, so this step will fail when the flow runs.')\">\n\t\t\t\t\t\t{{ t('nextcloud-vue', 'Unknown step') }}\n\t\t\t\t\t</span>\n\t\t\t\t\t<!-- The run state IN WORDS, next to the colour. A state told\n\t\t\t\t\t by colour alone is invisible to anyone who cannot tell\n\t\t\t\t\t the colours apart (WCAG 1.4.1), so every coloured node\n\t\t\t\t\t also says what it is. -->\n\t\t\t\t\t<span\n\t\t\t\t\t\tv-if=\"runStateOf(node.id)\"\n\t\t\t\t\t\tclass=\"cn-flow-detail__node-run\"\n\t\t\t\t\t\t:class=\"`cn-flow-detail__node-run--${runStateOf(node.id)}`\">\n\t\t\t\t\t\t{{ runStateLabel(runStateOf(node.id)) }}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t</template>\n\n\t\t\t<!-- A connection's own name, on the connection.\n\n\t\t\t Rendered only when the author gave the line one: CnFlowEdge\n\t\t\t gates the label control on what this slot RENDERS, not on\n\t\t\t whether it exists, so an unnamed line draws no empty chip. -->\n\t\t\t<template #edge-label=\"{ edge }\">\n\t\t\t\t<template v-if=\"edge.data && edge.data.label\">\n\t\t\t\t\t{{ edge.data.label }}\n\t\t\t\t</template>\n\t\t\t</template>\n\t\t</CnGraphCanvas>\n\n\t\t<!-- The step's own actions, at the step. Selecting a node used to only\n\t\t fill the sidebar, so Edit / Copy / Delete lived in another panel —\n\t\t or nowhere, for Copy. The canvas is where the graph is manipulated,\n\t\t so the actions on a step belong on the step. -->\n\t\t<CnContextMenu\n\t\t\tv-model:open=\"nodeMenuOpen\"\n\t\t\t:actions=\"nodeMenuActions\"\n\t\t\t:targetItem=\"nodeMenuTarget\"\n\t\t\t@close=\"closeNodeMenu\" />\n\n\t\t<!-- The line's own actions, at the line. A connection was the one thing\n\t\t on this canvas that could be drawn and then never touched again:\n\t\t selecting it did nothing at all, so there was no way to rename it,\n\t\t re-route it, or remove it short of deleting a step. -->\n\t\t<CnContextMenu\n\t\t\tv-model:open=\"edgeMenuOpen\"\n\t\t\t:actions=\"edgeMenuActions\"\n\t\t\t:targetItem=\"edgeMenuTarget\"\n\t\t\t@close=\"closeEdgeMenu\" />\n\n\t\t<CnFlowEdgeEditModal v-if=\"store.editingEdge !== null\" />\n\n\t\t<!-- 🔴 A LOADING STATE FIRST, BECAUSE THE EMPTY ONE IS AN ANSWER.\n\n\t\t \"No steps yet\" used to render on `nodes.length === 0` alone, which is\n\t\t also true of a flow that has not arrived yet. So the canvas told a\n\t\t visitor following a run link that the flow had no steps, while it was\n\t\t still fetching the flow, the run and the graph that run executed. It\n\t\t is the worst shape an empty state can have: a finished-looking answer\n\t\t that is indistinguishable from the real one, so nobody waits and\n\t\t nobody reloads.\n\n\t\t `store.canvasLoading` spans all three requests. `store.loading` does\n\t\t not: it is set around the flow LIST call only. -->\n\t\t<NcEmptyContent\n\t\t\tv-if=\"store.canvasLoading\"\n\t\t\tclass=\"cn-flow-detail__empty\"\n\t\t\tdata-testid=\"flow-canvas-loading\"\n\t\t\t:name=\"loadingName\"\n\t\t\t:description=\"loadingDescription\">\n\t\t\t<template #icon>\n\t\t\t\t<NcLoadingIcon :size=\"20\" />\n\t\t\t</template>\n\t\t</NcEmptyContent>\n\t\t<NcEmptyContent\n\t\t\tv-else-if=\"store.nodes.length === 0\"\n\t\t\tclass=\"cn-flow-detail__empty\"\n\t\t\tdata-testid=\"flow-canvas-empty\"\n\t\t\t:name=\"t('nextcloud-vue', 'No steps yet')\"\n\t\t\t:description=\"t('nextcloud-vue', 'Add a step from the sidebar to start building this flow.')\">\n\t\t\t<template #icon>\n\t\t\t\t<Sitemap :size=\"20\" />\n\t\t\t</template>\n\t\t</NcEmptyContent>\n\t\t<CnFlowStepPickerModal v-if=\"stepPickerOpen\" @close=\"stepPickerOpen = false\" />\n\t</div>\n</template>\n\n<script>\nimport { translate as t } from '@nextcloud/l10n'\nimport { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'\nimport CheckDecagram from 'vue-material-design-icons/CheckDecagram.vue'\nimport ContentPaste from 'vue-material-design-icons/ContentPaste.vue'\nimport ContentSave from 'vue-material-design-icons/ContentSave.vue'\nimport DockRight from 'vue-material-design-icons/DockRight.vue'\nimport Minus from 'vue-material-design-icons/Minus.vue'\nimport Play from 'vue-material-design-icons/Play.vue'\nimport Plus from 'vue-material-design-icons/Plus.vue'\nimport Sitemap from 'vue-material-design-icons/Sitemap.vue'\nimport SortVariant from 'vue-material-design-icons/SortVariant.vue'\nimport UndoVariant from 'vue-material-design-icons/UndoVariant.vue'\nimport VectorCurve from 'vue-material-design-icons/VectorCurve.vue'\nimport VectorLine from 'vue-material-design-icons/VectorLine.vue'\nimport VectorPolyline from 'vue-material-design-icons/VectorPolyline.vue'\nimport CnFlowEdgeEditModal from '../../dialogs/CnFlowEdgeEditModal.vue'\nimport CnFlowNodeEditModal from '../../dialogs/CnFlowNodeEditModal.vue'\nimport CnFlowStepPickerModal from '../../dialogs/CnFlowStepPickerModal.vue'\nimport CnContextMenu from '../CnContextMenu/CnContextMenu.vue'\nimport CnGraphCanvas from '../CnGraphCanvas/CnGraphCanvas.vue'\nimport CnFlowCanvasMessages from './CnFlowCanvasMessages.vue'\nimport { useContextMenu } from '../../composables/useContextMenu.js'\nimport { DEFAULT_EDGE_LINE_TYPE, EDGE_LINE_TYPES } from '../../composables/useFlowEdgeStyles.js'\nimport { resolveFlowNodeEditor } from '../../composables/useFlowNodeEditors.js'\nimport { useFlowStore } from '../../composables/useFlowStore.js'\n\n/**\n * Which glyph stands for which router, in menu order.\n *\n * Components rather than CnIcon names: a line's shape has no ADR-077 semantic\n * concept behind it, and inventing one for three drawing options would put a\n * private entry into a fleet-wide vocabulary. Edit / Copy / Delete DO have\n * concepts, so those stay named — see `nodeMenuActions`.\n */\nconst LINE_TYPE_ICONS = {\n\tsmoothstep: VectorPolyline,\n\tstraight: VectorLine,\n\tdefault: VectorCurve,\n}\n\n/**\n * The catch-up pace for a live watch, per hop.\n *\n * The engine persists the log once per worker pass, so one poll can deliver\n * five steps at once. Draining them at ~320ms a hop makes a burst read as\n * five quick hops — not a teleport, and not a fabricated slow-motion \"live\"\n * feed the data does not contain.\n */\nconst RUN_CATCH_UP_HOP_MS = 320\n\n/**\n * Replay pacing bounds. Replay timing is scaled from each step's REAL\n * `durationMs` — the only timing the log carries — and clamped so a\n * 40-minute wait step does not freeze the replay and a 3ms step is still\n * visible. Scaling real durations is presentation; inventing durations would\n * be fabrication — the clamp is the line between them.\n */\nconst REPLAY_HOP_MIN_MS = 200\nconst REPLAY_HOP_MAX_MS = 1500\n\nexport default {\n\tname: 'CnFlowDetail',\n\n\tcomponents: {\n\t\tCheckDecagram,\n\t\tCnFlowCanvasMessages,\n\t\tCnFlowEdgeEditModal,\n\t\tCnFlowNodeEditModal,\n\t\tCnFlowStepPickerModal,\n\t\tCnContextMenu,\n\t\tCnGraphCanvas,\n\t\tContentSave,\n\t\tDockRight,\n\t\tMinus,\n\t\tNcButton,\n\t\tNcEmptyContent,\n\t\tNcLoadingIcon,\n\t\tPlay,\n\t\tPlus,\n\t\tSitemap,\n\t\tSortVariant,\n\t\tUndoVariant,\n\t},\n\n\tprops: {\n\t\t/**\n\t\t * Flow uuid from the route. The literal `new` starts a blank flow, so\n\t\t * creating and editing share one page.\n\t\t */\n\t\tid: {\n\t\t\ttype: String,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * The owning app id to scope to, and to stamp on a new flow.\n\t\t */\n\t\tapp: {\n\t\t\ttype: String,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * A run to open on arrival, from `?run=` on the URL.\n\t\t *\n\t\t * A run's own address (`/apps/openregister/flow-runs/{uuid}`) resolves\n\t\t * the run, finds its flow and redirects to `/flows/{flowId}?run={uuid}`,\n\t\t * so the run reaches the editor as a query rather than as a path. It is\n\t\t * a PROP rather than a `$route` read: this is a library component, and a\n\t\t * consumer mounting it outside a route must still be able to say which\n\t\t * run to open.\n\t\t */\n\t\trun: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\t},\n\n\temits: ['save', 'run'],\n\n\tsetup() {\n\t\tconst {\n\t\t\tisOpen: nodeMenuOpen,\n\t\t\ttargetItem: nodeMenuTarget,\n\t\t\topen: openNodeMenu,\n\t\t\tclose: closeNodeMenu,\n\t\t} = useContextMenu()\n\n\t\t// A SECOND instance, not a shared one. Both menus position themselves by\n\t\t// writing cursor coordinates onto the document, and a single instance\n\t\t// re-targeted between a node and a line would carry the previous\n\t\t// target's actions for as long as the reopen took.\n\t\tconst {\n\t\t\tisOpen: edgeMenuOpen,\n\t\t\ttargetItem: edgeMenuTarget,\n\t\t\topen: openEdgeMenu,\n\t\t\tclose: closeEdgeMenu,\n\t\t} = useContextMenu()\n\n\t\treturn {\n\t\t\tstore: useFlowStore(),\n\t\t\tnodeMenuOpen,\n\t\t\tnodeMenuTarget,\n\t\t\topenNodeMenu,\n\t\t\tcloseNodeMenu,\n\t\t\tedgeMenuOpen,\n\t\t\tedgeMenuTarget,\n\t\t\topenEdgeMenu,\n\t\t\tcloseEdgeMenu,\n\t\t}\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\t// The step picker replaced the sidebar palette; it is a modal, so\n\t\t\t// its open state is the editor's rather than the sidebar's.\n\t\t\tstepPickerOpen: false,\n\t\t\t// Zoom is owned here, not by the canvas: a consumer that does not\n\t\t\t// bind it pins the canvas at 1 and silently kills the wheel gesture.\n\t\t\tzoom: 1,\n\t\t\tminZoom: 0.3,\n\t\t\tmaxZoom: 2,\n\n\t\t\t// The run animator: everything about HOW the watched or replayed\n\t\t\t// run paints onto the canvas. The store exposes facts (the run,\n\t\t\t// its ordered steps, which are new); the timeline lives here, the\n\t\t\t// only place that knows what a hop looks like.\n\t\t\t//\n\t\t\t// Derived per-node/per-edge classes come out of `runNodeStates`\n\t\t\t// and `canvasEdgesWithRunState`; this object is the cursor those\n\t\t\t// computeds derive from.\n\t\t\trunAnimation: {\n\t\t\t\t// 'watch', 'replay', or null while no run is on the canvas.\n\t\t\t\tmode: null,\n\n\t\t\t\t// Steps delivered but not yet animated. The engine persists\n\t\t\t\t// the log once per worker pass, so steps arrive in BURSTS;\n\t\t\t\t// the queue drains strictly in log order at a bounded pace —\n\t\t\t\t// never interpolated fake timing to look \"live\".\n\t\t\t\tqueue: [],\n\n\t\t\t\t// How many steps have been animated. Zero with an active run\n\t\t\t\t// is the anticipation state: the start nodes pulse, because\n\t\t\t\t// pretending a specific step is executing would be a lie.\n\t\t\t\tplayedCount: 0,\n\n\t\t\t\t// The node whose step is being animated right now (halo), and\n\t\t\t\t// the last node a step matched (the run's frontier).\n\t\t\t\tcurrentNodeId: null,\n\t\t\t\tlastNodeId: null,\n\n\t\t\t\t// The drawn line being traced this hop, and the lines already\n\t\t\t\t// walked (they keep a quiet success stroke).\n\t\t\t\ttraceLineId: null,\n\t\t\t\ttracedLineIds: [],\n\n\t\t\t\t// Residue: nodes whose steps completed, failed, or suspended.\n\t\t\t\tdoneNodeIds: [],\n\t\t\t\tfailedNodeIds: [],\n\t\t\t\tholdNodeIds: [],\n\n\t\t\t\t// Transitions the log names that match NO canvas node — the\n\t\t\t\t// flow was edited since the run. Skipped and surfaced, never\n\t\t\t\t// remapped onto the nearest card.\n\t\t\t\tskippedTransitions: [],\n\n\t\t\t\t// The hop timer. One at a time: a hop schedules the next.\n\t\t\t\ttimer: null,\n\t\t\t},\n\n\t\t\t// How many of `store.watchedSteps` this animator has consumed.\n\t\t\t// Index-diffing on the consumer side too, so a poll that delivers\n\t\t\t// nothing new enqueues nothing twice.\n\t\t\trunConsumed: 0,\n\n\t\t\t// Whether the author closed the \"steps skipped\" message for the\n\t\t\t// run currently on the canvas. Reset with the animator, so the\n\t\t\t// next run says it again: it is a fact about THAT run, not a\n\t\t\t// preference.\n\t\t\trunSkipDismissed: false,\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * Whether a run named by the URL is still being opened.\n\t\t *\n\t\t * The window this component used to spend in flow-edit mode. It starts\n\t\t * in `created()`, before the first paint, and ends when the run, its\n\t\t * objects and the graph it executed have all come back.\n\t\t *\n\t\t * @return {boolean} True while the run is being fetched.\n\t\t */\n\t\topeningRun() {\n\t\t\treturn this.store.openingRunUuid !== null\n\t\t},\n\n\t\t/**\n\t\t * What the toolbar calls itself.\n\t\t *\n\t\t * A screen reader reaching a toolbar labelled \"Flow editor\" on a page\n\t\t * that is about to be a read-only run has been told the wrong thing,\n\t\t * and it is the only label the strip has.\n\t\t *\n\t\t * @return {string} The toolbar's accessible name.\n\t\t */\n\t\ttoolbarLabel() {\n\t\t\treturn this.openingRun\n\t\t\t\t? this.t('nextcloud-vue', 'Flow run')\n\t\t\t\t: this.t('nextcloud-vue', 'Flow editor')\n\t\t},\n\n\t\t/**\n\t\t * The heading of the canvas loading state.\n\t\t *\n\t\t * Two sentences rather than one generic \"Loading\", because the two\n\t\t * cases are reached by different links and a reader who followed a run\n\t\t * link needs to see that the run is what is coming.\n\t\t *\n\t\t * @return {string} The heading.\n\t\t */\n\t\tloadingName() {\n\t\t\treturn this.store.inRunView\n\t\t\t\t? this.t('nextcloud-vue', 'Opening the run')\n\t\t\t\t: this.t('nextcloud-vue', 'Loading the flow')\n\t\t},\n\n\t\t/**\n\t\t * @return {string} The line under the canvas loading heading.\n\t\t */\n\t\tloadingDescription() {\n\t\t\treturn this.store.inRunView\n\t\t\t\t? this.t('nextcloud-vue', 'Reading the run and the graph it used.')\n\t\t\t\t: this.t('nextcloud-vue', 'Reading the steps of this flow.')\n\t\t},\n\n\t\t/**\n\t\t * Whether the graph carries a step that says a person starts this flow.\n\t\t *\n\t\t * Running by hand is not a property of the flow row, it is a step on\n\t\t * the canvas: `openregister.trigger-manual`. A flow whose only way in\n\t\t * is an object event or a schedule has no entry point a person can\n\t\t * take, so pressing Run would ask the engine to start something the\n\t\t * graph does not describe — and the engine refuses it, several\n\t\t * seconds later, in words about nodes rather than about the button\n\t\t * that was pressed.\n\t\t *\n\t\t * @return {boolean} Whether a manual trigger step is present.\n\t\t */\n\t\thasManualStart() {\n\t\t\treturn this.store.nodes.some((node) => node?.type === 'openregister.trigger-manual')\n\t\t},\n\n\t\t/**\n\t\t * Why Run is unavailable, or null when it is available.\n\t\t *\n\t\t * One title covering both reasons, in the order the author meets\n\t\t * them: a flow has to exist before it can be started, and it has to\n\t\t * have a way in before it can be started BY HAND. A disabled control\n\t\t * that does not say why is the thing this whole message pass was\n\t\t * about.\n\t\t *\n\t\t * @return {string|null} The explanation, or null.\n\t\t */\n\t\trunDisabledReason() {\n\t\t\tif (!this.store.flow.id) {\n\t\t\t\treturn this.t('nextcloud-vue', 'Save the flow before running it. The engine runs the stored flow, not the unsaved canvas.')\n\t\t\t}\n\n\t\t\tif (!this.hasManualStart) {\n\t\t\t\treturn this.t('nextcloud-vue', 'Running by hand needs a manual start step. Add \"When someone runs it\" to the canvas.')\n\t\t\t}\n\n\t\t\treturn null\n\t\t},\n\n\t\t/**\n\t\t * Why Save is unavailable, as the button's title.\n\t\t *\n\t\t * A disabled control with no explanation is a dead end: the reader can\n\t\t * see that saving is off and has nothing to act on. Both reasons here\n\t\t * have a way out, so both name it.\n\t\t *\n\t\t * @return {string|null} The reason, or null when Save is available.\n\t\t */\n\t\tsaveDisabledReason() {\n\t\t\tif (this.store.viewingVersion !== null) {\n\t\t\t\treturn this.t('nextcloud-vue', 'You are looking at a stored version. Go back to the flow to edit and save it.')\n\t\t\t}\n\n\t\t\tif (this.store.graphLocked) {\n\t\t\t\treturn this.t('nextcloud-vue', 'A published flow cannot be edited. Create a draft to change it.')\n\t\t\t}\n\n\t\t\treturn null\n\t\t},\n\n\t\t/**\n\t\t * What can be done to a step, as CnContextMenu's action list.\n\t\t *\n\t\t * Built as a computed rather than a constant because the labels are\n\t\t * translated, and `t()` must run after the locale is available.\n\t\t *\n\t\t * @return {Array<object>} The actions.\n\t\t */\n\t\tnodeMenuActions() {\n\t\t\treturn [\n\t\t\t\t{\n\t\t\t\t\t// ⚠️ THE VOCABULARY'S NAME, NOT THE GLYPH'S.\n\t\t\t\t\t//\n\t\t\t\t\t// `CnIcon` resolves a string against the ADR-077 semantic\n\t\t\t\t\t// vocabulary and falls back to a help-circle when it finds\n\t\t\t\t\t// nothing — SILENTLY, because an unknown name is\n\t\t\t\t\t// indistinguishable from a name nobody registered. This menu\n\t\t\t\t\t// asked for `Pencil` and `Delete`; the vocabulary publishes\n\t\t\t\t\t// `PencilOutline` and `DeleteOutline`. So two of the three\n\t\t\t\t\t// entries rendered a question mark, and `Copy` — whose name\n\t\t\t\t\t// happened to be right — rendered correctly, which is\n\t\t\t\t\t// exactly what made it read as a styling quirk rather than\n\t\t\t\t\t// as a lookup that failed.\n\t\t\t\t\t//\n\t\t\t\t\t// Named rather than imported so these stay tied to the\n\t\t\t\t\t// vocabulary: the point of ADR-077 is that Edit is the same\n\t\t\t\t\t// glyph in every app, and an import here would be a private\n\t\t\t\t\t// copy of that decision. Guarded by\n\t\t\t\t\t// tests/components/CnFlowMenuIcons.spec.js, which resolves\n\t\t\t\t\t// every name this menu uses and fails on the fallback.\n\t\t\t\t\tlabel: t('nextcloud-vue', 'Edit'),\n\t\t\t\t\ticon: 'PencilOutline',\n\t\t\t\t\thandler: (id) => {\n\t\t\t\t\t\tthis.store.editingNodeId = id\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tlabel: t('nextcloud-vue', 'Copy'),\n\t\t\t\t\ticon: 'ContentCopy',\n\t\t\t\t\thandler: (id) => {\n\t\t\t\t\t\tthis.store.copyNode(id)\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tlabel: t('nextcloud-vue', 'Delete'),\n\t\t\t\t\ticon: 'DeleteOutline',\n\t\t\t\t\tdestructive: true,\n\t\t\t\t\thandler: (id) => {\n\t\t\t\t\t\tthis.store.removeNode(id)\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t]\n\t\t},\n\n\t\t/**\n\t\t * What can be done to a connection, as CnContextMenu's action list.\n\t\t *\n\t\t * The three routers are FLAT entries rather than a submenu: CnContextMenu\n\t\t * has a custom-panel path, and it has no consumer anywhere in this\n\t\t * library — shipping a context menu on an untested code path to save\n\t\t * two rows is a poor trade. The router the line already uses is\n\t\t * disabled, with a `title` saying why, so the menu states the current\n\t\t * value instead of hiding it behind a click.\n\t\t *\n\t\t * `targetItem` is `{source, target}` — see `useFlowStore.editingEdge`\n\t\t * for why a line is identified by its endpoints and never by its id.\n\t\t *\n\t\t * @return {Array<object>} The actions.\n\t\t */\n\t\tedgeMenuActions() {\n\t\t\tconst styleActions = EDGE_LINE_TYPES.map((style) => ({\n\t\t\t\tlabel: style.label(),\n\t\t\t\ticon: LINE_TYPE_ICONS[style.id],\n\t\t\t\tdisabled: (line) => this.lineTypeOf(line) === style.id,\n\t\t\t\ttitle: (line) => (\n\t\t\t\t\tthis.lineTypeOf(line) === style.id\n\t\t\t\t\t\t? t('nextcloud-vue', 'This line is already drawn this way.')\n\t\t\t\t\t\t: null\n\t\t\t\t),\n\t\t\t\thandler: (line) => {\n\t\t\t\t\tthis.store.setEdgeFields({\n\t\t\t\t\t\tsource: line.source,\n\t\t\t\t\t\ttarget: line.target,\n\t\t\t\t\t\tfields: { lineType: style.id },\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t}))\n\n\t\t\treturn [\n\t\t\t\t{\n\t\t\t\t\tlabel: t('nextcloud-vue', 'Edit label'),\n\t\t\t\t\ticon: 'PencilOutline',\n\t\t\t\t\thandler: (line) => {\n\t\t\t\t\t\tthis.store.editingEdge = { source: line.source, target: line.target }\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t...styleActions,\n\t\t\t\t{\n\t\t\t\t\t// A connection has no useful DUPLICATE — two records with the\n\t\t\t\t\t// same endpoints draw on top of each other and `connect()`\n\t\t\t\t\t// refuses the second — so Copy takes the part of a line that\n\t\t\t\t\t// IS worth repeating: its label and its router.\n\t\t\t\t\tlabel: t('nextcloud-vue', 'Copy'),\n\t\t\t\t\ticon: 'ContentCopy',\n\t\t\t\t\thandler: (line) => {\n\t\t\t\t\t\tthis.store.copyEdgeStyle({ source: line.source, target: line.target })\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tlabel: t('nextcloud-vue', 'Paste style'),\n\t\t\t\t\ticon: ContentPaste,\n\t\t\t\t\tvisible: () => this.store.edgeStyleClipboard !== null,\n\t\t\t\t\thandler: (line) => {\n\t\t\t\t\t\tthis.store.pasteEdgeStyle({ source: line.source, target: line.target })\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tlabel: t('nextcloud-vue', 'Delete'),\n\t\t\t\t\ticon: 'DeleteOutline',\n\t\t\t\t\tdestructive: true,\n\t\t\t\t\thandler: (line) => {\n\t\t\t\t\t\tthis.store.removeEdge({ source: line.source, target: line.target })\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t]\n\t\t},\n\n\t\t/**\n\t\t * The flow's steps in Vue Flow's node shape.\n\t\t *\n\t\t * The mapping lives here rather than in the store because the store owns\n\t\t * the FLOW DOCUMENT — the thing that gets saved — and a canvas library's\n\t\t * node shape is a rendering concern. Pushing `position` into the store\n\t\t * would make the persisted document carry a dependency's vocabulary.\n\t\t *\n\t\t * `type` is pinned to `'default'` so every step renders through\n\t\t * CnFlowNode, and the flow's own type travels in `data.stepType`. Vue\n\t\t * Flow's `type` selects a COMPONENT; conflating it with the domain type\n\t\t * would mean a new step type could not render at all until someone\n\t\t * registered a component for it — exactly the \"unknown step\" case this\n\t\t * component exists to display gracefully.\n\t\t *\n\t\t * @return {Array<object>} Vue Flow nodes.\n\t\t */\n\t\tcanvasNodes() {\n\t\t\t// WHICH STEPS A LINE ACTUALLY TOUCHES, measured once per render\n\t\t\t// rather than once per node: `canvasEdges` expands the document's\n\t\t\t// list dialects (`{from: 'a', to: ['b','c']}`) into one line per\n\t\t\t// pair, and re-walking it inside the map below would be quadratic on\n\t\t\t// a graph large enough to care.\n\t\t\tconst targeted = new Set()\n\t\t\tconst sourced = new Set()\n\t\t\tfor (const line of this.store.canvasEdges) {\n\t\t\t\ttargeted.add(line.target)\n\t\t\t\tsourced.add(line.source)\n\t\t\t}\n\n\t\t\treturn (this.store.nodes || []).map((node) => ({\n\t\t\t\tid: node.id,\n\t\t\t\ttype: 'default',\n\t\t\t\t// BOTH SPELLINGS, for the same reason removeNode() reads both\n\t\t\t\t// edge spellings: `position: {x, y}` is what the SERVER stores,\n\t\t\t\t// and flat `x`/`y` is what addNode()/moveNode() write in memory.\n\t\t\t\t//\n\t\t\t\t// Reading only `node.x` meant `Number(undefined) || 0` for every\n\t\t\t\t// node of every PERSISTED flow — so a saved flow reloaded with\n\t\t\t\t// all of its nodes stacked at the origin, on top of each other.\n\t\t\t\t// A new flow looked fine because its nodes had never been\n\t\t\t\t// through the server. Measured on a live instance: of 100 stored\n\t\t\t\t// flows sampled, NOT ONE had a flat `x`/`y` node; a 76-node flow\n\t\t\t\t// reloaded with all 76 at (0, 0).\n\t\t\t\t//\n\t\t\t\t// The pile is invisible rather than obviously wrong, which is\n\t\t\t\t// why this read as \"the canvas renders nothing\" instead of \"the\n\t\t\t\t// layout is lost\".\n\t\t\t\tposition: {\n\t\t\t\t\tx: Number(node.x ?? node.position?.x) || 0,\n\t\t\t\t\ty: Number(node.y ?? node.position?.y) || 0,\n\t\t\t\t},\n\t\t\t\tdata: {\n\t\t\t\t\tstepType: node.type,\n\t\t\t\t\tlabel: this.nodeLabel(node),\n\t\t\t\t\tports: this.store.portsOfNode ? this.store.portsOfNode(node) : undefined,\n\n\t\t\t\t\t// A PORT THAT CANNOT BE CONNECTED IS NOT DRAWN.\n\t\t\t\t\t//\n\t\t\t\t\t// A run STARTS at a trigger, so an entry on one is an\n\t\t\t\t\t// affordance the engine will never honour; a run STOPS at an\n\t\t\t\t\t// end step, so an exit on one is the same lie in the other\n\t\t\t\t\t// direction. Both were being drawn on every node regardless\n\t\t\t\t\t// of role, which made the canvas claim a graph shape the\n\t\t\t\t\t// engine would refuse.\n\t\t\t\t\t//\n\t\t\t\t\t// Keyed on the CATALOGUE's role, like the accent colours\n\t\t\t\t\t// below — never on graph position, which once painted\n\t\t\t\t\t// unconnected steps green.\n\t\t\t\t\thasTarget: this.store.roleOfNodeType(node.type) !== 'trigger',\n\t\t\t\t\thasSource: this.store.roleOfNodeType(node.type) !== 'end',\n\n\t\t\t\t\t// Whether anything is actually wired to those ports. The\n\t\t\t\t\t// node paints an unconnected one as a warning, which is the\n\t\t\t\t\t// same finding `check()` returns — but at the port, where the\n\t\t\t\t\t// author can act on it, rather than as a node id in a card\n\t\t\t\t\t// on the other side of the screen.\n\t\t\t\t\thasIncoming: targeted.has(node.id),\n\t\t\t\t\thasOutgoing: sourced.has(node.id),\n\t\t\t\t},\n\t\t\t}))\n\t\t},\n\n\t\t/**\n\t\t * The editor for the node being edited: an app-registered one for its\n\t\t * type when there is one, the generic dialog otherwise.\n\t\t *\n\t\t * @return {object} The component.\n\t\t */\n\t\tnodeEditorComponent() {\n\t\t\tconst type = this.store.editingNode?.type\n\t\t\treturn (type && resolveFlowNodeEditor(type)) || CnFlowNodeEditModal\n\t\t},\n\n\t\t/**\n\t\t * @return {string} The note-card type for the check verdict.\n\t\t */\n\t\tcheckCardType() {\n\t\t\tconst result = this.store.checkResult\n\t\t\tif (!result) {\n\t\t\t\treturn 'success'\n\t\t\t}\n\t\t\tif (result.valid === false) {\n\t\t\t\treturn 'error'\n\t\t\t}\n\n\t\t\treturn (result.warnings || []).length ? 'warning' : 'success'\n\t\t},\n\n\t\t/**\n\t\t * @return {string} The one-line verdict.\n\t\t */\n\t\tcheckCardText() {\n\t\t\tconst result = this.store.checkResult\n\t\t\tif (!result) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tif (result.valid === false) {\n\t\t\t\treturn result.message || this.t('nextcloud-vue', 'This flow cannot run yet.')\n\t\t\t}\n\t\t\tif ((result.warnings || []).length) {\n\t\t\t\treturn this.t('nextcloud-vue', 'This flow can run, with warnings.')\n\t\t\t}\n\n\t\t\treturn this.t('nextcloud-vue', 'This flow looks runnable.')\n\t\t},\n\n\t\t/**\n\t\t * @return {Array<string>} The individual findings, readable.\n\t\t */\n\t\tcheckCardItems() {\n\t\t\tconst result = this.store.checkResult\n\t\t\tif (!result) {\n\t\t\t\treturn []\n\t\t\t}\n\n\t\t\tconst describe = (finding) => {\n\t\t\t\tif (typeof finding === 'string') {\n\t\t\t\t\treturn finding\n\t\t\t\t}\n\n\t\t\t\tconst parts = [finding.message || finding.reason || '']\n\t\t\t\tif (finding.node) {\n\t\t\t\t\tparts.push(`(${finding.node})`)\n\t\t\t\t}\n\n\t\t\t\treturn parts.filter(Boolean).join(' ')\n\t\t\t}\n\n\t\t\treturn [\n\t\t\t\t...(result.blocking || []).map(describe),\n\t\t\t\t...(result.warnings || []).map(describe),\n\t\t\t].filter(Boolean)\n\t\t},\n\n\t\t/**\n\t\t * What the run painted on each node, derived — never stored — from\n\t\t * the animator's cursor and residue.\n\t\t *\n\t\t * Precedence: a failure is never painted over (`failed` wins), a hold\n\t\t * is a state the user must notice (`hold` next), then the halo on the\n\t\t * step being animated (`active`), then the quiet success accent on\n\t\t * what completed (`done`).\n\t\t *\n\t\t * @return {{[key: string]: string}} Node id to run state.\n\t\t */\n\t\trunNodeStates() {\n\t\t\tconst anim = this.runAnimation\n\t\t\tif (anim.mode === null) {\n\t\t\t\treturn {}\n\t\t\t}\n\n\t\t\tconst states = {}\n\n\t\t\tfor (const id of anim.doneNodeIds) {\n\t\t\t\tstates[id] = 'done'\n\t\t\t}\n\n\t\t\t// The hold state: the last suspended step's node — and, when the\n\t\t\t// log lags behind a run that reports `suspended`, the marking's\n\t\t\t// places, which are node ids in the engine's workflow.\n\t\t\tconst holdIds = [...anim.holdNodeIds]\n\t\t\tconst run = this.store.watchedRun\n\t\t\tif (holdIds.length === 0\n\t\t\t\t&& anim.mode === 'watch'\n\t\t\t\t&& run?.status === 'suspended'\n\t\t\t\t&& run.marking !== null\n\t\t\t\t&& typeof run.marking === 'object') {\n\t\t\t\tfor (const place of Object.keys(run.marking)) {\n\t\t\t\t\tif (this.store.nodes.some((node) => node.id === place)) {\n\t\t\t\t\t\tholdIds.push(place)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor (const id of holdIds) {\n\t\t\t\tstates[id] = 'hold'\n\t\t\t}\n\n\t\t\tfor (const id of anim.failedNodeIds) {\n\t\t\t\tstates[id] = 'failed'\n\t\t\t}\n\n\t\t\t// The halo: the step being animated — or, on a live watch with the\n\t\t\t// queue drained, the run's frontier (its furthest logged node).\n\t\t\t// That frontier is what \"where is my case\" actually asks for.\n\t\t\tconst suspended = anim.mode === 'watch' && run?.status === 'suspended'\n\t\t\tconst current = anim.currentNodeId\n\t\t\t\t|| ((anim.mode === 'watch' && this.store.watchedRunActive && suspended === false)\n\t\t\t\t\t? anim.lastNodeId\n\t\t\t\t\t: null)\n\t\t\tif (current !== null && states[current] !== 'failed' && states[current] !== 'hold') {\n\t\t\t\tstates[current] = 'active'\n\t\t\t}\n\n\t\t\t// Anticipation: the run says `running` (or `queued`) and the log\n\t\t\t// is still empty — a mid-pass poll returns exactly this. The start\n\t\t\t// nodes pulse; pretending a specific step is executing would\n\t\t\t// fabricate liveness the data does not contain.\n\t\t\tif (anim.mode === 'watch'\n\t\t\t\t&& this.store.watchedRunActive\n\t\t\t\t&& suspended === false\n\t\t\t\t&& anim.playedCount === 0\n\t\t\t\t&& anim.queue.length === 0) {\n\t\t\t\tfor (const id of this.store.startNodeIds) {\n\t\t\t\t\tif (states[id] === undefined) {\n\t\t\t\t\t\tstates[id] = 'active'\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn states\n\t\t},\n\n\t\t/**\n\t\t * The canvas edges with the run's classes on them.\n\t\t *\n\t\t * Vue Flow applies an edge record's `class` to the `<g>` it draws, so\n\t\t * run state reaches the lines through the canvas's EXISTING `edges`\n\t\t * prop — no prop, event or slot of CnGraphCanvas changes, and the\n\t\t * canvas itself keeps knowing nothing about statuses (ADR-065).\n\t\t *\n\t\t * @return {Array<object>} The drawable lines, some carrying a class.\n\t\t */\n\t\tcanvasEdgesWithRunState() {\n\t\t\tconst anim = this.runAnimation\n\t\t\tif (anim.mode === null) {\n\t\t\t\treturn this.store.canvasEdges\n\t\t\t}\n\n\t\t\treturn this.store.canvasEdges.map((line) => {\n\t\t\t\tif (line.id === anim.traceLineId) {\n\t\t\t\t\treturn { ...line, class: 'cn-flow-detail__edge--run-tracing' }\n\t\t\t\t}\n\t\t\t\tif (anim.tracedLineIds.includes(line.id)) {\n\t\t\t\t\treturn { ...line, class: 'cn-flow-detail__edge--run-traced' }\n\t\t\t\t}\n\n\t\t\t\treturn line\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * The transitions the run log named that match no canvas node, once\n\t\t * each, for the warning card.\n\t\t *\n\t\t * @return {Array<string>} The unmatched transition names.\n\t\t */\n\t\tskippedRunTransitions() {\n\t\t\treturn [...new Set(this.runAnimation.skippedTransitions)]\n\t\t},\n\n\t\t/**\n\t\t * What to show the user when a save or a run was refused.\n\t\t *\n\t\t * Prefers the API's own `error` field, because that is the sentence\n\t\t * written for a person — \"A flow needs a name.\" says what to do, where\n\t\t * \"Request failed with status code 400\" does not.\n\t\t *\n\t\t * @return {string} The message.\n\t\t */\n\t\terrorText() {\n\t\t\tconst error = this.store.error\n\t\t\tif (!error) {\n\t\t\t\treturn ''\n\t\t\t}\n\n\t\t\treturn error?.response?.data?.error\n\t\t\t\t|| error?.response?.data?.message\n\t\t\t\t|| error?.message\n\t\t\t\t|| this.t('nextcloud-vue', 'The last action failed.')\n\t\t},\n\n\t\t/**\n\t\t * What stops this flow from finishing, as one readable sentence.\n\t\t *\n\t\t * @return {string|null} The message, or null when nothing is missing.\n\t\t */\n\t\tmissingEndsMessage() {\n\t\t\tconst missing = this.store.missingEnds\n\t\t\tif (missing.trigger && missing.end) {\n\t\t\t\treturn this.t('nextcloud-vue', 'This flow has no trigger and no end step, so it cannot start or finish.')\n\t\t\t}\n\t\t\tif (missing.trigger) {\n\t\t\t\treturn this.t('nextcloud-vue', 'This flow has no trigger, so nothing will start it.')\n\t\t\t}\n\t\t\tif (missing.end) {\n\t\t\t\treturn this.t('nextcloud-vue', 'This flow has no end step, so a run can never finish.')\n\t\t\t}\n\n\t\t\treturn null\n\t\t},\n\n\t\t/**\n\t\t * What the server refused, and what to do about it.\n\t\t *\n\t\t * 🔑 SWITCHED ON THE `reason` FIELD, never on the message text. The two\n\t\t * refusals an author meets want opposite actions — one needs a draft\n\t\t * created, the other needs a version published — and matching on English\n\t\t * prose is how a UI offers the wrong one.\n\t\t *\n\t\t * @return {string} The message.\n\t\t */\n\t\trefusalText() {\n\t\t\tconst reason = this.store.lifecycleRefusal?.reason\n\t\t\tconst messages = {\n\t\t\t\t'version-immutable': this.t('nextcloud-vue', 'This version is published and cannot be changed. Create a draft to make changes; the published version keeps running until you publish the draft.'),\n\t\t\t\t'not-a-draft': this.t('nextcloud-vue', 'Only a draft version can be published.'),\n\t\t\t\t'not-published': this.t('nextcloud-vue', 'Only a published version can be deprecated.'),\n\t\t\t\t'no-published-version': this.t('nextcloud-vue', 'This flow has no published version, so it cannot run. Publish a version first.'),\n\t\t\t\t'dead-end': this.t('nextcloud-vue', 'This version cannot be published while a step has nowhere to send its work.'),\n\t\t\t\t'version-in-use': this.t('nextcloud-vue', 'This version cannot be removed while a run is still using it.'),\n\t\t\t}\n\n\t\t\treturn messages[reason] || this.t('nextcloud-vue', 'That change was refused by the flow\\'s current state.')\n\t\t},\n\n\t\t/**\n\t\t * What the author needs to be told about this flow, right now.\n\t\t *\n\t\t * 🔴 THE `id` IS IDENTITY, AND THAT IS THE WHOLE ANTI-ACCUMULATION\n\t\t * MECHANISM. Every source contributes at most one entry, so a refusal\n\t\t * fired on five palette clicks is one card. Anything that wants to\n\t\t * \"add\" a message adds it under an id that already means that thing.\n\t\t *\n\t\t * ⚠️ THE LOCK AND ITS REFUSAL SHARE ONE ID ON PURPOSE. A published flow\n\t\t * already carries the standing `graph-locked` message before the author\n\t\t * tries anything. When the try then happens and `pushUndo()` refuses it,\n\t\t * a second card saying almost the same sentence would be the sidebar's\n\t\t * old problem in a new place — so the refusal REPLACES the standing\n\t\t * text instead. The wording changes, which is what makes the polite\n\t\t * live region announce it again: an identical string re-set in a live\n\t\t * region is not a change and most screen readers stay silent.\n\t\t *\n\t\t * @return {Array<object>} The messages for the canvas area.\n\t\t */\n\t\tcanvasMessages() {\n\t\t\tconst messages = []\n\t\t\tconst refusal = this.store.lifecycleRefusal\n\t\t\tconst lockRefused = refusal?.reason === 'version-immutable'\n\n\t\t\t// The route named a flow that does not resolve. FIRST, and on its\n\t\t\t// own, because nothing else on this canvas is about anything: the\n\t\t\t// editor used to render an empty grid here, which is exactly what a\n\t\t\t// brand-new flow looks like, so a deleted flow and a blank one were\n\t\t\t// the same picture. Everything below concerns a flow that IS loaded,\n\t\t\t// so it is skipped rather than stacked underneath.\n\t\t\tif (this.store.notFound) {\n\t\t\t\treturn [{\n\t\t\t\t\tid: 'flow-not-found',\n\t\t\t\t\tseverity: 'error',\n\t\t\t\t\ttext: this.t('nextcloud-vue', 'This flow could not be found. It may have been deleted, or you may not have access to it.'),\n\t\t\t\t\tdismissible: false,\n\t\t\t\t}]\n\t\t\t}\n\n\t\t\t// A save or a run the server refused outright. Its own sentence,\n\t\t\t// because \"A flow needs a name.\" says what to do and \"Request\n\t\t\t// failed with status code 400\" does not.\n\t\t\tif (this.store.error) {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'action-failed',\n\t\t\t\t\tseverity: 'error',\n\t\t\t\t\ttext: this.errorText,\n\t\t\t\t\tdismissible: true,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// The published-is-immutable rule, stated before the author trips\n\t\t\t// over it and restated as a refusal when they do.\n\t\t\tif (this.store.graphLocked) {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'graph-locked',\n\t\t\t\t\tseverity: 'warning',\n\t\t\t\t\ttext: lockRefused\n\t\t\t\t\t\t? this.refusalText\n\t\t\t\t\t\t: this.t('nextcloud-vue', 'This version is read-only. Create a draft to change its steps; the published version keeps running until you publish the draft.'),\n\t\t\t\t\taction: {\n\t\t\t\t\t\tlabel: this.t('nextcloud-vue', 'Create draft version'),\n\t\t\t\t\t\tdisabled: this.store.transitioning,\n\t\t\t\t\t},\n\t\t\t\t\t// Only the refusal can be dismissed. The lock itself stays:\n\t\t\t\t\t// it is true until the author creates a draft.\n\t\t\t\t\tdismissible: lockRefused,\n\t\t\t\t})\n\t\t\t} else if (refusal) {\n\t\t\t\t// Every other refusal: publishing what is not a draft, running\n\t\t\t\t// a flow with no published version, and so on.\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'refused',\n\t\t\t\t\tseverity: 'warning',\n\t\t\t\t\ttext: this.refusalText,\n\t\t\t\t\tdismissible: true,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tif (this.missingEndsMessage) {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'missing-ends',\n\t\t\t\t\tseverity: 'error',\n\t\t\t\t\ttext: this.missingEndsMessage,\n\t\t\t\t\tdismissible: false,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// An empty catalogue means no step can be added at all. Distinct\n\t\t\t// from a request still in the air: both are an empty list, and only\n\t\t\t// one of them is a problem.\n\t\t\tif (!this.store.catalogLoading && !this.store.nodeCatalog.length) {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'no-catalog',\n\t\t\t\t\tseverity: 'warning',\n\t\t\t\t\ttext: this.t('nextcloud-vue', 'The list of available steps could not be read, so no steps can be added. This does not mean the instance has none.'),\n\t\t\t\t\tdismissible: false,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tif (this.store.flow.enabled && !this.store.flow.owner) {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'no-owner',\n\t\t\t\t\tseverity: 'warning',\n\t\t\t\t\ttext: this.t('nextcloud-vue', 'This flow has no owner yet, so a trigger will not start it. Saving it makes you its owner.'),\n\t\t\t\t\tdismissible: false,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tif (this.store.dirty) {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'unsaved',\n\t\t\t\t\tseverity: 'warning',\n\t\t\t\t\ttext: this.t('nextcloud-vue', 'This flow has unsaved changes.'),\n\t\t\t\t\tdismissible: false,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// The engine's verdict from the Check button.\n\t\t\tif (this.store.checkResult) {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'check',\n\t\t\t\t\tseverity: this.checkCardType,\n\t\t\t\t\ttext: this.checkCardText,\n\t\t\t\t\titems: this.checkCardItems,\n\t\t\t\t\tdismissible: true,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// WHICH GRAPH IS THIS? A reader inspecting a run has to be able to\n\t\t\t// answer that, and the answer is not always the same. Three states,\n\t\t\t// one of which is silence: the snapshot is up, or it is not up and\n\t\t\t// there is a reason, or no run is open at all.\n\t\t\tif (this.store.viewingVersion !== null) {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'viewing-version',\n\t\t\t\t\tseverity: 'info',\n\t\t\t\t\ttext: this.t('nextcloud-vue', 'This is version {version} of the flow, the graph this run used. Go back to the flow to edit it.', { version: this.store.viewingVersion }),\n\t\t\t\t\tdismissible: false,\n\t\t\t\t})\n\t\t\t} else if (this.store.runGraphNotice?.reason === 'unsaved-edits') {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'run-graph-unsaved',\n\t\t\t\t\tseverity: 'warning',\n\t\t\t\t\ttext: this.t('nextcloud-vue', 'This run used version {version} of the flow. You have unsaved changes, so it is replayed over your current graph instead. Save or undo your changes to see the graph it used.', { version: this.store.runGraphNotice.version }),\n\t\t\t\t\tdismissible: false,\n\t\t\t\t})\n\t\t\t} else if (this.store.runGraphNotice?.reason === 'unreadable') {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'run-graph-unreadable',\n\t\t\t\t\tseverity: 'warning',\n\t\t\t\t\ttext: this.t('nextcloud-vue', 'Version {version} of this flow could not be read, so the run is replayed over the current graph. Read the run steps in the sidebar for what actually ran.', { version: this.store.runGraphNotice.version }),\n\t\t\t\t\tdismissible: false,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// Steps the run log names that are not on this canvas any more.\n\t\t\t//\n\t\t\t// Never while the snapshot is up: the snapshot IS the graph the run\n\t\t\t// executed, so every step it names is on the canvas by construction.\n\t\t\t// Leaving the check running would only report a bug in the snapshot,\n\t\t\t// and it would report it in words that blame the reader's flow.\n\t\t\tif (this.store.viewingVersion === null && this.skippedRunTransitions.length && !this.runSkipDismissed) {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'run-skipped',\n\t\t\t\t\tseverity: 'warning',\n\t\t\t\t\ttext: this.t('nextcloud-vue', 'Some steps of this run belong to nodes that are no longer on the canvas, so they were skipped: {nodes}', { nodes: this.skippedRunTransitions.join(', ') }),\n\t\t\t\t\tdismissible: true,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\treturn messages\n\t\t},\n\t},\n\n\twatch: {\n\t\t/**\n\t\t * Reload when the route names a different flow.\n\t\t *\n\t\t * `mounted` alone was not enough, and the gap was not cosmetic. Vue\n\t\t * reuses this component instance when only the route PARAM changes —\n\t\t * `/flows/:id` -> `/flows/new` and `/flows/a` -> `/flows/b` are the same\n\t\t * route record — so `mounted` does not fire again and the store keeps\n\t\t * the flow it already had.\n\t\t *\n\t\t * That left the canvas showing the previous flow AND the store still\n\t\t * holding its id, on a page the user believes is a different flow.\n\t\t * `save()` picks PUT over POST from `flow.id`, so pressing Save on what\n\t\t * looks like a blank \"new flow\" issued a PUT against the flow the user\n\t\t * had open a moment earlier — overwriting a real flow with a graph\n\t\t * meant for a new one.\n\t\t *\n\t\t * Reproduced on openregister 2026-08-05: opening \"Hydra label\n\t\t * transition\", then moving to /flows/new, left the Name field reading\n\t\t * \"Hydra label transition\" with that flow's nodes on the canvas.\n\t\t *\n\t\t * The first load stays in `mounted` rather than moving here behind\n\t\t * `immediate: true`: an immediate watcher fires before the component is\n\t\t * mounted, and this one awaits a network call whose result the canvas\n\t\t * renders. Guarding on `next === prev` keeps the two from racing.\n\t\t *\n\t\t * @param {string} next The incoming flow id, or 'new'.\n\t\t * @param {string} prev The outgoing one.\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync id(next, prev) {\n\t\t\tif (next === prev) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// The same arrival as a fresh mount, so it gets the same loading\n\t\t\t// state: the flow on the canvas belongs to the previous route and\n\t\t\t// counting its steps would answer a question about this one.\n\t\t\tthis.store.beginBootstrap(this.run)\n\t\t\ttry {\n\t\t\t\tawait this.store.load({ app: this.app, id: next })\n\t\t\t\tawait this.openRunFromRoute()\n\t\t\t} finally {\n\t\t\t\tthis.store.endBootstrap()\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Follow `?run=` to another run without a remount.\n\t\t *\n\t\t * Same route record, different query: Vue reuses this instance, so\n\t\t * `mounted` does not fire again — the same reason the flow-id watcher\n\t\t * above exists.\n\t\t *\n\t\t * @param {string} next The run now named by the URL.\n\t\t * @param {string} prev The one before it.\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync run(next, prev) {\n\t\t\tif (next === prev) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tawait this.openRunFromRoute()\n\t\t},\n\n\t\t/**\n\t\t * A new watch replaces whatever run was on the canvas: the animator\n\t\t * resets and the store's steps start flowing into the fresh queue.\n\t\t *\n\t\t * @param {string|null} next The newly watched run's uuid.\n\t\t * @return {void}\n\t\t */\n\t\t'store.watchedRunUuid': function(next) {\n\t\t\tif (next === null) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.startRunAnimation('watch')\n\t\t},\n\n\t\t/**\n\t\t * Steps arrived from a poll. Index-diffed on this side too — only the\n\t\t * entries beyond what was already consumed are queued, so a poll that\n\t\t * delivered nothing new enqueues nothing twice, and a resumed run's\n\t\t * appended entries queue without replaying the ones before them.\n\t\t *\n\t\t * @param {Array<object>} steps The watched run's log so far.\n\t\t * @return {void}\n\t\t */\n\t\t'store.watchedSteps': function(steps) {\n\t\t\tif (this.runAnimation.mode !== 'watch') {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif (steps.length <= this.runConsumed) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.runAnimation.queue.push(...steps.slice(this.runConsumed))\n\t\t\tthis.runConsumed = steps.length\n\t\t\tthis.drainRunQueue()\n\t\t},\n\n\t\t/**\n\t\t * The sidebar asked for a replay of the inspected run. The stored log\n\t\t * plays through the same animator, in log order, paced from each\n\t\t * step's real `durationMs`. Starting another replay cancels the one\n\t\t * in progress — the reset is the cancellation.\n\t\t *\n\t\t * @return {void}\n\t\t */\n\t\t'store.replayToken': function() {\n\t\t\tthis.startRunAnimation('replay')\n\t\t\tthis.runAnimation.queue.push(...this.store.steps)\n\t\t\tthis.drainRunQueue()\n\t\t},\n\n\t\t/**\n\t\t * A graph edit tears the run picture down. The log names node ids of\n\t\t * the graph AS IT RAN; painting it over an edited graph would show a\n\t\t * walk the engine never took over these nodes.\n\t\t *\n\t\t * @param {boolean} next Whether the flow now has unsaved changes.\n\t\t * @return {void}\n\t\t */\n\t\t'store.dirty': function(next) {\n\t\t\tif (next === true) {\n\t\t\t\tthis.cancelRunAnimation()\n\t\t\t}\n\t\t},\n\t},\n\n\t/**\n\t * Say that the editor is loading BEFORE it renders for the first time.\n\t *\n\t * 🔴 `mounted` IS TOO LATE, AND THAT IS THE WHOLE DEFECT. Vue paints the\n\t * component and then calls `mounted`, so a flag raised there is false for\n\t * the first frame. That frame is what a user who followed a run link saw:\n\t * the flow editor's toolbar over \"No steps yet\", on a URL that already\n\t * named a run. `created` runs before the first render, so the mode the URL\n\t * describes is known to every surface from the first paint, and the\n\t * requests below only fill it in.\n\t *\n\t * @return {void}\n\t */\n\tcreated() {\n\t\tthis.store.beginBootstrap(this.run)\n\t},\n\n\tasync mounted() {\n\t\tdocument.addEventListener('keydown', this.onDocumentKeydown)\n\n\t\t// `finally`, because a canvas stuck on \"loading\" forever is worse than\n\t\t// the empty state this replaced. `load()` swallows its own request\n\t\t// errors, but a throw anywhere else in the chain would strand the flag.\n\t\ttry {\n\t\t\tawait this.store.load({ app: this.app, id: this.id })\n\n\t\t\t// AFTER the load, never before. `inspectRun` reads the run and its\n\t\t\t// objects; doing it first would fill the panel and then have the flow\n\t\t\t// arrive underneath it.\n\t\t\tawait this.openRunFromRoute()\n\t\t} finally {\n\t\t\tthis.store.endBootstrap()\n\t\t}\n\t},\n\n\tbeforeUnmount() {\n\t\tdocument.removeEventListener('keydown', this.onDocumentKeydown)\n\n\t\t// The animator's timer must not outlive the canvas, and neither may\n\t\t// the store's poll loop outlive the surface that watches it — no\n\t\t// polling survives navigating away.\n\t\tthis.cancelRunAnimation()\n\t\tthis.store.stopWatching()\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Open the run the URL named, if it named one.\n\t\t *\n\t\t * Half of a deep link, and the half that is easy to leave out: the\n\t\t * `<a href>` in the Runs list is the visible part, but a visitor who\n\t\t * follows a run's own address lands on the flow with the run in the\n\t\t * query, and nothing wired that to `inspectRun()`. The link then opened\n\t\t * the right flow with no run selected, which reads as a broken link\n\t\t * rather than as a missing wire.\n\t\t *\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync openRunFromRoute() {\n\t\t\tif (!this.run) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Synchronously, before the fetch. `inspectRun()` sets\n\t\t\t// `inspectedRunUuid` itself, but only once it is called, and on a\n\t\t\t// fresh arrival that is behind the whole flow load. Recording the\n\t\t\t// run here means every surface reading `inRunView` agrees with the\n\t\t\t// address bar for the length of the load instead of after it.\n\t\t\tthis.store.beginOpeningRun(this.run)\n\t\t\ttry {\n\t\t\t\tawait this.store.inspectRun(this.run)\n\t\t\t} finally {\n\t\t\t\tthis.store.endOpeningRun()\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Ctrl+Z / Cmd+Z steps the GRAPH back one edit.\n\t\t *\n\t\t * Bound on `document` rather than the canvas element: the shortcut has\n\t\t * to work after clicking anywhere in the editor, and the canvas is not\n\t\t * what holds focus for most of a session.\n\t\t *\n\t\t * ⚠️ WHICH IS EXACTLY WHY IT HAS TO STAND DOWN. A document listener sees\n\t\t * every Ctrl+Z on the page, including the one a user presses to undo\n\t\t * TYPING in a step's name or its JSON config. Reverting the whole graph\n\t\t * because someone fixed a typo would be a far worse bug than having no\n\t\t * undo at all, so a keystroke aimed at editable text is left to the\n\t\t * browser's own undo. The open-editor check is a second guard for the\n\t\t * same reason: while a node's dialog is up, the user is editing that\n\t\t * node, not the graph.\n\t\t *\n\t\t * Shift+Ctrl+Z is deliberately NOT claimed — that is redo, and there is\n\t\t * no redo stack yet. Claiming it would swallow the key and do nothing.\n\t\t *\n\t\t * @param {KeyboardEvent} event The key event.\n\t\t * @return {void}\n\t\t */\n\t\tonDocumentKeydown(event) {\n\t\t\tif (event.key !== 'z' && event.key !== 'Z') {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif ((event.ctrlKey || event.metaKey) === false || event.shiftKey === true || event.altKey === true) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst target = event.target\n\t\t\tconst tag = String(target?.tagName || '').toLowerCase()\n\t\t\tif (tag === 'input' || tag === 'textarea' || target?.isContentEditable === true) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif (this.store.editingNodeId !== null) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif (this.store.canUndo === false) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tevent.preventDefault()\n\t\t\tthis.store.undo()\n\t\t},\n\n\t\t/**\n\t\t * Persist a node move that Vue Flow reports.\n\t\t *\n\t\t * Vue Flow emits ONE change stream for drags, keyboard moves and\n\t\t * programmatic updates alike, so this replaces the old `@node-move`.\n\t\t * Only `position` changes with a settled position are persisted:\n\t\t * intermediate drag frames arrive with `dragging: true` and writing\n\t\t * those would put a store commit on every animation frame.\n\t\t *\n\t\t * @param {Array<object>} changes Vue Flow's node changes.\n\t\t * @return {void}\n\t\t */\n\t\tonNodesChange(changes) {\n\t\t\tfor (const change of (changes || [])) {\n\t\t\t\tif (change.type !== 'position' || change.dragging === true) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tif (change.position === undefined || change.position === null) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tthis.store.moveNode({ id: change.id, x: change.position.x, y: change.position.y })\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * @param {object|null} line The menu's target line.\n\t\t * @return {string} The router it is drawn with.\n\t\t */\n\t\tlineTypeOf(line) {\n\t\t\treturn line?.lineType || DEFAULT_EDGE_LINE_TYPE\n\t\t},\n\n\t\t/**\n\t\t * A line was clicked: open its actions where the pointer is.\n\t\t *\n\t\t * Vue Flow reports `{ edge, event }`, and `edge.data.edge` is the STORED\n\t\t * record — but the menu is keyed on the endpoint pair rather than on\n\t\t * that record, because one stored record can draw several lines and only\n\t\t * the pair says which one was clicked.\n\t\t *\n\t\t * @param {object} event Vue Flow's edge-click event.\n\t\t * @return {void}\n\t\t */\n\t\tonEdgeSelect(event) {\n\t\t\tconst edge = event?.edge ?? event\n\t\t\tconst mouse = event?.event\n\n\t\t\tthis.openEdgeMenuFor(edge, mouse)\n\t\t},\n\n\t\t/**\n\t\t * The label is a control in its own right, so activating it opens the\n\t\t * same menu the line does — a user who aims at the name of a connection\n\t\t * means the connection.\n\t\t *\n\t\t * @param {string} id The drawn line's id.\n\t\t * @return {void}\n\t\t */\n\t\tonEdgeLabelClick(id) {\n\t\t\tconst line = this.store.canvasEdges.find((candidate) => candidate.id === id)\n\t\t\tif (line === undefined) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.store.editingEdge = { source: line.source, target: line.target }\n\t\t},\n\n\t\t/**\n\t\t * @param {object} payload A right-click on a line's label.\n\t\t * @param {string} payload.id The drawn line's id.\n\t\t * @param {MouseEvent} payload.event The pointer event.\n\t\t * @return {void}\n\t\t */\n\t\tonEdgeLabelContext({ id, event }) {\n\t\t\tconst line = this.store.canvasEdges.find((candidate) => candidate.id === id)\n\t\t\tif (line === undefined) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.openEdgeMenuFor(line, event)\n\t\t},\n\n\t\t/**\n\t\t * Persist a label slid along its line.\n\t\t *\n\t\t * REPORTED, then stored — CnFlowEdge never writes the edge itself, the\n\t\t * same rule node positions follow.\n\t\t *\n\t\t * @param {object} payload The move.\n\t\t * @param {string} payload.id The drawn line's id.\n\t\t * @param {number} payload.labelT Its new fraction along the line.\n\t\t * @return {void}\n\t\t */\n\t\tonEdgeLabelMove({ id, labelT }) {\n\t\t\tconst line = this.store.canvasEdges.find((candidate) => candidate.id === id)\n\t\t\tif (line === undefined) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.store.setEdgeFields({ source: line.source, target: line.target, fields: { labelT } })\n\t\t},\n\n\t\t/**\n\t\t * Open the line menu, if the click carried a pointer position.\n\t\t *\n\t\t * A keyboard-activated selection has no coordinates, and a menu placed at\n\t\t * (0, 0) is worse than no menu — so the selection stands and nothing\n\t\t * pops. The dialog remains reachable from the label control, which IS\n\t\t * focusable.\n\t\t *\n\t\t * @param {object} line The drawn line, or Vue Flow's edge record.\n\t\t * @param {MouseEvent=} mouse The pointer event, when there was one.\n\t\t * @return {void}\n\t\t */\n\t\topenEdgeMenuFor(line, mouse) {\n\t\t\tif (line?.source === undefined || mouse?.clientX === undefined) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.openEdgeMenu({\n\t\t\t\titem: {\n\t\t\t\t\tsource: line.source,\n\t\t\t\t\ttarget: line.target,\n\t\t\t\t\tlineType: line.data?.lineType,\n\t\t\t\t},\n\t\t\t\tevent: mouse,\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * Clicking the empty pane clears BOTH selections.\n\t\t *\n\t\t * @return {void}\n\t\t */\n\t\tonCanvasClick() {\n\t\t\tthis.store.selectedNodeId = null\n\t\t},\n\n\t\t/**\n\t\t * Vue Flow hands back `{ node }`; the store tracks a bare id.\n\t\t *\n\t\t * @param {object} event The node-click event.\n\t\t * @return {void}\n\t\t */\n\t\tonNodeSelect(event) {\n\t\t\tconst id = event?.node?.id ?? event?.id ?? null\n\t\t\tthis.store.selectedNodeId = id\n\n\t\t\t// Selection still happens — the sidebar keeps following the canvas —\n\t\t\t// and the menu opens ON TOP of it. They are not alternatives: one is\n\t\t\t// \"which step am I looking at\", the other is \"what can I do to it\".\n\t\t\tif (id === null) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst mouse = event?.event\n\t\t\tif (mouse?.clientX === undefined) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.openNodeMenu({ item: id, event: mouse })\n\t\t},\n\n\t\t/**\n\t\t * Place a palette node where it was dropped.\n\t\t *\n\t\t * @param {object} drop The drop payload.\n\t\t * @param {object} drop.position The point, already in canvas space.\n\t\t * @return {void}\n\t\t */\n\t\tonCanvasDrop({ position }) {\n\t\t\tif (!this.store.paletteDragType) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// `position` is already canvas space — Vue Flow's `project()` undid\n\t\t\t// pan and zoom in the canvas, same contract as before, different\n\t\t\t// arithmetic.\n\t\t\tthis.store.addNode(this.store.paletteDragType, position.x, position.y)\n\t\t\tthis.store.paletteDragType = null\n\t\t},\n\n\t\t/**\n\t\t * @return {void}\n\t\t */\n\t\tonSaveClick() {\n\t\t\t/**\n\t\t\t * @event save The Save button was pressed. The host persists via\n\t\t\t * `useFlowStore().save()` and, for a new flow, swaps the route to\n\t\t\t * the minted id — only the host knows whether one is needed.\n\t\t\t */\n\t\t\tthis.$emit('save')\n\t\t},\n\n\t\t/**\n\t\t * @return {void}\n\t\t */\n\t\tonRunClick() {\n\t\t\t/**\n\t\t\t * @event run The Run button was pressed. The host queues a run via\n\t\t\t * `useFlowStore().run()`.\n\t\t\t */\n\t\t\tthis.$emit('run')\n\t\t},\n\n\t\t/**\n\t\t * Reset the animator for a fresh run — a new watch or a replay.\n\t\t *\n\t\t * The reset IS the cancellation: a replay in progress, a previous\n\t\t * watch's residue, and any pending hop timer all go.\n\t\t *\n\t\t * @param {string} mode `watch` or `replay`.\n\t\t * @return {void}\n\t\t */\n\t\tstartRunAnimation(mode) {\n\t\t\tthis.cancelRunAnimation()\n\t\t\tthis.runAnimation.mode = mode\n\t\t},\n\n\t\t/**\n\t\t * Tear the run picture down: clear the timer, the queue, the residue.\n\t\t *\n\t\t * Called on unmount, on a graph edit, and by `startRunAnimation` when\n\t\t * a new run takes the canvas.\n\t\t *\n\t\t * @return {void}\n\t\t */\n\t\tcancelRunAnimation() {\n\t\t\tconst anim = this.runAnimation\n\t\t\tif (anim.timer !== null) {\n\t\t\t\tclearTimeout(anim.timer)\n\t\t\t}\n\n\t\t\tthis.runAnimation = {\n\t\t\t\tmode: null,\n\t\t\t\tqueue: [],\n\t\t\t\tplayedCount: 0,\n\t\t\t\tcurrentNodeId: null,\n\t\t\t\tlastNodeId: null,\n\t\t\t\ttraceLineId: null,\n\t\t\t\ttracedLineIds: [],\n\t\t\t\tdoneNodeIds: [],\n\t\t\t\tfailedNodeIds: [],\n\t\t\t\tholdNodeIds: [],\n\t\t\t\tskippedTransitions: [],\n\t\t\t\ttimer: null,\n\t\t\t}\n\t\t\tthis.runConsumed = 0\n\t\t\t// A dismissal belongs to the run it was about. The next run gets to\n\t\t\t// say what it skipped, even if the last one was waved away.\n\t\t\tthis.runSkipDismissed = false\n\t\t},\n\n\t\t/**\n\t\t * Close a canvas message the author has read.\n\t\t *\n\t\t * 🔑 CLEARS THE SOURCE, NOT A LOCAL \"HIDDEN\" FLAG. A dismissed message\n\t\t * whose source is still set would come back on the next unrelated\n\t\t * re-render, and the author would learn that the close button does not\n\t\t * work. Only one-off messages reach this: a standing condition renders\n\t\t * no dismiss control at all, because it would still be true afterwards.\n\t\t *\n\t\t * @param {string} id The message id.\n\t\t * @return {void}\n\t\t */\n\t\tdismissCanvasMessage(id) {\n\t\t\tif (id === 'action-failed') {\n\t\t\t\tthis.store.error = null\n\t\t\t} else if (id === 'refused' || id === 'graph-locked') {\n\t\t\t\t// The lock itself stays; only the refusal it just answered goes.\n\t\t\t\t// `graph-locked` falls back to its standing read-only wording.\n\t\t\t\tthis.store.lifecycleRefusal = null\n\t\t\t} else if (id === 'check') {\n\t\t\t\tthis.store.checkResult = null\n\t\t\t} else if (id === 'run-skipped') {\n\t\t\t\tthis.runSkipDismissed = true\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Run the action a canvas message offers.\n\t\t *\n\t\t * @param {string} id The message id.\n\t\t * @return {void}\n\t\t */\n\t\trunCanvasMessageAction(id) {\n\t\t\tif (id === 'graph-locked') {\n\t\t\t\tthis.store.createDraft()\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Play the next queued step, if a hop is not already in flight.\n\t\t *\n\t\t * Steps whose `transition` matches no canvas node are skipped HERE,\n\t\t * without spending a hop on them, and their names surfaced — the flow\n\t\t * was edited since the run, and remapping the step onto a different\n\t\t * card would draw a walk the engine never took. Subsequent steps\n\t\t * continue on their own nodes.\n\t\t *\n\t\t * @return {void}\n\t\t */\n\t\tdrainRunQueue() {\n\t\t\tconst anim = this.runAnimation\n\t\t\tif (anim.timer !== null) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\twhile (anim.queue.length > 0) {\n\t\t\t\tconst transition = String(anim.queue[0].transition || '')\n\t\t\t\tif (this.store.nodes.some((node) => node.id === transition)) {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\n\t\t\t\tanim.queue.shift()\n\t\t\t\tanim.skippedTransitions.push(transition)\n\t\t\t}\n\n\t\t\tif (anim.queue.length === 0) {\n\t\t\t\t// Drained. The halo hands over to the derived frontier state\n\t\t\t\t// (see `runNodeStates`); the trace does not linger on a line\n\t\t\t\t// whose hop is over.\n\t\t\t\tanim.currentNodeId = null\n\t\t\t\tanim.traceLineId = null\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.playRunStep(anim.queue.shift())\n\t\t},\n\n\t\t/**\n\t\t * One hop: paint one step onto its node and the edge that led there.\n\t\t *\n\t\t * @param {object} step The engine's log entry, as delivered.\n\t\t * @return {void}\n\t\t */\n\t\tplayRunStep(step) {\n\t\t\tconst anim = this.runAnimation\n\t\t\tconst nodeId = String(step.transition)\n\t\t\tconst previous = anim.lastNodeId\n\n\t\t\tanim.currentNodeId = nodeId\n\t\t\tanim.lastNodeId = nodeId\n\t\t\tanim.playedCount += 1\n\n\t\t\t// The edge just executed: the drawn line from the previously\n\t\t\t// animated node to this one, resolved through `canvasEdges` —\n\t\t\t// which already normalises `{from, to}` and list endpoints.\n\t\t\tanim.traceLineId = null\n\t\t\tif (previous !== null && previous !== nodeId) {\n\t\t\t\tconst line = this.store.canvasEdges.find((candidate) => candidate.source === previous && candidate.target === nodeId)\n\t\t\t\tif (line !== undefined) {\n\t\t\t\t\tanim.traceLineId = line.id\n\t\t\t\t\tif (anim.tracedLineIds.includes(line.id) === false) {\n\t\t\t\t\t\tanim.tracedLineIds.push(line.id)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Residue, from the engine's own closed status set. A failure —\n\t\t\t// or a stop that carries an error — stays red for the rest of the\n\t\t\t// run; completed and pinned keep the quiet success accent; a\n\t\t\t// suspended step holds until the watcher reports the run moved on.\n\t\t\tconst failed = step.status === 'failed'\n\t\t\t\t|| (step.status === 'stopped' && Boolean(step.error))\n\t\t\tif (failed === true) {\n\t\t\t\tif (anim.failedNodeIds.includes(nodeId) === false) {\n\t\t\t\t\tanim.failedNodeIds.push(nodeId)\n\t\t\t\t}\n\t\t\t} else if (step.status === 'completed' || step.status === 'pinned') {\n\t\t\t\tif (anim.doneNodeIds.includes(nodeId) === false) {\n\t\t\t\t\tanim.doneNodeIds.push(nodeId)\n\t\t\t\t}\n\t\t\t}\n\t\t\tanim.holdNodeIds = step.status === 'suspended' ? [nodeId] : []\n\n\t\t\tconst hop = this.runHopMs(step)\n\t\t\tif (hop <= 0) {\n\t\t\t\t// Reduced motion: no dwell, no travel — the whole queue drains\n\t\t\t\t// into static state colouring in one pass.\n\t\t\t\tthis.drainRunQueue()\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tanim.timer = setTimeout(() => {\n\t\t\t\tanim.timer = null\n\t\t\t\tthis.drainRunQueue()\n\t\t\t}, hop)\n\t\t},\n\n\t\t/**\n\t\t * How long one hop dwells.\n\t\t *\n\t\t * A live watch catches up at the bounded pace; a replay is paced from\n\t\t * the step's REAL `durationMs`, clamped to a watchable window. Under\n\t\t * reduced motion there is no dwell at all — the states paint\n\t\t * statically.\n\t\t *\n\t\t * @param {object} step The step being played.\n\t\t * @return {number} Milliseconds.\n\t\t */\n\t\trunHopMs(step) {\n\t\t\tif (this.prefersReducedMotion() === true) {\n\t\t\t\treturn 0\n\t\t\t}\n\n\t\t\tif (this.runAnimation.mode === 'replay') {\n\t\t\t\tconst real = Number(step.durationMs)\n\t\t\t\tconst ms = Number.isFinite(real) ? real : REPLAY_HOP_MIN_MS\n\n\t\t\t\treturn Math.min(REPLAY_HOP_MAX_MS, Math.max(REPLAY_HOP_MIN_MS, ms))\n\t\t\t}\n\n\t\t\treturn RUN_CATCH_UP_HOP_MS\n\t\t},\n\n\t\t/**\n\t\t * Whether the reader asked for reduced motion.\n\t\t *\n\t\t * The CSS side is handled by the media query on the animations; this\n\t\t * is the JS side, which skips the sequential reveal — hopping node to\n\t\t * node is itself motion.\n\t\t *\n\t\t * @return {boolean} True when motion should not play.\n\t\t */\n\t\tprefersReducedMotion() {\n\t\t\tif (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {\n\t\t\t\treturn false\n\t\t\t}\n\n\t\t\treturn window.matchMedia('(prefers-reduced-motion: reduce)').matches === true\n\t\t},\n\n\t\t/**\n\t\t * @param {string} id The node id.\n\t\t * @return {string|null} Its run state, or null outside a run.\n\t\t */\n\t\trunStateOf(id) {\n\t\t\treturn this.runNodeStates[id] || null\n\t\t},\n\n\t\t/**\n\t\t * The run state in words, for the chip beside the colour.\n\t\t *\n\t\t * @param {string} state The run state.\n\t\t * @return {string} The label.\n\t\t */\n\t\trunStateLabel(state) {\n\t\t\tconst labels = {\n\t\t\t\tactive: this.t('nextcloud-vue', 'Running'),\n\t\t\t\tdone: this.t('nextcloud-vue', 'Done'),\n\t\t\t\tfailed: this.t('nextcloud-vue', 'Failed'),\n\t\t\t\thold: this.t('nextcloud-vue', 'Waiting'),\n\t\t\t}\n\n\t\t\treturn labels[state] || state\n\t\t},\n\n\t\t/**\n\t\t * Step the zoom, rounded so repeated presses do not drift on floats.\n\t\t *\n\t\t * @param {number} delta The step, e.g. ±0.1.\n\t\t * @return {void}\n\t\t */\n\t\tzoomBy(delta) {\n\t\t\tthis.zoomTo(this.zoom + delta)\n\t\t},\n\n\t\t/**\n\t\t * Zoom the CANVAS, which is the only thing that holds a transform.\n\t\t *\n\t\t * `zoom` here is a mirror of what the canvas reports, never the source:\n\t\t * setting it moved a number this component owned and nothing else, so\n\t\t * the toolbar counted up and down over a canvas that never moved.\n\t\t *\n\t\t * @param {number} value The wanted zoom, clamped to the declared range.\n\t\t * @return {void}\n\t\t */\n\t\tzoomTo(value) {\n\t\t\tconst next = Math.max(this.minZoom, Math.min(this.maxZoom, value))\n\t\t\tthis.$refs.canvas?.zoomTo?.(Math.round(next * 100) / 100)\n\t\t},\n\n\t\t/**\n\t\t * Whether the engine knows this node type.\n\t\t *\n\t\t * Shown on the card rather than hidden. A node the catalogue cannot\n\t\t * explain WILL fail its step, and a flow that looks fine on the canvas\n\t\t * and dies at run time is the exact failure this component's ancestor\n\t\t * shipped.\n\t\t *\n\t\t * @param {string} type The node type.\n\t\t * @return {boolean} True when the catalogue does not know it.\n\t\t */\n\t\tisUnknown(type) {\n\t\t\t// An empty catalogue means it could not be loaded, not that every\n\t\t\t// node is unknown — flagging all of them then would be noise.\n\t\t\tif (!this.store.nodeCatalog.length) {\n\t\t\t\treturn false\n\t\t\t}\n\n\t\t\treturn this.store.catalogEntry(type) === null\n\t\t},\n\n\t\t/**\n\t\t * Human label for a node type, from the catalogue.\n\t\t *\n\t\t * No local name table: a type the catalogue cannot explain is shown as\n\t\t * its raw id rather than guessed at from a list that may not match the\n\t\t * engine.\n\t\t *\n\t\t * @param {string} type The node type.\n\t\t * @return {string} The label.\n\t\t */\n\t\ttypeLabel(type) {\n\t\t\tconst entry = this.store.catalogEntry(type)\n\n\t\t\treturn entry ? (entry.displayName || entry.id) : (type || '—')\n\t\t},\n\n\t\t/**\n\t\t * A node type turned into a usable CSS class suffix.\n\t\t *\n\t\t * Engine ids are namespaced (`hermiq.agent-step`), and a dot in the\n\t\t * middle of a class name is a compound selector rather than a name — so\n\t\t * a per-type accent silently matched nothing for every catalogue type.\n\t\t *\n\t\t * @param {string} type The node type.\n\t\t * @return {string} The slug.\n\t\t */\n\t\ttypeSlug(type) {\n\t\t\treturn String(type || '').replace(/[^a-zA-Z0-9]+/g, '-')\n\t\t},\n\n\t\t/**\n\t\t * Short summary of a node's configuration, shown on the card.\n\t\t *\n\t\t * Deliberately GENERIC. The version this was ported from switched on a\n\t\t * hard-coded list of bare node ids, so it described exactly the four\n\t\t * types one app knew about and said nothing about any other app's\n\t\t * nodes — including the ones its own palette offered. Summarising the\n\t\t * config that is actually set describes every node type, present and\n\t\t * future, without the builder having to know any of them.\n\t\t *\n\t\t * @param {object} node The node.\n\t\t * @return {string} The label.\n\t\t */\n\t\tnodeLabel(node) {\n\t\t\t// A name the author gave the step beats a derived summary.\n\t\t\tif (node.name) {\n\t\t\t\treturn node.name\n\t\t\t}\n\n\t\t\tconst config = (node.config || {})\n\t\t\tconst keys = Object.keys(config).filter((k) => config[k] !== '' && config[k] !== null)\n\n\t\t\tif (!keys.length) {\n\t\t\t\treturn this.t('nextcloud-vue', 'not configured')\n\t\t\t}\n\n\t\t\tconst first = keys[0]\n\t\t\tconst value = config[first]\n\t\t\tconst shown = (typeof value === 'object') ? '…' : String(value)\n\n\t\t\tif (keys.length === 1) {\n\t\t\t\treturn `${first}: ${shown}`\n\t\t\t}\n\n\t\t\treturn `${first}: ${shown} +${keys.length - 1}`\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-flow-detail {\n\tposition: relative;\n\tblock-size: 100%;\n\tinline-size: 100%;\n\t/* The dotted grid every flow tool draws: it says \"canvas\", and it makes\n\t drag alignment visible without a snapping feature. */\n\tbackground-image: radial-gradient(var(--color-border) 1px, transparent 1px);\n\tbackground-size: 20px 20px;\n}\n\n/* The canvas paints an opaque main-background of its own, which sat exactly\n on top of the grid above. Cleared here, for flow editors only — other\n CnGraphCanvas consumers keep their solid ground. */\n.cn-flow-detail :deep(.cn-graph-canvas) {\n\tbackground: transparent;\n}\n\n.cn-flow-detail__toolbar {\n\tposition: absolute;\n\tinset-block-start: 12px;\n\tinset-inline-end: 12px;\n\tz-index: 10;\n\tdisplay: flex;\n\tgap: 4px;\n\talign-items: center;\n\tpadding: 4px;\n\tborder: 1px solid var(--color-border);\n\tborder-radius: var(--border-radius-large);\n\tbackground: var(--color-main-background);\n\tbox-shadow: 0 1px 4px var(--color-box-shadow);\n}\n\n/* The toolbar while a run is being opened: the spinner and its sentence sit\n where the editor's buttons were, so the strip keeps its height and the zoom\n controls do not jump sideways when the run arrives. */\n.cn-flow-detail__toolbar-status {\n\tdisplay: flex;\n\tgap: 6px;\n\talign-items: center;\n\tpadding-inline: 8px;\n\tcolor: var(--color-text-maxcontrast);\n}\n\n.cn-flow-detail__toolbar-group {\n\tdisplay: flex;\n\talign-items: center;\n\tmargin-inline-start: 4px;\n\tpadding-inline-start: 8px;\n\tborder-inline-start: 1px solid var(--color-border);\n}\n\n/* ONE container, not a card in a card: the canvas wrapper draws the box —\n border, radius, background, selection — and this card only fills it. Its\n earlier own border/background rendered as a visible nested box.\n\n ⚠️ THE NO-BOX IS DECLARED, NOT LEFT OUT, AND IT IS SCOPED THROUGH THE PARENT\n ON PURPOSE. Removing the border by simply deleting the declaration looked\n like it worked here and did not work in a browser: several installed apps\n still bundle an OLDER build of this component, Vue's scoped hash for it is\n the same in both (`data-v-…` hashes the file, not its contents), and their\n stale `.cn-flow-detail__node` rule therefore lands in the same page. Nothing\n in the current build declared `border` at all, so the old declaration was\n the only one in the cascade and simply won — the nested box came back on a\n page whose own copy of the library was already fixed.\n\n So the rule states the absence, and `.cn-flow-detail` in front of it raises\n the specificity above any same-name copy, which makes this independent of\n which bundle a page happens to inject last. */\n.cn-flow-detail .cn-flow-detail__node {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: 2px;\n\tjustify-content: center;\n\tblock-size: 100%;\n\t/* No padding at all: `.cn-flow-node` already pads the box, and the role\n\t accent moved OFF this element onto the node's own border below — the 8px\n\t left inset that used to clear it was the second half of the nested-box\n\t effect, holding the body away from an edge it no longer meets. */\n\tpadding: 0;\n\tborder: 0;\n\tborder-radius: inherit;\n\tbackground: none;\n\toverflow: hidden;\n\n\t/* ⚠️ THE ACCENT'S ABSENCE HAS TO BE DECLARED, NOT MERELY LEFT OUT — AND A\n\t LIVE INSTANCE IS THE ONLY PLACE THAT SHOWS WHY.\n\n\t Moving the role accent onto `.cn-flow-node` below meant deleting the\n\t `box-shadow` from the three `--role-*` rules that used to sit on THIS\n\t element. That is enough in this repository's own harness, where exactly\n\t one build of this component exists.\n\n\t It is not enough in a Nextcloud page. Measured on a live instance\n\t (dossiq, /apps/dossiq/flows/…): THREE stale copies of\n\t `.cn-flow-detail__node--role-trigger[data-v-65800ae2]` were injected by\n\t other apps' bundles alongside the current one, all carrying the old inset\n\t shadow. Vue's scoped hash is identical in every copy because it hashes the\n\t FILE PATH, not the contents — so an older build's rule lands in the same\n\t cascade under the same attribute, and with nothing in the current build\n\t declaring `box-shadow` for this element the stale rule simply won. The\n\t accent painted on the wrapper AND on the body, and the card-in-card came\n\t back on a page whose own copy of the library was already fixed.\n\n\t The e2e cannot catch this: its harness has one build, so\n\t `getComputedStyle(body).boxShadow === 'none'` passes there either way.\n\t Only this declaration holds it, and only because\n\t `.cn-flow-detail .cn-flow-detail__node` (0,3,0) outranks the stale\n\t `.cn-flow-detail__node--role-trigger` (0,2,0).\n\n\t The `border: 0` above is the same defence, written for the same reason\n\t when the nested BORDER came back the same way. */\n\tbox-shadow: none;\n}\n\n/* Unknown step -> error border on the NODE WRAPPER, so the whole node reads as\n wrong rather than just its body.\n\n This targeted `.cn-graph-canvas__node` until the Vue Flow migration renamed\n the wrapper to `.cn-flow-node`, and nothing caught it: a CSS rule whose\n selector stops matching does not warn, it just silently stops painting. The\n class kept appearing in the shipped stylesheet and source maps, so it read\n as present while unknown nodes quietly lost their error border. */\n.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--unknown)) {\n\tborder-color: var(--color-error);\n}\n\n/* Role accents, keyed on the CATALOGUE's role — never on graph position,\n which once painted unconnected steps green.\n\n ⚠️ ON THE NODE'S OWN BORDER, NOT ON THE BODY INSIDE IT. The accent used to be\n an inset shadow on `.cn-flow-detail__node`, which sits inside\n `.cn-flow-node`'s 2px border and 12px of padding — so the bar drew a second\n vertical edge a few pixels in from the first, and a node read as a card\n inside a card. Nothing had a border it should not have; the accent was simply\n painted on the wrong box.\n\n Still an inset box-shadow, per this repository's rule 8: `border-inline-start`\n would widen the node's left edge from 2px to 4px and shove every node's body\n sideways, which is a layout change rather than a fix for one. `:has()` is how\n a rule on the body reaches the wrapper — the same mechanism the unknown-step\n border below already uses. */\n.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--role-trigger)) {\n\tbox-shadow: inset 4px 0 0 0 var(--color-success);\n}\n\n.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--role-step)) {\n\tbox-shadow: inset 4px 0 0 0 var(--color-primary-element);\n}\n\n.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--role-end)) {\n\tbox-shadow: inset 4px 0 0 0 var(--color-error);\n}\n\n.cn-flow-detail__node-type {\n\tfont-weight: 600;\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n}\n\n.cn-flow-detail__node-label,\n.cn-flow-detail__node-warning {\n\tfont-size: 0.85em;\n\tcolor: var(--color-text-maxcontrast);\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n}\n\n.cn-flow-detail__node-warning {\n\tcolor: var(--color-error-text);\n}\n\n.cn-flow-detail__empty {\n\tposition: absolute;\n\tinset: 0;\n\tpointer-events: none;\n}\n\n/* ---- Run state on the nodes ------------------------------------------- */\n\n/* The FlowMock visual grammar in Nextcloud tokens: `--color-success` for the\n halo/trace/done (FlowMock's mint), `--color-error` for failed (its\n vermillion), `--color-warning` for the hold. No `--c-*` marketing tokens —\n semantic variables are what keep nldesign theming working.\n\n ON THE NODE'S OUTLINE, NOT ITS BOX-SHADOW. The role accents above already\n own `box-shadow` on the same `.cn-flow-node:has(...)` element, and a second\n rule setting it would silently erase whichever loses the cascade. `outline`\n is a separate channel: run state and role accent paint together. It also\n costs no layout — an outline never moves the node's body. */\n.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--run-done)) {\n\toutline: 2px solid var(--color-success);\n\toutline-offset: 1px;\n}\n\n.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--run-active)) {\n\toutline: 3px solid var(--color-success);\n\toutline-offset: 1px;\n\tanimation: cn-flow-detail-run-halo 1.1s ease-in-out infinite;\n}\n\n.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--run-failed)) {\n\toutline: 3px solid var(--color-error);\n\toutline-offset: 1px;\n}\n\n/* Visibly WAITING: dashed (a different shape from executing and from failed,\n so the states differ by more than hue) and pulsing slowly. */\n.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--run-hold)) {\n\toutline: 3px dashed var(--color-warning);\n\toutline-offset: 1px;\n\tanimation: cn-flow-detail-run-hold 2.4s ease-in-out infinite;\n}\n\n/* The halo: the outline breathes outwards and fades — a pulse with no layout\n cost, on the wrapper the role accents already reach through `:has()`. */\n@keyframes cn-flow-detail-run-halo {\n\t0%,\n\t100% {\n\t\toutline-offset: 1px;\n\t\toutline-color: var(--color-success);\n\t}\n\n\t50% {\n\t\toutline-offset: 7px;\n\t\toutline-color: transparent;\n\t}\n}\n\n@keyframes cn-flow-detail-run-hold {\n\t0%,\n\t100% {\n\t\toutline-color: var(--color-warning);\n\t}\n\n\t50% {\n\t\toutline-color: transparent;\n\t}\n}\n\n/* The state in words — never colour alone (WCAG 1.4.1). */\n.cn-flow-detail__node-run {\n\tfont-size: 0.75em;\n\tfont-weight: 600;\n\ttext-transform: uppercase;\n\tletter-spacing: 0.04em;\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n\tcolor: var(--color-success-text, var(--color-success));\n}\n\n.cn-flow-detail__node-run--failed {\n\tcolor: var(--color-error-text, var(--color-error));\n}\n\n.cn-flow-detail__node-run--hold {\n\tcolor: var(--color-warning-text, var(--color-warning));\n}\n\n/* ---- Run state on the edges ------------------------------------------- */\n\n/* Lines already walked keep a quiet success stroke for the rest of the run. */\n.cn-flow-detail :deep(.vue-flow__edge.cn-flow-detail__edge--run-traced .vue-flow__edge-path),\n.cn-flow-detail :deep(.vue-flow__edge.cn-flow-detail__edge--run-tracing .vue-flow__edge-path) {\n\tstroke: var(--color-success);\n}\n\n/* The trace: a success-coloured segment travelling the edge just executed,\n source to target — `stroke-dashoffset` walking a dash pattern forwards,\n FlowMock's own technique.\n\n IT RIDES THE EDGE'S SECOND PATH, NOT THE LINE ITSELF. CnFlowEdge already\n draws a direction pulse as a separate path over the same routed geometry,\n exactly so the base line can stay solid (a dashed connection means\n something else in every diagram convention). The trace re-styles that path\n for the hop being animated: denser, thicker, faster, and in the success\n colour. When the hop ends the class leaves and the pulse returns to its\n quiet self. */\n.cn-flow-detail :deep(.vue-flow__edge.cn-flow-detail__edge--run-tracing .cn-flow-edge__pulse) {\n\tstroke: var(--color-success);\n\tstroke-width: 4;\n\tstroke-dasharray: 10 22;\n\tanimation: cn-flow-detail-run-trace 0.4s linear infinite;\n}\n\n@keyframes cn-flow-detail-run-trace {\n\tfrom {\n\t\tstroke-dashoffset: 32;\n\t}\n\n\tto {\n\t\tstroke-dashoffset: 0;\n\t}\n}\n\n/* Reduced motion: the state COLOURS stay — done, failed, hold and current\n remain distinguishable — but nothing travels and nothing pulses. The\n sequential hop reveal is also skipped on the JS side (`runHopMs`), and\n CnFlowEdge already hides its pulse path entirely under this query, which\n takes the trace's travel with it. */\n@media (prefers-reduced-motion: reduce) {\n\t.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--run-active)),\n\t.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--run-hold)) {\n\t\tanimation: none;\n\t}\n}\n</style>\n"],"names":["_createElementVNode","_createElementBlock","_Fragment","_createVNode","_createTextVNode","_createBlock","_openBlock","_toDisplayString","_resolveDynamicComponent","_withCtx"],"mappings":";;AA6CM,MAAA,UAAA,GAAA,EAAA,OAAM,gBAAgB,GAAA;;;;AA0GZ,EAAA,KAAK,EAAC,gCAAG;AAAA,EAAA,aAAA,EAAA,0BAAA;;AAKjB,MAAA,UAAA,GAAA,EAAA,KAAA,EAAA,+BAAA,GAAA;;;AA4FG,MAAA,UAAA,GAAA,EAAA,KAAI,EAAE,4BAA4B,GAAA;;;;;;;;;;;;;;;;;;;;;;;IAxMzCA,kBA8II,CAAA,KAAA,EAAA;AA9ID,MAAA,KAAA,EAAA,yBAAA;AAAA,MAAA,IAAA,EAAA,SAAA;AAAA,MAAA,YAAA,EAAA,QAAA,CAAA,YAAA;;QAiBa,QAAU,CAAA,UAAA;wBAAzBC,kBAgFS,CAAAC,QAAA,EAAA,EAAA,GAAA,EAAA,CAAA,EAAA,EAAA;uBAzDC,CAAA,mBAAA,EAAA;AAND,cAAA,aAAW,EAAE,eAAS;AAC9B,cAAA,OAAK,uCAAE,KAAC,CAAA,cAAA,GAAA,IAAA,CAAA,CAAA;;AACE,cAAA,IAAI,UACd,MAAmB;gBAAnBC,WAAmB,CAAA,eAAA,EAAA,EAAZ,IAAI,EAAE,EAAE,EAAA,CAAA;;+BACL,MACX;AADW,gBAAAC,eAAA,CAAA,GACX,mBAAG,IAAC,CAAA,CAAA,CAAA,eAAA,EAAA,YAAA,CAAA,CAAA,EAAA,CAAA,YAAA;;;;uBAkBM,CAAA,mBAAA,EAAA;AAVD,cAAA,OAAO,EAAC,SAAK;AACrB,cAAA,QAAO,EAAG,MAAA,CAAA,KAAI,CAAA,MAAA,IAAA,CAAA,MAAA,CAAA,KAAA,CAAA,IAAA,CAAA,IAAA,IAAA,MAAA,CAAA,KAAA,CAAA,WAAA;AACd,cAAA,KAAK,EAAE,QAAkB,CAAA,kBAAA;AACzB,cAAA,aAAU,EAAC,kBAAkB;AAC7B,cAAA,OAAK,EAAE,QAAE,CAAA,WAAA;;AACC,cAAA,IAAI,UACd,MAAQ;AAAA,gBAAA,CAAA,MAAA,CAAA,KAAA,CAAA,MAAA;kCAARC,WAAQ,CAAA,wBAAA,EAAA;;AAAA,sBAAA,IAAA,EAAA,EAAA;;kCACRA,WAAyB,CAAA,sBAAA,EAAA;;AAAJ,sBAAA,IAAI,EAAA,EAAA;;;+BACf,MACX;AADW,gBAAAD,eAAA,CAAA,GACX,mBAAG,IAAC,CAAA,CAAA,CAAA,eAAA,EAAA,MAAA,CAAA,CAAA,EAAA,CAAA,YAAA;;;;uBAUM,CAAA,mBAAA,EAAA;cARA,QAAQ,EAAE,MAAK,CAAA,KAAA,CAAA,OAAA,IAAA,CAAA,MAAA,CAAA,KAAA,CAAA,IAAA,CAAA,EAAA,IAAA,CAAA,QAAA,CAAA,cAAA;AACxB,cAAA,KAAK,EAAE,QAAO,CAAA,iBAAA;2BACJ,EAAC,iBAAc;AACzB,cAAA,OAAK,EAAE,QAAE,CAAA,UAAA;;AACD,cAAA,IAAA,UACR,MAAmB;gBAAnBD,WAAmB,CAAA,eAAA,EAAA,EAAb,IAAM,EAAA,EAAG,EAAA,CAAA;;+BACL,MACP;AADO,gBAAAC,eAAA,CAAA,GACP,mBAAA,IAAA,CAAA,CAAA,CAAA,eAAA,EAAA,KAAA,CAAA,CAAA,EAAA,CAAA,YAAA;;;;uBAUI,CAAA,mBAAA,EAAA;AARC,cAAA,OAAO,EAAC,UAAQ;cACxB,QAAQ,EAAE,YAAK,CAAC,QAAQ,KAAK,MAAK,CAAA,KAAA,CAAC,MAAM,MAAM;cAC/C,OAAK,EAAA,MAAA,CAAA,CAAA,CAAA,KAAA,MAAA,CAAA,CAAA,CAAA,GAAA,MAAA,KAAE,MAAK,CAAA,KAAA,CAAC,KAAK,EAAA,CAAA,CAAA;;AACR,cAAA,IAAI,UACd,MAAc;AAAA,gBAAA,CAAA,MAAA,CAAA,KAAA,CAAA,QAAA;kCAAdC,WAAc,CAAA,wBAAA,EAAA;;AAAA,sBAAA,IAAA,EAAA,EAAA;;kCACdA,WAAyB,CAAA,wBAAA,EAAA;;AAAH,sBAAA,IAAG,EAAA,EAAA;;;+BAChB,MACV;AADU,gBAAAD,eAAA,CAAA,GACV,mBAAG,IAAC,CAAA,CAAA,CAAA,eAAA,EAAA,OAAA,CAAA,CAAA,EAAA,CAAA,YAAA;;;;uBAUA,CAAA,mBAAA,EAAA;;AAPH,cAAA,QAAQ,EAAG,CAAA,MAAA,CAAA,KAAK,CAAC,KAAK,CAAE,MAAK;0BACnB,EAAG,IAAA,CAAA,CAAA,CAAA,eAAA,EAAA,6BAAA,CAAA;qBACN,IAAA,CAAA,CAAA,CAAA,eAAA,EAAA,6BAAA,CAAA;cACL,OAAG,EAAA,MAAA,CAAA,CAAA,CAAA,KAAA,MAAA,CAAA,CAAA,CAAA,GAAA,MAAA,KAAE,MAAK,CAAA,KAAA,CAAC,QAAM,EAAA,CAAA,CAAA;;AACT,cAAA,IAAI,UACd,MAAyB;gBAAzBD,WAAyB,CAAA,sBAAA,EAAA,EAAX,IAAI,EAAE,EAAE,EAAA,CAAA;;;;uBAcb,CAAA,mBAAA,EAAA;AARD,cAAA,OAAO,EAAC,UAAS;cACzB,QAAQ,EAAA,CAAG,MAAK,CAAA,KAAA,CAAC,OAAO;AACxB,cAAA,YAAU,EAAE,IAAC,CAAA,CAAA,CAAA,eAAA,EAAA,sBAAA,CAAA;AACb,cAAA,KAAK,EAAE,IAAA,CAAA,CAAA,CAAA,eAAA,EAAA,sBAAA,CAAA;cACP,OAAK,EAAA,MAAA,CAAA,CAAA,CAAA,KAAA,MAAA,CAAA,CAAA,CAAA,GAAA,MAAA,KAAE,MAAK,CAAA,KAAA,CAAC;;AACJ,cAAA,IAAA,UACT,MAAQ;gBAARA,WAAQ,CAAA,sBAAA,EAAA,EAAA,IAAA,EAAA,EAAA,EAAA,CAAA;;;;;AAST,WAAAG,SAAA,EAAA,EAAAL,kBAAA,CAGK,QAHL,UAGK,EAAA;YAFLE,WAAe,CAAA,wBAAA,EAAA,EAAA,IAAA,EAAA,EAAA,EAAA,CAAA;AAAA,YAAAC,eAAA,CAAA,GACd,mBAAC,IAAC,CAAA,CAAA,CAAA,eAAA,EAAA,iBAAA,CAAA,CAAA,EAAA,CAAA,YAAA;;AAGL,MAAAJ,kBAAA,CAsBM,OAtBN,UAsBM,EAAA;mBAdM,CAAA,mBAAA,EAAA;;UANT,QAAQ,EAAE,KAAK,CAAA,IAAA,IAAG,KAAO,CAAA,OAAA;AACzB,UAAA,YAAU,EAAE,IAAC,CAAA,CAAA,CAAA,eAAA,EAAA,UAAA,CAAA;AACb,UAAA,OAAK,uCAAE,QAAM,CAAA,MAAA,CAAA,CAAA,GAAA,CAAA,CAAA,CAAA;;AACH,UAAA,IAAI,UACd,MAAoB;YAApBG,WAAoB,CAAA,gBAAA,EAAA,EAAZ,IAAI,EAAE,EAAE,EAAA,CAAA;;;;QAGjBA;AAAS,UAAA,OAAO,EAAC,UAAM;AACtB,UAAA,YAAQ,EAAA,IAAA,CAAA,CAAA,CAAA,eAAA,EAAA,YAAA,CAAA;AACR,UAAA,OAAK,uCAAE,QAAA,CAAA,MAAA,CAAA,CAAA,CAAA,CAAA,CAAA;;2BACR,MAAE;AAAA,YAAAC,eAAA,CAAAG,eAAA,CAAA,IAAA,CAAA,KAAA,CAAA,KAAA,CAAA,IAAA,WAAA;;;;QAEHJ,WAOY,CAAA,mBAAA,EAAA;AAPF,UAAA,OAAO,EAAC,UAAU;kBAClB,EAAE,KAAI,CAAA,IAAA,IAAI,KAAO,CAAA,OAAA;AACxB,UAAA,YAAS,EAAC,IAAE,CAAA,CAAA,CAAA,eAAA,EAAA,SAAA,CAAA;AACb,UAAA,OAAK,uCAAE,QAAM,CAAA,MAAA,CAAA,GAAA,CAAA,CAAA,CAAA;;wBAEd,MAAoB;YAApBA,WAAoB,CAAA,eAAA,EAAA,EAAZ,MAAM,EAAG,EAAA,CAAA;;;;;AAMF,MAAA,CAAA,CAAA,MAAA,CAAA,KAAK,CAAC,WAAW;mCAQvB,CAAA,mBAAA,EAAA;;AAPV,YAAA,OAAO,EAAC,UAAU;AACjB,YAAA,YAAU,EAAE,IAAC,CAAA,CAAA,CAAA,eAAA,EAAA,wBAAA,CAAA;AACb,YAAA,KAAK,EAAE,IAAC,CAAA,CAAA,CAAA,eAAA,EAAA,wBAAA,CAAA;;;AAEE,YAAA,IAAI,gBACU;yBAAA,CAAA,oBAAA,EAAA,EAAX,IAAG,EAAE,EAAE,EAAA,CAAA;;;;;;AAQX,IAAA,CAAA,MAAA,CAAA,KAAA,CAAA,aAAA,KAAA,IAAA;kCAAAK,wBAAA,QAAA,CAAA,mBAAA,CAAA,EAAA,EAAA,GAAA,EAAA,CAAA,EAAA,CAAA;;IASXL,WAE2B,CAAA,+BAAA,EAAA;AAFL,MAAA,QAAQ,EAAE,QAAK,CAAA,cAAA;AACpC,MAAA,SAAO,EAAE,QAAoB,CAAA,oBAAA;AAC7B,MAAA,QAAM,EAAE,QAAkB,CAAA,sBAAA;;IAU1BA,WA8Dc,CAAA,wBAAA,EAAA;SA7DZ,EAAC,QAAQ;AACV,MAAA,KAAI,EAAE,QAAE,CAAA,WAAA;WACJ,EAAE,QAAuB,CAAA,uBAAA;AAC5B,MAAA,OAAK,EAAE,KAAO,CAAA,OAAA;AACd,MAAA,OAAK,EAAE,KAAO,CAAA,OAAA;MACd,YAAQ,EAAA,MAAA,CAAA,CAAA,CAAA,KAAA,MAAA,CAAA,CAAA,CAAA,GAAA,MAAA,KAAE,KAAI,CAAA,IAAA,GAAG,MAAM,CAAA,CAAA;kBACvB,EAAA,QAAA,CAAA,YAAA;AACD,MAAA,YAAS,EAAE,QAAY,CAAA,YAAA;AACvB,MAAA,gBAAa,EAAE,QAAgB,CAAA,gBAAA;AAC/B,MAAA,kBAAe,EAAE,QAAkB,CAAA,kBAAA;qBAC/B,EAAA,QAAA,CAAA,eAAA;AACH,MAAA,aAAS,EAAE,QAAa,CAAA,aAAA;AACxB,MAAA,aAAS,EAAE,QAAa,CAAA,aAAA;kBAChB,EAAE,MAAA,CAAA,CAAA,CAAA,KAAA,MAAA,CAAA,CAAA,CAAA,GAAA,MAAA,KAAA,MAAA,CAAA,KAAK,CAAC,UAAS,CAAE,MAAM,CAAA,CAAA,CAAA;AACjC,MAAA,SAAK,EAAE,MAAA,CAAA,EAAA,CAAA,KAAA,MAAA,CAAA,EAAA,CAAA,GAAA,MAAA,KAAA,MAAA,CAAA,KAAK,CAAC,OAAO,CAAE,MAAK,CAAA,CAAA,CAAA;AAC5B,MAAA,YAAM,EAAA,QAAA,CAAA,YAAA;;MAKF,IAAA,EAAAM,OAAA,EA4BC,EA5BD,IAAA,EAAA,KAAA;0BA4BC,CAAA,KAAA,EAAA;AA1BJ,UAAA,KAAI,kBAAC,sBAAsB,EAAA;AACa,MAAA,CAAA,sBAAA,EAAA,QAAA,CAAA,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,QAAA,CAAA,CAAA,CAAA;AAAA,MAAA,CAAA,2BAAA,EAAA,MAAA,CAAA,KAAA,CAAA,cAAA,CAAA,IAAA,CAAA,IAAA,CAAA,QAAA,CAAA,CAAA,CAAA;AAAA,MAAA,EAAA,+BAAA,EAAA,QAAA,CAAA,SAAA,CAAA,IAAA,CAAA,IAAA,CAAA,QAAA,CAAA,EAAA;MAAA,QAAA,CAAA,UAAA,CAAA,IAAA,CAAA,EAAA,iCAAA,QAAA,CAAA,UAAA,CAAA,IAAA,CAAA,EAAA,CAAA,CAAA,CAAA,GAAA,IAAA;;AAM3D,UAAA,UAAQ,2BAAO,MAAI,CAAA,KAAA,CAAE,aAAa,GAAG,IAAI,CAAC,EAAE,CAAA,EAAA,CAAA,MAAA,CAAA,CAAA;;;6BAEgB,kBAAA,EAAAF,eAAA,CAAjB,IAAI,CAAC,IAAI,CAAC,KAAK,CAAA,EAAA,CAAA,YAAA;AAEpD,UAAA,CAAA,QAAA,CAAA,SAAS,CAAC,KAAK,IAAI,CAAE,QAAQ,CAAA;8CAI7B,CAAA,MAAA,EAAA;;AAHN,gBAAA,KAAK,EAAE,8BAAsB;AAC5B,gBAAA,KAAK,EAAE,IAAC,CAAA,CAAA,CAAA,eAAA,EAAA,qFAAA,CAAA;iCACN,IAAC,CAAA,CAAA,CAAA,eAAA,EAAA,cAAA,CAAA,CAAA,EAAA,CAAA,oBAAA,UAAA,CAAA;;WAOE,QAAS,CAAA,UAAA,CAAE,IAAI,CAAA,EAAG,CAAA;8CAIlB,CAAA,MAAA,EAAA;;AAHL,gBAAA,KAAI,kBAAC,0BAAyB,EAAA,CAAA,0BAAA,EACtB,mBAAA,CAAA,IAAA,CAAA,EAAA,CAAA,CAAA,CAAA,CAAA,CAAA;AACN,eAAA,EAAAA,eAAA,CAAA,QAAA,CAAA,aAAa,CAAC,QAAA,CAAA,UAAM,CAAA,IAAA,CAAA,EAAA,CAAA,CAAA,CAAA,EAAA,CAAA,mBAAA;;;;MAUf,YAAU,EAAAE,OAAA,CACpB,CAEW,EAHa,IAAC,EAAA,KAAA;AACT,QAAA,CAAA,IAAI,CAAC,IAAG,IAAA,IAAA,CAAA,IAAA,CAAA,KAAA;0BAAxBR,kBAEW,CAAAC,QAAA,EAAA,EAAA,GAAA,EAAA,CAAA,EAAA,EAAA;8CADP,IAAI,CAAC,IAAI,CAAC,KAAK,CAAA,EAAA,CAAA,YAAA;;;;;;eAaF,CAAA,wBAAA,EAAA;AAHV,MAAA,IAAI,EAAE,MAAY,CAAA,YAAA;+DAAZ,MAAY,CAAA,YAAA,IAAA,MAAA,CAAA,CAAA;aAClB,EAAG,QAAe,CAAA,eAAA;kBACb,MAAc,CAAA,cAAA;aACrB,EAAE,MAAU,CAAA,aAAA;;eAUH,CAAA,wBAAA,EAAA;AAHP,MAAA,IAAI,EAAE,MAAY,CAAA,YAAA;+DAAZ,MAAY,CAAA,YAAA,IAAA,MAAA,CAAA,CAAA;AACzB,MAAA,OAAO,EAAE,QAAQ,CAAA,eAAA;AACjB,MAAA,UAAU,EAAE,MAAc,CAAA,cAAA;AAC1B,MAAA,OAAK,EAAE,MAAO,CAAA,aAAA;;AAEL,IAAA,CAAA,MAAA,CAAA,KAAA,CAAA,WAAA,KAAA,IAAA;iCAAA,CAAA,8BAAA,EAAA,EAAA,GAAA,EAAA,CAAA,EAAA,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"CnFlowDetail.vue3.js","sources":["../../../../src/components/CnFlowDetail/CnFlowDetail.vue?vue&type=template&id=65800ae2&lang.js"],"sourcesContent":["<!--\n CnFlowDetail — the canvas half of the flow editor.\n\n Geometry and interaction (pan, zoom, drag, drag-to-connect) come from\n CnGraphCanvas; this component supplies typed node cards, directional edge\n routing, and the editor toolbar (Save / Run / Check / arrange / zoom) — the\n actions that concern the graph live ON the graph. The palette, node config\n and flow settings live in CnFlowSidebar. The two halves render in different\n parts of the tree (page body vs Nextcloud's app sidebar), so they share\n `useFlowStore` rather than passing props. Save and Run are EMITTED, not\n handled: only the host knows whether a freshly minted id needs a route swap.\n\n Ported from hermiq's GraphBuilder, with ONE behavioural fix carried through\n the port: every label and config pane keys on the CATALOGUE id\n (`openregister.set-fields`, `hermiq.agent-step`), never a bare id. hermiq's\n builder fed its palette from the catalogue but matched bare ids everywhere\n else, so a node placed from the palette had no config pane and was skipped at\n run time — with the run reporting success.\n\n SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>\n SPDX-License-Identifier: EUPL-1.2\n-->\n<template>\n\t<div class=\"cn-flow-detail\">\n\t\t<!-- The editor's controls, ON the canvas: the actions that concern the\n\t\t graph live with the graph, the way every flow tool draws it. -->\n\t\t<div class=\"cn-flow-detail__toolbar\" role=\"toolbar\" :aria-label=\"toolbarLabel\">\n\t\t\t<!--\n\t\t\t\t🔴 NOT OFFERED WHILE A RUN IS BEING OPENED.\n\n\t\t\t\tA visitor arriving on `/flows/{id}?run={uuid}` was given the whole\n\t\t\t\teditor for the length of three requests: Add a step, Save, Run and\n\t\t\t\tCheck, on a page the URL had already said was a run. Every one of\n\t\t\t\tthem was about to become read-only, and Save was the dangerous one:\n\t\t\t\t`save()` picks PUT from `flow.id`, and the graph underneath was\n\t\t\t\tabout to be replaced by a historic snapshot.\n\n\t\t\t\tHidden rather than disabled, and only for this window. A row of\n\t\t\t\tgreyed buttons is a claim that the actions exist and are refused;\n\t\t\t\twhat is true here is that the page is not the editor yet. Once the\n\t\t\t\trun is open the toolbar comes back exactly as it was, with the\n\t\t\t\tsnapshot lock doing the refusing, and that part was already right.\n\t\t\t-->\n\t\t\t<template v-if=\"!openingRun\">\n\t\t\t\t<!--\n\t\t\t\t\tFIRST, because it is the first thing a new flow needs and the\n\t\t\t\t\tpalette it replaced was three clicks away in a sidebar tab.\n\t\t\t\t-->\n\t\t\t\t<!--\n\t\t\t\t\t⚠️ NOT DISABLED ON A LOCKED GRAPH, and that is deliberate. A\n\t\t\t\t\tpublished version cannot be changed, and the obvious move is to\n\t\t\t\t\tgrey this out — but the refusal on the canvas is the thing that\n\t\t\t\t\tTELLS the author so, and offers \"Create draft version\" next to\n\t\t\t\t\tit. A silent grey button says only that something is wrong.\n\n\t\t\t\t\tDifferent from Run, which IS disabled: pressing Run on a flow\n\t\t\t\t\twith no manual start produced a slow, confusing engine error\n\t\t\t\t\tseveral seconds later. Pressing this produces an immediate\n\t\t\t\t\tmessage beside the graph it is about.\n\t\t\t\t-->\n\t\t\t\t<NcButton data-testid=\"flow-add-step\"\n\t\t\t\t\t@click=\"stepPickerOpen = true\">\n\t\t\t\t\t<template #icon>\n\t\t\t\t\t\t<Plus :size=\"20\" />\n\t\t\t\t\t</template>\n\t\t\t\t\t{{ t('nextcloud-vue', 'Add a step') }}\n\t\t\t\t</NcButton>\n\t\t\t\t<!-- Disabled on a locked graph, which is a published or deprecated\n\t\t\t\t flow and now also a version snapshot. It was enabled on both\n\t\t\t\t before: pressing it on a published flow spent a request to be\n\t\t\t\t told no, and pressing it on a snapshot would have written the\n\t\t\t\t snapshot over the live flow. The store refuses the snapshot case\n\t\t\t\t outright; this is the half that says so before the click. -->\n\t\t\t\t<NcButton variant=\"primary\"\n\t\t\t\t\t:disabled=\"store.saving || !store.flow.name || store.graphLocked\"\n\t\t\t\t\t:title=\"saveDisabledReason\"\n\t\t\t\t\tdata-testid=\"flow-save-button\"\n\t\t\t\t\t@click=\"onSaveClick\">\n\t\t\t\t\t<template #icon>\n\t\t\t\t\t\t<NcLoadingIcon v-if=\"store.saving\" :size=\"20\" />\n\t\t\t\t\t\t<ContentSave v-else :size=\"20\" />\n\t\t\t\t\t</template>\n\t\t\t\t\t{{ t('nextcloud-vue', 'Save') }}\n\t\t\t\t</NcButton>\n\t\t\t\t<NcButton :disabled=\"store.running || !store.flow.id || !hasManualStart\"\n\t\t\t\t\t:title=\"runDisabledReason\"\n\t\t\t\t\tdata-testid=\"flow-run-button\"\n\t\t\t\t\t@click=\"onRunClick\">\n\t\t\t\t\t<template #icon>\n\t\t\t\t\t\t<Play :size=\"20\" />\n\t\t\t\t\t</template>\n\t\t\t\t\t{{ t('nextcloud-vue', 'Run') }}\n\t\t\t\t</NcButton>\n\t\t\t\t<NcButton variant=\"tertiary\"\n\t\t\t\t\t:disabled=\"store.checking || !store.nodes.length\"\n\t\t\t\t\t@click=\"store.check()\">\n\t\t\t\t\t<template #icon>\n\t\t\t\t\t\t<NcLoadingIcon v-if=\"store.checking\" :size=\"20\" />\n\t\t\t\t\t\t<CheckDecagram v-else :size=\"20\" />\n\t\t\t\t\t</template>\n\t\t\t\t\t{{ t('nextcloud-vue', 'Check') }}\n\t\t\t\t</NcButton>\n\t\t\t\t<NcButton variant=\"tertiary\"\n\t\t\t\t\t:disabled=\"!store.nodes.length\"\n\t\t\t\t\t:aria-label=\"t('nextcloud-vue', 'Arrange steps automatically')\"\n\t\t\t\t\t:title=\"t('nextcloud-vue', 'Arrange steps automatically')\"\n\t\t\t\t\t@click=\"store.autoSort()\">\n\t\t\t\t\t<template #icon>\n\t\t\t\t\t\t<SortVariant :size=\"20\" />\n\t\t\t\t\t</template>\n\t\t\t\t</NcButton>\n\t\t\t\t<!-- Undo has a BUTTON as well as Ctrl+Z. A shortcut nobody is told\n\t\t\t\t about is a feature only its author has: the affordance is what\n\t\t\t\t tells a user the canvas is safe to experiment on. -->\n\t\t\t\t<NcButton variant=\"tertiary\"\n\t\t\t\t\t:disabled=\"!store.canUndo\"\n\t\t\t\t\t:aria-label=\"t('nextcloud-vue', 'Undo the last change')\"\n\t\t\t\t\t:title=\"t('nextcloud-vue', 'Undo the last change')\"\n\t\t\t\t\t@click=\"store.undo()\">\n\t\t\t\t\t<template #icon>\n\t\t\t\t\t\t<UndoVariant :size=\"20\" />\n\t\t\t\t\t</template>\n\t\t\t\t</NcButton>\n\t\t\t</template>\n\n\t\t\t<!-- What the toolbar says instead: a run is being opened. Silence\n\t\t\t here would leave the strip empty apart from the zoom controls,\n\t\t\t and an empty toolbar reads as a broken page rather than as a\n\t\t\t page mid-load. -->\n\t\t\t<span v-else class=\"cn-flow-detail__toolbar-status\" data-testid=\"flow-toolbar-opening-run\">\n\t\t\t\t<NcLoadingIcon :size=\"20\" />\n\t\t\t\t{{ t('nextcloud-vue', 'Opening the run') }}\n\t\t\t</span>\n\n\t\t\t<div class=\"cn-flow-detail__toolbar-group\">\n\t\t\t\t<NcButton variant=\"tertiary\"\n\t\t\t\t\t:disabled=\"zoom <= minZoom\"\n\t\t\t\t\t:aria-label=\"t('nextcloud-vue', 'Zoom out')\"\n\t\t\t\t\t@click=\"zoomBy(-0.1)\">\n\t\t\t\t\t<template #icon>\n\t\t\t\t\t\t<Minus :size=\"20\" />\n\t\t\t\t\t</template>\n\t\t\t\t</NcButton>\n\t\t\t\t<NcButton variant=\"tertiary\"\n\t\t\t\t\t:aria-label=\"t('nextcloud-vue', 'Reset zoom')\"\n\t\t\t\t\t@click=\"zoomTo(1)\">\n\t\t\t\t\t{{ Math.round(zoom * 100) }}%\n\t\t\t\t</NcButton>\n\t\t\t\t<NcButton variant=\"tertiary\"\n\t\t\t\t\t:disabled=\"zoom >= maxZoom\"\n\t\t\t\t\t:aria-label=\"t('nextcloud-vue', 'Zoom in')\"\n\t\t\t\t\t@click=\"zoomBy(0.1)\">\n\t\t\t\t\t<template #icon>\n\t\t\t\t\t\t<Plus :size=\"20\" />\n\t\t\t\t\t</template>\n\t\t\t\t</NcButton>\n\t\t\t</div>\n\n\t\t\t<!-- The way back to a closed sidebar has to live OUTSIDE it. -->\n\t\t\t<NcButton v-if=\"!store.sidebarOpen\"\n\t\t\t\tvariant=\"tertiary\"\n\t\t\t\t:aria-label=\"t('nextcloud-vue', 'Show the flow controls')\"\n\t\t\t\t:title=\"t('nextcloud-vue', 'Show the flow controls')\"\n\t\t\t\t@click=\"store.sidebarOpen = true\">\n\t\t\t\t<template #icon>\n\t\t\t\t\t<DockRight :size=\"20\" />\n\t\t\t\t</template>\n\t\t\t</NcButton>\n\t\t</div>\n\n\t\t<!-- A node type whose configuration IS another product surface gets\n\t\t that surface as its editor (the registry); everything else gets\n\t\t the generic form. Same draft contract either way. -->\n\t\t<component :is=\"nodeEditorComponent\" v-if=\"store.editingNodeId !== null\" />\n\n\t\t<!-- EVERYTHING THE EDITOR HAS TO SAY, IN ONE PLACE, ON THE CANVAS.\n\n\t\t Refusals and standing conditions both. They used to be spread over\n\t\t two sidebar tabs and two cards here, and the one an author most\n\t\t needed — \"this version is published, your step was not added\" —\n\t\t was the furthest away: top of the Steps tab, above a scrolling\n\t\t palette, on the other side of the screen from the click. -->\n\t\t<CnFlowCanvasMessages :messages=\"canvasMessages\"\n\t\t\t@dismiss=\"dismissCanvasMessage\"\n\t\t\t@action=\"runCanvasMessageAction\" />\n\n\t\t<!-- Edges are Vue Flow's now. The hand-drawn `#edge` slot and its\n\t\t orthogonal `edgePath()` are gone: Vue Flow routes and arrows edges\n\t\t itself, and it measures the rendered node instead of being told a\n\t\t `nodeWidth`/`nodeHeight` to guess the centre from. -->\n\t\t<!-- Run state travels ON the edge records, through the canvas's\n\t\t existing `edges` prop: Vue Flow applies an edge's `class` to the\n\t\t line it draws, so the canvas itself stays a geometry-only renderer\n\t\t with no status concept (ADR-065). -->\n\t\t<CnGraphCanvas\n\t\t\tref=\"canvas\"\n\t\t\t:nodes=\"canvasNodes\"\n\t\t\t:edges=\"canvasEdgesWithRunState\"\n\t\t\t:minZoom=\"minZoom\"\n\t\t\t:maxZoom=\"maxZoom\"\n\t\t\t@zoomChange=\"zoom = $event\"\n\t\t\t@nodeSelect=\"onNodeSelect\"\n\t\t\t@nodeActivate=\"onNodeActivate\"\n\t\t\t@nodeMenu=\"onNodeMenu\"\n\t\t\t@edgeSelect=\"onEdgeSelect\"\n\t\t\t@edgeLabelClick=\"onEdgeLabelClick\"\n\t\t\t@edgeLabelContext=\"onEdgeLabelContext\"\n\t\t\t@edgeLabelMove=\"onEdgeLabelMove\"\n\t\t\t@canvasClick=\"onCanvasClick\"\n\t\t\t@nodesChange=\"onNodesChange\"\n\t\t\t@nodeRemove=\"store.removeNode($event)\"\n\t\t\t@connect=\"store.connect($event)\"\n\t\t\t@canvasDrop=\"onCanvasDrop\">\n\t\t\t<!-- The step's own chrome. `node.data` carries the flow node, because\n\t\t\t Vue Flow's `type` selects a COMPONENT while the flow's own type\n\t\t\t is domain data — conflating the two would make every new step\n\t\t\t type need a registered component before it could render at all. -->\n\t\t\t<template #node=\"{ node }\">\n\t\t\t\t<div\n\t\t\t\t\tclass=\"cn-flow-detail__node\"\n\t\t\t\t\t:class=\"[\n\t\t\t\t\t\t`cn-flow-detail__node--${typeSlug(node.data.stepType)}`,\n\t\t\t\t\t\t`cn-flow-detail__node--role-${store.roleOfNodeType(node.data.stepType)}`,\n\t\t\t\t\t\t{ 'cn-flow-detail__node--unknown': isUnknown(node.data.stepType) },\n\t\t\t\t\t\trunStateOf(node.id) ? `cn-flow-detail__node--run-${runStateOf(node.id)}` : null,\n\t\t\t\t\t]\"\n\t\t\t\t\t@dblclick.stop=\"store.editingNodeId = node.id\">\n\t\t\t\t\t<span class=\"cn-flow-detail__node-type\">{{ typeLabel(node.data.stepType) }}</span>\n\t\t\t\t\t<span class=\"cn-flow-detail__node-label\">{{ node.data.label }}</span>\n\t\t\t\t\t<span\n\t\t\t\t\t\tv-if=\"isUnknown(node.data.stepType)\"\n\t\t\t\t\t\tclass=\"cn-flow-detail__node-warning\"\n\t\t\t\t\t\t:title=\"t('nextcloud-vue', 'The engine does not know this node type, so this step will fail when the flow runs.')\">\n\t\t\t\t\t\t{{ t('nextcloud-vue', 'Unknown step') }}\n\t\t\t\t\t</span>\n\t\t\t\t\t<!-- The run state IN WORDS, next to the colour. A state told\n\t\t\t\t\t by colour alone is invisible to anyone who cannot tell\n\t\t\t\t\t the colours apart (WCAG 1.4.1), so every coloured node\n\t\t\t\t\t also says what it is. -->\n\t\t\t\t\t<span\n\t\t\t\t\t\tv-if=\"runStateOf(node.id)\"\n\t\t\t\t\t\tclass=\"cn-flow-detail__node-run\"\n\t\t\t\t\t\t:class=\"`cn-flow-detail__node-run--${runStateOf(node.id)}`\">\n\t\t\t\t\t\t{{ runStateLabel(runStateOf(node.id)) }}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t</template>\n\n\t\t\t<!-- A connection's own name, on the connection.\n\n\t\t\t Rendered only when the author gave the line one: CnFlowEdge\n\t\t\t gates the label control on what this slot RENDERS, not on\n\t\t\t whether it exists, so an unnamed line draws no empty chip. -->\n\t\t\t<template #edge-label=\"{ edge }\">\n\t\t\t\t<template v-if=\"edge.data && edge.data.label\">\n\t\t\t\t\t{{ edge.data.label }}\n\t\t\t\t</template>\n\t\t\t</template>\n\t\t</CnGraphCanvas>\n\n\t\t<!-- The step's own actions, at the step. Selecting a node used to only\n\t\t fill the sidebar, so Edit / Copy / Delete lived in another panel —\n\t\t or nowhere, for Copy. The canvas is where the graph is manipulated,\n\t\t so the actions on a step belong on the step. -->\n\t\t<CnContextMenu\n\t\t\tv-model:open=\"nodeMenuOpen\"\n\t\t\t:actions=\"nodeMenuActions\"\n\t\t\t:targetItem=\"nodeMenuTarget\"\n\t\t\t@close=\"closeNodeMenu\" />\n\n\t\t<!-- The line's own actions, at the line. A connection was the one thing\n\t\t on this canvas that could be drawn and then never touched again:\n\t\t selecting it did nothing at all, so there was no way to rename it,\n\t\t re-route it, or remove it short of deleting a step. -->\n\t\t<CnContextMenu\n\t\t\tv-model:open=\"edgeMenuOpen\"\n\t\t\t:actions=\"edgeMenuActions\"\n\t\t\t:targetItem=\"edgeMenuTarget\"\n\t\t\t@close=\"closeEdgeMenu\" />\n\n\t\t<CnFlowEdgeEditModal v-if=\"store.editingEdge !== null\" />\n\n\t\t<!-- 🔴 A LOADING STATE FIRST, BECAUSE THE EMPTY ONE IS AN ANSWER.\n\n\t\t \"No steps yet\" used to render on `nodes.length === 0` alone, which is\n\t\t also true of a flow that has not arrived yet. So the canvas told a\n\t\t visitor following a run link that the flow had no steps, while it was\n\t\t still fetching the flow, the run and the graph that run executed. It\n\t\t is the worst shape an empty state can have: a finished-looking answer\n\t\t that is indistinguishable from the real one, so nobody waits and\n\t\t nobody reloads.\n\n\t\t `store.canvasLoading` spans all three requests. `store.loading` does\n\t\t not: it is set around the flow LIST call only. -->\n\t\t<NcEmptyContent\n\t\t\tv-if=\"store.canvasLoading\"\n\t\t\tclass=\"cn-flow-detail__empty\"\n\t\t\tdata-testid=\"flow-canvas-loading\"\n\t\t\t:name=\"loadingName\"\n\t\t\t:description=\"loadingDescription\">\n\t\t\t<template #icon>\n\t\t\t\t<NcLoadingIcon :size=\"20\" />\n\t\t\t</template>\n\t\t</NcEmptyContent>\n\t\t<NcEmptyContent\n\t\t\tv-else-if=\"store.nodes.length === 0\"\n\t\t\tclass=\"cn-flow-detail__empty\"\n\t\t\tdata-testid=\"flow-canvas-empty\"\n\t\t\t:name=\"t('nextcloud-vue', 'No steps yet')\"\n\t\t\t:description=\"t('nextcloud-vue', 'Add a step from the sidebar to start building this flow.')\">\n\t\t\t<template #icon>\n\t\t\t\t<Sitemap :size=\"20\" />\n\t\t\t</template>\n\t\t</NcEmptyContent>\n\t\t<CnFlowStepPickerModal v-if=\"stepPickerOpen\" @close=\"stepPickerOpen = false\" />\n\t</div>\n</template>\n\n<script>\nimport { translate as t } from '@nextcloud/l10n'\nimport { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'\nimport CheckDecagram from 'vue-material-design-icons/CheckDecagram.vue'\nimport ContentPaste from 'vue-material-design-icons/ContentPaste.vue'\nimport ContentSave from 'vue-material-design-icons/ContentSave.vue'\nimport DockRight from 'vue-material-design-icons/DockRight.vue'\nimport Minus from 'vue-material-design-icons/Minus.vue'\nimport Play from 'vue-material-design-icons/Play.vue'\nimport Plus from 'vue-material-design-icons/Plus.vue'\nimport Sitemap from 'vue-material-design-icons/Sitemap.vue'\nimport SortVariant from 'vue-material-design-icons/SortVariant.vue'\nimport UndoVariant from 'vue-material-design-icons/UndoVariant.vue'\nimport VectorCurve from 'vue-material-design-icons/VectorCurve.vue'\nimport VectorLine from 'vue-material-design-icons/VectorLine.vue'\nimport VectorPolyline from 'vue-material-design-icons/VectorPolyline.vue'\nimport CnFlowEdgeEditModal from '../../dialogs/CnFlowEdgeEditModal.vue'\nimport CnFlowNodeEditModal from '../../dialogs/CnFlowNodeEditModal.vue'\nimport CnFlowStepPickerModal from '../../dialogs/CnFlowStepPickerModal.vue'\nimport CnContextMenu from '../CnContextMenu/CnContextMenu.vue'\nimport CnGraphCanvas from '../CnGraphCanvas/CnGraphCanvas.vue'\nimport CnFlowCanvasMessages from './CnFlowCanvasMessages.vue'\nimport { useContextMenu } from '../../composables/useContextMenu.js'\nimport { DEFAULT_EDGE_LINE_TYPE, EDGE_LINE_TYPES } from '../../composables/useFlowEdgeStyles.js'\nimport { resolveFlowNodeEditor } from '../../composables/useFlowNodeEditors.js'\nimport { useFlowStore } from '../../composables/useFlowStore.js'\n\n/**\n * Which glyph stands for which router, in menu order.\n *\n * Components rather than CnIcon names: a line's shape has no ADR-077 semantic\n * concept behind it, and inventing one for three drawing options would put a\n * private entry into a fleet-wide vocabulary. Edit / Copy / Delete DO have\n * concepts, so those stay named — see `nodeMenuActions`.\n */\nconst LINE_TYPE_ICONS = {\n\tsmoothstep: VectorPolyline,\n\tstraight: VectorLine,\n\tdefault: VectorCurve,\n}\n\n/**\n * The catch-up pace for a live watch, per hop.\n *\n * The engine persists the log once per worker pass, so one poll can deliver\n * five steps at once. Draining them at ~320ms a hop makes a burst read as\n * five quick hops — not a teleport, and not a fabricated slow-motion \"live\"\n * feed the data does not contain.\n */\nconst RUN_CATCH_UP_HOP_MS = 320\n\n/**\n * Replay pacing bounds. Replay timing is scaled from each step's REAL\n * `durationMs` — the only timing the log carries — and clamped so a\n * 40-minute wait step does not freeze the replay and a 3ms step is still\n * visible. Scaling real durations is presentation; inventing durations would\n * be fabrication — the clamp is the line between them.\n */\nconst REPLAY_HOP_MIN_MS = 200\nconst REPLAY_HOP_MAX_MS = 1500\n\nexport default {\n\tname: 'CnFlowDetail',\n\n\tcomponents: {\n\t\tCheckDecagram,\n\t\tCnFlowCanvasMessages,\n\t\tCnFlowEdgeEditModal,\n\t\tCnFlowNodeEditModal,\n\t\tCnFlowStepPickerModal,\n\t\tCnContextMenu,\n\t\tCnGraphCanvas,\n\t\tContentSave,\n\t\tDockRight,\n\t\tMinus,\n\t\tNcButton,\n\t\tNcEmptyContent,\n\t\tNcLoadingIcon,\n\t\tPlay,\n\t\tPlus,\n\t\tSitemap,\n\t\tSortVariant,\n\t\tUndoVariant,\n\t},\n\n\tprops: {\n\t\t/**\n\t\t * Flow uuid from the route. The literal `new` starts a blank flow, so\n\t\t * creating and editing share one page.\n\t\t */\n\t\tid: {\n\t\t\ttype: String,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * The owning app id to scope to, and to stamp on a new flow.\n\t\t */\n\t\tapp: {\n\t\t\ttype: String,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * A run to open on arrival, from `?run=` on the URL.\n\t\t *\n\t\t * A run's own address (`/apps/openregister/flow-runs/{uuid}`) resolves\n\t\t * the run, finds its flow and redirects to `/flows/{flowId}?run={uuid}`,\n\t\t * so the run reaches the editor as a query rather than as a path. It is\n\t\t * a PROP rather than a `$route` read: this is a library component, and a\n\t\t * consumer mounting it outside a route must still be able to say which\n\t\t * run to open.\n\t\t */\n\t\trun: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\t},\n\n\temits: ['save', 'run'],\n\n\tsetup() {\n\t\tconst {\n\t\t\tisOpen: nodeMenuOpen,\n\t\t\ttargetItem: nodeMenuTarget,\n\t\t\topen: openNodeMenu,\n\t\t\tclose: closeNodeMenu,\n\t\t} = useContextMenu()\n\n\t\t// A SECOND instance, not a shared one. Both menus position themselves by\n\t\t// writing cursor coordinates onto the document, and a single instance\n\t\t// re-targeted between a node and a line would carry the previous\n\t\t// target's actions for as long as the reopen took.\n\t\tconst {\n\t\t\tisOpen: edgeMenuOpen,\n\t\t\ttargetItem: edgeMenuTarget,\n\t\t\topen: openEdgeMenu,\n\t\t\tclose: closeEdgeMenu,\n\t\t} = useContextMenu()\n\n\t\treturn {\n\t\t\tstore: useFlowStore(),\n\t\t\tnodeMenuOpen,\n\t\t\tnodeMenuTarget,\n\t\t\topenNodeMenu,\n\t\t\tcloseNodeMenu,\n\t\t\tedgeMenuOpen,\n\t\t\tedgeMenuTarget,\n\t\t\topenEdgeMenu,\n\t\t\tcloseEdgeMenu,\n\t\t}\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\t// The step picker replaced the sidebar palette; it is a modal, so\n\t\t\t// its open state is the editor's rather than the sidebar's.\n\t\t\tstepPickerOpen: false,\n\t\t\t// Zoom is owned here, not by the canvas: a consumer that does not\n\t\t\t// bind it pins the canvas at 1 and silently kills the wheel gesture.\n\t\t\tzoom: 1,\n\t\t\tminZoom: 0.3,\n\t\t\tmaxZoom: 2,\n\n\t\t\t// The run animator: everything about HOW the watched or replayed\n\t\t\t// run paints onto the canvas. The store exposes facts (the run,\n\t\t\t// its ordered steps, which are new); the timeline lives here, the\n\t\t\t// only place that knows what a hop looks like.\n\t\t\t//\n\t\t\t// Derived per-node/per-edge classes come out of `runNodeStates`\n\t\t\t// and `canvasEdgesWithRunState`; this object is the cursor those\n\t\t\t// computeds derive from.\n\t\t\trunAnimation: {\n\t\t\t\t// 'watch', 'replay', or null while no run is on the canvas.\n\t\t\t\tmode: null,\n\n\t\t\t\t// Steps delivered but not yet animated. The engine persists\n\t\t\t\t// the log once per worker pass, so steps arrive in BURSTS;\n\t\t\t\t// the queue drains strictly in log order at a bounded pace —\n\t\t\t\t// never interpolated fake timing to look \"live\".\n\t\t\t\tqueue: [],\n\n\t\t\t\t// How many steps have been animated. Zero with an active run\n\t\t\t\t// is the anticipation state: the start nodes pulse, because\n\t\t\t\t// pretending a specific step is executing would be a lie.\n\t\t\t\tplayedCount: 0,\n\n\t\t\t\t// The node whose step is being animated right now (halo), and\n\t\t\t\t// the last node a step matched (the run's frontier).\n\t\t\t\tcurrentNodeId: null,\n\t\t\t\tlastNodeId: null,\n\n\t\t\t\t// The drawn line being traced this hop, and the lines already\n\t\t\t\t// walked (they keep a quiet success stroke).\n\t\t\t\ttraceLineId: null,\n\t\t\t\ttracedLineIds: [],\n\n\t\t\t\t// Residue: nodes whose steps completed, failed, or suspended.\n\t\t\t\tdoneNodeIds: [],\n\t\t\t\tfailedNodeIds: [],\n\t\t\t\tholdNodeIds: [],\n\n\t\t\t\t// Transitions the log names that match NO canvas node — the\n\t\t\t\t// flow was edited since the run. Skipped and surfaced, never\n\t\t\t\t// remapped onto the nearest card.\n\t\t\t\tskippedTransitions: [],\n\n\t\t\t\t// The hop timer. One at a time: a hop schedules the next.\n\t\t\t\ttimer: null,\n\t\t\t},\n\n\t\t\t// How many of `store.watchedSteps` this animator has consumed.\n\t\t\t// Index-diffing on the consumer side too, so a poll that delivers\n\t\t\t// nothing new enqueues nothing twice.\n\t\t\trunConsumed: 0,\n\n\t\t\t// Whether the author closed the \"steps skipped\" message for the\n\t\t\t// run currently on the canvas. Reset with the animator, so the\n\t\t\t// next run says it again: it is a fact about THAT run, not a\n\t\t\t// preference.\n\t\t\trunSkipDismissed: false,\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * Whether a run named by the URL is still being opened.\n\t\t *\n\t\t * The window this component used to spend in flow-edit mode. It starts\n\t\t * in `created()`, before the first paint, and ends when the run, its\n\t\t * objects and the graph it executed have all come back.\n\t\t *\n\t\t * @return {boolean} True while the run is being fetched.\n\t\t */\n\t\topeningRun() {\n\t\t\treturn this.store.openingRunUuid !== null\n\t\t},\n\n\t\t/**\n\t\t * What the toolbar calls itself.\n\t\t *\n\t\t * A screen reader reaching a toolbar labelled \"Flow editor\" on a page\n\t\t * that is about to be a read-only run has been told the wrong thing,\n\t\t * and it is the only label the strip has.\n\t\t *\n\t\t * @return {string} The toolbar's accessible name.\n\t\t */\n\t\ttoolbarLabel() {\n\t\t\treturn this.openingRun\n\t\t\t\t? this.t('nextcloud-vue', 'Flow run')\n\t\t\t\t: this.t('nextcloud-vue', 'Flow editor')\n\t\t},\n\n\t\t/**\n\t\t * The heading of the canvas loading state.\n\t\t *\n\t\t * Two sentences rather than one generic \"Loading\", because the two\n\t\t * cases are reached by different links and a reader who followed a run\n\t\t * link needs to see that the run is what is coming.\n\t\t *\n\t\t * @return {string} The heading.\n\t\t */\n\t\tloadingName() {\n\t\t\treturn this.store.inRunView\n\t\t\t\t? this.t('nextcloud-vue', 'Opening the run')\n\t\t\t\t: this.t('nextcloud-vue', 'Loading the flow')\n\t\t},\n\n\t\t/**\n\t\t * @return {string} The line under the canvas loading heading.\n\t\t */\n\t\tloadingDescription() {\n\t\t\treturn this.store.inRunView\n\t\t\t\t? this.t('nextcloud-vue', 'Reading the run and the graph it used.')\n\t\t\t\t: this.t('nextcloud-vue', 'Reading the steps of this flow.')\n\t\t},\n\n\t\t/**\n\t\t * Whether the graph carries a step that says a person starts this flow.\n\t\t *\n\t\t * Running by hand is not a property of the flow row, it is a step on\n\t\t * the canvas: `openregister.trigger-manual`. A flow whose only way in\n\t\t * is an object event or a schedule has no entry point a person can\n\t\t * take, so pressing Run would ask the engine to start something the\n\t\t * graph does not describe — and the engine refuses it, several\n\t\t * seconds later, in words about nodes rather than about the button\n\t\t * that was pressed.\n\t\t *\n\t\t * @return {boolean} Whether a manual trigger step is present.\n\t\t */\n\t\thasManualStart() {\n\t\t\treturn this.store.nodes.some((node) => node?.type === 'openregister.trigger-manual')\n\t\t},\n\n\t\t/**\n\t\t * Why Run is unavailable, or null when it is available.\n\t\t *\n\t\t * One title covering both reasons, in the order the author meets\n\t\t * them: a flow has to exist before it can be started, and it has to\n\t\t * have a way in before it can be started BY HAND. A disabled control\n\t\t * that does not say why is the thing this whole message pass was\n\t\t * about.\n\t\t *\n\t\t * @return {string|null} The explanation, or null.\n\t\t */\n\t\trunDisabledReason() {\n\t\t\tif (!this.store.flow.id) {\n\t\t\t\treturn this.t('nextcloud-vue', 'Save the flow before running it. The engine runs the stored flow, not the unsaved canvas.')\n\t\t\t}\n\n\t\t\tif (!this.hasManualStart) {\n\t\t\t\treturn this.t('nextcloud-vue', 'Running by hand needs a manual start step. Add \"When someone runs it\" to the canvas.')\n\t\t\t}\n\n\t\t\treturn null\n\t\t},\n\n\t\t/**\n\t\t * Why Save is unavailable, as the button's title.\n\t\t *\n\t\t * A disabled control with no explanation is a dead end: the reader can\n\t\t * see that saving is off and has nothing to act on. Both reasons here\n\t\t * have a way out, so both name it.\n\t\t *\n\t\t * @return {string|null} The reason, or null when Save is available.\n\t\t */\n\t\tsaveDisabledReason() {\n\t\t\tif (this.store.viewingVersion !== null) {\n\t\t\t\treturn this.t('nextcloud-vue', 'You are looking at a stored version. Go back to the flow to edit and save it.')\n\t\t\t}\n\n\t\t\tif (this.store.graphLocked) {\n\t\t\t\treturn this.t('nextcloud-vue', 'A published flow cannot be edited. Create a draft to change it.')\n\t\t\t}\n\n\t\t\treturn null\n\t\t},\n\n\t\t/**\n\t\t * What can be done to a step, as CnContextMenu's action list.\n\t\t *\n\t\t * Built as a computed rather than a constant because the labels are\n\t\t * translated, and `t()` must run after the locale is available.\n\t\t *\n\t\t * @return {Array<object>} The actions.\n\t\t */\n\t\tnodeMenuActions() {\n\t\t\treturn [\n\t\t\t\t{\n\t\t\t\t\t// ⚠️ THE VOCABULARY'S NAME, NOT THE GLYPH'S.\n\t\t\t\t\t//\n\t\t\t\t\t// `CnIcon` resolves a string against the ADR-077 semantic\n\t\t\t\t\t// vocabulary and falls back to a help-circle when it finds\n\t\t\t\t\t// nothing — SILENTLY, because an unknown name is\n\t\t\t\t\t// indistinguishable from a name nobody registered. This menu\n\t\t\t\t\t// asked for `Pencil` and `Delete`; the vocabulary publishes\n\t\t\t\t\t// `PencilOutline` and `DeleteOutline`. So two of the three\n\t\t\t\t\t// entries rendered a question mark, and `Copy` — whose name\n\t\t\t\t\t// happened to be right — rendered correctly, which is\n\t\t\t\t\t// exactly what made it read as a styling quirk rather than\n\t\t\t\t\t// as a lookup that failed.\n\t\t\t\t\t//\n\t\t\t\t\t// Named rather than imported so these stay tied to the\n\t\t\t\t\t// vocabulary: the point of ADR-077 is that Edit is the same\n\t\t\t\t\t// glyph in every app, and an import here would be a private\n\t\t\t\t\t// copy of that decision. Guarded by\n\t\t\t\t\t// tests/components/CnFlowMenuIcons.spec.js, which resolves\n\t\t\t\t\t// every name this menu uses and fails on the fallback.\n\t\t\t\t\tlabel: t('nextcloud-vue', 'Edit'),\n\t\t\t\t\ticon: 'PencilOutline',\n\t\t\t\t\thandler: (id) => {\n\t\t\t\t\t\tthis.store.editingNodeId = id\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tlabel: t('nextcloud-vue', 'Copy'),\n\t\t\t\t\ticon: 'ContentCopy',\n\t\t\t\t\thandler: (id) => {\n\t\t\t\t\t\tthis.store.copyNode(id)\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tlabel: t('nextcloud-vue', 'Delete'),\n\t\t\t\t\ticon: 'DeleteOutline',\n\t\t\t\t\tdestructive: true,\n\t\t\t\t\thandler: (id) => {\n\t\t\t\t\t\tthis.store.removeNode(id)\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t]\n\t\t},\n\n\t\t/**\n\t\t * What can be done to a connection, as CnContextMenu's action list.\n\t\t *\n\t\t * The three routers are FLAT entries rather than a submenu: CnContextMenu\n\t\t * has a custom-panel path, and it has no consumer anywhere in this\n\t\t * library — shipping a context menu on an untested code path to save\n\t\t * two rows is a poor trade. The router the line already uses is\n\t\t * disabled, with a `title` saying why, so the menu states the current\n\t\t * value instead of hiding it behind a click.\n\t\t *\n\t\t * `targetItem` is `{source, target}` — see `useFlowStore.editingEdge`\n\t\t * for why a line is identified by its endpoints and never by its id.\n\t\t *\n\t\t * @return {Array<object>} The actions.\n\t\t */\n\t\tedgeMenuActions() {\n\t\t\tconst styleActions = EDGE_LINE_TYPES.map((style) => ({\n\t\t\t\tlabel: style.label(),\n\t\t\t\ticon: LINE_TYPE_ICONS[style.id],\n\t\t\t\tdisabled: (line) => this.lineTypeOf(line) === style.id,\n\t\t\t\ttitle: (line) => (\n\t\t\t\t\tthis.lineTypeOf(line) === style.id\n\t\t\t\t\t\t? t('nextcloud-vue', 'This line is already drawn this way.')\n\t\t\t\t\t\t: null\n\t\t\t\t),\n\t\t\t\thandler: (line) => {\n\t\t\t\t\tthis.store.setEdgeFields({\n\t\t\t\t\t\tsource: line.source,\n\t\t\t\t\t\ttarget: line.target,\n\t\t\t\t\t\tfields: { lineType: style.id },\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t}))\n\n\t\t\treturn [\n\t\t\t\t{\n\t\t\t\t\tlabel: t('nextcloud-vue', 'Edit label'),\n\t\t\t\t\ticon: 'PencilOutline',\n\t\t\t\t\thandler: (line) => {\n\t\t\t\t\t\tthis.store.editingEdge = { source: line.source, target: line.target }\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t...styleActions,\n\t\t\t\t{\n\t\t\t\t\t// A connection has no useful DUPLICATE — two records with the\n\t\t\t\t\t// same endpoints draw on top of each other and `connect()`\n\t\t\t\t\t// refuses the second — so Copy takes the part of a line that\n\t\t\t\t\t// IS worth repeating: its label and its router.\n\t\t\t\t\tlabel: t('nextcloud-vue', 'Copy'),\n\t\t\t\t\ticon: 'ContentCopy',\n\t\t\t\t\thandler: (line) => {\n\t\t\t\t\t\tthis.store.copyEdgeStyle({ source: line.source, target: line.target })\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tlabel: t('nextcloud-vue', 'Paste style'),\n\t\t\t\t\ticon: ContentPaste,\n\t\t\t\t\tvisible: () => this.store.edgeStyleClipboard !== null,\n\t\t\t\t\thandler: (line) => {\n\t\t\t\t\t\tthis.store.pasteEdgeStyle({ source: line.source, target: line.target })\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tlabel: t('nextcloud-vue', 'Delete'),\n\t\t\t\t\ticon: 'DeleteOutline',\n\t\t\t\t\tdestructive: true,\n\t\t\t\t\thandler: (line) => {\n\t\t\t\t\t\tthis.store.removeEdge({ source: line.source, target: line.target })\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t]\n\t\t},\n\n\t\t/**\n\t\t * The flow's steps in Vue Flow's node shape.\n\t\t *\n\t\t * The mapping lives here rather than in the store because the store owns\n\t\t * the FLOW DOCUMENT — the thing that gets saved — and a canvas library's\n\t\t * node shape is a rendering concern. Pushing `position` into the store\n\t\t * would make the persisted document carry a dependency's vocabulary.\n\t\t *\n\t\t * `type` is pinned to `'default'` so every step renders through\n\t\t * CnFlowNode, and the flow's own type travels in `data.stepType`. Vue\n\t\t * Flow's `type` selects a COMPONENT; conflating it with the domain type\n\t\t * would mean a new step type could not render at all until someone\n\t\t * registered a component for it — exactly the \"unknown step\" case this\n\t\t * component exists to display gracefully.\n\t\t *\n\t\t * @return {Array<object>} Vue Flow nodes.\n\t\t */\n\t\tcanvasNodes() {\n\t\t\t// WHICH STEPS A LINE ACTUALLY TOUCHES, measured once per render\n\t\t\t// rather than once per node: `canvasEdges` expands the document's\n\t\t\t// list dialects (`{from: 'a', to: ['b','c']}`) into one line per\n\t\t\t// pair, and re-walking it inside the map below would be quadratic on\n\t\t\t// a graph large enough to care.\n\t\t\tconst targeted = new Set()\n\t\t\tconst sourced = new Set()\n\t\t\tfor (const line of this.store.canvasEdges) {\n\t\t\t\ttargeted.add(line.target)\n\t\t\t\tsourced.add(line.source)\n\t\t\t}\n\n\t\t\treturn (this.store.nodes || []).map((node) => ({\n\t\t\t\tid: node.id,\n\t\t\t\ttype: 'default',\n\t\t\t\t// BOTH SPELLINGS, for the same reason removeNode() reads both\n\t\t\t\t// edge spellings: `position: {x, y}` is what the SERVER stores,\n\t\t\t\t// and flat `x`/`y` is what addNode()/moveNode() write in memory.\n\t\t\t\t//\n\t\t\t\t// Reading only `node.x` meant `Number(undefined) || 0` for every\n\t\t\t\t// node of every PERSISTED flow — so a saved flow reloaded with\n\t\t\t\t// all of its nodes stacked at the origin, on top of each other.\n\t\t\t\t// A new flow looked fine because its nodes had never been\n\t\t\t\t// through the server. Measured on a live instance: of 100 stored\n\t\t\t\t// flows sampled, NOT ONE had a flat `x`/`y` node; a 76-node flow\n\t\t\t\t// reloaded with all 76 at (0, 0).\n\t\t\t\t//\n\t\t\t\t// The pile is invisible rather than obviously wrong, which is\n\t\t\t\t// why this read as \"the canvas renders nothing\" instead of \"the\n\t\t\t\t// layout is lost\".\n\t\t\t\tposition: {\n\t\t\t\t\tx: Number(node.x ?? node.position?.x) || 0,\n\t\t\t\t\ty: Number(node.y ?? node.position?.y) || 0,\n\t\t\t\t},\n\t\t\t\tdata: {\n\t\t\t\t\tstepType: node.type,\n\t\t\t\t\tlabel: this.nodeLabel(node),\n\t\t\t\t\tports: this.store.portsOfNode ? this.store.portsOfNode(node) : undefined,\n\n\t\t\t\t\t// A PORT THAT CANNOT BE CONNECTED IS NOT DRAWN.\n\t\t\t\t\t//\n\t\t\t\t\t// A run STARTS at a trigger, so an entry on one is an\n\t\t\t\t\t// affordance the engine will never honour; a run STOPS at an\n\t\t\t\t\t// end step, so an exit on one is the same lie in the other\n\t\t\t\t\t// direction. Both were being drawn on every node regardless\n\t\t\t\t\t// of role, which made the canvas claim a graph shape the\n\t\t\t\t\t// engine would refuse.\n\t\t\t\t\t//\n\t\t\t\t\t// Keyed on the CATALOGUE's role, like the accent colours\n\t\t\t\t\t// below — never on graph position, which once painted\n\t\t\t\t\t// unconnected steps green.\n\t\t\t\t\thasTarget: this.store.roleOfNodeType(node.type) !== 'trigger',\n\t\t\t\t\thasSource: this.store.roleOfNodeType(node.type) !== 'end',\n\n\t\t\t\t\t// Whether anything is actually wired to those ports. The\n\t\t\t\t\t// node paints an unconnected one as a warning, which is the\n\t\t\t\t\t// same finding `check()` returns — but at the port, where the\n\t\t\t\t\t// author can act on it, rather than as a node id in a card\n\t\t\t\t\t// on the other side of the screen.\n\t\t\t\t\thasIncoming: targeted.has(node.id),\n\t\t\t\t\thasOutgoing: sourced.has(node.id),\n\t\t\t\t},\n\t\t\t}))\n\t\t},\n\n\t\t/**\n\t\t * The editor for the node being edited: an app-registered one for its\n\t\t * type when there is one, the generic dialog otherwise.\n\t\t *\n\t\t * @return {object} The component.\n\t\t */\n\t\tnodeEditorComponent() {\n\t\t\tconst type = this.store.editingNode?.type\n\t\t\treturn (type && resolveFlowNodeEditor(type)) || CnFlowNodeEditModal\n\t\t},\n\n\t\t/**\n\t\t * @return {string} The note-card type for the check verdict.\n\t\t */\n\t\tcheckCardType() {\n\t\t\tconst result = this.store.checkResult\n\t\t\tif (!result) {\n\t\t\t\treturn 'success'\n\t\t\t}\n\t\t\tif (result.valid === false) {\n\t\t\t\treturn 'error'\n\t\t\t}\n\n\t\t\treturn (result.warnings || []).length ? 'warning' : 'success'\n\t\t},\n\n\t\t/**\n\t\t * @return {string} The one-line verdict.\n\t\t */\n\t\tcheckCardText() {\n\t\t\tconst result = this.store.checkResult\n\t\t\tif (!result) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tif (result.valid === false) {\n\t\t\t\treturn result.message || this.t('nextcloud-vue', 'This flow cannot run yet.')\n\t\t\t}\n\t\t\tif ((result.warnings || []).length) {\n\t\t\t\treturn this.t('nextcloud-vue', 'This flow can run, with warnings.')\n\t\t\t}\n\n\t\t\treturn this.t('nextcloud-vue', 'This flow looks runnable.')\n\t\t},\n\n\t\t/**\n\t\t * @return {Array<string>} The individual findings, readable.\n\t\t */\n\t\tcheckCardItems() {\n\t\t\tconst result = this.store.checkResult\n\t\t\tif (!result) {\n\t\t\t\treturn []\n\t\t\t}\n\n\t\t\tconst describe = (finding) => {\n\t\t\t\tif (typeof finding === 'string') {\n\t\t\t\t\treturn finding\n\t\t\t\t}\n\n\t\t\t\tconst parts = [finding.message || finding.reason || '']\n\t\t\t\tif (finding.node) {\n\t\t\t\t\tparts.push(`(${finding.node})`)\n\t\t\t\t}\n\n\t\t\t\treturn parts.filter(Boolean).join(' ')\n\t\t\t}\n\n\t\t\treturn [\n\t\t\t\t...(result.blocking || []).map(describe),\n\t\t\t\t...(result.warnings || []).map(describe),\n\t\t\t].filter(Boolean)\n\t\t},\n\n\t\t/**\n\t\t * What the run painted on each node, derived — never stored — from\n\t\t * the animator's cursor and residue.\n\t\t *\n\t\t * Precedence: a failure is never painted over (`failed` wins), a hold\n\t\t * is a state the user must notice (`hold` next), then the halo on the\n\t\t * step being animated (`active`), then the quiet success accent on\n\t\t * what completed (`done`).\n\t\t *\n\t\t * @return {{[key: string]: string}} Node id to run state.\n\t\t */\n\t\trunNodeStates() {\n\t\t\tconst anim = this.runAnimation\n\t\t\tif (anim.mode === null) {\n\t\t\t\treturn {}\n\t\t\t}\n\n\t\t\tconst states = {}\n\n\t\t\tfor (const id of anim.doneNodeIds) {\n\t\t\t\tstates[id] = 'done'\n\t\t\t}\n\n\t\t\t// The hold state: the last suspended step's node — and, when the\n\t\t\t// log lags behind a run that reports `suspended`, the marking's\n\t\t\t// places, which are node ids in the engine's workflow.\n\t\t\tconst holdIds = [...anim.holdNodeIds]\n\t\t\tconst run = this.store.watchedRun\n\t\t\tif (holdIds.length === 0\n\t\t\t\t&& anim.mode === 'watch'\n\t\t\t\t&& run?.status === 'suspended'\n\t\t\t\t&& run.marking !== null\n\t\t\t\t&& typeof run.marking === 'object') {\n\t\t\t\tfor (const place of Object.keys(run.marking)) {\n\t\t\t\t\tif (this.store.nodes.some((node) => node.id === place)) {\n\t\t\t\t\t\tholdIds.push(place)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor (const id of holdIds) {\n\t\t\t\tstates[id] = 'hold'\n\t\t\t}\n\n\t\t\tfor (const id of anim.failedNodeIds) {\n\t\t\t\tstates[id] = 'failed'\n\t\t\t}\n\n\t\t\t// The halo: the step being animated — or, on a live watch with the\n\t\t\t// queue drained, the run's frontier (its furthest logged node).\n\t\t\t// That frontier is what \"where is my case\" actually asks for.\n\t\t\tconst suspended = anim.mode === 'watch' && run?.status === 'suspended'\n\t\t\tconst current = anim.currentNodeId\n\t\t\t\t|| ((anim.mode === 'watch' && this.store.watchedRunActive && suspended === false)\n\t\t\t\t\t? anim.lastNodeId\n\t\t\t\t\t: null)\n\t\t\tif (current !== null && states[current] !== 'failed' && states[current] !== 'hold') {\n\t\t\t\tstates[current] = 'active'\n\t\t\t}\n\n\t\t\t// Anticipation: the run says `running` (or `queued`) and the log\n\t\t\t// is still empty — a mid-pass poll returns exactly this. The start\n\t\t\t// nodes pulse; pretending a specific step is executing would\n\t\t\t// fabricate liveness the data does not contain.\n\t\t\tif (anim.mode === 'watch'\n\t\t\t\t&& this.store.watchedRunActive\n\t\t\t\t&& suspended === false\n\t\t\t\t&& anim.playedCount === 0\n\t\t\t\t&& anim.queue.length === 0) {\n\t\t\t\tfor (const id of this.store.startNodeIds) {\n\t\t\t\t\tif (states[id] === undefined) {\n\t\t\t\t\t\tstates[id] = 'active'\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn states\n\t\t},\n\n\t\t/**\n\t\t * The canvas edges with the run's classes on them.\n\t\t *\n\t\t * Vue Flow applies an edge record's `class` to the `<g>` it draws, so\n\t\t * run state reaches the lines through the canvas's EXISTING `edges`\n\t\t * prop — no prop, event or slot of CnGraphCanvas changes, and the\n\t\t * canvas itself keeps knowing nothing about statuses (ADR-065).\n\t\t *\n\t\t * @return {Array<object>} The drawable lines, some carrying a class.\n\t\t */\n\t\tcanvasEdgesWithRunState() {\n\t\t\tconst anim = this.runAnimation\n\t\t\tif (anim.mode === null) {\n\t\t\t\treturn this.store.canvasEdges\n\t\t\t}\n\n\t\t\treturn this.store.canvasEdges.map((line) => {\n\t\t\t\tif (line.id === anim.traceLineId) {\n\t\t\t\t\treturn { ...line, class: 'cn-flow-detail__edge--run-tracing' }\n\t\t\t\t}\n\t\t\t\tif (anim.tracedLineIds.includes(line.id)) {\n\t\t\t\t\treturn { ...line, class: 'cn-flow-detail__edge--run-traced' }\n\t\t\t\t}\n\n\t\t\t\treturn line\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * The transitions the run log named that match no canvas node, once\n\t\t * each, for the warning card.\n\t\t *\n\t\t * @return {Array<string>} The unmatched transition names.\n\t\t */\n\t\tskippedRunTransitions() {\n\t\t\treturn [...new Set(this.runAnimation.skippedTransitions)]\n\t\t},\n\n\t\t/**\n\t\t * What to show the user when a save or a run was refused.\n\t\t *\n\t\t * Prefers the API's own `error` field, because that is the sentence\n\t\t * written for a person — \"A flow needs a name.\" says what to do, where\n\t\t * \"Request failed with status code 400\" does not.\n\t\t *\n\t\t * @return {string} The message.\n\t\t */\n\t\terrorText() {\n\t\t\tconst error = this.store.error\n\t\t\tif (!error) {\n\t\t\t\treturn ''\n\t\t\t}\n\n\t\t\treturn error?.response?.data?.error\n\t\t\t\t|| error?.response?.data?.message\n\t\t\t\t|| error?.message\n\t\t\t\t|| this.t('nextcloud-vue', 'The last action failed.')\n\t\t},\n\n\t\t/**\n\t\t * What stops this flow from finishing, as one readable sentence.\n\t\t *\n\t\t * @return {string|null} The message, or null when nothing is missing.\n\t\t */\n\t\tmissingEndsMessage() {\n\t\t\tconst missing = this.store.missingEnds\n\t\t\tif (missing.trigger && missing.end) {\n\t\t\t\treturn this.t('nextcloud-vue', 'This flow has no trigger and no end step, so it cannot start or finish.')\n\t\t\t}\n\t\t\tif (missing.trigger) {\n\t\t\t\treturn this.t('nextcloud-vue', 'This flow has no trigger, so nothing will start it.')\n\t\t\t}\n\t\t\tif (missing.end) {\n\t\t\t\treturn this.t('nextcloud-vue', 'This flow has no end step, so a run can never finish.')\n\t\t\t}\n\n\t\t\treturn null\n\t\t},\n\n\t\t/**\n\t\t * What the server refused, and what to do about it.\n\t\t *\n\t\t * 🔑 SWITCHED ON THE `reason` FIELD, never on the message text. The two\n\t\t * refusals an author meets want opposite actions — one needs a draft\n\t\t * created, the other needs a version published — and matching on English\n\t\t * prose is how a UI offers the wrong one.\n\t\t *\n\t\t * @return {string} The message.\n\t\t */\n\t\trefusalText() {\n\t\t\tconst reason = this.store.lifecycleRefusal?.reason\n\t\t\tconst messages = {\n\t\t\t\t'version-immutable': this.t('nextcloud-vue', 'This version is published and cannot be changed. Create a draft to make changes; the published version keeps running until you publish the draft.'),\n\t\t\t\t'not-a-draft': this.t('nextcloud-vue', 'Only a draft version can be published.'),\n\t\t\t\t'not-published': this.t('nextcloud-vue', 'Only a published version can be deprecated.'),\n\t\t\t\t'no-published-version': this.t('nextcloud-vue', 'This flow has no published version, so it cannot run. Publish a version first.'),\n\t\t\t\t'dead-end': this.t('nextcloud-vue', 'This version cannot be published while a step has nowhere to send its work.'),\n\t\t\t\t'version-in-use': this.t('nextcloud-vue', 'This version cannot be removed while a run is still using it.'),\n\t\t\t}\n\n\t\t\treturn messages[reason] || this.t('nextcloud-vue', 'That change was refused by the flow\\'s current state.')\n\t\t},\n\n\t\t/**\n\t\t * What the author needs to be told about this flow, right now.\n\t\t *\n\t\t * 🔴 THE `id` IS IDENTITY, AND THAT IS THE WHOLE ANTI-ACCUMULATION\n\t\t * MECHANISM. Every source contributes at most one entry, so a refusal\n\t\t * fired on five palette clicks is one card. Anything that wants to\n\t\t * \"add\" a message adds it under an id that already means that thing.\n\t\t *\n\t\t * ⚠️ THE LOCK AND ITS REFUSAL SHARE ONE ID ON PURPOSE. A published flow\n\t\t * already carries the standing `graph-locked` message before the author\n\t\t * tries anything. When the try then happens and `pushUndo()` refuses it,\n\t\t * a second card saying almost the same sentence would be the sidebar's\n\t\t * old problem in a new place — so the refusal REPLACES the standing\n\t\t * text instead. The wording changes, which is what makes the polite\n\t\t * live region announce it again: an identical string re-set in a live\n\t\t * region is not a change and most screen readers stay silent.\n\t\t *\n\t\t * @return {Array<object>} The messages for the canvas area.\n\t\t */\n\t\tcanvasMessages() {\n\t\t\tconst messages = []\n\t\t\tconst refusal = this.store.lifecycleRefusal\n\t\t\tconst lockRefused = refusal?.reason === 'version-immutable'\n\n\t\t\t// The route named a flow that does not resolve. FIRST, and on its\n\t\t\t// own, because nothing else on this canvas is about anything: the\n\t\t\t// editor used to render an empty grid here, which is exactly what a\n\t\t\t// brand-new flow looks like, so a deleted flow and a blank one were\n\t\t\t// the same picture. Everything below concerns a flow that IS loaded,\n\t\t\t// so it is skipped rather than stacked underneath.\n\t\t\tif (this.store.notFound) {\n\t\t\t\treturn [{\n\t\t\t\t\tid: 'flow-not-found',\n\t\t\t\t\tseverity: 'error',\n\t\t\t\t\ttext: this.t('nextcloud-vue', 'This flow could not be found. It may have been deleted, or you may not have access to it.'),\n\t\t\t\t\tdismissible: false,\n\t\t\t\t}]\n\t\t\t}\n\n\t\t\t// A save or a run the server refused outright. Its own sentence,\n\t\t\t// because \"A flow needs a name.\" says what to do and \"Request\n\t\t\t// failed with status code 400\" does not.\n\t\t\tif (this.store.error) {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'action-failed',\n\t\t\t\t\tseverity: 'error',\n\t\t\t\t\ttext: this.errorText,\n\t\t\t\t\tdismissible: true,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// The published-is-immutable rule, stated before the author trips\n\t\t\t// over it and restated as a refusal when they do.\n\t\t\tif (this.store.graphLocked) {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'graph-locked',\n\t\t\t\t\tseverity: 'warning',\n\t\t\t\t\ttext: lockRefused\n\t\t\t\t\t\t? this.refusalText\n\t\t\t\t\t\t: this.t('nextcloud-vue', 'This version is read-only. Create a draft to change its steps; the published version keeps running until you publish the draft.'),\n\t\t\t\t\taction: {\n\t\t\t\t\t\tlabel: this.t('nextcloud-vue', 'Create draft version'),\n\t\t\t\t\t\tdisabled: this.store.transitioning,\n\t\t\t\t\t},\n\t\t\t\t\t// Only the refusal can be dismissed. The lock itself stays:\n\t\t\t\t\t// it is true until the author creates a draft.\n\t\t\t\t\tdismissible: lockRefused,\n\t\t\t\t})\n\t\t\t} else if (refusal) {\n\t\t\t\t// Every other refusal: publishing what is not a draft, running\n\t\t\t\t// a flow with no published version, and so on.\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'refused',\n\t\t\t\t\tseverity: 'warning',\n\t\t\t\t\ttext: this.refusalText,\n\t\t\t\t\tdismissible: true,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tif (this.missingEndsMessage) {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'missing-ends',\n\t\t\t\t\tseverity: 'error',\n\t\t\t\t\ttext: this.missingEndsMessage,\n\t\t\t\t\tdismissible: false,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// An empty catalogue means no step can be added at all. Distinct\n\t\t\t// from a request still in the air: both are an empty list, and only\n\t\t\t// one of them is a problem.\n\t\t\tif (!this.store.catalogLoading && !this.store.nodeCatalog.length) {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'no-catalog',\n\t\t\t\t\tseverity: 'warning',\n\t\t\t\t\ttext: this.t('nextcloud-vue', 'The list of available steps could not be read, so no steps can be added. This does not mean the instance has none.'),\n\t\t\t\t\tdismissible: false,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tif (this.store.flow.enabled && !this.store.flow.owner) {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'no-owner',\n\t\t\t\t\tseverity: 'warning',\n\t\t\t\t\ttext: this.t('nextcloud-vue', 'This flow has no owner yet, so a trigger will not start it. Saving it makes you its owner.'),\n\t\t\t\t\tdismissible: false,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tif (this.store.dirty) {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'unsaved',\n\t\t\t\t\tseverity: 'warning',\n\t\t\t\t\ttext: this.t('nextcloud-vue', 'This flow has unsaved changes.'),\n\t\t\t\t\tdismissible: false,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// The engine's verdict from the Check button.\n\t\t\tif (this.store.checkResult) {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'check',\n\t\t\t\t\tseverity: this.checkCardType,\n\t\t\t\t\ttext: this.checkCardText,\n\t\t\t\t\titems: this.checkCardItems,\n\t\t\t\t\tdismissible: true,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// WHICH GRAPH IS THIS? A reader inspecting a run has to be able to\n\t\t\t// answer that, and the answer is not always the same. Three states,\n\t\t\t// one of which is silence: the snapshot is up, or it is not up and\n\t\t\t// there is a reason, or no run is open at all.\n\t\t\tif (this.store.viewingVersion !== null) {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'viewing-version',\n\t\t\t\t\tseverity: 'info',\n\t\t\t\t\ttext: this.t('nextcloud-vue', 'This is version {version} of the flow, the graph this run used. Go back to the flow to edit it.', { version: this.store.viewingVersion }),\n\t\t\t\t\tdismissible: false,\n\t\t\t\t})\n\t\t\t} else if (this.store.runGraphNotice?.reason === 'unsaved-edits') {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'run-graph-unsaved',\n\t\t\t\t\tseverity: 'warning',\n\t\t\t\t\ttext: this.t('nextcloud-vue', 'This run used version {version} of the flow. You have unsaved changes, so it is replayed over your current graph instead. Save or undo your changes to see the graph it used.', { version: this.store.runGraphNotice.version }),\n\t\t\t\t\tdismissible: false,\n\t\t\t\t})\n\t\t\t} else if (this.store.runGraphNotice?.reason === 'unreadable') {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'run-graph-unreadable',\n\t\t\t\t\tseverity: 'warning',\n\t\t\t\t\ttext: this.t('nextcloud-vue', 'Version {version} of this flow could not be read, so the run is replayed over the current graph. Read the run steps in the sidebar for what actually ran.', { version: this.store.runGraphNotice.version }),\n\t\t\t\t\tdismissible: false,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// Steps the run log names that are not on this canvas any more.\n\t\t\t//\n\t\t\t// Never while the snapshot is up: the snapshot IS the graph the run\n\t\t\t// executed, so every step it names is on the canvas by construction.\n\t\t\t// Leaving the check running would only report a bug in the snapshot,\n\t\t\t// and it would report it in words that blame the reader's flow.\n\t\t\tif (this.store.viewingVersion === null && this.skippedRunTransitions.length && !this.runSkipDismissed) {\n\t\t\t\tmessages.push({\n\t\t\t\t\tid: 'run-skipped',\n\t\t\t\t\tseverity: 'warning',\n\t\t\t\t\ttext: this.t('nextcloud-vue', 'Some steps of this run belong to nodes that are no longer on the canvas, so they were skipped: {nodes}', { nodes: this.skippedRunTransitions.join(', ') }),\n\t\t\t\t\tdismissible: true,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\treturn messages\n\t\t},\n\t},\n\n\twatch: {\n\t\t/**\n\t\t * Reload when the route names a different flow.\n\t\t *\n\t\t * `mounted` alone was not enough, and the gap was not cosmetic. Vue\n\t\t * reuses this component instance when only the route PARAM changes —\n\t\t * `/flows/:id` -> `/flows/new` and `/flows/a` -> `/flows/b` are the same\n\t\t * route record — so `mounted` does not fire again and the store keeps\n\t\t * the flow it already had.\n\t\t *\n\t\t * That left the canvas showing the previous flow AND the store still\n\t\t * holding its id, on a page the user believes is a different flow.\n\t\t * `save()` picks PUT over POST from `flow.id`, so pressing Save on what\n\t\t * looks like a blank \"new flow\" issued a PUT against the flow the user\n\t\t * had open a moment earlier — overwriting a real flow with a graph\n\t\t * meant for a new one.\n\t\t *\n\t\t * Reproduced on openregister 2026-08-05: opening \"Hydra label\n\t\t * transition\", then moving to /flows/new, left the Name field reading\n\t\t * \"Hydra label transition\" with that flow's nodes on the canvas.\n\t\t *\n\t\t * The first load stays in `mounted` rather than moving here behind\n\t\t * `immediate: true`: an immediate watcher fires before the component is\n\t\t * mounted, and this one awaits a network call whose result the canvas\n\t\t * renders. Guarding on `next === prev` keeps the two from racing.\n\t\t *\n\t\t * @param {string} next The incoming flow id, or 'new'.\n\t\t * @param {string} prev The outgoing one.\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync id(next, prev) {\n\t\t\tif (next === prev) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// The same arrival as a fresh mount, so it gets the same loading\n\t\t\t// state: the flow on the canvas belongs to the previous route and\n\t\t\t// counting its steps would answer a question about this one.\n\t\t\tthis.store.beginBootstrap(this.run)\n\t\t\ttry {\n\t\t\t\tawait this.store.load({ app: this.app, id: next })\n\t\t\t\tawait this.openRunFromRoute()\n\t\t\t} finally {\n\t\t\t\tthis.store.endBootstrap()\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Follow `?run=` to another run without a remount.\n\t\t *\n\t\t * Same route record, different query: Vue reuses this instance, so\n\t\t * `mounted` does not fire again — the same reason the flow-id watcher\n\t\t * above exists.\n\t\t *\n\t\t * @param {string} next The run now named by the URL.\n\t\t * @param {string} prev The one before it.\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync run(next, prev) {\n\t\t\tif (next === prev) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tawait this.openRunFromRoute()\n\t\t},\n\n\t\t/**\n\t\t * A new watch replaces whatever run was on the canvas: the animator\n\t\t * resets and the store's steps start flowing into the fresh queue.\n\t\t *\n\t\t * @param {string|null} next The newly watched run's uuid.\n\t\t * @return {void}\n\t\t */\n\t\t'store.watchedRunUuid': function(next) {\n\t\t\tif (next === null) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.startRunAnimation('watch')\n\t\t},\n\n\t\t/**\n\t\t * Steps arrived from a poll. Index-diffed on this side too — only the\n\t\t * entries beyond what was already consumed are queued, so a poll that\n\t\t * delivered nothing new enqueues nothing twice, and a resumed run's\n\t\t * appended entries queue without replaying the ones before them.\n\t\t *\n\t\t * @param {Array<object>} steps The watched run's log so far.\n\t\t * @return {void}\n\t\t */\n\t\t'store.watchedSteps': function(steps) {\n\t\t\tif (this.runAnimation.mode !== 'watch') {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif (steps.length <= this.runConsumed) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.runAnimation.queue.push(...steps.slice(this.runConsumed))\n\t\t\tthis.runConsumed = steps.length\n\t\t\tthis.drainRunQueue()\n\t\t},\n\n\t\t/**\n\t\t * The sidebar asked for a replay of the inspected run. The stored log\n\t\t * plays through the same animator, in log order, paced from each\n\t\t * step's real `durationMs`. Starting another replay cancels the one\n\t\t * in progress — the reset is the cancellation.\n\t\t *\n\t\t * @return {void}\n\t\t */\n\t\t'store.replayToken': function() {\n\t\t\tthis.startRunAnimation('replay')\n\t\t\tthis.runAnimation.queue.push(...this.store.steps)\n\t\t\tthis.drainRunQueue()\n\t\t},\n\n\t\t/**\n\t\t * A graph edit tears the run picture down. The log names node ids of\n\t\t * the graph AS IT RAN; painting it over an edited graph would show a\n\t\t * walk the engine never took over these nodes.\n\t\t *\n\t\t * @param {boolean} next Whether the flow now has unsaved changes.\n\t\t * @return {void}\n\t\t */\n\t\t'store.dirty': function(next) {\n\t\t\tif (next === true) {\n\t\t\t\tthis.cancelRunAnimation()\n\t\t\t}\n\t\t},\n\t},\n\n\t/**\n\t * Say that the editor is loading BEFORE it renders for the first time.\n\t *\n\t * 🔴 `mounted` IS TOO LATE, AND THAT IS THE WHOLE DEFECT. Vue paints the\n\t * component and then calls `mounted`, so a flag raised there is false for\n\t * the first frame. That frame is what a user who followed a run link saw:\n\t * the flow editor's toolbar over \"No steps yet\", on a URL that already\n\t * named a run. `created` runs before the first render, so the mode the URL\n\t * describes is known to every surface from the first paint, and the\n\t * requests below only fill it in.\n\t *\n\t * @return {void}\n\t */\n\tcreated() {\n\t\tthis.store.beginBootstrap(this.run)\n\t},\n\n\tasync mounted() {\n\t\tdocument.addEventListener('keydown', this.onDocumentKeydown)\n\n\t\t// `finally`, because a canvas stuck on \"loading\" forever is worse than\n\t\t// the empty state this replaced. `load()` swallows its own request\n\t\t// errors, but a throw anywhere else in the chain would strand the flag.\n\t\ttry {\n\t\t\tawait this.store.load({ app: this.app, id: this.id })\n\n\t\t\t// AFTER the load, never before. `inspectRun` reads the run and its\n\t\t\t// objects; doing it first would fill the panel and then have the flow\n\t\t\t// arrive underneath it.\n\t\t\tawait this.openRunFromRoute()\n\t\t} finally {\n\t\t\tthis.store.endBootstrap()\n\t\t}\n\t},\n\n\tbeforeUnmount() {\n\t\tdocument.removeEventListener('keydown', this.onDocumentKeydown)\n\n\t\t// The animator's timer must not outlive the canvas, and neither may\n\t\t// the store's poll loop outlive the surface that watches it — no\n\t\t// polling survives navigating away.\n\t\tthis.cancelRunAnimation()\n\t\tthis.store.stopWatching()\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Open the run the URL named, if it named one.\n\t\t *\n\t\t * Half of a deep link, and the half that is easy to leave out: the\n\t\t * `<a href>` in the Runs list is the visible part, but a visitor who\n\t\t * follows a run's own address lands on the flow with the run in the\n\t\t * query, and nothing wired that to `inspectRun()`. The link then opened\n\t\t * the right flow with no run selected, which reads as a broken link\n\t\t * rather than as a missing wire.\n\t\t *\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync openRunFromRoute() {\n\t\t\tif (!this.run) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Synchronously, before the fetch. `inspectRun()` sets\n\t\t\t// `inspectedRunUuid` itself, but only once it is called, and on a\n\t\t\t// fresh arrival that is behind the whole flow load. Recording the\n\t\t\t// run here means every surface reading `inRunView` agrees with the\n\t\t\t// address bar for the length of the load instead of after it.\n\t\t\tthis.store.beginOpeningRun(this.run)\n\t\t\ttry {\n\t\t\t\tawait this.store.inspectRun(this.run)\n\t\t\t} finally {\n\t\t\t\tthis.store.endOpeningRun()\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Ctrl+Z / Cmd+Z steps the GRAPH back one edit.\n\t\t *\n\t\t * Bound on `document` rather than the canvas element: the shortcut has\n\t\t * to work after clicking anywhere in the editor, and the canvas is not\n\t\t * what holds focus for most of a session.\n\t\t *\n\t\t * ⚠️ WHICH IS EXACTLY WHY IT HAS TO STAND DOWN. A document listener sees\n\t\t * every Ctrl+Z on the page, including the one a user presses to undo\n\t\t * TYPING in a step's name or its JSON config. Reverting the whole graph\n\t\t * because someone fixed a typo would be a far worse bug than having no\n\t\t * undo at all, so a keystroke aimed at editable text is left to the\n\t\t * browser's own undo. The open-editor check is a second guard for the\n\t\t * same reason: while a node's dialog is up, the user is editing that\n\t\t * node, not the graph.\n\t\t *\n\t\t * Shift+Ctrl+Z is deliberately NOT claimed — that is redo, and there is\n\t\t * no redo stack yet. Claiming it would swallow the key and do nothing.\n\t\t *\n\t\t * @param {KeyboardEvent} event The key event.\n\t\t * @return {void}\n\t\t */\n\t\tonDocumentKeydown(event) {\n\t\t\tif (event.key !== 'z' && event.key !== 'Z') {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif ((event.ctrlKey || event.metaKey) === false || event.shiftKey === true || event.altKey === true) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst target = event.target\n\t\t\tconst tag = String(target?.tagName || '').toLowerCase()\n\t\t\tif (tag === 'input' || tag === 'textarea' || target?.isContentEditable === true) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif (this.store.editingNodeId !== null) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif (this.store.canUndo === false) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tevent.preventDefault()\n\t\t\tthis.store.undo()\n\t\t},\n\n\t\t/**\n\t\t * Persist a node move that Vue Flow reports.\n\t\t *\n\t\t * Vue Flow emits ONE change stream for drags, keyboard moves and\n\t\t * programmatic updates alike, so this replaces the old `@node-move`.\n\t\t * Only `position` changes with a settled position are persisted:\n\t\t * intermediate drag frames arrive with `dragging: true` and writing\n\t\t * those would put a store commit on every animation frame.\n\t\t *\n\t\t * @param {Array<object>} changes Vue Flow's node changes.\n\t\t * @return {void}\n\t\t */\n\t\tonNodesChange(changes) {\n\t\t\tfor (const change of (changes || [])) {\n\t\t\t\tif (change.type !== 'position' || change.dragging === true) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tif (change.position === undefined || change.position === null) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tthis.store.moveNode({ id: change.id, x: change.position.x, y: change.position.y })\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * @param {object|null} line The menu's target line.\n\t\t * @return {string} The router it is drawn with.\n\t\t */\n\t\tlineTypeOf(line) {\n\t\t\treturn line?.lineType || DEFAULT_EDGE_LINE_TYPE\n\t\t},\n\n\t\t/**\n\t\t * A line was clicked: open its actions where the pointer is.\n\t\t *\n\t\t * Vue Flow reports `{ edge, event }`, and `edge.data.edge` is the STORED\n\t\t * record — but the menu is keyed on the endpoint pair rather than on\n\t\t * that record, because one stored record can draw several lines and only\n\t\t * the pair says which one was clicked.\n\t\t *\n\t\t * @param {object} event Vue Flow's edge-click event.\n\t\t * @return {void}\n\t\t */\n\t\tonEdgeSelect(event) {\n\t\t\tconst edge = event?.edge ?? event\n\t\t\tconst mouse = event?.event\n\n\t\t\tthis.openEdgeMenuFor(edge, mouse)\n\t\t},\n\n\t\t/**\n\t\t * The label is a control in its own right, so activating it opens the\n\t\t * same menu the line does — a user who aims at the name of a connection\n\t\t * means the connection.\n\t\t *\n\t\t * @param {string} id The drawn line's id.\n\t\t * @return {void}\n\t\t */\n\t\tonEdgeLabelClick(id) {\n\t\t\tconst line = this.store.canvasEdges.find((candidate) => candidate.id === id)\n\t\t\tif (line === undefined) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.store.editingEdge = { source: line.source, target: line.target }\n\t\t},\n\n\t\t/**\n\t\t * @param {object} payload A right-click on a line's label.\n\t\t * @param {string} payload.id The drawn line's id.\n\t\t * @param {MouseEvent} payload.event The pointer event.\n\t\t * @return {void}\n\t\t */\n\t\tonEdgeLabelContext({ id, event }) {\n\t\t\tconst line = this.store.canvasEdges.find((candidate) => candidate.id === id)\n\t\t\tif (line === undefined) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.openEdgeMenuFor(line, event)\n\t\t},\n\n\t\t/**\n\t\t * Persist a label slid along its line.\n\t\t *\n\t\t * REPORTED, then stored — CnFlowEdge never writes the edge itself, the\n\t\t * same rule node positions follow.\n\t\t *\n\t\t * @param {object} payload The move.\n\t\t * @param {string} payload.id The drawn line's id.\n\t\t * @param {number} payload.labelT Its new fraction along the line.\n\t\t * @return {void}\n\t\t */\n\t\tonEdgeLabelMove({ id, labelT }) {\n\t\t\tconst line = this.store.canvasEdges.find((candidate) => candidate.id === id)\n\t\t\tif (line === undefined) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.store.setEdgeFields({ source: line.source, target: line.target, fields: { labelT } })\n\t\t},\n\n\t\t/**\n\t\t * Open the line menu, if the click carried a pointer position.\n\t\t *\n\t\t * A keyboard-activated selection has no coordinates, and a menu placed at\n\t\t * (0, 0) is worse than no menu — so the selection stands and nothing\n\t\t * pops. The dialog remains reachable from the label control, which IS\n\t\t * focusable.\n\t\t *\n\t\t * @param {object} line The drawn line, or Vue Flow's edge record.\n\t\t * @param {MouseEvent=} mouse The pointer event, when there was one.\n\t\t * @return {void}\n\t\t */\n\t\topenEdgeMenuFor(line, mouse) {\n\t\t\tif (line?.source === undefined || mouse?.clientX === undefined) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.openEdgeMenu({\n\t\t\t\titem: {\n\t\t\t\t\tsource: line.source,\n\t\t\t\t\ttarget: line.target,\n\t\t\t\t\tlineType: line.data?.lineType,\n\t\t\t\t},\n\t\t\t\tevent: mouse,\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * Clicking the empty pane clears BOTH selections.\n\t\t *\n\t\t * @return {void}\n\t\t */\n\t\tonCanvasClick() {\n\t\t\tthis.store.selectedNodeId = null\n\t\t},\n\n\t\t/**\n\t\t * Vue Flow hands back `{ node }`; the store tracks a bare id.\n\t\t *\n\t\t * @param {object} event The node-click event.\n\t\t * @return {void}\n\t\t */\n\t\tonNodeSelect(event) {\n\t\t\tconst id = event?.node?.id ?? event?.id ?? null\n\t\t\tthis.store.selectedNodeId = id\n\n\t\t\t// Selection still happens — the sidebar keeps following the canvas —\n\t\t\t// and the menu opens ON TOP of it. They are not alternatives: one is\n\t\t\t// \"which step am I looking at\", the other is \"what can I do to it\".\n\t\t\tif (id === null) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst mouse = event?.event\n\t\t\tif (mouse?.clientX === undefined) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.openNodeMenu({ item: id, event: mouse })\n\t\t},\n\n\t\t/**\n\t\t * Enter or Space on a step: open its editor, as a double-click does.\n\t\t *\n\t\t * @param {string} id The step id.\n\t\t * @return {void}\n\t\t */\n\t\tonNodeActivate(id) {\n\t\t\tthis.store.selectedNodeId = id\n\t\t\tthis.store.editingNodeId = id\n\t\t},\n\n\t\t/**\n\t\t * Shift+F10 or the menu key on a step: its menu, at the step.\n\t\t *\n\t\t * @param {object} request The node's request.\n\t\t * @param {string} request.id The step id.\n\t\t * @param {number} request.clientX Where to place the menu.\n\t\t * @param {number} request.clientY Where to place the menu.\n\t\t * @return {void}\n\t\t */\n\t\tonNodeMenu({ id, clientX, clientY }) {\n\t\t\tthis.store.selectedNodeId = id\n\t\t\tthis.openNodeMenu({ item: id, event: { clientX, clientY } })\n\t\t},\n\n\t\t/**\n\t\t * Place a palette node where it was dropped.\n\t\t *\n\t\t * @param {object} drop The drop payload.\n\t\t * @param {object} drop.position The point, already in canvas space.\n\t\t * @return {void}\n\t\t */\n\t\tonCanvasDrop({ position }) {\n\t\t\tif (!this.store.paletteDragType) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// `position` is already canvas space — Vue Flow's `project()` undid\n\t\t\t// pan and zoom in the canvas, same contract as before, different\n\t\t\t// arithmetic.\n\t\t\tthis.store.addNode(this.store.paletteDragType, position.x, position.y)\n\t\t\tthis.store.paletteDragType = null\n\t\t},\n\n\t\t/**\n\t\t * @return {void}\n\t\t */\n\t\tonSaveClick() {\n\t\t\t/**\n\t\t\t * @event save The Save button was pressed. The host persists via\n\t\t\t * `useFlowStore().save()` and, for a new flow, swaps the route to\n\t\t\t * the minted id — only the host knows whether one is needed.\n\t\t\t */\n\t\t\tthis.$emit('save')\n\t\t},\n\n\t\t/**\n\t\t * @return {void}\n\t\t */\n\t\tonRunClick() {\n\t\t\t/**\n\t\t\t * @event run The Run button was pressed. The host queues a run via\n\t\t\t * `useFlowStore().run()`.\n\t\t\t */\n\t\t\tthis.$emit('run')\n\t\t},\n\n\t\t/**\n\t\t * Reset the animator for a fresh run — a new watch or a replay.\n\t\t *\n\t\t * The reset IS the cancellation: a replay in progress, a previous\n\t\t * watch's residue, and any pending hop timer all go.\n\t\t *\n\t\t * @param {string} mode `watch` or `replay`.\n\t\t * @return {void}\n\t\t */\n\t\tstartRunAnimation(mode) {\n\t\t\tthis.cancelRunAnimation()\n\t\t\tthis.runAnimation.mode = mode\n\t\t},\n\n\t\t/**\n\t\t * Tear the run picture down: clear the timer, the queue, the residue.\n\t\t *\n\t\t * Called on unmount, on a graph edit, and by `startRunAnimation` when\n\t\t * a new run takes the canvas.\n\t\t *\n\t\t * @return {void}\n\t\t */\n\t\tcancelRunAnimation() {\n\t\t\tconst anim = this.runAnimation\n\t\t\tif (anim.timer !== null) {\n\t\t\t\tclearTimeout(anim.timer)\n\t\t\t}\n\n\t\t\tthis.runAnimation = {\n\t\t\t\tmode: null,\n\t\t\t\tqueue: [],\n\t\t\t\tplayedCount: 0,\n\t\t\t\tcurrentNodeId: null,\n\t\t\t\tlastNodeId: null,\n\t\t\t\ttraceLineId: null,\n\t\t\t\ttracedLineIds: [],\n\t\t\t\tdoneNodeIds: [],\n\t\t\t\tfailedNodeIds: [],\n\t\t\t\tholdNodeIds: [],\n\t\t\t\tskippedTransitions: [],\n\t\t\t\ttimer: null,\n\t\t\t}\n\t\t\tthis.runConsumed = 0\n\t\t\t// A dismissal belongs to the run it was about. The next run gets to\n\t\t\t// say what it skipped, even if the last one was waved away.\n\t\t\tthis.runSkipDismissed = false\n\t\t},\n\n\t\t/**\n\t\t * Close a canvas message the author has read.\n\t\t *\n\t\t * 🔑 CLEARS THE SOURCE, NOT A LOCAL \"HIDDEN\" FLAG. A dismissed message\n\t\t * whose source is still set would come back on the next unrelated\n\t\t * re-render, and the author would learn that the close button does not\n\t\t * work. Only one-off messages reach this: a standing condition renders\n\t\t * no dismiss control at all, because it would still be true afterwards.\n\t\t *\n\t\t * @param {string} id The message id.\n\t\t * @return {void}\n\t\t */\n\t\tdismissCanvasMessage(id) {\n\t\t\tif (id === 'action-failed') {\n\t\t\t\tthis.store.error = null\n\t\t\t} else if (id === 'refused' || id === 'graph-locked') {\n\t\t\t\t// The lock itself stays; only the refusal it just answered goes.\n\t\t\t\t// `graph-locked` falls back to its standing read-only wording.\n\t\t\t\tthis.store.lifecycleRefusal = null\n\t\t\t} else if (id === 'check') {\n\t\t\t\tthis.store.checkResult = null\n\t\t\t} else if (id === 'run-skipped') {\n\t\t\t\tthis.runSkipDismissed = true\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Run the action a canvas message offers.\n\t\t *\n\t\t * @param {string} id The message id.\n\t\t * @return {void}\n\t\t */\n\t\trunCanvasMessageAction(id) {\n\t\t\tif (id === 'graph-locked') {\n\t\t\t\tthis.store.createDraft()\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Play the next queued step, if a hop is not already in flight.\n\t\t *\n\t\t * Steps whose `transition` matches no canvas node are skipped HERE,\n\t\t * without spending a hop on them, and their names surfaced — the flow\n\t\t * was edited since the run, and remapping the step onto a different\n\t\t * card would draw a walk the engine never took. Subsequent steps\n\t\t * continue on their own nodes.\n\t\t *\n\t\t * @return {void}\n\t\t */\n\t\tdrainRunQueue() {\n\t\t\tconst anim = this.runAnimation\n\t\t\tif (anim.timer !== null) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\twhile (anim.queue.length > 0) {\n\t\t\t\tconst transition = String(anim.queue[0].transition || '')\n\t\t\t\tif (this.store.nodes.some((node) => node.id === transition)) {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\n\t\t\t\tanim.queue.shift()\n\t\t\t\tanim.skippedTransitions.push(transition)\n\t\t\t}\n\n\t\t\tif (anim.queue.length === 0) {\n\t\t\t\t// Drained. The halo hands over to the derived frontier state\n\t\t\t\t// (see `runNodeStates`); the trace does not linger on a line\n\t\t\t\t// whose hop is over.\n\t\t\t\tanim.currentNodeId = null\n\t\t\t\tanim.traceLineId = null\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.playRunStep(anim.queue.shift())\n\t\t},\n\n\t\t/**\n\t\t * One hop: paint one step onto its node and the edge that led there.\n\t\t *\n\t\t * @param {object} step The engine's log entry, as delivered.\n\t\t * @return {void}\n\t\t */\n\t\tplayRunStep(step) {\n\t\t\tconst anim = this.runAnimation\n\t\t\tconst nodeId = String(step.transition)\n\t\t\tconst previous = anim.lastNodeId\n\n\t\t\tanim.currentNodeId = nodeId\n\t\t\tanim.lastNodeId = nodeId\n\t\t\tanim.playedCount += 1\n\n\t\t\t// The edge just executed: the drawn line from the previously\n\t\t\t// animated node to this one, resolved through `canvasEdges` —\n\t\t\t// which already normalises `{from, to}` and list endpoints.\n\t\t\tanim.traceLineId = null\n\t\t\tif (previous !== null && previous !== nodeId) {\n\t\t\t\tconst line = this.store.canvasEdges.find((candidate) => candidate.source === previous && candidate.target === nodeId)\n\t\t\t\tif (line !== undefined) {\n\t\t\t\t\tanim.traceLineId = line.id\n\t\t\t\t\tif (anim.tracedLineIds.includes(line.id) === false) {\n\t\t\t\t\t\tanim.tracedLineIds.push(line.id)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Residue, from the engine's own closed status set. A failure —\n\t\t\t// or a stop that carries an error — stays red for the rest of the\n\t\t\t// run; completed and pinned keep the quiet success accent; a\n\t\t\t// suspended step holds until the watcher reports the run moved on.\n\t\t\tconst failed = step.status === 'failed'\n\t\t\t\t|| (step.status === 'stopped' && Boolean(step.error))\n\t\t\tif (failed === true) {\n\t\t\t\tif (anim.failedNodeIds.includes(nodeId) === false) {\n\t\t\t\t\tanim.failedNodeIds.push(nodeId)\n\t\t\t\t}\n\t\t\t} else if (step.status === 'completed' || step.status === 'pinned') {\n\t\t\t\tif (anim.doneNodeIds.includes(nodeId) === false) {\n\t\t\t\t\tanim.doneNodeIds.push(nodeId)\n\t\t\t\t}\n\t\t\t}\n\t\t\tanim.holdNodeIds = step.status === 'suspended' ? [nodeId] : []\n\n\t\t\tconst hop = this.runHopMs(step)\n\t\t\tif (hop <= 0) {\n\t\t\t\t// Reduced motion: no dwell, no travel — the whole queue drains\n\t\t\t\t// into static state colouring in one pass.\n\t\t\t\tthis.drainRunQueue()\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tanim.timer = setTimeout(() => {\n\t\t\t\tanim.timer = null\n\t\t\t\tthis.drainRunQueue()\n\t\t\t}, hop)\n\t\t},\n\n\t\t/**\n\t\t * How long one hop dwells.\n\t\t *\n\t\t * A live watch catches up at the bounded pace; a replay is paced from\n\t\t * the step's REAL `durationMs`, clamped to a watchable window. Under\n\t\t * reduced motion there is no dwell at all — the states paint\n\t\t * statically.\n\t\t *\n\t\t * @param {object} step The step being played.\n\t\t * @return {number} Milliseconds.\n\t\t */\n\t\trunHopMs(step) {\n\t\t\tif (this.prefersReducedMotion() === true) {\n\t\t\t\treturn 0\n\t\t\t}\n\n\t\t\tif (this.runAnimation.mode === 'replay') {\n\t\t\t\tconst real = Number(step.durationMs)\n\t\t\t\tconst ms = Number.isFinite(real) ? real : REPLAY_HOP_MIN_MS\n\n\t\t\t\treturn Math.min(REPLAY_HOP_MAX_MS, Math.max(REPLAY_HOP_MIN_MS, ms))\n\t\t\t}\n\n\t\t\treturn RUN_CATCH_UP_HOP_MS\n\t\t},\n\n\t\t/**\n\t\t * Whether the reader asked for reduced motion.\n\t\t *\n\t\t * The CSS side is handled by the media query on the animations; this\n\t\t * is the JS side, which skips the sequential reveal — hopping node to\n\t\t * node is itself motion.\n\t\t *\n\t\t * @return {boolean} True when motion should not play.\n\t\t */\n\t\tprefersReducedMotion() {\n\t\t\tif (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {\n\t\t\t\treturn false\n\t\t\t}\n\n\t\t\treturn window.matchMedia('(prefers-reduced-motion: reduce)').matches === true\n\t\t},\n\n\t\t/**\n\t\t * @param {string} id The node id.\n\t\t * @return {string|null} Its run state, or null outside a run.\n\t\t */\n\t\trunStateOf(id) {\n\t\t\treturn this.runNodeStates[id] || null\n\t\t},\n\n\t\t/**\n\t\t * The run state in words, for the chip beside the colour.\n\t\t *\n\t\t * @param {string} state The run state.\n\t\t * @return {string} The label.\n\t\t */\n\t\trunStateLabel(state) {\n\t\t\tconst labels = {\n\t\t\t\tactive: this.t('nextcloud-vue', 'Running'),\n\t\t\t\tdone: this.t('nextcloud-vue', 'Done'),\n\t\t\t\tfailed: this.t('nextcloud-vue', 'Failed'),\n\t\t\t\thold: this.t('nextcloud-vue', 'Waiting'),\n\t\t\t}\n\n\t\t\treturn labels[state] || state\n\t\t},\n\n\t\t/**\n\t\t * Step the zoom, rounded so repeated presses do not drift on floats.\n\t\t *\n\t\t * @param {number} delta The step, e.g. ±0.1.\n\t\t * @return {void}\n\t\t */\n\t\tzoomBy(delta) {\n\t\t\tthis.zoomTo(this.zoom + delta)\n\t\t},\n\n\t\t/**\n\t\t * Zoom the CANVAS, which is the only thing that holds a transform.\n\t\t *\n\t\t * `zoom` here is a mirror of what the canvas reports, never the source:\n\t\t * setting it moved a number this component owned and nothing else, so\n\t\t * the toolbar counted up and down over a canvas that never moved.\n\t\t *\n\t\t * @param {number} value The wanted zoom, clamped to the declared range.\n\t\t * @return {void}\n\t\t */\n\t\tzoomTo(value) {\n\t\t\tconst next = Math.max(this.minZoom, Math.min(this.maxZoom, value))\n\t\t\tthis.$refs.canvas?.zoomTo?.(Math.round(next * 100) / 100)\n\t\t},\n\n\t\t/**\n\t\t * Whether the engine knows this node type.\n\t\t *\n\t\t * Shown on the card rather than hidden. A node the catalogue cannot\n\t\t * explain WILL fail its step, and a flow that looks fine on the canvas\n\t\t * and dies at run time is the exact failure this component's ancestor\n\t\t * shipped.\n\t\t *\n\t\t * @param {string} type The node type.\n\t\t * @return {boolean} True when the catalogue does not know it.\n\t\t */\n\t\tisUnknown(type) {\n\t\t\t// An empty catalogue means it could not be loaded, not that every\n\t\t\t// node is unknown — flagging all of them then would be noise.\n\t\t\tif (!this.store.nodeCatalog.length) {\n\t\t\t\treturn false\n\t\t\t}\n\n\t\t\treturn this.store.catalogEntry(type) === null\n\t\t},\n\n\t\t/**\n\t\t * Human label for a node type, from the catalogue.\n\t\t *\n\t\t * No local name table: a type the catalogue cannot explain is shown as\n\t\t * its raw id rather than guessed at from a list that may not match the\n\t\t * engine.\n\t\t *\n\t\t * @param {string} type The node type.\n\t\t * @return {string} The label.\n\t\t */\n\t\ttypeLabel(type) {\n\t\t\tconst entry = this.store.catalogEntry(type)\n\n\t\t\treturn entry ? (entry.displayName || entry.id) : (type || '—')\n\t\t},\n\n\t\t/**\n\t\t * A node type turned into a usable CSS class suffix.\n\t\t *\n\t\t * Engine ids are namespaced (`hermiq.agent-step`), and a dot in the\n\t\t * middle of a class name is a compound selector rather than a name — so\n\t\t * a per-type accent silently matched nothing for every catalogue type.\n\t\t *\n\t\t * @param {string} type The node type.\n\t\t * @return {string} The slug.\n\t\t */\n\t\ttypeSlug(type) {\n\t\t\treturn String(type || '').replace(/[^a-zA-Z0-9]+/g, '-')\n\t\t},\n\n\t\t/**\n\t\t * Short summary of a node's configuration, shown on the card.\n\t\t *\n\t\t * Deliberately GENERIC. The version this was ported from switched on a\n\t\t * hard-coded list of bare node ids, so it described exactly the four\n\t\t * types one app knew about and said nothing about any other app's\n\t\t * nodes — including the ones its own palette offered. Summarising the\n\t\t * config that is actually set describes every node type, present and\n\t\t * future, without the builder having to know any of them.\n\t\t *\n\t\t * @param {object} node The node.\n\t\t * @return {string} The label.\n\t\t */\n\t\tnodeLabel(node) {\n\t\t\t// A name the author gave the step beats a derived summary.\n\t\t\tif (node.name) {\n\t\t\t\treturn node.name\n\t\t\t}\n\n\t\t\tconst config = (node.config || {})\n\t\t\tconst keys = Object.keys(config).filter((k) => config[k] !== '' && config[k] !== null)\n\n\t\t\tif (!keys.length) {\n\t\t\t\treturn this.t('nextcloud-vue', 'not configured')\n\t\t\t}\n\n\t\t\tconst first = keys[0]\n\t\t\tconst value = config[first]\n\t\t\tconst shown = (typeof value === 'object') ? '…' : String(value)\n\n\t\t\tif (keys.length === 1) {\n\t\t\t\treturn `${first}: ${shown}`\n\t\t\t}\n\n\t\t\treturn `${first}: ${shown} +${keys.length - 1}`\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-flow-detail {\n\tposition: relative;\n\tblock-size: 100%;\n\tinline-size: 100%;\n\t/* The dotted grid every flow tool draws: it says \"canvas\", and it makes\n\t drag alignment visible without a snapping feature. */\n\tbackground-image: radial-gradient(var(--color-border) 1px, transparent 1px);\n\tbackground-size: 20px 20px;\n}\n\n/* The canvas paints an opaque main-background of its own, which sat exactly\n on top of the grid above. Cleared here, for flow editors only — other\n CnGraphCanvas consumers keep their solid ground. */\n.cn-flow-detail :deep(.cn-graph-canvas) {\n\tbackground: transparent;\n}\n\n.cn-flow-detail__toolbar {\n\tposition: absolute;\n\tinset-block-start: 12px;\n\tinset-inline-end: 12px;\n\tz-index: 10;\n\tdisplay: flex;\n\tgap: 4px;\n\talign-items: center;\n\tpadding: 4px;\n\tborder: 1px solid var(--color-border);\n\tborder-radius: var(--border-radius-large);\n\tbackground: var(--color-main-background);\n\tbox-shadow: 0 1px 4px var(--color-box-shadow);\n}\n\n/* The toolbar while a run is being opened: the spinner and its sentence sit\n where the editor's buttons were, so the strip keeps its height and the zoom\n controls do not jump sideways when the run arrives. */\n.cn-flow-detail__toolbar-status {\n\tdisplay: flex;\n\tgap: 6px;\n\talign-items: center;\n\tpadding-inline: 8px;\n\tcolor: var(--color-text-maxcontrast);\n}\n\n.cn-flow-detail__toolbar-group {\n\tdisplay: flex;\n\talign-items: center;\n\tmargin-inline-start: 4px;\n\tpadding-inline-start: 8px;\n\tborder-inline-start: 1px solid var(--color-border);\n}\n\n/* ONE container, not a card in a card: the canvas wrapper draws the box —\n border, radius, background, selection — and this card only fills it. Its\n earlier own border/background rendered as a visible nested box.\n\n ⚠️ THE NO-BOX IS DECLARED, NOT LEFT OUT, AND IT IS SCOPED THROUGH THE PARENT\n ON PURPOSE. Removing the border by simply deleting the declaration looked\n like it worked here and did not work in a browser: several installed apps\n still bundle an OLDER build of this component, Vue's scoped hash for it is\n the same in both (`data-v-…` hashes the file, not its contents), and their\n stale `.cn-flow-detail__node` rule therefore lands in the same page. Nothing\n in the current build declared `border` at all, so the old declaration was\n the only one in the cascade and simply won — the nested box came back on a\n page whose own copy of the library was already fixed.\n\n So the rule states the absence, and `.cn-flow-detail` in front of it raises\n the specificity above any same-name copy, which makes this independent of\n which bundle a page happens to inject last. */\n.cn-flow-detail .cn-flow-detail__node {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: 2px;\n\tjustify-content: center;\n\tblock-size: 100%;\n\t/* No padding at all: `.cn-flow-node` already pads the box, and the role\n\t accent moved OFF this element onto the node's own border below — the 8px\n\t left inset that used to clear it was the second half of the nested-box\n\t effect, holding the body away from an edge it no longer meets. */\n\tpadding: 0;\n\tborder: 0;\n\tborder-radius: inherit;\n\tbackground: none;\n\toverflow: hidden;\n\n\t/* ⚠️ THE ACCENT'S ABSENCE HAS TO BE DECLARED, NOT MERELY LEFT OUT — AND A\n\t LIVE INSTANCE IS THE ONLY PLACE THAT SHOWS WHY.\n\n\t Moving the role accent onto `.cn-flow-node` below meant deleting the\n\t `box-shadow` from the three `--role-*` rules that used to sit on THIS\n\t element. That is enough in this repository's own harness, where exactly\n\t one build of this component exists.\n\n\t It is not enough in a Nextcloud page. Measured on a live instance\n\t (dossiq, /apps/dossiq/flows/…): THREE stale copies of\n\t `.cn-flow-detail__node--role-trigger[data-v-65800ae2]` were injected by\n\t other apps' bundles alongside the current one, all carrying the old inset\n\t shadow. Vue's scoped hash is identical in every copy because it hashes the\n\t FILE PATH, not the contents — so an older build's rule lands in the same\n\t cascade under the same attribute, and with nothing in the current build\n\t declaring `box-shadow` for this element the stale rule simply won. The\n\t accent painted on the wrapper AND on the body, and the card-in-card came\n\t back on a page whose own copy of the library was already fixed.\n\n\t The e2e cannot catch this: its harness has one build, so\n\t `getComputedStyle(body).boxShadow === 'none'` passes there either way.\n\t Only this declaration holds it, and only because\n\t `.cn-flow-detail .cn-flow-detail__node` (0,3,0) outranks the stale\n\t `.cn-flow-detail__node--role-trigger` (0,2,0).\n\n\t The `border: 0` above is the same defence, written for the same reason\n\t when the nested BORDER came back the same way. */\n\tbox-shadow: none;\n}\n\n/* Unknown step -> error border on the NODE WRAPPER, so the whole node reads as\n wrong rather than just its body.\n\n This targeted `.cn-graph-canvas__node` until the Vue Flow migration renamed\n the wrapper to `.cn-flow-node`, and nothing caught it: a CSS rule whose\n selector stops matching does not warn, it just silently stops painting. The\n class kept appearing in the shipped stylesheet and source maps, so it read\n as present while unknown nodes quietly lost their error border. */\n.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--unknown)) {\n\tborder-color: var(--color-error);\n}\n\n/* Role accents, keyed on the CATALOGUE's role — never on graph position,\n which once painted unconnected steps green.\n\n ⚠️ ON THE NODE'S OWN BORDER, NOT ON THE BODY INSIDE IT. The accent used to be\n an inset shadow on `.cn-flow-detail__node`, which sits inside\n `.cn-flow-node`'s 2px border and 12px of padding — so the bar drew a second\n vertical edge a few pixels in from the first, and a node read as a card\n inside a card. Nothing had a border it should not have; the accent was simply\n painted on the wrong box.\n\n Still an inset box-shadow, per this repository's rule 8: `border-inline-start`\n would widen the node's left edge from 2px to 4px and shove every node's body\n sideways, which is a layout change rather than a fix for one. `:has()` is how\n a rule on the body reaches the wrapper — the same mechanism the unknown-step\n border below already uses. */\n.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--role-trigger)) {\n\tbox-shadow: inset 4px 0 0 0 var(--color-success);\n}\n\n.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--role-step)) {\n\tbox-shadow: inset 4px 0 0 0 var(--color-primary-element);\n}\n\n.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--role-end)) {\n\tbox-shadow: inset 4px 0 0 0 var(--color-error);\n}\n\n.cn-flow-detail__node-type {\n\tfont-weight: 600;\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n}\n\n.cn-flow-detail__node-label,\n.cn-flow-detail__node-warning {\n\tfont-size: 0.85em;\n\tcolor: var(--color-text-maxcontrast);\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n}\n\n.cn-flow-detail__node-warning {\n\tcolor: var(--color-error-text);\n}\n\n.cn-flow-detail__empty {\n\tposition: absolute;\n\tinset: 0;\n\tpointer-events: none;\n}\n\n/* ---- Run state on the nodes ------------------------------------------- */\n\n/* The FlowMock visual grammar in Nextcloud tokens: `--color-success` for the\n halo/trace/done (FlowMock's mint), `--color-error` for failed (its\n vermillion), `--color-warning` for the hold. No `--c-*` marketing tokens —\n semantic variables are what keep nldesign theming working.\n\n ON THE NODE'S OUTLINE, NOT ITS BOX-SHADOW. The role accents above already\n own `box-shadow` on the same `.cn-flow-node:has(...)` element, and a second\n rule setting it would silently erase whichever loses the cascade. `outline`\n is a separate channel: run state and role accent paint together. It also\n costs no layout — an outline never moves the node's body. */\n.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--run-done)) {\n\toutline: 2px solid var(--color-success);\n\toutline-offset: 1px;\n}\n\n.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--run-active)) {\n\toutline: 3px solid var(--color-success);\n\toutline-offset: 1px;\n\tanimation: cn-flow-detail-run-halo 1.1s ease-in-out infinite;\n}\n\n.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--run-failed)) {\n\toutline: 3px solid var(--color-error);\n\toutline-offset: 1px;\n}\n\n/* Visibly WAITING: dashed (a different shape from executing and from failed,\n so the states differ by more than hue) and pulsing slowly. */\n.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--run-hold)) {\n\toutline: 3px dashed var(--color-warning);\n\toutline-offset: 1px;\n\tanimation: cn-flow-detail-run-hold 2.4s ease-in-out infinite;\n}\n\n/* The halo: the outline breathes outwards and fades — a pulse with no layout\n cost, on the wrapper the role accents already reach through `:has()`. */\n@keyframes cn-flow-detail-run-halo {\n\t0%,\n\t100% {\n\t\toutline-offset: 1px;\n\t\toutline-color: var(--color-success);\n\t}\n\n\t50% {\n\t\toutline-offset: 7px;\n\t\toutline-color: transparent;\n\t}\n}\n\n@keyframes cn-flow-detail-run-hold {\n\t0%,\n\t100% {\n\t\toutline-color: var(--color-warning);\n\t}\n\n\t50% {\n\t\toutline-color: transparent;\n\t}\n}\n\n/* The state in words — never colour alone (WCAG 1.4.1). */\n.cn-flow-detail__node-run {\n\tfont-size: 0.75em;\n\tfont-weight: 600;\n\ttext-transform: uppercase;\n\tletter-spacing: 0.04em;\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n\tcolor: var(--color-success-text, var(--color-success));\n}\n\n.cn-flow-detail__node-run--failed {\n\tcolor: var(--color-error-text, var(--color-error));\n}\n\n.cn-flow-detail__node-run--hold {\n\tcolor: var(--color-warning-text, var(--color-warning));\n}\n\n/* ---- Run state on the edges ------------------------------------------- */\n\n/* Lines already walked keep a quiet success stroke for the rest of the run. */\n.cn-flow-detail :deep(.vue-flow__edge.cn-flow-detail__edge--run-traced .vue-flow__edge-path),\n.cn-flow-detail :deep(.vue-flow__edge.cn-flow-detail__edge--run-tracing .vue-flow__edge-path) {\n\tstroke: var(--color-success);\n}\n\n/* The trace: a success-coloured segment travelling the edge just executed,\n source to target — `stroke-dashoffset` walking a dash pattern forwards,\n FlowMock's own technique.\n\n IT RIDES THE EDGE'S SECOND PATH, NOT THE LINE ITSELF. CnFlowEdge already\n draws a direction pulse as a separate path over the same routed geometry,\n exactly so the base line can stay solid (a dashed connection means\n something else in every diagram convention). The trace re-styles that path\n for the hop being animated: denser, thicker, faster, and in the success\n colour. When the hop ends the class leaves and the pulse returns to its\n quiet self. */\n.cn-flow-detail :deep(.vue-flow__edge.cn-flow-detail__edge--run-tracing .cn-flow-edge__pulse) {\n\tstroke: var(--color-success);\n\tstroke-width: 4;\n\tstroke-dasharray: 10 22;\n\tanimation: cn-flow-detail-run-trace 0.4s linear infinite;\n}\n\n@keyframes cn-flow-detail-run-trace {\n\tfrom {\n\t\tstroke-dashoffset: 32;\n\t}\n\n\tto {\n\t\tstroke-dashoffset: 0;\n\t}\n}\n\n/* Reduced motion: the state COLOURS stay — done, failed, hold and current\n remain distinguishable — but nothing travels and nothing pulses. The\n sequential hop reveal is also skipped on the JS side (`runHopMs`), and\n CnFlowEdge already hides its pulse path entirely under this query, which\n takes the trace's travel with it. */\n@media (prefers-reduced-motion: reduce) {\n\t.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--run-active)),\n\t.cn-flow-detail :deep(.cn-flow-node:has(.cn-flow-detail__node--run-hold)) {\n\t\tanimation: none;\n\t}\n}\n</style>\n"],"names":["_createElementVNode","_createElementBlock","_Fragment","_createVNode","_createTextVNode","_createBlock","_openBlock","_toDisplayString","_resolveDynamicComponent","_withCtx"],"mappings":"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"}
@@ -123,6 +123,19 @@ var script = {
123
123
  * edges, its undo stack, its persistence — so nothing is removed here.
124
124
  */
125
125
  'remove',
126
+
127
+ /**
128
+ * @event activate Enter or Space on the focused node: open this step.
129
+ * Carries the node id. The pointer equivalent is a double-click.
130
+ */
131
+ 'activate',
132
+
133
+ /**
134
+ * @event menu Shift+F10 or the menu key on the focused node: show this
135
+ * step's actions. Carries `{ id, clientX, clientY }`, a point at the
136
+ * node, so a host positions the menu as it would for a click.
137
+ */
138
+ 'menu',
126
139
  ],
127
140
 
128
141
  setup() {
@@ -377,7 +390,8 @@ var script = {
377
390
  * The keyboard contract.
378
391
  *
379
392
  * Arrow keys move; `c` arms an exit and, on a second node, completes the
380
- * connection; `Escape` cancels. Repeated `c` on the SAME node steps
393
+ * connection; `Escape` cancels. Enter or Space opens the step, Shift+F10
394
+ * or the menu key opens its actions. Repeated `c` on the SAME node steps
381
395
  * through its exits rather than re-arming the first — the behaviour a
382
396
  * mouse gets for free by pointing.
383
397
  *
@@ -426,6 +440,27 @@ var script = {
426
440
  return
427
441
  }
428
442
 
443
+ // Opening the step, and its menu, from the keyboard. Only for a key
444
+ // pressed ON the node: a port inside it is a control of its own.
445
+ if (event.target === event.currentTarget) {
446
+ if (event.key === 'Enter' || event.key === ' ') {
447
+ event.preventDefault();
448
+ this.$emit('activate', this.id);
449
+ return
450
+ }
451
+
452
+ if ((event.key === 'F10' && event.shiftKey === true) || event.key === 'ContextMenu') {
453
+ event.preventDefault();
454
+ const rect = event.currentTarget.getBoundingClientRect();
455
+ this.$emit('menu', {
456
+ id: this.id,
457
+ clientX: rect.left + rect.width / 2,
458
+ clientY: rect.bottom,
459
+ });
460
+ return
461
+ }
462
+ }
463
+
429
464
  // Delete and Backspace both, because which one removes a thing is a
430
465
  // platform habit rather than a preference: Backspace is the delete
431
466
  // key on a Mac keyboard, and a user who learned one does not think