@revenexx/studio-integrations 0.5.0 → 0.6.0

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 (129) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/README.md +17 -4
  3. package/dist/module.json +1 -1
  4. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +15 -14
  5. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +91 -19
  6. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +58 -21
  7. package/dist/runtime/components/Integrations/CredentialTypePicker.vue +4 -8
  8. package/dist/runtime/components/Integrations/EventTypeCombobox.d.vue.ts +1 -0
  9. package/dist/runtime/components/Integrations/EventTypeCombobox.vue +4 -2
  10. package/dist/runtime/components/Integrations/EventTypeCombobox.vue.d.ts +1 -0
  11. package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +4 -0
  12. package/dist/runtime/components/Integrations/ExpressionInput.vue +54 -25
  13. package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +4 -0
  14. package/dist/runtime/components/Integrations/GroupFrameView.vue +12 -12
  15. package/dist/runtime/components/Integrations/Layout.vue +21 -2
  16. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +10 -11
  17. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +2 -5
  18. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +2 -0
  19. package/dist/runtime/components/Integrations/NodeConfigField.vue +160 -67
  20. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +2 -0
  21. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +6 -0
  22. package/dist/runtime/components/Integrations/NodeInspector.vue +1075 -303
  23. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +6 -0
  24. package/dist/runtime/components/Integrations/NodeLogo.d.vue.ts +12 -0
  25. package/dist/runtime/components/Integrations/NodeLogo.vue +2 -30
  26. package/dist/runtime/components/Integrations/NodeLogo.vue.d.ts +12 -0
  27. package/dist/runtime/components/Integrations/NodeMark.d.vue.ts +15 -0
  28. package/dist/runtime/components/Integrations/NodeMark.vue +79 -0
  29. package/dist/runtime/components/Integrations/NodeMark.vue.d.ts +15 -0
  30. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +21 -0
  31. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +42 -0
  32. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +21 -0
  33. package/dist/runtime/components/Integrations/NodePalette.vue +100 -34
  34. package/dist/runtime/components/Integrations/QueryBuilder.vue +8 -1
  35. package/dist/runtime/components/Integrations/RunActionsMenu.vue +3 -1
  36. package/dist/runtime/components/Integrations/RunPayloadInput.vue +17 -4
  37. package/dist/runtime/components/Integrations/StickyNoteView.vue +2 -1
  38. package/dist/runtime/components/Integrations/TemplateCard.d.vue.ts +18 -0
  39. package/dist/runtime/components/Integrations/TemplateCard.vue +109 -0
  40. package/dist/runtime/components/Integrations/TemplateCard.vue.d.ts +18 -0
  41. package/dist/runtime/components/Integrations/TemplateFlowPreview.d.vue.ts +2 -2
  42. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +7 -10
  43. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue.d.ts +2 -2
  44. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +21 -37
  45. package/dist/runtime/components/Integrations/WorkflowEdgeView.d.vue.ts +18 -4
  46. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +23 -7
  47. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue.d.ts +18 -4
  48. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +22 -1
  49. package/dist/runtime/components/Integrations/WorkflowEditor.vue +170 -48
  50. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +22 -1
  51. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +4 -20
  52. package/dist/runtime/components/Integrations/WorkflowIconPicker.d.vue.ts +19 -1
  53. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +11 -2
  54. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue.d.ts +19 -1
  55. package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +4 -0
  56. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +189 -46
  57. package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +4 -0
  58. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +4 -20
  59. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +15 -9
  60. package/dist/runtime/components/Integrations/WorkflowTagsInput.d.vue.ts +1 -0
  61. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +7 -2
  62. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue.d.ts +1 -0
  63. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +4 -2
  64. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +30 -17
  65. package/dist/runtime/components/Integrations/WorkflowsTable.vue +47 -7
  66. package/dist/runtime/composables/useCredentialTypes.js +4 -4
  67. package/dist/runtime/composables/useIntegrationsAudit.js +4 -3
  68. package/dist/runtime/composables/useIntegrationsCredentials.js +4 -4
  69. package/dist/runtime/composables/useIntegrationsDeadLetters.js +7 -6
  70. package/dist/runtime/composables/useIntegrationsNodes.js +4 -4
  71. package/dist/runtime/composables/useIntegrationsRuns.js +11 -6
  72. package/dist/runtime/composables/useIntegrationsSchedules.d.ts +4 -0
  73. package/dist/runtime/composables/useIntegrationsSchedules.js +10 -3
  74. package/dist/runtime/composables/useIntegrationsSecrets.js +10 -7
  75. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +1 -1
  76. package/dist/runtime/composables/useIntegrationsTemplates.js +12 -8
  77. package/dist/runtime/composables/useIntegrationsTriggers.js +6 -6
  78. package/dist/runtime/composables/useIntegrationsWorkflows.js +8 -4
  79. package/dist/runtime/composables/useLoadGate.d.ts +17 -0
  80. package/dist/runtime/composables/useLoadGate.js +8 -0
  81. package/dist/runtime/data/branchReorder.d.ts +37 -0
  82. package/dist/runtime/data/branchReorder.js +71 -0
  83. package/dist/runtime/data/handleId.d.ts +23 -0
  84. package/dist/runtime/data/handleId.js +6 -0
  85. package/dist/runtime/data/integrationTemplates.d.ts +39 -0
  86. package/dist/runtime/data/integrationTemplates.js +15 -0
  87. package/dist/runtime/data/integrationWorkflowSamples.js +1 -1
  88. package/dist/runtime/data/nodeIcons.d.ts +2 -0
  89. package/dist/runtime/data/nodeIcons.js +73 -0
  90. package/dist/runtime/data/nodeInputData.d.ts +16 -4
  91. package/dist/runtime/data/nodeInputData.js +4 -0
  92. package/dist/runtime/data/payloadForm.d.ts +9 -2
  93. package/dist/runtime/data/payloadForm.js +27 -3
  94. package/dist/runtime/data/portTone.d.ts +17 -0
  95. package/dist/runtime/data/portTone.js +7 -0
  96. package/dist/runtime/data/webhookAuth.d.ts +18 -0
  97. package/dist/runtime/data/webhookAuth.js +19 -0
  98. package/dist/runtime/data/workflowIconCatalog.js +2 -5
  99. package/dist/runtime/pages/integrations/audit.vue +91 -6
  100. package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +6 -4
  101. package/dist/runtime/pages/integrations/credentials.vue +119 -21
  102. package/dist/runtime/pages/integrations/index.vue +49 -44
  103. package/dist/runtime/pages/integrations/runs.vue +1 -1
  104. package/dist/runtime/pages/integrations/schedules.vue +51 -7
  105. package/dist/runtime/pages/integrations/secrets.vue +29 -7
  106. package/dist/runtime/pages/integrations/templates/[slug].vue +52 -30
  107. package/dist/runtime/pages/integrations/templates/index.vue +57 -86
  108. package/dist/runtime/pages/integrations/workflows/[id].vue +243 -25
  109. package/dist/runtime/pages/integrations/workflows/index.vue +15 -2
  110. package/dist/runtime/types/integrations.d.ts +65 -6
  111. package/dist/runtime/utils/apiError.d.ts +47 -0
  112. package/dist/runtime/utils/apiError.js +32 -0
  113. package/dist/runtime/utils/cockpitSources.d.ts +153 -0
  114. package/dist/runtime/utils/cockpitSources.js +14 -0
  115. package/dist/runtime/utils/loadGate.d.ts +61 -0
  116. package/dist/runtime/utils/loadGate.js +22 -0
  117. package/dist/runtime/utils/localize.d.ts +29 -0
  118. package/dist/runtime/utils/localize.js +4 -0
  119. package/dist/runtime/utils/notify.d.ts +59 -0
  120. package/dist/runtime/utils/notify.js +21 -0
  121. package/dist/runtime/utils/revenexxCredentialSource.d.ts +26 -0
  122. package/dist/runtime/utils/revenexxCredentialSource.js +8 -0
  123. package/dist/runtime/utils/schedule.d.ts +141 -0
  124. package/dist/runtime/utils/schedule.js +207 -0
  125. package/dist/runtime/utils/validationFindings.d.ts +101 -0
  126. package/dist/runtime/utils/validationFindings.js +53 -0
  127. package/dist/runtime/utils/whenIdle.d.ts +42 -0
  128. package/dist/runtime/utils/whenIdle.js +12 -0
  129. package/package.json +8 -2
@@ -1,19 +1,30 @@
1
1
  <script setup>
2
2
  import { computed, nextTick, onMounted, provide, ref, watch } from "vue";
3
- import { TrashBinMinimalistic as Trash2, Copy } from "#solar-icons";
4
- import { ChevronLeft, ChevronRight, Clock, Play, Search, Webhook, X, Zap } from "lucide-vue-next";
3
+ import { AddCircle as Plus, TrashBinMinimalistic as Trash2, Copy } from "#solar-icons";
4
+ import { ChevronDown, ChevronLeft, ChevronRight, Clock, Database, MessageSquareText, Play, Search, Webhook, X, Zap } from "lucide-vue-next";
5
5
  import { toast } from "vue-sonner";
6
6
  import {
7
7
  Button,
8
+ Dialog,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogHeader,
12
+ DialogTitle,
8
13
  Input,
9
14
  Label,
15
+ Popover,
16
+ PopoverContent,
17
+ PopoverTrigger,
10
18
  Select,
11
19
  SelectContent,
12
20
  SelectItem,
21
+ SelectSeparator,
13
22
  SelectTrigger,
14
23
  SelectValue,
15
24
  Switch,
16
- Textarea
25
+ Textarea,
26
+ ToggleGroup,
27
+ ToggleGroupItem
17
28
  } from "@revenexx/studio";
18
29
  import EventTypeCombobox from "./EventTypeCombobox.vue";
19
30
  import { NODE_CATEGORIES, findManifest } from "../../data/integrationNodeCatalog";
@@ -24,12 +35,16 @@ import {
24
35
  triggerPayloadExpr,
25
36
  variableExpr
26
37
  } from "../../data/nodeInputData";
38
+ import { portDotClass } from "../../data/portTone";
39
+ import { authChoiceFromConfig, configPatchForAuthChoice } from "../../data/webhookAuth";
27
40
  import {
28
41
  dataTypeOf,
29
42
  portValuesFromStepOutput,
30
43
  previewValue,
31
44
  valueAtPath
32
45
  } from "../../data/nodeRunData";
46
+ import { resolveDisplayName, resolveLocalized } from "../../utils/localize";
47
+ import { describeRecurrence, parseCron, scheduleTimezone, toCron } from "../../utils/schedule";
33
48
  const props = defineProps({
34
49
  selection: { type: [Object, null], required: true },
35
50
  secretKeys: { type: Array, required: true },
@@ -40,7 +55,8 @@ const props = defineProps({
40
55
  edges: { type: Array, required: false },
41
56
  triggers: { type: Array, required: false },
42
57
  dynamicOutputs: { type: Object, required: false },
43
- workflowId: { type: [Number, String], required: false }
58
+ workflowId: { type: [Number, String], required: false },
59
+ refusedFields: { type: Object, required: false }
44
60
  });
45
61
  const emit = defineEmits(["update", "close", "remove", "navigate"]);
46
62
  function clone(v) {
@@ -70,15 +86,11 @@ const manifest = computed(() => {
70
86
  const wn = draftNode.value;
71
87
  return wn ? findManifest(wn.nodeSlug, wn.nodeVersion) : null;
72
88
  });
73
- function labelText(v) {
74
- if (!v) return "";
75
- return typeof v === "string" ? v : v.en ?? Object.values(v)[0] ?? "";
76
- }
77
89
  const TRIGGER_GLYPHS = { manual: Play, schedule: Clock, webhook: Webhook, event: Zap };
78
90
  const TRIGGER_LABELS = { manual: "Manual", schedule: "Schedule", webhook: "Webhook", event: "Event" };
79
91
  const triggerGlyph = computed(() => TRIGGER_GLYPHS[draftTrigger.value?.type ?? "manual"]);
80
92
  const namePlaceholder = computed(() => {
81
- if (draftNode.value) return labelText(manifest.value?.name) || draftNode.value.nodeSlug;
93
+ if (draftNode.value) return resolveLocalized(manifest.value?.name) || draftNode.value.nodeSlug;
82
94
  return TRIGGER_LABELS[draftTrigger.value?.type ?? "manual"] + " trigger";
83
95
  });
84
96
  const subtitleText = computed(() => {
@@ -86,20 +98,49 @@ const subtitleText = computed(() => {
86
98
  if (wn) return wn.nodeSlug + "@" + wn.nodeVersion;
87
99
  return TRIGGER_LABELS[draftTrigger.value?.type ?? "manual"] + " trigger";
88
100
  });
101
+ const manifestDescription = computed(() => resolveLocalized(manifest.value?.description));
89
102
  const groupLabel = computed(() => {
90
103
  if (!isNode.value) return "Trigger";
91
104
  const groups = manifest.value?.groups;
92
- if (groups?.length) return labelText(groups[groups.length - 1]);
105
+ if (groups?.length) return resolveLocalized(groups[groups.length - 1]);
93
106
  return NODE_CATEGORIES.find((c) => c.key === manifest.value?.category)?.label ?? "Node";
94
107
  });
108
+ const nameValue = computed(() => {
109
+ const name = draft.value.name;
110
+ return resolveDisplayName(name);
111
+ });
95
112
  function writeName(name) {
96
113
  draft.value = { ...draft.value, name };
97
114
  }
115
+ const noteOpen = ref(false);
116
+ const noteText = computed(() => (draftNode.value?.notes ?? "").trim());
117
+ const noteLabel = computed(() => noteText.value ? "Note" : "Add note");
118
+ function writeNote(v) {
119
+ const text = String(v ?? "");
120
+ patchNode({ notes: text.trim() ? text : void 0 });
121
+ }
98
122
  const tab = ref("parameters");
99
123
  const showSettingsTab = computed(() => isNode.value);
124
+ const inspectorTabs = computed(() => [
125
+ { key: "parameters", label: "Parameters" },
126
+ ...showSettingsTab.value ? [{ key: "settings", label: "Settings" }, { key: "output", label: "Output" }] : []
127
+ ]);
128
+ watch(showSettingsTab, (hasThem) => {
129
+ if (!hasThem) tab.value = "parameters";
130
+ });
100
131
  const SETTINGS_FIELD_KEY = /(^|_)(timeout|retry|retries|backoff)|(timeout|retry|retries|backoff)(ms|sec|seconds|attempts)?$/i;
101
132
  const parameterFields = computed(() => (manifest.value?.config ?? []).filter((f) => !SETTINGS_FIELD_KEY.test(f.key)));
102
133
  const settingsFields = computed(() => (manifest.value?.config ?? []).filter((f) => SETTINGS_FIELD_KEY.test(f.key)));
134
+ const requiredFields = computed(() => parameterFields.value.filter((f) => f.required));
135
+ const optionalFields = computed(() => parameterFields.value.filter((f) => !f.required));
136
+ function optionalShouldOpen() {
137
+ if (!requiredFields.value.length) return true;
138
+ return optionalFields.value.some((f) => props.refusedFields?.[f.key] !== void 0);
139
+ }
140
+ const optionalOpen = ref(optionalShouldOpen());
141
+ watch([() => props.selection?.id, manifest], () => {
142
+ optionalOpen.value = optionalShouldOpen();
143
+ });
103
144
  function dependentKeys(changedKey) {
104
145
  const fields = manifest.value?.config ?? [];
105
146
  const out = /* @__PURE__ */ new Set();
@@ -139,12 +180,6 @@ function patchTrigger(patch) {
139
180
  if (!draftTrigger.value) return;
140
181
  draft.value = { ...draftTrigger.value, ...patch };
141
182
  }
142
- const requiredMissingCount = computed(() => {
143
- if (!draftNode.value || !manifest.value?.config) return 0;
144
- const cfg = draftNode.value.config ?? {};
145
- const empty = (v) => v === void 0 || v === null || v === "" || Array.isArray(v) && v.length === 0;
146
- return manifest.value.config.filter((f) => f.required && empty(cfg[f.key])).length;
147
- });
148
183
  async function copyConfig() {
149
184
  if (!draftNode.value) return;
150
185
  try {
@@ -185,6 +220,263 @@ function writeTriggerConfig(key, value) {
185
220
  if (!wt) return;
186
221
  patchTrigger({ config: { ...wt.config, [key]: value } });
187
222
  }
223
+ const MINUTE_INTERVALS = [1, 2, 5, 10, 15, 20, 30];
224
+ const HOUR_INTERVALS = [2, 3, 4, 6, 8, 12];
225
+ const MINUTES_PAST = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55];
226
+ const DAY_OPTIONS = [
227
+ { value: 1, short: "Mo", name: "Mondays" },
228
+ { value: 2, short: "Tu", name: "Tuesdays" },
229
+ { value: 3, short: "We", name: "Wednesdays" },
230
+ { value: 4, short: "Th", name: "Thursdays" },
231
+ { value: 5, short: "Fr", name: "Fridays" },
232
+ { value: 6, short: "Sa", name: "Saturdays" },
233
+ { value: 0, short: "Su", name: "Sundays" }
234
+ ];
235
+ const FREQUENCY_OPTIONS = [
236
+ { value: "minutes", label: "Every few minutes" },
237
+ { value: "hourly", label: "Every hour" },
238
+ { value: "hours", label: "Every few hours" },
239
+ { value: "times", label: "Several times a day" },
240
+ { value: "daily", label: "Every day" },
241
+ { value: "weekly", label: "Every week" },
242
+ { value: "monthly", label: "Every month" }
243
+ ];
244
+ const HOURS_OF_DAY = Array.from({ length: 24 }, (_, h) => h);
245
+ const FREQUENCY_ADVANCED = "advanced";
246
+ const cronValue = computed(() => readTriggerConfig("cron") ?? "");
247
+ const hasCron = computed(() => cronValue.value.trim() !== "");
248
+ const recurrence = computed(() => parseCron(cronValue.value));
249
+ const advancedOpen = ref(false);
250
+ watch(() => draftTrigger.value?.id, () => {
251
+ advancedOpen.value = false;
252
+ });
253
+ const frequency = computed(() => advancedOpen.value ? FREQUENCY_ADVANCED : recurrence.value?.kind ?? FREQUENCY_ADVANCED);
254
+ const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
255
+ const carry = ref({
256
+ hour: 8,
257
+ minute: 0,
258
+ days: [1],
259
+ day: 1,
260
+ minuteInterval: 15,
261
+ hourInterval: 4,
262
+ // "Several times a day" carries its own two sets: its days start at every day,
263
+ // where a weekly schedule's start at one, and its hours start empty so the
264
+ // first pair is built from the time already on screen rather than from a
265
+ // working hour nobody chose.
266
+ hours: [],
267
+ timeDays: ALL_DAYS
268
+ });
269
+ watch(recurrence, (r) => {
270
+ if (!r) return;
271
+ if ("hour" in r) carry.value.hour = r.hour;
272
+ if ("minute" in r) carry.value.minute = r.minute;
273
+ if (r.kind === "weekly") carry.value.days = r.days;
274
+ if (r.kind === "monthly") carry.value.day = r.day;
275
+ if (r.kind === "minutes") carry.value.minuteInterval = r.interval;
276
+ if (r.kind === "hours") carry.value.hourInterval = r.interval;
277
+ if (r.kind === "times") {
278
+ carry.value.hours = r.hours;
279
+ carry.value.timeDays = r.days ?? ALL_DAYS;
280
+ }
281
+ }, { immediate: true });
282
+ function recurrenceFor(kind) {
283
+ const c = carry.value;
284
+ switch (kind) {
285
+ case "minutes":
286
+ return { kind: "minutes", interval: c.minuteInterval };
287
+ case "hourly":
288
+ return { kind: "hourly", minute: c.minute };
289
+ case "hours":
290
+ return { kind: "hours", interval: c.hourInterval, minute: c.minute };
291
+ case "daily":
292
+ return { kind: "daily", hour: c.hour, minute: c.minute };
293
+ case "times":
294
+ return {
295
+ kind: "times",
296
+ minute: c.minute,
297
+ // Two is the smallest set that is "several"; one hour is the daily rhythm.
298
+ // With none carried yet, the rhythm starts from the hour that was on screen
299
+ // and the one after it — a schedule set for 02:00 becomes 02:00 and 03:00
300
+ // rather than losing the time the author had already chosen.
301
+ hours: c.hours.length >= 2 ? c.hours : [c.hour, (c.hour + 1) % 24].sort((a, b) => a - b),
302
+ days: c.timeDays.length ? c.timeDays : null
303
+ };
304
+ case "weekly":
305
+ return { kind: "weekly", days: c.days.length ? c.days : [1], hour: c.hour, minute: c.minute };
306
+ case "monthly":
307
+ return { kind: "monthly", day: c.day, hour: c.hour, minute: c.minute };
308
+ }
309
+ }
310
+ function writeRecurrence(kind) {
311
+ writeTriggerConfig("cron", toCron(recurrenceFor(kind)));
312
+ }
313
+ function applyFrequency(v) {
314
+ const next = v;
315
+ advancedOpen.value = next === FREQUENCY_ADVANCED;
316
+ if (next !== FREQUENCY_ADVANCED) writeRecurrence(next);
317
+ }
318
+ function applyToCurrent() {
319
+ const f = frequency.value;
320
+ if (f !== FREQUENCY_ADVANCED) writeRecurrence(f);
321
+ }
322
+ function pad2(n) {
323
+ return String(n).padStart(2, "0");
324
+ }
325
+ function withCurrent(options, current) {
326
+ return options.includes(current) ? options : [...options, current].sort((a, b) => a - b);
327
+ }
328
+ const minuteIntervalOptions = computed(() => withCurrent(MINUTE_INTERVALS, carry.value.minuteInterval));
329
+ const minutePastOptions = computed(() => withCurrent(MINUTES_PAST, carry.value.minute));
330
+ const timeValue = computed(() => `${pad2(carry.value.hour)}:${pad2(carry.value.minute)}`);
331
+ function applyTime(v) {
332
+ const m = /^(\d{1,2}):(\d{2})/.exec(String(v ?? ""));
333
+ if (!m) return;
334
+ carry.value.hour = Math.min(23, Number(m[1]));
335
+ carry.value.minute = Math.min(59, Number(m[2]));
336
+ applyToCurrent();
337
+ }
338
+ const selectedDays = computed(() => (frequency.value === "times" ? carry.value.timeDays : carry.value.days).map(String));
339
+ function applyDays(v) {
340
+ const picked = (Array.isArray(v) ? v : []).map(Number).filter((n) => Number.isInteger(n) && n >= 0 && n <= 6);
341
+ const forTimes = frequency.value === "times";
342
+ const previous = forTimes ? carry.value.timeDays : carry.value.days;
343
+ const days = [...new Set(picked.length ? picked : previous)].sort((a, b) => a - b);
344
+ if (forTimes) {
345
+ carry.value.timeDays = days;
346
+ writeRecurrence("times");
347
+ return;
348
+ }
349
+ carry.value.days = days;
350
+ writeRecurrence("weekly");
351
+ }
352
+ const selectedHours = computed(() => carry.value.hours.map(String));
353
+ function applyHours(v) {
354
+ const picked = (Array.isArray(v) ? v : []).map(Number).filter((n) => Number.isInteger(n) && n >= 0 && n <= 23);
355
+ const hours = [...new Set(picked)].sort((a, b) => a - b);
356
+ carry.value.hours = hours.length >= 2 ? hours : carry.value.hours;
357
+ writeRecurrence("times");
358
+ }
359
+ function applyMinuteInterval(v) {
360
+ carry.value.minuteInterval = Number(v);
361
+ writeRecurrence("minutes");
362
+ }
363
+ function applyHourInterval(v) {
364
+ carry.value.hourInterval = Number(v);
365
+ writeRecurrence("hours");
366
+ }
367
+ function applyMinutePast(v) {
368
+ carry.value.minute = Number(v);
369
+ applyToCurrent();
370
+ }
371
+ function applyDayOfMonth(v) {
372
+ carry.value.day = Number(v);
373
+ writeRecurrence("monthly");
374
+ }
375
+ const scheduleSentence = computed(() => {
376
+ const r = recurrence.value;
377
+ if (!r) return null;
378
+ return `${describeRecurrence(r)} \xB7 ${scheduleTimezone(readTriggerConfig("timezone"))}`;
379
+ });
380
+ const webhookUrl = computed(() => {
381
+ const wt = draftTrigger.value;
382
+ if (!wt || wt.type !== "webhook") return null;
383
+ const inbound = wt.config.inbound;
384
+ if (inbound?.url) return inbound.url;
385
+ const base = String(useRuntimeConfig().public.integrationsApi ?? "").replace(/\/+$/, "");
386
+ return base ? `${base}/webhooks/${wt.id}` : null;
387
+ });
388
+ const webhookIsPublic = computed(() => !!draftTrigger.value?.config?.public);
389
+ const authChoice = computed(() => authChoiceFromConfig(draftTrigger.value?.config));
390
+ function writeTriggerConfigPatch(patch) {
391
+ const wt = draftTrigger.value;
392
+ if (!wt) return;
393
+ patchTrigger({ config: { ...wt.config, ...patch } });
394
+ }
395
+ function applyAuthChoice(v) {
396
+ writeTriggerConfigPatch(configPatchForAuthChoice(v));
397
+ }
398
+ function writeAuthConfigKey(key, value) {
399
+ const cfg = draftTrigger.value?.config;
400
+ writeTriggerConfigPatch({ authConfig: { ...cfg?.authConfig, [key]: value || void 0 } });
401
+ }
402
+ const webhookExposed = computed(() => !!draftTrigger.value?.config?.inbound?.url);
403
+ const rotateBusy = ref(false);
404
+ const rotatedSecret = ref(null);
405
+ const rotateOpen = ref(false);
406
+ async function rotateEndpointSecret() {
407
+ const wt = draftTrigger.value;
408
+ if (!wt || !props.workflowId) return;
409
+ rotateBusy.value = true;
410
+ try {
411
+ const api2 = useIntegrationsApi();
412
+ const res = await api2.post(`/workflows/${props.workflowId}/triggers/${wt.id}/rotate-secret`, {});
413
+ const body = res ?? {};
414
+ const secret = body.inbound_secret ?? body.data?.inbound_secret ?? null;
415
+ if (secret) {
416
+ rotatedSecret.value = secret;
417
+ rotateOpen.value = true;
418
+ } else {
419
+ toast.error("The endpoint did not return a secret.");
420
+ }
421
+ } catch (e) {
422
+ toast.error(e.message || "Could not rotate the secret.");
423
+ } finally {
424
+ rotateBusy.value = false;
425
+ }
426
+ }
427
+ async function copySecret() {
428
+ if (!rotatedSecret.value) return;
429
+ try {
430
+ await navigator.clipboard.writeText(rotatedSecret.value);
431
+ toast.success("Secret copied");
432
+ } catch {
433
+ toast.error("Could not copy the secret");
434
+ }
435
+ }
436
+ async function copyWebhookUrl() {
437
+ if (!webhookUrl.value) return;
438
+ try {
439
+ await navigator.clipboard.writeText(webhookUrl.value);
440
+ toast.success("Webhook URL copied");
441
+ } catch {
442
+ toast.error("Could not copy the URL");
443
+ }
444
+ }
445
+ const definedEvents = typeof useDefinedEvents === "function" ? useDefinedEvents() : { emits: ref([]), load: async () => {
446
+ } };
447
+ const eventTopic = computed(() => {
448
+ const t = readTriggerConfig("event_type");
449
+ return typeof t === "string" && t.trim() !== "" ? t.trim() : null;
450
+ });
451
+ watch(eventTopic, (topic) => {
452
+ if (topic) void definedEvents.load();
453
+ }, { immediate: true });
454
+ const eventSample = computed(() => {
455
+ if (!eventTopic.value) return null;
456
+ const sample = definedEvents.emits.value.find((e) => e.topic === eventTopic.value)?.sample;
457
+ return sample ? JSON.stringify(sample, null, 2) : null;
458
+ });
459
+ const manualFields = computed(() => readTriggerConfig("payloadFields") ?? []);
460
+ function writeManualFields(next) {
461
+ writeTriggerConfig("payloadFields", next.length ? next : void 0);
462
+ }
463
+ function setManualField(idx, patch) {
464
+ writeManualFields(manualFields.value.map((f, i) => i === idx ? { ...f, ...patch } : f));
465
+ }
466
+ function addManualField() {
467
+ writeManualFields([...manualFields.value, { path: "", label: "", kind: "text" }]);
468
+ }
469
+ function removeManualField(idx) {
470
+ manualFieldUids.value.splice(idx, 1);
471
+ writeManualFields(manualFields.value.filter((_, i) => i !== idx));
472
+ }
473
+ let nextManualFieldUid = 0;
474
+ const manualFieldUids = ref([]);
475
+ watch(() => manualFields.value.length, (n) => {
476
+ const uids = manualFieldUids.value;
477
+ while (uids.length < n) uids.push(nextManualFieldUid++);
478
+ if (uids.length > n) uids.splice(n);
479
+ }, { immediate: true });
188
480
  const { loadForWorkflow, getSteps } = useIntegrationsRuns();
189
481
  const runData = ref(null);
190
482
  const runInfo = ref(null);
@@ -275,7 +567,7 @@ const sampleContext = computed(() => {
275
567
  });
276
568
  provide("integrationsSampleContext", sampleContext);
277
569
  provide("integrationsNodeNames", computed(
278
- () => Object.fromEntries((props.nodes ?? []).map((n) => [n.id, labelText(n.name) || n.nodeSlug]))
570
+ () => Object.fromEntries((props.nodes ?? []).map((n) => [n.id, resolveDisplayName(n.name) || n.nodeSlug]))
279
571
  ));
280
572
  const upstreamSections = computed(() => {
281
573
  if (!isNode.value) return [];
@@ -283,7 +575,7 @@ const upstreamSections = computed(() => {
283
575
  const m = findManifest(node.nodeSlug, node.nodeVersion);
284
576
  return {
285
577
  id: node.id,
286
- name: labelText(node.name) || node.nodeSlug,
578
+ name: resolveDisplayName(node.name) || node.nodeSlug,
287
579
  manifest: m,
288
580
  direct,
289
581
  ports: withSampledFields(portsForNode(node, m, props.dynamicOutputs?.[node.id]), sampleFor(node.id))
@@ -291,21 +583,16 @@ const upstreamSections = computed(() => {
291
583
  });
292
584
  });
293
585
  const variableNames = computed(() => Object.keys(props.variables ?? {}));
294
- function portDotClass(kind, name) {
295
- if (name === "true") return "bg-emerald-500";
296
- if (name === "false") return "bg-amber-400";
297
- if (kind === "error") return "bg-rose-500";
298
- if (kind === "branch") return "bg-violet-500";
299
- return "bg-slate-400";
300
- }
301
586
  const lastField = ref(null);
302
587
  const lastFocusKind = ref(null);
588
+ const inputPaneOpen = ref(false);
303
589
  const exprInsertHandler = ref(null);
304
590
  provide("integrationsExpressionInsert", exprInsertHandler);
305
591
  function onCenterFocusIn(e) {
306
592
  const t = e.target;
307
593
  if (t instanceof HTMLElement && t.closest(".expr-editor")) {
308
594
  lastFocusKind.value = "editor";
595
+ inputPaneOpen.value = true;
309
596
  return;
310
597
  }
311
598
  if (t instanceof HTMLTextAreaElement) {
@@ -339,6 +626,7 @@ function insertExpression(expression) {
339
626
  toast("Expression copied. Click into a field first to insert it directly.");
340
627
  }
341
628
  const chipClass = "max-w-full cursor-grab truncate rounded-md border border-black/[0.06] bg-slate-50 px-1.5 py-0.5 font-mono text-[11px] text-slate-700 transition hover:border-black/[0.12] hover:bg-slate-100 active:cursor-grabbing";
629
+ const rowClass = "flex w-full min-w-0 cursor-grab items-center gap-1.5 rounded-md px-1.5 py-[3px] text-left font-mono text-[11px] text-slate-700 transition hover:bg-slate-100 active:cursor-grabbing";
342
630
  function onChipDragStart(e, expression) {
343
631
  e.dataTransfer?.setData("text/plain", expression);
344
632
  if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy";
@@ -408,6 +696,14 @@ const visibleVariableNames = computed(() => {
408
696
  const q = searchQuery.value;
409
697
  return q ? variableNames.value.filter((n) => n.toLowerCase().includes(q)) : variableNames.value;
410
698
  });
699
+ const variableRows = computed(() => visibleVariableNames.value.map((name) => {
700
+ const def = props.variables?.[name]?.default;
701
+ return {
702
+ name,
703
+ glyph: typeGlyph(dataTypeOf(def)),
704
+ value: def === void 0 || def === "" ? null : previewValue(def)
705
+ };
706
+ }));
411
707
  const searchEmpty = computed(
412
708
  () => !!searchQuery.value && !visibleSections.value.length && !showTriggerChip.value && !visibleVariableNames.value.length
413
709
  );
@@ -563,30 +859,129 @@ async function runTestStep() {
563
859
  width/height, border and the close control. -->
564
860
  <div class="flex min-h-0 flex-1 flex-col bg-white">
565
861
  <!-- Header -->
566
- <div class="flex shrink-0 items-center gap-3 border-b border-black/[0.06] px-4 py-3 pr-14">
862
+ <div class="flex shrink-0 items-center gap-3 border-b border-black/[0.06] px-4 py-3">
567
863
  <div class="flex size-9 shrink-0 items-center justify-center rounded-md border border-black/[0.06] bg-slate-50 text-slate-600">
568
- <IntegrationsNodeLogo v-if="isNode" :manifest="manifest" :size="20" />
864
+ <IntegrationsNodeMark v-if="isNode" :manifest="manifest" :size="20" />
569
865
  <component :is="triggerGlyph" v-else :size="18" />
570
866
  </div>
571
867
  <div class="min-w-0 flex-1">
868
+ <!-- Named explicitly: the placeholder is the node's slug, which left
869
+ this field accessibly called `revenexx:sftp-list` — a name for the
870
+ type, not for the field. -->
572
871
  <input
573
872
  ref="nameInput"
574
- :value="labelText(draft.name)"
873
+ aria-label="Node name"
874
+ :value="nameValue"
575
875
  :placeholder="namePlaceholder"
576
- class="w-full truncate bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground/60"
876
+ class="w-full truncate rounded-sm bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground/60 focus:ring-2 focus:ring-primary/40"
577
877
  @input="writeName($event.target.value)"
578
878
  >
579
- <div class="truncate font-mono text-[11px] text-muted-foreground">
580
- {{ subtitleText }}
879
+ <div class="flex items-center gap-1.5 text-xs text-muted-foreground">
880
+ <span class="truncate">{{ subtitleText }}</span>
881
+ <!-- What the node TYPE is for, one click behind the line that names
882
+ the type. Not printed into the header: it is read once, when the
883
+ node is new to someone, and a manifest that writes three
884
+ paragraphs would otherwise push the form off the screen every
885
+ time it is opened by someone who already knows. No description in
886
+ the manifest, no question mark — nothing to ask. -->
887
+ <Popover v-if="manifestDescription">
888
+ <PopoverTrigger as-child>
889
+ <!-- The ring stays 16px because that is what a `?` in a subtitle
890
+ line should look like; the TARGET is grown to 24px by the
891
+ `after` overlay. A 16px target inline under the full-width
892
+ name input misses WCAG 2.5.8 and cannot claim the spacing
893
+ exception — a 24px circle centred here reaches into the input,
894
+ which is a target of its own. And this is the only route to
895
+ the description at all. -->
896
+ <button
897
+ type="button"
898
+ aria-label="What this node does"
899
+ class="relative flex size-4 shrink-0 items-center justify-center rounded-full border border-black/[0.12] text-[10px] font-semibold leading-none transition after:absolute after:-inset-1 after:content-[''] hover:border-black/25 hover:bg-black/[0.04] hover:text-foreground"
900
+ >
901
+ ?
902
+ </button>
903
+ </PopoverTrigger>
904
+ <PopoverContent aria-label="What this node does" align="start" class="w-96 rounded-lg border border-black/[0.06] bg-white p-4 shadow-lg">
905
+ <div class="flex items-center gap-2.5">
906
+ <span class="flex size-8 shrink-0 items-center justify-center rounded-md border border-black/[0.06] bg-slate-50 text-slate-600">
907
+ <IntegrationsNodeMark :manifest="manifest" :size="18" />
908
+ </span>
909
+ <div class="min-w-0">
910
+ <div class="truncate text-sm font-semibold text-foreground">
911
+ {{ resolveLocalized(manifest?.name) }}
912
+ </div>
913
+ <div class="truncate text-xs text-muted-foreground">
914
+ {{ subtitleText }}
915
+ </div>
916
+ </div>
917
+ </div>
918
+ <!-- Reading matter, so it gets reading measure and scrolls when
919
+ the node package is generous. -->
920
+ <p class="mt-3 max-h-64 overflow-y-auto text-sm leading-relaxed text-foreground/80">
921
+ {{ manifestDescription }}
922
+ </p>
923
+ </PopoverContent>
924
+ </Popover>
581
925
  </div>
582
926
  </div>
583
927
  <span class="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-slate-600">
584
928
  {{ groupLabel }}
585
929
  </span>
930
+ <!-- The user's own note. A control of its own, deliberately not a second
931
+ paragraph under the name: the line above is what the node TYPE does
932
+ and cannot be edited, this is what one person wrote about this one
933
+ node. Named like the badge on the card, so the canvas and the dialog
934
+ agree on what the thing is called. -->
935
+ <Popover v-if="draftNode" v-model:open="noteOpen">
936
+ <PopoverTrigger as-child>
937
+ <button
938
+ type="button"
939
+ class="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition"
940
+ :class="noteText ? 'border-amber-500/40 bg-amber-500/10 text-amber-700' : 'border-black/[0.08] text-muted-foreground hover:bg-black/[0.04] hover:text-foreground'"
941
+ >
942
+ <MessageSquareText :size="12" />
943
+ {{ noteLabel }}
944
+ </button>
945
+ </PopoverTrigger>
946
+ <PopoverContent aria-label="Note" align="end" class="w-80 space-y-1.5 rounded-lg border border-black/[0.06] bg-white p-3 shadow-lg">
947
+ <Label class="text-mono text-muted-foreground">Note</Label>
948
+ <Textarea
949
+ aria-label="Note"
950
+ :model-value="draftNode.notes ?? ''"
951
+ :rows="4"
952
+ placeholder="Why this node is here…"
953
+ class="rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white"
954
+ @update:model-value="writeNote"
955
+ />
956
+ </PopoverContent>
957
+ </Popover>
958
+ <!-- The Input pane on request. Also opens by itself when the cursor lands
959
+ in an expression field — this is for reading it, or for dragging out
960
+ of it, before a field is chosen.
961
+
962
+ Not offered on a trigger: a trigger starts the workflow, so there is no
963
+ upstream data, and none of its settings takes an expression — the pane
964
+ would open onto its own explanation of why it is empty, in a dialog
965
+ sized for the few controls a trigger has. -->
966
+ <button
967
+ v-if="isNode"
968
+ type="button"
969
+ class="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition"
970
+ :class="inputPaneOpen ? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700' : 'border-black/[0.08] text-muted-foreground hover:bg-black/[0.04] hover:text-foreground'"
971
+ :aria-pressed="inputPaneOpen"
972
+ @click="inputPaneOpen = !inputPaneOpen"
973
+ >
974
+ <Database :size="12" />
975
+ Input data
976
+ </button>
977
+ <!-- Icon-only controls: `title` alone leaves them nameless to a screen
978
+ reader (and unaddressable to a test without a CSS selector), so each
979
+ carries the same sentence as an aria-label. -->
586
980
  <div v-if="sequence.length > 1" class="flex shrink-0 items-center gap-0.5 border-l border-black/[0.06] pl-2">
587
981
  <button
588
982
  type="button"
589
983
  :disabled="!hasPrev"
984
+ aria-label="Previous step (saves changes)"
590
985
  title="Previous step (saves changes)"
591
986
  class="flex size-7 items-center justify-center rounded-md text-muted-foreground transition hover:bg-black/[0.04] hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
592
987
  @click="navigate(-1)"
@@ -596,6 +991,7 @@ async function runTestStep() {
596
991
  <button
597
992
  type="button"
598
993
  :disabled="!hasNext"
994
+ aria-label="Next step (saves changes)"
599
995
  title="Next step (saves changes)"
600
996
  class="flex size-7 items-center justify-center rounded-md text-muted-foreground transition hover:bg-black/[0.04] hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
601
997
  @click="navigate(1)"
@@ -603,14 +999,25 @@ async function runTestStep() {
603
999
  <ChevronRight :size="14" />
604
1000
  </button>
605
1001
  </div>
1002
+ <!-- Close lives in the header row so it lines up with the chevrons
1003
+ (the DialogContent built-in X floats at its own offset). -->
1004
+ <button
1005
+ type="button"
1006
+ aria-label="Close"
1007
+ title="Close"
1008
+ class="-mr-1.5 flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-black/[0.04] hover:text-foreground"
1009
+ @click="emit('close')"
1010
+ >
1011
+ <X :size="15" />
1012
+ </button>
606
1013
  </div>
607
1014
 
608
1015
  <!-- Body: Input pane + tabbed center -->
609
1016
  <div class="flex min-h-0 flex-1">
610
1017
  <!-- Input pane -->
611
- <aside class="flex w-[300px] shrink-0 flex-col border-r border-black/[0.06] bg-muted/10" @dragend="onChipDragEnd">
1018
+ <aside v-if="inputPaneOpen" class="flex w-[300px] shrink-0 flex-col border-r border-black/[0.06] bg-muted/10" @dragend="onChipDragEnd">
612
1019
  <div class="shrink-0 border-b border-black/[0.06] px-3 py-2.5">
613
- <div class="text-sm font-semibold text-foreground">
1020
+ <div class="text-mono text-muted-foreground">
614
1021
  Input
615
1022
  </div>
616
1023
  <p class="mt-0.5 text-xs leading-snug text-muted-foreground">
@@ -621,8 +1028,9 @@ async function runTestStep() {
621
1028
  <input
622
1029
  v-model="inputSearch"
623
1030
  type="text"
1031
+ aria-label="Search data"
624
1032
  placeholder="Search data"
625
- class="h-7 w-full rounded-md border border-black/[0.08] bg-white pl-6.5 pr-2 text-xs outline-none transition placeholder:text-muted-foreground/60 focus:border-black/[0.16]"
1033
+ class="h-7 w-full rounded-md border border-black/[0.08] bg-white pl-6.5 pr-2 text-xs outline-none transition placeholder:text-muted-foreground/60 focus:border-black/[0.16] focus:ring-2 focus:ring-primary/40"
626
1034
  >
627
1035
  </div>
628
1036
  <div v-if="workflowId" class="mt-2 flex items-center gap-1.5">
@@ -670,7 +1078,7 @@ async function runTestStep() {
670
1078
  @click="toggleNodeSection(s.id)"
671
1079
  >
672
1080
  <ChevronRight :size="11" class="shrink-0 text-muted-foreground transition-transform" :class="sectionClosed(s.id) ? '' : 'rotate-90'" />
673
- <IntegrationsNodeLogo :manifest="s.manifest" :size="14" />
1081
+ <IntegrationsNodeMark :manifest="s.manifest" :size="14" :badge="false" />
674
1082
  <span class="min-w-0 truncate text-xs font-medium text-foreground">{{ s.name }}</span>
675
1083
  <span
676
1084
  v-if="!s.direct"
@@ -686,25 +1094,37 @@ async function runTestStep() {
686
1094
  <span class="size-1.5 rounded-full" :class="portDotClass(p.kind, p.name)" />
687
1095
  <span class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{{ p.name }}</span>
688
1096
  </div>
689
- <div class="flex flex-wrap gap-1">
1097
+ <!-- Schema rows, n8n-style: glyph · key · sampled value. -->
1098
+ <div class="space-y-px">
690
1099
  <button
691
1100
  type="button"
692
- :class="chipClass"
1101
+ :class="rowClass"
1102
+ :aria-label="nodeOutputExpr(s.id, p.name)"
693
1103
  :title="nodeOutputExpr(s.id, p.name)"
694
1104
  draggable="true"
695
1105
  @dragstart="onChipDragStart($event, nodeOutputExpr(s.id, p.name))"
696
1106
  @click="insertExpression(nodeOutputExpr(s.id, p.name))"
697
- ><span v-for="(seg, i) in highlightSegments(p.name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span></button>
698
- <button
699
- v-for="f in p.fields"
700
- :key="f.name"
701
- type="button"
702
- :class="chipClass"
703
- :title="nodeOutputExpr(s.id, p.name, f.name) + (f.dataType ? ' \xB7 ' + f.dataType : '')"
704
- draggable="true"
705
- @dragstart="onChipDragStart($event, nodeOutputExpr(s.id, p.name, f.name))"
706
- @click="insertExpression(nodeOutputExpr(s.id, p.name, f.name))"
707
- ><span v-if="typeGlyph(f.dataType)" class="mr-1 font-semibold" :class="typeGlyph(f.dataType).cls">{{ typeGlyph(f.dataType).char }}</span><span v-for="(seg, i) in highlightSegments(f.name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span><span v-if="chipValue(s.id, p.name, f.name) !== null" class="text-slate-400">: {{ chipValue(s.id, p.name, f.name) }}</span></button>
1107
+ >
1108
+ <span class="w-3 shrink-0 text-center font-semibold text-slate-400">{}</span>
1109
+ <span class="shrink-0 font-medium"><span v-for="(seg, i) in highlightSegments(p.name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span></span>
1110
+ </button>
1111
+ <div class="space-y-px pl-3">
1112
+ <button
1113
+ v-for="f in p.fields"
1114
+ :key="f.name"
1115
+ type="button"
1116
+ :class="rowClass"
1117
+ :aria-label="nodeOutputExpr(s.id, p.name, f.name)"
1118
+ :title="nodeOutputExpr(s.id, p.name, f.name) + (f.dataType ? ' \xB7 ' + f.dataType : '')"
1119
+ draggable="true"
1120
+ @dragstart="onChipDragStart($event, nodeOutputExpr(s.id, p.name, f.name))"
1121
+ @click="insertExpression(nodeOutputExpr(s.id, p.name, f.name))"
1122
+ >
1123
+ <span class="w-3 shrink-0 text-center font-semibold" :class="typeGlyph(f.dataType)?.cls ?? 'text-slate-300'">{{ typeGlyph(f.dataType)?.char ?? "\xB7" }}</span>
1124
+ <span class="shrink-0"><span v-for="(seg, i) in highlightSegments(f.name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span></span>
1125
+ <span v-if="chipValue(s.id, p.name, f.name) !== null" class="min-w-0 flex-1 truncate text-slate-400">{{ chipValue(s.id, p.name, f.name) }}</span>
1126
+ </button>
1127
+ </div>
708
1128
  </div>
709
1129
  </div>
710
1130
  </div>
@@ -720,6 +1140,7 @@ async function runTestStep() {
720
1140
  <button
721
1141
  type="button"
722
1142
  :class="chipClass"
1143
+ :aria-label="triggerPayloadExpr()"
723
1144
  :title="triggerPayloadExpr()"
724
1145
  draggable="true"
725
1146
  @dragstart="onChipDragStart($event, triggerPayloadExpr())"
@@ -743,17 +1164,23 @@ async function runTestStep() {
743
1164
  <div class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
744
1165
  Variables
745
1166
  </div>
746
- <div v-if="visibleVariableNames.length" class="flex flex-wrap gap-1">
1167
+ <!-- Schema rows with the declared default as the value. -->
1168
+ <div v-if="variableRows.length" class="space-y-px">
747
1169
  <button
748
- v-for="name in visibleVariableNames"
749
- :key="name"
1170
+ v-for="row in variableRows"
1171
+ :key="row.name"
750
1172
  type="button"
751
- :class="chipClass"
752
- :title="variableExpr(name)"
1173
+ :class="rowClass"
1174
+ :aria-label="variableExpr(row.name)"
1175
+ :title="variableExpr(row.name)"
753
1176
  draggable="true"
754
- @dragstart="onChipDragStart($event, variableExpr(name))"
755
- @click="insertExpression(variableExpr(name))"
756
- ><span v-for="(seg, i) in highlightSegments(name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span></button>
1177
+ @dragstart="onChipDragStart($event, variableExpr(row.name))"
1178
+ @click="insertExpression(variableExpr(row.name))"
1179
+ >
1180
+ <span class="w-3 shrink-0 text-center font-semibold" :class="row.glyph?.cls ?? 'text-slate-300'">{{ row.glyph?.char ?? "\xB7" }}</span>
1181
+ <span class="shrink-0"><span v-for="(seg, i) in highlightSegments(row.name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span></span>
1182
+ <span v-if="row.value !== null" class="min-w-0 flex-1 truncate text-slate-400">{{ row.value }}</span>
1183
+ </button>
757
1184
  </div>
758
1185
  <p v-else class="text-xs leading-snug text-muted-foreground">
759
1186
  No workflow variables defined.
@@ -768,25 +1195,8 @@ async function runTestStep() {
768
1195
 
769
1196
  <!-- Center: tabs + content -->
770
1197
  <section class="flex min-w-0 flex-1 flex-col">
771
- <div class="flex shrink-0 items-center gap-1 border-b border-black/[0.06] px-4">
772
- <button
773
- type="button"
774
- class="relative -mb-px border-b-2 px-2.5 py-2 text-sm font-medium transition"
775
- :class="tab === 'parameters' ? 'border-foreground text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'"
776
- @click="tab = 'parameters'"
777
- >
778
- Parameters
779
- </button>
780
- <button
781
- v-if="showSettingsTab"
782
- type="button"
783
- class="relative -mb-px border-b-2 px-2.5 py-2 text-sm font-medium transition"
784
- :class="tab === 'settings' ? 'border-foreground text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'"
785
- @click="tab = 'settings'"
786
- >
787
- Settings
788
- </button>
789
- </div>
1198
+ <!-- Same TabBar as the workflow page — one tab look everywhere. -->
1199
+ <TabBar v-model="tab" :tabs="inspectorTabs" class="shrink-0 px-4" />
790
1200
 
791
1201
  <div
792
1202
  class="min-h-0 flex-1 overflow-y-auto p-4"
@@ -797,111 +1207,416 @@ async function runTestStep() {
797
1207
  @drop="onCenterDrop"
798
1208
  >
799
1209
  <!-- Parameters: trigger config -->
800
- <div v-if="!isNode && draftTrigger" class="mx-auto w-full max-w-3xl space-y-3">
1210
+ <!-- Full width: triggers have no output pane, a capped column
1211
+ would float lost in the wide center. -->
1212
+ <div v-if="!isNode && draftTrigger" class="w-full space-y-3">
801
1213
  <!-- Active toggle. Persists with the workflow save like every other trigger field. -->
802
- <div class="flex items-center justify-between rounded-lg border border-black/[0.06] bg-muted/20 px-3 py-2">
1214
+ <div class="flex items-center gap-3 rounded-lg border border-black/[0.06] bg-muted/20 px-3 py-2">
1215
+ <!-- The name lives in the text next to the switch, which is not
1216
+ a <label> — so the switch says it itself. -->
1217
+ <Switch
1218
+ aria-label="Active"
1219
+ class="data-[state=unchecked]:bg-slate-300"
1220
+ :model-value="draftTrigger.active !== false"
1221
+ @update:model-value="(v) => patchTrigger({ active: v })"
1222
+ />
803
1223
  <div>
804
1224
  <div class="text-sm font-medium">Active</div>
805
1225
  <p class="text-xs text-muted-foreground">
806
1226
  A run needs at least one active trigger.
807
1227
  </p>
808
1228
  </div>
809
- <Switch
810
- :model-value="draftTrigger.active !== false"
811
- @update:model-value="(v) => patchTrigger({ active: v })"
812
- />
813
1229
  </div>
1230
+ <!--
1231
+ The schedule is picked, not written. Each frequency shows only its
1232
+ own fields, and the expression appears in one place only: the
1233
+ Advanced field, for a schedule no picker can represent.
1234
+ -->
814
1235
  <template v-if="draftTrigger.type === 'schedule'">
815
- <div class="space-y-1.5">
816
- <Label class="text-mono text-muted-foreground">Cron</Label>
817
- <Input
818
- :model-value="readTriggerConfig('cron') ?? ''"
819
- placeholder="0 2 * * *"
820
- class="h-8 text-sm bg-white font-mono"
821
- @update:model-value="(v) => writeTriggerConfig('cron', v)"
822
- />
1236
+ <div class="grid grid-cols-2 gap-3">
1237
+ <div class="space-y-1.5">
1238
+ <!-- "Frequency", not "Runs": a run is an execution everywhere
1239
+ else in the studio, and there is a whole page of them. -->
1240
+ <Label for="ni-frequency" class="text-mono text-muted-foreground">Frequency</Label>
1241
+ <Select :model-value="frequency" @update:model-value="applyFrequency">
1242
+ <SelectTrigger id="ni-frequency" aria-label="Frequency" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1243
+ <SelectValue />
1244
+ </SelectTrigger>
1245
+ <SelectContent>
1246
+ <SelectItem v-for="f in FREQUENCY_OPTIONS" :key="f.value" :value="f.value">{{ f.label }}</SelectItem>
1247
+ <SelectSeparator />
1248
+ <SelectItem :value="FREQUENCY_ADVANCED">Advanced (cron)</SelectItem>
1249
+ </SelectContent>
1250
+ </Select>
1251
+ </div>
1252
+
1253
+ <!-- Every few minutes: the gap between firings. -->
1254
+ <div v-if="frequency === 'minutes'" class="space-y-1.5">
1255
+ <Label for="ni-minute-interval" class="text-mono text-muted-foreground">Interval</Label>
1256
+ <Select :model-value="String(carry.minuteInterval)" @update:model-value="applyMinuteInterval">
1257
+ <SelectTrigger id="ni-minute-interval" aria-label="Interval" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1258
+ <SelectValue />
1259
+ </SelectTrigger>
1260
+ <SelectContent>
1261
+ <SelectItem v-for="n in minuteIntervalOptions" :key="n" :value="String(n)">
1262
+ {{ n === 1 ? "Every minute" : `Every ${n} minutes` }}
1263
+ </SelectItem>
1264
+ </SelectContent>
1265
+ </Select>
1266
+ </div>
1267
+
1268
+ <!-- Every few hours: the gap, plus where in the hour it lands. -->
1269
+ <div v-if="frequency === 'hours'" class="space-y-1.5">
1270
+ <Label for="ni-hour-interval" class="text-mono text-muted-foreground">Interval</Label>
1271
+ <Select :model-value="String(carry.hourInterval)" @update:model-value="applyHourInterval">
1272
+ <SelectTrigger id="ni-hour-interval" aria-label="Interval" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1273
+ <SelectValue />
1274
+ </SelectTrigger>
1275
+ <SelectContent>
1276
+ <SelectItem v-for="n in HOUR_INTERVALS" :key="n" :value="String(n)">Every {{ n }} hours</SelectItem>
1277
+ </SelectContent>
1278
+ </Select>
1279
+ </div>
1280
+
1281
+ <div v-if="frequency === 'hourly' || frequency === 'hours' || frequency === 'times'" class="space-y-1.5">
1282
+ <Label for="ni-minute-past" class="text-mono text-muted-foreground">Minutes past the hour</Label>
1283
+ <Select :model-value="String(carry.minute)" @update:model-value="applyMinutePast">
1284
+ <SelectTrigger id="ni-minute-past" aria-label="Minutes past the hour" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1285
+ <SelectValue />
1286
+ </SelectTrigger>
1287
+ <SelectContent>
1288
+ <SelectItem v-for="n in minutePastOptions" :key="n" :value="String(n)">:{{ pad2(n) }}</SelectItem>
1289
+ </SelectContent>
1290
+ </Select>
1291
+ </div>
1292
+
1293
+ <!-- A fixed time of day: the one field all three calendar
1294
+ rhythms share, which is why it survives a change of
1295
+ frequency. -->
1296
+ <div v-if="frequency === 'daily' || frequency === 'weekly' || frequency === 'monthly'" class="space-y-1.5">
1297
+ <Label for="ni-time" class="text-mono text-muted-foreground">Time</Label>
1298
+ <Input
1299
+ id="ni-time"
1300
+ type="time"
1301
+ aria-label="Time"
1302
+ :model-value="timeValue"
1303
+ class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white tabular-nums"
1304
+ @update:model-value="applyTime"
1305
+ />
1306
+ </div>
1307
+
1308
+ <div v-if="frequency === 'monthly'" class="space-y-1.5">
1309
+ <Label for="ni-day-of-month" class="text-mono text-muted-foreground">Day of month</Label>
1310
+ <Select :model-value="String(carry.day)" @update:model-value="applyDayOfMonth">
1311
+ <SelectTrigger id="ni-day-of-month" aria-label="Day of month" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1312
+ <SelectValue />
1313
+ </SelectTrigger>
1314
+ <SelectContent>
1315
+ <SelectItem v-for="n in 31" :key="n" :value="String(n)">{{ n }}</SelectItem>
1316
+ </SelectContent>
1317
+ </Select>
1318
+ </div>
1319
+
1320
+ <!-- Advanced: the expression itself, the only place it is shown. -->
1321
+ <div v-if="frequency === FREQUENCY_ADVANCED" class="space-y-1.5">
1322
+ <Label for="ni-cron" class="text-mono text-muted-foreground">Cron expression</Label>
1323
+ <Input
1324
+ id="ni-cron"
1325
+ aria-label="Cron expression"
1326
+ :model-value="cronValue"
1327
+ placeholder="0 9-17 * * 1-5"
1328
+ class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white font-mono"
1329
+ @update:model-value="(v) => writeTriggerConfig('cron', v)"
1330
+ />
1331
+ </div>
823
1332
  </div>
824
- <div class="space-y-1.5">
825
- <Label class="text-mono text-muted-foreground">Timezone</Label>
826
- <Input
827
- :model-value="readTriggerConfig('timezone') ?? ''"
828
- placeholder="Europe/Berlin"
829
- class="h-8 text-sm bg-white"
830
- @update:model-value="(v) => writeTriggerConfig('timezone', v)"
831
- />
1333
+
1334
+ <!--
1335
+ The hours of "several times a day". Two rows of twelve, and the
1336
+ minute above is shared by all of them: cron cannot express times
1337
+ with different minutes in one expression, so 07:30 and 09:15 is two
1338
+ schedule triggers rather than one field per row.
1339
+ -->
1340
+ <div v-if="frequency === 'times'" class="space-y-1.5">
1341
+ <Label class="text-mono text-muted-foreground">Hours</Label>
1342
+ <ToggleGroup
1343
+ type="multiple"
1344
+ aria-label="Hours"
1345
+ :model-value="selectedHours"
1346
+ class="grid w-fit grid-cols-12 gap-1"
1347
+ @update:model-value="applyHours"
1348
+ >
1349
+ <ToggleGroupItem
1350
+ v-for="h in HOURS_OF_DAY"
1351
+ :key="h"
1352
+ :value="String(h)"
1353
+ :aria-label="`${pad2(h)}:${pad2(carry.minute)}`"
1354
+ class="h-7 w-9 rounded-md border border-black/[0.08] bg-white text-xs tabular-nums data-[state=on]:border-emerald-500/40 data-[state=on]:bg-emerald-500/10 data-[state=on]:text-emerald-700"
1355
+ >
1356
+ {{ pad2(h) }}
1357
+ </ToggleGroupItem>
1358
+ </ToggleGroup>
832
1359
  </div>
1360
+
1361
+ <!-- Weekdays: full width, because seven toggles in half of the
1362
+ grid would wrap. Serves both rhythms that fall on days. -->
1363
+ <div v-if="frequency === 'weekly' || frequency === 'times'" class="space-y-1.5">
1364
+ <Label class="text-mono text-muted-foreground">Days</Label>
1365
+ <ToggleGroup
1366
+ type="multiple"
1367
+ aria-label="Days"
1368
+ :model-value="selectedDays"
1369
+ class="justify-start gap-1"
1370
+ @update:model-value="applyDays"
1371
+ >
1372
+ <ToggleGroupItem
1373
+ v-for="d in DAY_OPTIONS"
1374
+ :key="d.value"
1375
+ :value="String(d.value)"
1376
+ :aria-label="d.name"
1377
+ class="h-8 w-10 rounded-md border border-black/[0.08] bg-white text-sm data-[state=on]:border-emerald-500/40 data-[state=on]:bg-emerald-500/10 data-[state=on]:text-emerald-700"
1378
+ >
1379
+ {{ d.short }}
1380
+ </ToggleGroupItem>
1381
+ </ToggleGroup>
1382
+ </div>
1383
+
1384
+ <!--
1385
+ The confirmation. The timezone is in it because the platform reads
1386
+ the expression's numbers as wall-clock time in that zone, so a time
1387
+ without its zone would be half an answer.
1388
+ -->
1389
+ <p v-if="scheduleSentence" aria-label="When it fires" class="text-xs text-emerald-700">{{ scheduleSentence }}</p>
1390
+ <!-- No expression at all is its own answer, and the same one the
1391
+ Schedules page gives for this trigger: it is not written as
1392
+ anything, and it does not run. A trigger placed from the palette
1393
+ carries an expression, so this is for one that arrived without
1394
+ one. -->
1395
+ <p v-else-if="!hasCron" aria-label="When it fires" class="text-xs text-muted-foreground">
1396
+ No schedule set — pick a frequency, or write an expression.
1397
+ </p>
1398
+ <p v-else class="text-xs text-muted-foreground">
1399
+ This schedule is written as an expression these settings cannot show. It runs exactly as written.
1400
+ </p>
1401
+ <p v-if="frequency === 'monthly' && carry.day >= 29" class="text-xs text-muted-foreground">
1402
+ Months without this day are skipped.
1403
+ </p>
833
1404
  </template>
834
1405
  <template v-else-if="draftTrigger.type === 'webhook'">
835
1406
  <div class="space-y-1.5">
836
- <Label class="text-mono text-muted-foreground">Path</Label>
1407
+ <Label for="ni-webhook-url" class="text-mono text-muted-foreground">Webhook URL</Label>
1408
+ <div v-if="webhookUrl" class="flex items-center gap-1.5">
1409
+ <Input
1410
+ id="ni-webhook-url"
1411
+ readonly
1412
+ :model-value="webhookUrl"
1413
+ class="h-8 flex-1 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-xs"
1414
+ @focus="$event.target.select()"
1415
+ />
1416
+ <button
1417
+ type="button"
1418
+ title="Copy URL"
1419
+ class="flex size-8 shrink-0 items-center justify-center rounded-md border border-black/[0.08] bg-white text-muted-foreground transition hover:text-foreground"
1420
+ @click="copyWebhookUrl"
1421
+ >
1422
+ <Copy :size="14" />
1423
+ </button>
1424
+ </div>
1425
+ <p class="text-xs text-muted-foreground">
1426
+ <template v-if="webhookIsPublic">Public ingress endpoint — callers authenticate with the endpoint secret issued when the trigger was exposed.</template>
1427
+ <template v-else>Authenticated endpoint: callers need a platform API token (Bearer). Enable the public endpoint for external systems.</template>
1428
+ </p>
1429
+ </div>
1430
+ <div class="flex items-center gap-3 rounded-lg border border-black/[0.06] bg-muted/20 px-3 py-2">
1431
+ <Switch
1432
+ aria-label="Public endpoint"
1433
+ class="data-[state=unchecked]:bg-slate-300"
1434
+ :model-value="webhookIsPublic"
1435
+ @update:model-value="(v) => writeTriggerConfigPatch(v ? { public: true, ...draftTrigger.config.authProfile ? {} : configPatchForAuthChoice('token') } : { public: void 0 })"
1436
+ />
1437
+ <div>
1438
+ <div class="text-sm font-medium">Public endpoint</div>
1439
+ <p class="text-xs text-muted-foreground">
1440
+ Saving provisions a public URL on the webhook ingress (with its own endpoint secret) — for external systems without a platform token.
1441
+ </p>
1442
+ </div>
1443
+ </div>
1444
+ <div class="grid grid-cols-2 gap-3">
1445
+ <div class="space-y-1.5">
1446
+ <Label for="ni-webhook-method" class="text-mono text-muted-foreground">Method</Label>
1447
+ <Select
1448
+ :model-value="readTriggerConfig('method') ?? 'POST'"
1449
+ @update:model-value="(v) => writeTriggerConfig('method', v)"
1450
+ >
1451
+ <SelectTrigger id="ni-webhook-method" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1452
+ <SelectValue />
1453
+ </SelectTrigger>
1454
+ <SelectContent>
1455
+ <SelectItem value="GET">GET</SelectItem>
1456
+ <SelectItem value="POST">POST</SelectItem>
1457
+ <SelectItem value="PUT">PUT</SelectItem>
1458
+ <SelectItem value="PATCH">PATCH</SelectItem>
1459
+ <SelectItem value="DELETE">DELETE</SelectItem>
1460
+ </SelectContent>
1461
+ </Select>
1462
+ </div>
1463
+ <div v-if="webhookIsPublic" class="space-y-1.5">
1464
+ <Label for="ni-webhook-protection" class="text-mono text-muted-foreground">Protection</Label>
1465
+ <Select :model-value="authChoice" @update:model-value="applyAuthChoice">
1466
+ <SelectTrigger id="ni-webhook-protection" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1467
+ <SelectValue />
1468
+ </SelectTrigger>
1469
+ <SelectContent>
1470
+ <SelectItem value="token">API key / token header</SelectItem>
1471
+ <SelectItem value="bearer">Bearer token</SelectItem>
1472
+ <SelectItem value="basic">Basic auth</SelectItem>
1473
+ <SelectItem value="none">None (capture everything)</SelectItem>
1474
+ </SelectContent>
1475
+ </Select>
1476
+ <p class="text-xs text-muted-foreground">Verified by the webhook ingress before the workflow runs.</p>
1477
+ </div>
1478
+ </div>
1479
+ <div v-if="webhookIsPublic && authChoice === 'token'" class="space-y-1.5">
1480
+ <Label for="ni-auth-header" class="text-mono text-muted-foreground">Header name</Label>
837
1481
  <Input
838
- :model-value="readTriggerConfig('path') ?? ''"
839
- placeholder="/orders"
840
- class="h-8 text-sm bg-white font-mono"
841
- @update:model-value="(v) => writeTriggerConfig('path', v)"
1482
+ id="ni-auth-header"
1483
+ :model-value="draftTrigger.config.authConfig?.header ?? ''"
1484
+ placeholder="X-Webhook-Token"
1485
+ class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white text-sm"
1486
+ @update:model-value="(v) => writeAuthConfigKey('header', String(v))"
842
1487
  />
1488
+ <p class="text-xs text-muted-foreground">The header carrying the endpoint secret (e.g. X-Api-Key). Empty uses X-Webhook-Token.</p>
843
1489
  </div>
844
- <div class="space-y-1.5">
845
- <Label class="text-mono text-muted-foreground">Method</Label>
846
- <Select
847
- :model-value="readTriggerConfig('method') ?? 'POST'"
848
- @update:model-value="(v) => writeTriggerConfig('method', v)"
849
- >
850
- <SelectTrigger class="h-8 text-sm bg-white">
851
- <SelectValue />
852
- </SelectTrigger>
853
- <SelectContent>
854
- <SelectItem value="GET">GET</SelectItem>
855
- <SelectItem value="POST">POST</SelectItem>
856
- <SelectItem value="PUT">PUT</SelectItem>
857
- <SelectItem value="PATCH">PATCH</SelectItem>
858
- <SelectItem value="DELETE">DELETE</SelectItem>
859
- </SelectContent>
860
- </Select>
1490
+ <div v-if="webhookIsPublic && authChoice === 'basic'" class="space-y-1.5">
1491
+ <Label for="ni-auth-username" class="text-mono text-muted-foreground">Username</Label>
1492
+ <Input
1493
+ id="ni-auth-username"
1494
+ :model-value="draftTrigger.config.authConfig?.username ?? ''"
1495
+ placeholder="Any username"
1496
+ class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white text-sm"
1497
+ @update:model-value="(v) => writeAuthConfigKey('username', String(v))"
1498
+ />
1499
+ <p class="text-xs text-muted-foreground">Optional — pins the Basic-auth username. The password is the endpoint secret.</p>
861
1500
  </div>
862
- <div class="space-y-1.5">
863
- <Label class="text-mono text-muted-foreground">Signing secret</Label>
864
- <Select
865
- :model-value="readTriggerConfig('secretRef') ?? ''"
866
- @update:model-value="(v) => writeTriggerConfig('secretRef', v)"
867
- >
868
- <SelectTrigger class="h-8 text-sm bg-white">
869
- <SelectValue placeholder="None" />
870
- </SelectTrigger>
871
- <SelectContent>
872
- <SelectItem v-for="k in secretKeys" :key="k" :value="k">{{ k }}</SelectItem>
873
- </SelectContent>
874
- </Select>
1501
+ <div v-if="webhookExposed && authChoice !== 'none'" class="flex items-center gap-2">
1502
+ <Button type="button" variant="outline" size="sm" class="h-7 text-sm" :disabled="rotateBusy" @click="rotateEndpointSecret">
1503
+ {{ rotateBusy ? "Rotating\u2026" : "Rotate endpoint secret" }}
1504
+ </Button>
1505
+ <p class="text-xs text-muted-foreground">Issues a new secret (shown once); the previous one keeps working briefly.</p>
875
1506
  </div>
1507
+
1508
+ <Dialog v-model:open="rotateOpen">
1509
+ <DialogContent class="sm:max-w-md">
1510
+ <DialogHeader>
1511
+ <DialogTitle>New endpoint secret</DialogTitle>
1512
+ <DialogDescription>
1513
+ Copy it now — it is shown only this once. Give it to the calling system
1514
+ ({{ authChoice === "basic" ? "as the Basic-auth password" : authChoice === "bearer" ? "as the Bearer token" : "in the token header" }}).
1515
+ </DialogDescription>
1516
+ </DialogHeader>
1517
+ <div class="flex items-center gap-1.5">
1518
+ <Input
1519
+ readonly
1520
+ :model-value="rotatedSecret ?? ''"
1521
+ class="h-8 flex-1 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-xs"
1522
+ @focus="$event.target.select()"
1523
+ />
1524
+ <button
1525
+ type="button"
1526
+ title="Copy secret"
1527
+ class="flex size-8 shrink-0 items-center justify-center rounded-md border border-black/[0.08] bg-white text-muted-foreground transition hover:text-foreground"
1528
+ @click="copySecret"
1529
+ >
1530
+ <Copy :size="14" />
1531
+ </button>
1532
+ </div>
1533
+ </DialogContent>
1534
+ </Dialog>
876
1535
  </template>
877
1536
  <template v-else-if="draftTrigger.type === 'event'">
878
1537
  <div class="space-y-1.5">
879
- <Label class="text-mono text-muted-foreground">Event</Label>
1538
+ <Label for="ni-event-type" class="text-mono text-muted-foreground">Event</Label>
880
1539
  <EventTypeCombobox
1540
+ input-id="ni-event-type"
881
1541
  :model-value="readTriggerConfig('event_type') ?? ''"
882
1542
  @update:model-value="(v) => writeTriggerConfig('event_type', v)"
883
1543
  />
884
- <p class="text-sm text-muted-foreground">
1544
+ <p class="text-xs text-muted-foreground">
885
1545
  Runs whenever this platform event fires (e.g. <code class="font-mono">customer.created</code>, <code class="font-mono">order.placed</code>). The event payload is available to the workflow as <code class="font-mono">trigger.payload</code>.
886
1546
  </p>
887
1547
  </div>
1548
+ <div v-if="eventSample" class="space-y-1.5">
1549
+ <!-- A caption, not a field label: it heads a read-only block,
1550
+ so it stays a plain line rather than an orphan <label>. -->
1551
+ <div class="text-mono text-muted-foreground">Sample payload</div>
1552
+ <pre class="max-h-72 overflow-auto rounded-md border border-black/[0.06] bg-muted/20 p-2.5 font-mono text-[11px] leading-relaxed text-slate-700">{{ eventSample }}</pre>
1553
+ <p class="text-xs text-muted-foreground">From the event catalogue — the shape this event delivers as <code class="font-mono">trigger.payload</code>.</p>
1554
+ </div>
1555
+ <p v-else-if="eventTopic" class="text-xs text-muted-foreground">
1556
+ No sample payload in the event catalogue for this topic.
1557
+ </p>
888
1558
  </template>
889
1559
  <template v-else>
890
- <p class="text-sm text-muted-foreground">
891
- Manual triggers have no configuration. The workflow runs when invoked from the editor or via the API.
892
- </p>
1560
+ <div class="space-y-1.5">
1561
+ <!-- Caption for the field list below, not a label for one
1562
+ control — the row's own controls are named individually. -->
1563
+ <div class="text-mono text-muted-foreground">Run payload fields</div>
1564
+ <p class="text-xs text-muted-foreground">
1565
+ What the Run dialog asks for when this workflow is started by hand. Each field lands in
1566
+ <code class="font-mono">trigger.payload.&lt;key&gt;</code>; a File field becomes a manual upload.
1567
+ Fields referenced by nodes via <code class="font-mono">trigger.payload.*</code> are added automatically.
1568
+ </p>
1569
+ </div>
1570
+ <div v-if="manualFields.length" class="space-y-1.5">
1571
+ <div v-for="(mf, idx) in manualFields" :key="manualFieldUids[idx] ?? idx" class="flex items-center gap-1.5">
1572
+ <!-- A repeating row has no room for visible labels, so each
1573
+ control carries its own name and says which row it is. -->
1574
+ <Select :model-value="mf.kind ?? 'text'" @update:model-value="(v) => setManualField(idx, { kind: v })">
1575
+ <SelectTrigger :aria-label="`Kind of payload field ${idx + 1}`" size="sm" class="h-8 w-24 shrink-0 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1576
+ <SelectValue />
1577
+ </SelectTrigger>
1578
+ <SelectContent>
1579
+ <SelectItem value="text">Text</SelectItem>
1580
+ <SelectItem value="file">File</SelectItem>
1581
+ </SelectContent>
1582
+ </Select>
1583
+ <Input
1584
+ :aria-label="`Key of payload field ${idx + 1}`"
1585
+ :model-value="mf.path"
1586
+ placeholder="key (e.g. orderId or import.file)"
1587
+ class="h-8 w-[38%] rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-sm"
1588
+ @update:model-value="(v) => setManualField(idx, { path: String(v) })"
1589
+ />
1590
+ <Input
1591
+ :aria-label="`Label of payload field ${idx + 1}`"
1592
+ :model-value="mf.label ?? ''"
1593
+ placeholder="Label shown in the Run dialog"
1594
+ class="h-8 min-w-0 flex-1 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white text-sm"
1595
+ @update:model-value="(v) => setManualField(idx, { label: String(v) })"
1596
+ />
1597
+ <button
1598
+ type="button"
1599
+ :aria-label="`Remove payload field ${idx + 1}`"
1600
+ class="flex size-7 shrink-0 items-center justify-center rounded text-muted-foreground transition hover:text-rose-600"
1601
+ @click="removeManualField(idx)"
1602
+ >
1603
+ <Trash2 :size="13" />
1604
+ </button>
1605
+ </div>
1606
+ </div>
1607
+ <Button type="button" variant="outline" size="sm" class="h-7 text-sm" @click="addManualField">
1608
+ <Plus :size="12" />
1609
+ Add field
1610
+ </Button>
893
1611
  </template>
894
1612
  </div>
895
1613
 
896
1614
  <!-- Parameters: node config, generated from the manifest -->
897
- <div v-else-if="tab === 'parameters' && draftNode" class="mx-auto w-full max-w-3xl">
1615
+ <div v-else-if="tab === 'parameters' && draftNode" class="mx-auto w-full max-w-5xl">
898
1616
  <div v-if="manifest?.config?.length" class="space-y-3">
899
1617
  <div class="flex items-center justify-between">
900
1618
  <span class="text-mono text-muted-foreground">Configuration</span>
901
1619
  <div class="flex items-center gap-2">
902
- <span v-if="requiredMissingCount" class="text-sm text-amber-600">
903
- {{ requiredMissingCount }} required left
904
- </span>
905
1620
  <button
906
1621
  type="button"
907
1622
  class="flex items-center gap-1 rounded px-1 py-0.5 text-sm text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
@@ -921,7 +1636,7 @@ async function runTestStep() {
921
1636
  </div>
922
1637
  </div>
923
1638
  <IntegrationsNodeConfigField
924
- v-for="field in parameterFields"
1639
+ v-for="field in requiredFields"
925
1640
  :key="field.key"
926
1641
  :field="field"
927
1642
  :config="draftNode.config ?? {}"
@@ -932,8 +1647,51 @@ async function runTestStep() {
932
1647
  :depth="0"
933
1648
  :variables="variables"
934
1649
  :upstream-outputs="upstreamOutputs"
1650
+ :refused="refusedFields?.[field.key]"
935
1651
  @set="(k, v) => writeConfig(k, v)"
936
1652
  />
1653
+
1654
+ <!-- Optional settings, folded. The count is on the control on
1655
+ purpose: "Show 9 optional settings" is a decision the user can
1656
+ take without opening it, "Show more" is not. -->
1657
+ <template v-if="optionalFields.length">
1658
+ <button
1659
+ v-if="!optionalOpen"
1660
+ type="button"
1661
+ class="flex items-center gap-1.5 rounded px-1 py-1 text-sm text-muted-foreground transition hover:text-foreground"
1662
+ @click="optionalOpen = true"
1663
+ >
1664
+ <ChevronRight :size="13" />
1665
+ Show {{ optionalFields.length }} optional setting{{ optionalFields.length === 1 ? "" : "s" }}
1666
+ </button>
1667
+ <template v-else>
1668
+ <button
1669
+ v-if="requiredFields.length"
1670
+ type="button"
1671
+ class="flex items-center gap-1.5 rounded px-1 py-1 text-sm text-muted-foreground transition hover:text-foreground"
1672
+ @click="optionalOpen = false"
1673
+ >
1674
+ <ChevronDown :size="13" />
1675
+ Hide {{ optionalFields.length }} optional setting{{ optionalFields.length === 1 ? "" : "s" }}
1676
+ </button>
1677
+ <IntegrationsNodeConfigField
1678
+ v-for="field in optionalFields"
1679
+ :key="field.key"
1680
+ :field="field"
1681
+ :config="draftNode.config ?? {}"
1682
+ :slug="draftNode.nodeSlug"
1683
+ :version="draftNode.nodeVersion"
1684
+ :secret-keys="secretKeys"
1685
+ :static-keys="(manifest.config ?? []).map((f2) => f2.key)"
1686
+ :depth="0"
1687
+ :variables="variables"
1688
+ :upstream-outputs="upstreamOutputs"
1689
+ :refused="refusedFields?.[field.key]"
1690
+ @set="(k, v) => writeConfig(k, v)"
1691
+ />
1692
+ </template>
1693
+ </template>
1694
+
937
1695
  <p v-if="!parameterFields.length" class="text-sm text-muted-foreground italic">
938
1696
  All of this node's options live on the Settings tab.
939
1697
  </p>
@@ -944,10 +1702,19 @@ async function runTestStep() {
944
1702
  </div>
945
1703
 
946
1704
  <!-- Settings: node-instance execution settings -->
947
- <div v-else-if="tab === 'settings' && draftNode" class="mx-auto w-full max-w-3xl space-y-6">
948
- <!-- Node behavior: settings-like fields from the node's own config -->
1705
+ <div v-else-if="tab === 'settings' && draftNode" class="mx-auto w-full max-w-5xl space-y-6">
1706
+ <!-- The node's OWN options that happen to be about waiting or
1707
+ retrying. They read like engine settings and sit next to them,
1708
+ which is why both blocks say out loud which is which: a node's
1709
+ "Poll timeout" and the engine's "Timeout" are otherwise two
1710
+ identical-looking numbers with nothing to tell them apart. -->
949
1711
  <div v-if="settingsFields.length" class="space-y-3">
950
- <span class="text-mono text-muted-foreground">Node behavior</span>
1712
+ <div>
1713
+ <span class="text-mono text-muted-foreground">Options of this node</span>
1714
+ <p class="text-xs text-muted-foreground">
1715
+ Settings this node type defines for itself.
1716
+ </p>
1717
+ </div>
951
1718
  <IntegrationsNodeConfigField
952
1719
  v-for="field in settingsFields"
953
1720
  :key="field.key"
@@ -960,51 +1727,67 @@ async function runTestStep() {
960
1727
  :depth="0"
961
1728
  :variables="variables"
962
1729
  :upstream-outputs="upstreamOutputs"
1730
+ :refused="refusedFields?.[field.key]"
963
1731
  @set="(k, v) => writeConfig(k, v)"
964
1732
  />
965
1733
  </div>
1734
+ <!-- The rule only earns its place when there is something above to
1735
+ divide from. -->
1736
+ <div :class="settingsFields.length ? 'border-t border-black/[0.06] pt-5' : ''">
1737
+ <span class="text-mono text-muted-foreground">How the engine runs this step</span>
1738
+ <p class="text-xs text-muted-foreground">
1739
+ The same settings on every node type, applied by the engine around whatever the node does.
1740
+ </p>
1741
+ </div>
1742
+
966
1743
  <!-- Retry policy -->
967
- <section class="space-y-2">
968
- <div class="flex items-center justify-between rounded-lg border border-black/[0.06] bg-muted/20 px-3 py-2">
1744
+ <!-- One card: the toggle heads it, the policy fields expand
1745
+ inside — they belong to the same schematic unit. -->
1746
+ <section class="rounded-lg border border-black/[0.06] bg-muted/20">
1747
+ <div class="flex items-center gap-3 px-3 py-2">
1748
+ <Switch
1749
+ aria-label="Retry on failure"
1750
+ class="data-[state=unchecked]:bg-slate-300"
1751
+ :model-value="!!draftNode.retry"
1752
+ @update:model-value="(v) => patchNode({ retry: v ? { maxAttempts: 3, backoffSeconds: 1, strategy: 'exponential' } : void 0 })"
1753
+ />
969
1754
  <div>
970
1755
  <div class="text-sm font-medium">Retry on failure</div>
971
1756
  <p class="text-xs text-muted-foreground">
972
1757
  Re-run this node automatically when an attempt fails.
973
1758
  </p>
974
1759
  </div>
975
- <Switch
976
- :model-value="!!draftNode.retry"
977
- @update:model-value="(v) => patchNode({ retry: v ? { maxAttempts: 3, backoffSeconds: 1, strategy: 'exponential' } : void 0 })"
978
- />
979
1760
  </div>
980
- <div v-if="draftNode.retry" class="grid grid-cols-2 gap-3 rounded-lg border border-black/[0.06] p-3">
1761
+ <div v-if="draftNode.retry" class="grid grid-cols-4 gap-3 border-t border-black/[0.06] p-3">
981
1762
  <div class="space-y-1.5">
982
- <Label class="text-mono text-muted-foreground">Max attempts</Label>
1763
+ <Label for="ni-retry-attempts" class="text-mono text-muted-foreground">Max attempts</Label>
983
1764
  <Input
1765
+ id="ni-retry-attempts"
984
1766
  type="number"
985
1767
  min="1"
986
1768
  :model-value="draftNode.retry?.maxAttempts ?? 3"
987
- class="h-8 text-sm bg-white"
1769
+ class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white"
988
1770
  @update:model-value="(v) => patchNode({ retry: { ...draftNode.retry, maxAttempts: Number(v) || 1 } })"
989
1771
  />
990
1772
  </div>
991
1773
  <div class="space-y-1.5">
992
- <Label class="text-mono text-muted-foreground">Backoff seconds</Label>
1774
+ <Label for="ni-retry-backoff" class="text-mono text-muted-foreground">Backoff seconds</Label>
993
1775
  <Input
1776
+ id="ni-retry-backoff"
994
1777
  type="number"
995
1778
  min="0"
996
1779
  :model-value="draftNode.retry?.backoffSeconds ?? 0"
997
- class="h-8 text-sm bg-white"
1780
+ class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white"
998
1781
  @update:model-value="(v) => patchNode({ retry: { ...draftNode.retry, backoffSeconds: Number(v) || 0 } })"
999
1782
  />
1000
1783
  </div>
1001
1784
  <div class="col-span-2 space-y-1.5">
1002
- <Label class="text-mono text-muted-foreground">Strategy</Label>
1785
+ <Label for="ni-retry-strategy" class="text-mono text-muted-foreground">Strategy</Label>
1003
1786
  <Select
1004
1787
  :model-value="draftNode.retry?.strategy ?? 'exponential'"
1005
1788
  @update:model-value="(v) => patchNode({ retry: { ...draftNode.retry, strategy: v } })"
1006
1789
  >
1007
- <SelectTrigger class="h-8 text-sm bg-white">
1790
+ <SelectTrigger id="ni-retry-strategy" size="sm" class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1008
1791
  <SelectValue />
1009
1792
  </SelectTrigger>
1010
1793
  <SelectContent>
@@ -1018,14 +1801,15 @@ async function runTestStep() {
1018
1801
 
1019
1802
  <!-- Timeout: engine start-to-close ceiling for one attempt -->
1020
1803
  <section class="space-y-1.5">
1021
- <Label class="text-mono text-muted-foreground">Timeout</Label>
1804
+ <Label for="ni-timeout" class="text-mono text-muted-foreground">Timeout</Label>
1022
1805
  <Input
1806
+ id="ni-timeout"
1023
1807
  type="number"
1024
1808
  min="1"
1025
1809
  max="7200"
1026
1810
  placeholder="600"
1027
1811
  :model-value="draftNode.timeoutSeconds ?? ''"
1028
- class="h-8 text-sm bg-white"
1812
+ class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white"
1029
1813
  @update:model-value="(v) => patchNode({ timeoutSeconds: Number(v) > 0 ? Math.min(Math.trunc(Number(v)), 7200) : void 0 })"
1030
1814
  />
1031
1815
  <p class="text-xs text-muted-foreground">
@@ -1035,12 +1819,12 @@ async function runTestStep() {
1035
1819
 
1036
1820
  <!-- On error: what happens once the retries are exhausted -->
1037
1821
  <section class="space-y-1.5">
1038
- <Label class="text-mono text-muted-foreground">On error</Label>
1822
+ <Label for="ni-on-error" class="text-mono text-muted-foreground">On error</Label>
1039
1823
  <Select
1040
1824
  :model-value="draftNode.onError ?? 'fail'"
1041
1825
  @update:model-value="(v) => patchNode({ onError: v === 'route' ? 'route' : void 0 })"
1042
1826
  >
1043
- <SelectTrigger class="h-8 text-sm bg-white">
1827
+ <SelectTrigger id="ni-on-error" size="sm" class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1044
1828
  <SelectValue />
1045
1829
  </SelectTrigger>
1046
1830
  <SelectContent>
@@ -1052,165 +1836,153 @@ async function runTestStep() {
1052
1836
  Applies after the retry policy is exhausted. A Stop and Error node always fails the run.
1053
1837
  </p>
1054
1838
  </section>
1839
+ </div>
1055
1840
 
1056
- <!-- Notes: editor-only blob field -->
1057
- <section class="space-y-1.5">
1058
- <Label class="text-mono text-muted-foreground">Notes</Label>
1841
+ <!-- Test: run this one step and look at what came back. Its own tab
1842
+ rather than a permanent column — it is the last thing done, if it
1843
+ is done at all, and it needs the width when it is. -->
1844
+ <div v-else-if="tab === 'output' && draftNode" class="mx-auto w-full max-w-5xl space-y-5">
1845
+ <section class="space-y-2">
1846
+ <div class="flex items-center justify-between gap-2">
1847
+ <p class="text-xs text-muted-foreground">
1848
+ What downstream nodes receive from this one. Declared by the node type;
1849
+ filled with real values once the step has run.
1850
+ </p>
1851
+ <div class="flex shrink-0 items-center rounded-md border border-black/[0.06] bg-white p-0.5">
1852
+ <button type="button" :class="outputView === 'schema' ? segActive : segIdle" @click="outputView = 'schema'">
1853
+ Schema
1854
+ </button>
1855
+ <button type="button" :disabled="!selfSample" :class="outputView === 'json' ? segActive : segIdle" @click="outputView = 'json'">
1856
+ JSON
1857
+ </button>
1858
+ </div>
1859
+ </div>
1860
+ <template v-if="outputView === 'schema'">
1861
+ <IntegrationsNodeOutputSchema :ports="selfPorts" :value-at="(port, field2) => chipValue(draft.id, port, field2)" />
1862
+ </template>
1863
+ <pre v-else class="overflow-auto rounded-md border border-black/[0.06] bg-white p-3 font-mono text-xs leading-snug">{{ selfSampleJson }}</pre>
1864
+ </section>
1865
+
1866
+ <section class="space-y-2 border-t border-black/[0.06] pt-4">
1867
+ <span class="text-mono text-muted-foreground">Real values</span>
1868
+ <div class="flex items-center gap-2">
1869
+ <Button
1870
+ size="sm"
1871
+ class="h-8"
1872
+ :disabled="testRunning"
1873
+ title="Runs this node once with the current draft config"
1874
+ @click="runTestStep"
1875
+ >
1876
+ {{ testRunning ? "Testing\u2026" : "Test step" }}
1877
+ </Button>
1878
+ <button
1879
+ type="button"
1880
+ class="rounded px-1 py-1 text-sm font-medium text-muted-foreground transition hover:text-foreground"
1881
+ @click="toggleTestInput"
1882
+ >
1883
+ {{ testInputOpen ? "Hide input" : "Edit input" }}
1884
+ </button>
1885
+ <span class="text-xs text-muted-foreground">
1886
+ Runs this step alone, against the draft config — the workflow is not started.
1887
+ </span>
1888
+ </div>
1059
1889
  <Textarea
1060
- :model-value="draftNode.notes ?? ''"
1061
- :rows="3"
1062
- placeholder="Optional note about this node"
1063
- class="text-sm bg-white"
1064
- @update:model-value="(v) => patchNode({ notes: String(v) || void 0 })"
1890
+ v-if="testInputOpen"
1891
+ v-model="testInputDraft"
1892
+ aria-label="Test input"
1893
+ :rows="8"
1894
+ spellcheck="false"
1895
+ class="rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-xs"
1065
1896
  />
1066
- <p class="text-xs text-muted-foreground">
1067
- Only shown in the editor; never sent to the engine.
1897
+ <p v-if="testError" class="text-sm text-rose-600">
1898
+ {{ testError }}
1068
1899
  </p>
1900
+ <div v-if="testLogs.length" class="max-h-48 space-y-0.5 overflow-y-auto rounded-md border border-black/[0.06] bg-white p-2">
1901
+ <div
1902
+ v-for="(l, i) in testLogs"
1903
+ :key="i"
1904
+ class="font-mono text-[11px] leading-snug"
1905
+ :class="l.level === 'error' ? 'text-rose-600' : l.level === 'warn' ? 'text-amber-600' : 'text-slate-600'"
1906
+ >
1907
+ {{ l.message }}
1908
+ </div>
1909
+ </div>
1069
1910
  </section>
1070
- </div>
1071
- </div>
1072
- </section>
1073
1911
 
1074
- <!-- Output pane: what this node hands downstream -->
1075
- <aside v-if="isNode" class="flex w-[280px] shrink-0 flex-col border-l border-black/[0.06] bg-muted/10">
1076
- <div class="shrink-0 border-b border-black/[0.06] px-3 py-2.5">
1077
- <div class="flex items-center justify-between gap-2">
1078
- <div class="text-sm font-semibold text-foreground">
1079
- Output
1080
- </div>
1081
- <div class="flex items-center rounded-md border border-black/[0.06] bg-white p-0.5">
1082
- <button type="button" :class="outputView === 'schema' ? segActive : segIdle" @click="outputView = 'schema'">
1083
- Schema
1084
- </button>
1085
- <button type="button" :disabled="!selfSample" :class="outputView === 'json' ? segActive : segIdle" @click="outputView = 'json'">
1086
- JSON
1087
- </button>
1088
- </div>
1089
- </div>
1090
- <p class="mt-0.5 text-xs leading-snug text-muted-foreground">
1091
- What downstream nodes receive from this one.
1092
- </p>
1093
- </div>
1094
- <div class="shrink-0 space-y-1.5 border-b border-black/[0.06] px-3 py-2">
1095
- <div class="flex items-center gap-2">
1096
- <Button
1097
- size="sm"
1098
- class="h-7 flex-1 text-xs"
1099
- :disabled="testRunning"
1100
- title="Runs this node once with the current draft config"
1101
- @click="runTestStep"
1102
- >
1103
- {{ testRunning ? "Testing\u2026" : "Test step" }}
1104
- </Button>
1105
- <button
1106
- type="button"
1107
- class="shrink-0 text-[11px] font-medium text-muted-foreground transition hover:text-foreground"
1108
- @click="toggleTestInput"
1109
- >
1110
- {{ testInputOpen ? "Hide input" : "Edit input" }}
1111
- </button>
1112
- </div>
1113
- <Textarea
1114
- v-if="testInputOpen"
1115
- v-model="testInputDraft"
1116
- :rows="5"
1117
- spellcheck="false"
1118
- class="bg-white font-mono text-[11px]"
1119
- />
1120
- <p v-if="testError" class="text-[11px] leading-snug text-rose-600">
1121
- {{ testError }}
1122
- </p>
1123
- <div v-if="testLogs.length" class="max-h-24 space-y-0.5 overflow-y-auto rounded-md border border-black/[0.06] bg-white p-1.5">
1124
- <div
1125
- v-for="(l, i) in testLogs"
1126
- :key="i"
1127
- class="font-mono text-[10px] leading-snug"
1128
- :class="l.level === 'error' ? 'text-rose-600' : l.level === 'warn' ? 'text-amber-600' : 'text-slate-600'"
1129
- >
1130
- {{ l.message }}
1131
- </div>
1132
- </div>
1133
- </div>
1134
- <div class="min-h-0 flex-1 overflow-y-auto px-3 py-3">
1135
- <template v-if="outputView === 'schema'">
1136
- <div v-for="p in selfPorts" :key="p.name" class="mb-3 space-y-1">
1137
- <div class="flex items-center gap-1.5">
1138
- <span class="size-1.5 rounded-full" :class="portDotClass(p.kind, p.name)" />
1139
- <span class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{{ p.name }}</span>
1140
- </div>
1141
- <div v-if="p.fields.length" class="space-y-0.5">
1142
- <div v-for="f in p.fields" :key="f.name" class="flex min-w-0 items-baseline gap-1 font-mono text-[11px]">
1143
- <span v-if="typeGlyph(f.dataType)" class="shrink-0 font-semibold" :class="typeGlyph(f.dataType).cls">{{ typeGlyph(f.dataType).char }}</span>
1144
- <span class="shrink-0 text-slate-700">{{ f.name }}</span>
1145
- <span v-if="chipValue(draft.id, p.name, f.name) !== null" class="min-w-0 truncate text-slate-400">: {{ chipValue(draft.id, p.name, f.name) }}</span>
1912
+ <section class="space-y-2 border-t border-black/[0.06] pt-4">
1913
+ <template v-if="!pinEditorOpen">
1914
+ <div class="flex items-center gap-2">
1915
+ <Button variant="outline" size="sm" class="h-8" @click="openPinEditor">
1916
+ {{ selfPinned ? "Edit pinned sample" : "Pin sample output" }}
1917
+ </Button>
1918
+ <button
1919
+ v-if="selfPinned"
1920
+ type="button"
1921
+ class="text-sm font-medium text-rose-600 transition hover:text-rose-700"
1922
+ @click="removePin"
1923
+ >
1924
+ Unpin
1925
+ </button>
1926
+ <span class="text-xs text-muted-foreground">
1927
+ A pinned sample stands in for run data while you map downstream fields.
1928
+ </span>
1146
1929
  </div>
1147
- </div>
1148
- <p v-else class="text-[11px] leading-snug text-muted-foreground">
1149
- No declared fields<template v-if="chipValue(draft.id, p.name) !== null">; sampled: <span class="font-mono">{{ chipValue(draft.id, p.name) }}</span></template>.
1150
- </p>
1151
- </div>
1152
- <p v-if="!selfPorts.length" class="text-xs leading-snug text-muted-foreground">
1153
- This node declares no outputs.
1154
- </p>
1155
- </template>
1156
- <pre v-else class="overflow-auto rounded-md border border-black/[0.06] bg-white p-2 font-mono text-[11px] leading-snug">{{ selfSampleJson }}</pre>
1157
- </div>
1158
- <div class="shrink-0 border-t border-black/[0.06] px-3 py-2">
1159
- <template v-if="!pinEditorOpen">
1160
- <div class="flex items-center gap-2">
1161
- <button
1162
- type="button"
1163
- class="rounded-md border border-black/[0.08] bg-white px-2 py-1 text-[11px] font-medium text-muted-foreground transition hover:bg-muted/30 hover:text-foreground"
1164
- @click="openPinEditor"
1165
- >
1166
- {{ selfPinned ? "Edit pinned sample" : "Pin sample output" }}
1167
- </button>
1168
- <button
1169
- v-if="selfPinned"
1170
- type="button"
1171
- class="text-[11px] font-medium text-rose-600 transition hover:text-rose-700"
1172
- @click="removePin"
1173
- >
1174
- Unpin
1175
- </button>
1176
- </div>
1177
- <p class="mt-1 text-[11px] leading-snug text-muted-foreground">
1178
- A pinned sample stands in for run data while you map downstream fields.
1179
- </p>
1180
- </template>
1181
- <template v-else>
1182
- <Textarea
1183
- v-model="pinDraft"
1184
- :rows="8"
1185
- spellcheck="false"
1186
- class="bg-white font-mono text-[11px]"
1187
- />
1188
- <p v-if="pinError" class="mt-1 text-[11px] text-amber-600">
1189
- {{ pinError }}
1190
- </p>
1191
- <div class="mt-1.5 flex items-center gap-2">
1192
- <Button size="sm" class="h-7 text-xs" @click="savePin">
1193
- Pin
1194
- </Button>
1195
- <Button size="sm" variant="outline" class="h-7 text-xs" @click="pinEditorOpen = false">
1196
- Cancel
1197
- </Button>
1198
- </div>
1199
- </template>
1930
+ </template>
1931
+ <template v-else>
1932
+ <Textarea
1933
+ v-model="pinDraft"
1934
+ aria-label="Pinned sample output"
1935
+ :rows="10"
1936
+ spellcheck="false"
1937
+ class="rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-xs"
1938
+ />
1939
+ <p v-if="pinError" class="text-sm text-amber-600">
1940
+ {{ pinError }}
1941
+ </p>
1942
+ <div class="flex items-center gap-2">
1943
+ <Button size="sm" class="h-8" @click="savePin">
1944
+ Pin
1945
+ </Button>
1946
+ <Button size="sm" variant="outline" class="h-8" @click="pinEditorOpen = false">
1947
+ Cancel
1948
+ </Button>
1949
+ </div>
1950
+ </template>
1951
+ </section>
1952
+ </div>
1200
1953
  </div>
1201
- </aside>
1954
+ </section>
1202
1955
  </div>
1203
1956
 
1204
1957
  <!-- Footer: draft semantics — nothing reaches the blob until Save -->
1205
1958
  <div class="flex shrink-0 items-center justify-between gap-3 border-t border-black/[0.06] px-4 py-3">
1959
+ <!-- Quiet until reached for: a bare glyph that unfolds into its name and
1960
+ its colour on hover or focus.
1961
+
1962
+ It is the rarest of the four ways to delete a node — the canvas
1963
+ offers the card's toolbar, the context menu and the Delete key — and
1964
+ it sits in the row the user reads to decide what happens to their
1965
+ edits, where a third, louder option reads as one of the answers.
1966
+ Rose carries a meaning now, too: a setting the last save refused.
1967
+ Spending it on a footer button weakens that.
1968
+
1969
+ The name is on the button itself, not only in the revealed span, so
1970
+ it reaches a screen reader without a pointer. Revealing on focus as
1971
+ well as hover is what keeps it usable from the keyboard. The button
1972
+ grows leftmost into empty space, so nothing else moves. -->
1206
1973
  <Button
1207
1974
  variant="ghost"
1208
1975
  size="sm"
1209
- class="h-8 text-sm text-rose-600 hover:bg-rose-500/10 hover:text-rose-700"
1976
+ aria-label="Remove from workflow"
1977
+ class="group h-8 gap-0 text-sm text-muted-foreground transition-colors hover:bg-rose-500/10 hover:text-rose-700 focus-visible:bg-rose-500/10 focus-visible:text-rose-700"
1210
1978
  @click="emit('remove')"
1211
1979
  >
1212
1980
  <Trash2 :size="13" />
1213
- Remove from workflow
1981
+ <span
1982
+ class="max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-150 group-hover:ml-1.5 group-hover:max-w-48 group-hover:opacity-100 group-focus-visible:ml-1.5 group-focus-visible:max-w-48 group-focus-visible:opacity-100"
1983
+ >
1984
+ Remove from workflow
1985
+ </span>
1214
1986
  </Button>
1215
1987
  <div class="flex items-center gap-2">
1216
1988
  <span v-if="dirty" class="text-xs text-muted-foreground">Unsaved changes</span>