@unofficialbox/box-open-elements 0.28.7 → 0.29.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.
@@ -13,6 +13,7 @@ const emit = (element, name, detail) => element.dispatchEvent(new CustomEvent(na
13
13
  composed: true,
14
14
  cancelable: true,
15
15
  }));
16
+ const formatRunDuration = (ms) => ms >= 1000 ? `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 1)} s` : `${Math.round(ms)} ms`;
16
17
  /** Projects host documents without owning workflow schemas or persistence. */
17
18
  export class ProcessModeler extends BaseElement {
18
19
  static tagName = "box-process-modeler";
@@ -47,6 +48,7 @@ export class ProcessModeler extends BaseElement {
47
48
  insertion;
48
49
  keyboardInsertion;
49
50
  keyboardReturn;
51
+ transientChooserAnchor;
50
52
  stopKeyboardAnchor;
51
53
  activeDrawer;
52
54
  drawerReturn;
@@ -322,7 +324,11 @@ export class ProcessModeler extends BaseElement {
322
324
  if (command.layout)
323
325
  this.layoutValue = structuredClone(command.layout);
324
326
  this.refresh();
325
- if (!command.layout &&
327
+ const inserted = edit.type === 'insert' && !edit.boxId && !command.layout
328
+ ? this.projection.boxes.find(box => !previousIds.has(box.id)) : undefined;
329
+ const reflowed = inserted ? this.reflowStraightInsert(edit, inserted.id, before) : false;
330
+ if (!reflowed &&
331
+ !command.layout &&
326
332
  edit.position &&
327
333
  Number.isFinite(edit.position.x) &&
328
334
  Number.isFinite(edit.position.y)) {
@@ -422,6 +428,42 @@ export class ProcessModeler extends BaseElement {
422
428
  }
423
429
  return next;
424
430
  }
431
+ /** Make room only on a simple, root-level horizontal connection. */
432
+ reflowStraightInsert(edit, insertedId, before) {
433
+ const source = this.projection.boxes.find(box => box.id === edit.from);
434
+ const target = this.projection.boxes.find(box => box.id === edit.to);
435
+ const inserted = this.projection.boxes.find(box => box.id === insertedId);
436
+ if (!source || !target || !inserted || source.parentId || target.parentId || inserted.parentId)
437
+ return false;
438
+ const start = before.boxes[source.id], end = before.boxes[target.id];
439
+ const fresh = this.layoutValue.boxes[insertedId];
440
+ if (!start || !end || !fresh)
441
+ return false;
442
+ const sourceWidth = start.width ?? 224, sourceHeight = start.height ?? 64;
443
+ const targetHeight = end.height ?? 64, width = fresh.width ?? 224, height = fresh.height ?? 64;
444
+ if (start.x + sourceWidth >= end.x || Math.abs(start.y + sourceHeight / 2 - end.y - targetHeight / 2) > 24)
445
+ return false;
446
+ const x = start.x + sourceWidth + 80;
447
+ const delta = Math.max(0, x + width + 80 - end.x);
448
+ const next = this.layout;
449
+ const shifted = new Set(this.projection.boxes.filter(box => !box.parentId && before.boxes[box.id]?.x >= end.x).map(box => box.id));
450
+ for (let changed = true; changed;) {
451
+ changed = false;
452
+ for (const box of this.projection.boxes)
453
+ if (box.parentId && shifted.has(box.parentId) && !shifted.has(box.id)) {
454
+ shifted.add(box.id);
455
+ changed = true;
456
+ }
457
+ }
458
+ for (const box of this.projection.boxes) {
459
+ if (box.id === insertedId || !shifted.has(box.id))
460
+ continue;
461
+ next.boxes[box.id] = { ...next.boxes[box.id], x: next.boxes[box.id].x + delta };
462
+ }
463
+ next.boxes[insertedId] = { ...fresh, x, y: start.y + (sourceHeight - height) / 2 };
464
+ this.layoutValue = next;
465
+ return true;
466
+ }
425
467
  tidy() {
426
468
  if (this.locked)
427
469
  return;
@@ -439,7 +481,7 @@ export class ProcessModeler extends BaseElement {
439
481
  element.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'translate(0, 0)' }], { duration: 320, easing: 'ease-out' });
440
482
  }
441
483
  }
442
- this.fit();
484
+ this.fitTo(this.narrowValue ? 0.55 : 0.7);
443
485
  }
444
486
  resize(id, width, height) {
445
487
  if (!this.layoutValue.boxes[id] ||
@@ -510,10 +552,11 @@ export class ProcessModeler extends BaseElement {
510
552
  this.viewport.zoom = next;
511
553
  this.paintViewport();
512
554
  }
513
- fit() {
555
+ fit() { this.fitTo(0.25); }
556
+ fitTo(minimumZoom) {
514
557
  const bounds = this.bounds();
515
558
  const canvas = this.shadowRoot.querySelector("[part=canvas]");
516
- this.viewport.zoom = Math.max(0.25, Math.min(1.5, ((canvas.clientWidth || 700) - 40) / bounds.width, ((canvas.clientHeight || 480) - 40) / bounds.height));
559
+ this.viewport.zoom = Math.max(minimumZoom, Math.min(1.5, ((canvas.clientWidth || 700) - 40) / bounds.width, ((canvas.clientHeight || 480) - 40) / bounds.height));
517
560
  this.viewport.x = 20 - bounds.x * this.viewport.zoom;
518
561
  this.viewport.y = 20 - bounds.y * this.viewport.zoom;
519
562
  this.paintViewport();
@@ -1355,7 +1398,11 @@ export class ProcessModeler extends BaseElement {
1355
1398
  if (event.ctrlKey && event.altKey && this.catalog.length && !this.disableConnections) {
1356
1399
  const side = direction[0] < 0 ? "west" : direction[0] > 0 ? "east" : direction[1] < 0 ? "north" : "south";
1357
1400
  const pos = this.layoutValue.boxes[box.id];
1358
- this.openChooser({ type: "add", from: box.id, fromSide: side, position: { x: pos.x + direction[0] * 320, y: pos.y + direction[1] * 170 } });
1401
+ const anchor = Array.from(this.shadowRoot.querySelectorAll('[part=port]'))
1402
+ .find(element => element.dataset.owner === box.id && element.dataset.side === side);
1403
+ const returnFocus = Array.from(this.shadowRoot.querySelectorAll('[data-box-id]'))
1404
+ .find(element => element.dataset.boxId === box.id);
1405
+ this.openAnchoredChooser(this.nextEdit(box, side, { x: pos.x + direction[0] * 320, y: pos.y + direction[1] * 170 }), this.nextChooserTitle(box, side), anchor, returnFocus);
1359
1406
  return;
1360
1407
  }
1361
1408
  if (event.altKey) {
@@ -1479,7 +1526,6 @@ export class ProcessModeler extends BaseElement {
1479
1526
  if (!ghost) {
1480
1527
  ghost = svgElement("polyline");
1481
1528
  ghost.setAttribute("part", "connection-preview");
1482
- ghost.style.cssText = "fill:none;stroke:var(--boe-token-surface-surface-brand,#0061d5);stroke-width:3";
1483
1529
  this.shadowRoot.querySelector('[part=lines]').append(ghost);
1484
1530
  }
1485
1531
  const start = this.portDrag.start;
@@ -1488,7 +1534,7 @@ export class ProcessModeler extends BaseElement {
1488
1534
  const pinned = target ? this.sideAt(target, point) : undefined;
1489
1535
  this.shadowRoot.querySelectorAll('[data-box-id]').forEach(box => { box.dataset.connectTarget = String(box.dataset.boxId === target?.id); });
1490
1536
  this.shadowRoot.querySelectorAll('[part=port]').forEach(port => { port.dataset.hot = String(port.dataset.owner === target?.id && port.dataset.side === pinned); });
1491
- this.showConnectTooltip(point, target ? pinned ? `Pin to the ${pinned} of ${target.title}` : `Connect to ${target.title}` : 'Drop on a step to connect', !target);
1537
+ this.showConnectTooltip(point, target ? pinned ? `Pin to the ${pinned} of ${target.title}` : `Connect to ${target.title}` : 'Drop here to add a connected step');
1492
1538
  return;
1493
1539
  }
1494
1540
  if (this.endDrag) {
@@ -1589,12 +1635,23 @@ export class ProcessModeler extends BaseElement {
1589
1635
  if (Math.hypot(point.x - port.start.x, point.y - port.start.y) < 8) {
1590
1636
  const pos = this.layoutValue.boxes[port.id];
1591
1637
  const offsets = { east: [320, 0], west: [-320, 0], north: [0, -170], south: [0, 170] }[port.side];
1592
- this.openChooser({ type: "add", from: port.id, fromSide: port.side, position: { x: pos.x + offsets[0], y: pos.y + offsets[1] } });
1638
+ const source = this.projection.boxes.find(box => box.id === port.id);
1639
+ const anchor = Array.from(this.shadowRoot.querySelectorAll('[part=port]'))
1640
+ .find(element => element.dataset.owner === port.id && element.dataset.side === port.side);
1641
+ this.openAnchoredChooser(this.nextEdit(source, port.side, { x: pos.x + offsets[0], y: pos.y + offsets[1] }), this.nextChooserTitle(source, port.side), anchor);
1593
1642
  }
1594
1643
  else {
1595
1644
  const target = this.boxAt(point, port.id);
1596
1645
  if (target)
1597
1646
  this.requestEdit({ type: "connect", from: port.id, to: target.id, fromSide: port.side, toSide: this.sideAt(target, point) });
1647
+ else {
1648
+ const source = this.projection.boxes.find(box => box.id === port.id);
1649
+ const frame = this.boxAt(point, undefined, true);
1650
+ if ((frame?.id ?? null) !== (source.parentId ?? null))
1651
+ this.setStatus('Drop inside the same frame to add a connected step');
1652
+ else
1653
+ this.openChooserAtPoint({ type: 'add', from: port.id, fromSide: port.side, parentId: source.parentId, position: point }, `Add after ${source.title}`, point);
1654
+ }
1598
1655
  }
1599
1656
  }
1600
1657
  return;
@@ -1773,11 +1830,45 @@ export class ProcessModeler extends BaseElement {
1773
1830
  promoteModal(chooser);
1774
1831
  picker.focus();
1775
1832
  }
1833
+ nextEdit(box, side, position) {
1834
+ const shape = box.shape ?? this.catalog.find(kind => kind.kind === box.kind)?.shape;
1835
+ const outgoing = this.projection.lines.filter(line => line.from === box.id);
1836
+ if ((!side || side === 'east') && shape !== 'gateway' && outgoing.length === 1)
1837
+ return { type: 'insert', lineId: outgoing[0].id, from: box.id, to: outgoing[0].to };
1838
+ return { type: 'add', from: box.id, ...(side ? { fromSide: side } : {}), parentId: box.parentId, ...(position ? { position } : {}) };
1839
+ }
1840
+ nextChooserTitle(box, side) {
1841
+ const edit = this.nextEdit(box, side);
1842
+ if (edit.type === 'insert') {
1843
+ const target = this.projection.boxes.find(candidate => candidate.id === edit.to);
1844
+ return `Insert between ${box.title} and ${target?.title ?? 'the next step'}`;
1845
+ }
1846
+ if (box.shape === 'gateway')
1847
+ return `Add a path from ${box.title}`;
1848
+ return side && side !== 'east' ? `Add ${{ north: 'above', south: 'below', west: 'before' }[side]} ${box.title}, connected` : `Add after ${box.title}`;
1849
+ }
1850
+ openChooserAtPoint(edit, title, point, returnFocus) {
1851
+ if (this.keyboardInsertion)
1852
+ this.closeKeyboardChooser();
1853
+ const anchor = document.createElement('span');
1854
+ anchor.style.cssText = `position:absolute;left:${point.x}px;top:${point.y}px;width:1px;height:1px;pointer-events:none`;
1855
+ this.shadowRoot.querySelector('[part=world]').append(anchor);
1856
+ this.transientChooserAnchor = anchor;
1857
+ this.openAnchoredChooser(edit, title, anchor, returnFocus);
1858
+ }
1859
+ openLineChooser(line, returnFocus) {
1860
+ const source = this.projection.boxes.find(box => box.id === line.from);
1861
+ const target = this.projection.boxes.find(box => box.id === line.to);
1862
+ const points = this.routedLines.get(line.id) ?? routeProcessLine(line, this.layoutValue, this.projection);
1863
+ this.openChooserAtPoint({ type: 'insert', lineId: line.id, from: line.from, to: line.to }, `Insert between ${source?.title ?? line.from} and ${target?.title ?? line.to}`, lineMidpoint(points), returnFocus);
1864
+ }
1776
1865
  closeKeyboardChooser(returnFocus = false) {
1777
1866
  const chooser = this.shadowRoot?.querySelector('[part=keyboard-chooser]');
1778
1867
  dismissPopover(chooser ?? null);
1779
1868
  this.stopKeyboardAnchor?.();
1780
1869
  this.stopKeyboardAnchor = undefined;
1870
+ this.transientChooserAnchor?.remove();
1871
+ this.transientChooserAnchor = undefined;
1781
1872
  document.removeEventListener('pointerdown', this.dismissKeyboardChooserOutside, true);
1782
1873
  this.keyboardInsertion = undefined;
1783
1874
  if (returnFocus)
@@ -1790,32 +1881,35 @@ export class ProcessModeler extends BaseElement {
1790
1881
  this.closeKeyboardChooser();
1791
1882
  };
1792
1883
  openKeyboardChooser(box) {
1793
- const chooser = this.shadowRoot.querySelector('[part=keyboard-chooser]');
1794
1884
  const shape = box.shape ?? this.catalog.find(kind => kind.kind === box.kind)?.shape ?? (box.frame ? 'frame' : 'task');
1795
1885
  const outgoing = this.projection.lines.filter(line => line.from === box.id);
1796
1886
  const target = outgoing.length === 1 ? this.projection.boxes.find(candidate => candidate.id === outgoing[0].to) : undefined;
1797
1887
  const insert = shape !== 'gateway' && outgoing.length === 1 && target;
1888
+ const anchor = Array.from(this.shadowRoot.querySelectorAll('[data-box-id]'))
1889
+ .find(element => element.dataset.boxId === box.id);
1890
+ this.openAnchoredChooser(insert
1891
+ ? { type: 'insert', lineId: outgoing[0].id, from: box.id, to: target.id }
1892
+ : { type: 'add', from: box.id }, insert ? `Insert between ${box.title} and ${target.title}`
1893
+ : shape === 'gateway' ? `Add a path from ${box.title}` : `Add after ${box.title}`, anchor);
1894
+ }
1895
+ openAnchoredChooser(edit, title, anchor, returnFocus) {
1896
+ const chooser = this.shadowRoot.querySelector('[part=keyboard-chooser]');
1798
1897
  if (this.keyboardInsertion)
1799
1898
  this.closeKeyboardChooser();
1800
- this.keyboardInsertion = insert
1801
- ? { type: 'insert', lineId: outgoing[0].id, from: box.id, to: target.id }
1802
- : { type: 'add', from: box.id };
1803
- chooser.querySelector('[part=chooser-title]').textContent = insert
1804
- ? `Insert between ${box.title} and ${target.title}`
1805
- : shape === 'gateway' ? `Add a path from ${box.title}` : `Add after ${box.title}`;
1899
+ this.keyboardInsertion = edit;
1900
+ chooser.querySelector('[part=chooser-title]').textContent = title;
1806
1901
  const picker = chooser.querySelector('box-kind-picker');
1807
1902
  picker.catalog = this.catalog.filter(kind => !kind.placement);
1808
1903
  picker.refresh();
1809
- this.keyboardReturn = Array.from(this.shadowRoot.querySelectorAll('[data-box-id]'))
1810
- .find(element => element.dataset.boxId === box.id);
1811
- if (!this.keyboardReturn || !promotePopover(chooser)) {
1904
+ this.keyboardReturn = returnFocus ?? anchor;
1905
+ if (!anchor || !promotePopover(chooser)) {
1812
1906
  const edit = this.keyboardInsertion;
1813
1907
  this.closeKeyboardChooser();
1814
1908
  if (edit)
1815
1909
  this.openChooser(edit);
1816
1910
  return;
1817
1911
  }
1818
- this.stopKeyboardAnchor = trackAnchor(this.keyboardReturn, chooser, {
1912
+ this.stopKeyboardAnchor = trackAnchor(anchor, chooser, {
1819
1913
  placement: { side: 'right', align: 'start' }, offset: 8, padding: 12,
1820
1914
  });
1821
1915
  document.addEventListener('pointerdown', this.dismissKeyboardChooserOutside, true);
@@ -1970,14 +2064,14 @@ export class ProcessModeler extends BaseElement {
1970
2064
  element.append(loop);
1971
2065
  }
1972
2066
  const metrics = this.showLastRunValue ? this.lastRunValue?.steps[box.id] : undefined;
1973
- if (metrics) {
2067
+ if (this.showLastRunValue && this.lastRunValue && shape === 'task') {
1974
2068
  const line = document.createElement("span");
1975
2069
  line.setAttribute("part", "metrics");
1976
- line.textContent = metrics.notInRun ? "Not in this run" : [
1977
- metrics.callsPerSecond === undefined ? "" : `${metrics.callsPerSecond.toLocaleString()}/s`,
1978
- metrics.p95Ms === undefined ? "" : `p95 ${metrics.p95Ms.toLocaleString()} ms`,
2070
+ line.textContent = !metrics || metrics.notInRun ? "Not in the last run" : [
2071
+ metrics.callsPerSecond === undefined ? "" : `${metrics.callsPerSecond.toFixed(1)}/s`,
2072
+ metrics.p95Ms === undefined ? "" : `p95 ${formatRunDuration(metrics.p95Ms)}`,
1979
2073
  ].filter(Boolean).join(" · ");
1980
- if (metrics.failedShare !== undefined && metrics.failedShare > 0) {
2074
+ if (metrics?.failedShare !== undefined && metrics.failedShare > 0 && !metrics.notInRun) {
1981
2075
  const failed = document.createElement('span');
1982
2076
  failed.setAttribute('part', 'metric-warning');
1983
2077
  failed.textContent = `${(metrics.failedShare * 100).toFixed(1)}% failed`;
@@ -2021,7 +2115,7 @@ export class ProcessModeler extends BaseElement {
2021
2115
  else
2022
2116
  this.select(box.id);
2023
2117
  });
2024
- if (!this.disableConnections && !this.locked) {
2118
+ if (!this.disableConnections && !this.locked && !['end', 'finish'].includes(box.kind)) {
2025
2119
  for (const side of ["north", "east", "south", "west"]) {
2026
2120
  const port = document.createElement("button");
2027
2121
  port.type = "button";
@@ -2034,7 +2128,7 @@ export class ProcessModeler extends BaseElement {
2034
2128
  if (!event.detail) {
2035
2129
  const pos = this.layoutValue.boxes[box.id];
2036
2130
  const offsets = { east: [320, 0], west: [-320, 0], north: [0, -170], south: [0, 170] }[side];
2037
- this.openChooser({ type: "add", from: box.id, fromSide: side, position: { x: pos.x + offsets[0], y: pos.y + offsets[1] } });
2131
+ this.openAnchoredChooser(this.nextEdit(box, side, { x: pos.x + offsets[0], y: pos.y + offsets[1] }), this.nextChooserTitle(box, side), port);
2038
2132
  }
2039
2133
  });
2040
2134
  element.append(port);
@@ -2096,7 +2190,7 @@ export class ProcessModeler extends BaseElement {
2096
2190
  if (line.dashed)
2097
2191
  path.setAttribute("stroke-dasharray", "6 4");
2098
2192
  if (this.showLastRunValue && line.share !== undefined) {
2099
- path.style.strokeWidth = `${1.5 + Math.max(0, Math.min(1, line.share)) * 2}px`;
2193
+ path.style.strokeWidth = `${Math.round((1.5 + Math.max(0, Math.min(1, line.share)) * 2.5) * 100) / 100}px`;
2100
2194
  path.setAttribute('aria-label', `${Math.round(line.share * 100)}% of last-run traffic`);
2101
2195
  }
2102
2196
  lines.append(path);
@@ -2138,6 +2232,11 @@ export class ProcessModeler extends BaseElement {
2138
2232
  const branchIndex = this.projection.lines.filter(candidate => candidate.from === line.from).indexOf(line);
2139
2233
  text.textContent = line.label ?? (line.weight !== undefined ? String(line.weight) : /decision/i.test(source.kind) ? ["Yes", "No"][branchIndex] ?? `Route ${branchIndex + 1}` : /try/i.test(source.kind) && line.dashed ? "If it fails" : "");
2140
2234
  label.append(text);
2235
+ if (this.showLastRunValue && line.share !== undefined) {
2236
+ const share = document.createElement('small');
2237
+ share.textContent = `${Math.round(line.share * 100)}%`;
2238
+ label.append(share);
2239
+ }
2141
2240
  const actions = document.createElement("div");
2142
2241
  actions.setAttribute("part", "connection-actions");
2143
2242
  label.append(actions);
@@ -2186,7 +2285,7 @@ export class ProcessModeler extends BaseElement {
2186
2285
  to: line.to,
2187
2286
  };
2188
2287
  if (type === "insert" && this.catalog.length) {
2189
- this.openChooser(edit);
2288
+ this.openLineChooser(line, button);
2190
2289
  }
2191
2290
  else
2192
2291
  this.requestEdit(edit);
@@ -2675,8 +2774,13 @@ export class ProcessModeler extends BaseElement {
2675
2774
  const report = document.createElement('section');
2676
2775
  report.setAttribute('part', 'inspector-metrics');
2677
2776
  const title = document.createElement('h3');
2678
- title.textContent = this.lastRunValue?.label ?? 'Last run';
2777
+ title.textContent = 'Last run';
2679
2778
  report.append(title);
2779
+ if (this.lastRunValue?.label) {
2780
+ const source = document.createElement('p');
2781
+ source.textContent = `From ${this.lastRunValue.label}.`;
2782
+ report.append(source);
2783
+ }
2680
2784
  if (metrics.notInRun) {
2681
2785
  const note = document.createElement('p');
2682
2786
  note.textContent = 'Not in this run';
@@ -2684,9 +2788,7 @@ export class ProcessModeler extends BaseElement {
2684
2788
  }
2685
2789
  else {
2686
2790
  const list = document.createElement('dl');
2687
- for (const [label, value] of [['Per second', metrics.callsPerSecond?.toLocaleString()], ['95% within', metrics.p95Ms === undefined ? undefined : `${metrics.p95Ms.toLocaleString()} ms`], ['Failed', metrics.failedShare === undefined ? undefined : `${(metrics.failedShare * 100).toFixed(1)}%`]]) {
2688
- if (value === undefined)
2689
- continue;
2791
+ for (const [label, value] of [['Per second', metrics.callsPerSecond === undefined ? '–' : metrics.callsPerSecond.toFixed(1)], ['95% finished within', metrics.p95Ms === undefined ? '–' : formatRunDuration(metrics.p95Ms)], ['Failed', metrics.failedShare === undefined ? '–' : `${(metrics.failedShare * 100).toFixed(1)}%`]]) {
2690
2792
  const term = document.createElement('dt');
2691
2793
  term.textContent = label;
2692
2794
  const detail = document.createElement('dd');
@@ -2718,26 +2820,35 @@ export class ProcessModeler extends BaseElement {
2718
2820
  }
2719
2821
  renderSelectionToolbar() {
2720
2822
  const toolbar = this.shadowRoot.querySelector('[part=selection-toolbar]');
2721
- const key = `${[...this.selectedIds].join(',')}|${this.selectedLineId}|${Boolean(this.selectedLineId && this.layoutValue.lines?.[this.selectedLineId])}|${this.locked}|${this.narrowValue}|${this.projection.lines.filter(line => line.from === this.selectedId).map(line => `${line.id}:${line.label}:${line.dashed}`).join(',')}`;
2823
+ const key = `${[...this.selectedIds].join(',')}|${this.selected?.kind}|${this.selected?.shape}|${this.selectedLineId}|${Boolean(this.selectedLineId && this.layoutValue.lines?.[this.selectedLineId])}|${this.locked}|${this.narrowValue}|${this.projection.lines.filter(line => line.from === this.selectedId).map(line => `${line.id}:${line.label}:${line.dashed}`).join(',')}`;
2722
2824
  if (key === this.selectionToolbarKey)
2723
2825
  return;
2724
2826
  this.selectionToolbarKey = key;
2725
2827
  toolbar.replaceChildren();
2726
2828
  if (!this.selectedIds.size && !this.selectedLineId)
2727
2829
  return;
2728
- const add = (label, command, action) => {
2830
+ const add = (label, command, action, withPlus = false) => {
2729
2831
  const button = document.createElement('button');
2730
2832
  button.type = 'button';
2731
2833
  button.dataset.selectionCommand = command;
2732
- button.textContent = label;
2733
2834
  button.setAttribute('aria-label', label);
2734
2835
  button.disabled = this.locked;
2735
2836
  button.onclick = action;
2837
+ if (withPlus) {
2838
+ const icon = document.createElement('span');
2839
+ icon.setAttribute('part', 'selection-plus');
2840
+ icon.setAttribute('aria-hidden', 'true');
2841
+ icon.textContent = '+';
2842
+ button.append(icon);
2843
+ }
2844
+ const text = document.createElement('span');
2845
+ text.textContent = label;
2846
+ button.append(text);
2736
2847
  toolbar.append(button);
2737
2848
  };
2738
2849
  if (this.selectedLine) {
2739
2850
  const line = this.selectedLine;
2740
- add('Insert a step', 'insert', () => this.openChooser({ type: 'insert', lineId: line.id, from: line.from, to: line.to }));
2851
+ add('Insert a step', 'insert', () => this.openLineChooser(line, toolbar.querySelector('[data-selection-command=insert]') ?? undefined), true);
2741
2852
  if (this.layoutValue.lines?.[line.id])
2742
2853
  add('Reset line', 'reset-line', () => this.resetLine(line.id));
2743
2854
  if (!this.disableConnections)
@@ -2746,11 +2857,16 @@ export class ProcessModeler extends BaseElement {
2746
2857
  else if (this.selectedIds.size === 1) {
2747
2858
  const box = this.selected;
2748
2859
  const failurePath = box.kind === 'try' && !this.projection.lines.some(line => line.from === box.id && (line.dashed || line.label === 'If it fails'));
2749
- add(box.shape === 'gateway' ? 'Add a path' : failurePath ? 'If it fails' : 'Add next', 'add-next', () => this.openChooser({ type: 'add', from: box.id, ...(failurePath ? { routeLabel: 'If it fails', dashed: true } : {}) }));
2750
- add('Duplicate', 'duplicate', () => { const position = this.layoutValue.boxes[box.id]; this.requestEdit({ type: 'duplicate', sourceId: box.id, position: { x: position.x + 32, y: position.y + 32 } }); });
2751
- add('Delete', 'delete-many', () => this.selectionCommand('delete-many'));
2752
2860
  if (this.narrowValue)
2753
2861
  add('Details', 'details', () => this.openDrawer('inspector'));
2862
+ if (!['end', 'finish', 'section', 'note'].includes(box.kind))
2863
+ add(box.shape === 'gateway' ? 'Add a path' : 'Add next', 'add-next', () => this.openAnchoredChooser(this.nextEdit(box), this.nextChooserTitle(box), toolbar.querySelector('[data-selection-command=add-next]') ?? undefined), true);
2864
+ if (failurePath)
2865
+ add('If it fails', 'add-failure', () => this.openAnchoredChooser({ type: 'add', from: box.id, routeLabel: 'If it fails', dashed: true }, `If a step in ${box.title} fails`, toolbar.querySelector('[data-selection-command=add-failure]') ?? undefined, Array.from(this.shadowRoot.querySelectorAll('[data-box-id]')).find(element => element.dataset.boxId === box.id)), true);
2866
+ if (!['start', 'timer', 'section'].includes(box.kind))
2867
+ add('Duplicate', 'duplicate', () => { const position = this.layoutValue.boxes[box.id]; this.requestEdit({ type: 'duplicate', sourceId: box.id, position: { x: position.x + 32, y: position.y + 32 } }); });
2868
+ if (!['start', 'timer'].includes(box.kind))
2869
+ add('Delete', 'delete-many', () => this.selectionCommand('delete-many'));
2754
2870
  }
2755
2871
  else {
2756
2872
  add('Line up', 'align', () => this.selectionCommand('align'));
@@ -2780,7 +2896,9 @@ export class ProcessModeler extends BaseElement {
2780
2896
  checksStatus.dataset.state = count ? 'bad' : 'ready';
2781
2897
  }
2782
2898
  paintViewport() {
2783
- this.shadowRoot.querySelector("[part=world]").style.transform =
2899
+ const world = this.shadowRoot.querySelector("[part=world]");
2900
+ world.style.setProperty('--boe-process-inverse-zoom', String(1 / this.viewport.zoom));
2901
+ world.style.transform =
2784
2902
  `translate(${this.viewport.x}px,${this.viewport.y}px) scale(${this.viewport.zoom})`;
2785
2903
  const canvas = this.shadowRoot.querySelector("[part=canvas]");
2786
2904
  const grid = 16 * this.viewport.zoom * (this.viewport.zoom < 0.5 ? 4 : 1);
@@ -1,4 +1,5 @@
1
1
  export * from "./types.js";
2
2
  export * from "./run-trace.js";
3
+ export * from "./trace-waterfall.js";
3
4
  export * from "./progress.js";
4
5
  export * from "./run-summary.js";
@@ -2,5 +2,6 @@
2
2
  export * from "./types.js";
3
3
  // Machine execution trace surface
4
4
  export * from "./run-trace.js";
5
+ export * from "./trace-waterfall.js";
5
6
  export * from "./progress.js";
6
7
  export * from "./run-summary.js";
@@ -1,5 +1,6 @@
1
1
  import type { RunResolution, RunStep, RunStepStatus } from "./types.js";
2
2
  import { BaseElement } from "../../core/index.js";
3
+ export type RunTraceDensity = "default" | "compact";
3
4
  /** Every status in words — colour never carries the meaning alone. */
4
5
  export declare const RUN_STEP_STATUS_LABEL: Record<RunStepStatus, string>;
5
6
  /**
@@ -28,6 +29,8 @@ export declare class RunTrace extends BaseElement {
28
29
  private readonly previousStatuses;
29
30
  get heading(): string;
30
31
  set heading(value: string);
32
+ get density(): RunTraceDensity;
33
+ set density(value: RunTraceDensity);
31
34
  get steps(): RunStep[];
32
35
  set steps(value: RunStep[]);
33
36
  /** The resolved run — same object the render uses. */
@@ -34,6 +34,29 @@ const STATUS_TONE = {
34
34
  const elementStyles = `
35
35
  :host {
36
36
  display: block;
37
+ --_run-marker-column: var(--boe-run-trace-marker-column-width, 1.1rem);
38
+ --_run-marker-size: var(--boe-run-trace-marker-size, 1rem);
39
+ --_run-marker-inline: var(--boe-run-trace-marker-inline-inset, 0.15rem);
40
+ --_run-marker-block: var(--boe-run-trace-marker-block-offset, 0.2rem);
41
+ --_run-step-padding: var(--boe-run-trace-step-padding-block, 0.55rem);
42
+ --_run-step-row-gap: var(--boe-run-trace-step-row-gap, 0rem);
43
+ --_run-step-column-gap: var(--boe-run-trace-step-column-gap, 0.65rem);
44
+ --_run-child-row-gap: var(--boe-run-trace-child-row-gap, 0.4rem);
45
+ --_run-connector-start-gap: var(--boe-run-trace-connector-start-gap, 0.1rem);
46
+ --_run-connector-overhang: var(--boe-run-trace-connector-end-overhang, 0.35rem);
47
+ }
48
+
49
+ :host([density="compact"]) {
50
+ --_run-marker-column: var(--boe-run-trace-marker-column-width, 1rem);
51
+ --_run-marker-size: var(--boe-run-trace-marker-size, 0.875rem);
52
+ --_run-marker-inline: var(--boe-run-trace-marker-inline-inset, 0.1rem);
53
+ --_run-marker-block: var(--boe-run-trace-marker-block-offset, 0.15rem);
54
+ --_run-step-padding: var(--boe-run-trace-step-padding-block, 0.35rem);
55
+ --_run-step-row-gap: var(--boe-run-trace-step-row-gap, 0rem);
56
+ --_run-step-column-gap: var(--boe-run-trace-step-column-gap, 0.5rem);
57
+ --_run-child-row-gap: var(--boe-run-trace-child-row-gap, 0.25rem);
58
+ --_run-connector-start-gap: var(--boe-run-trace-connector-start-gap, 0.04rem);
59
+ --_run-connector-overhang: var(--boe-run-trace-connector-end-overhang, 0.25rem);
37
60
  }
38
61
 
39
62
  /* The host's own display would otherwise beat the UA rule for [hidden],
@@ -94,6 +117,8 @@ const elementStyles = `
94
117
  }
95
118
 
96
119
  [part="steps"] {
120
+ display: grid;
121
+ row-gap: var(--_run-step-row-gap);
97
122
  margin: 0;
98
123
  padding: 0;
99
124
  list-style: none;
@@ -104,28 +129,33 @@ const elementStyles = `
104
129
  [part="step"] {
105
130
  position: relative;
106
131
  display: grid;
107
- grid-template-columns: auto 1fr;
108
- gap: 0.65rem;
109
- padding: 0.55rem 0 0.55rem 0.15rem;
132
+ grid-template-columns: var(--_run-marker-column) minmax(0, 1fr);
133
+ column-gap: var(--_run-step-column-gap);
134
+ padding: var(--_run-step-padding) 0 var(--_run-step-padding) var(--_run-marker-inline);
110
135
  }
111
136
 
112
137
  [part="step"]:not(:last-child)::after {
113
138
  content: "";
114
139
  position: absolute;
115
- left: calc(0.15rem + 0.55rem - 1px);
116
- top: 1.9rem;
117
- bottom: -0.35rem;
140
+ left: calc(var(--_run-marker-inline) + var(--_run-marker-column) / 2 - 1px);
141
+ top: calc(var(--_run-step-padding) + var(--_run-marker-block) + var(--_run-marker-column) / 2 + var(--_run-marker-size) / 2 + var(--_run-connector-start-gap));
142
+ bottom: calc(-1 * var(--_run-connector-overhang) - var(--_run-step-row-gap));
118
143
  width: 2px;
119
144
  background: color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 72%, transparent);
120
145
  }
121
146
 
122
147
  [part="marker"] {
123
- inline-size: 1.1rem;
124
- block-size: 1.1rem;
125
- margin-top: 0.2rem;
148
+ inline-size: var(--_run-marker-column);
149
+ block-size: var(--_run-marker-column);
150
+ margin-top: var(--_run-marker-block);
126
151
  border-radius: 999px;
127
- border: 2px solid var(--run-tone, var(--boe-token-text-text-secondary, #6f6f6f));
128
- background: color-mix(in srgb, var(--run-tone, var(--boe-token-text-text-secondary, #6f6f6f)) 14%, var(--boe-token-surface-surface, #ffffff) 86%);
152
+ align-items: center;
153
+ justify-content: center;
154
+ }
155
+
156
+ [part="marker"] .boe-status {
157
+ inline-size: var(--_run-marker-size);
158
+ block-size: var(--_run-marker-size);
129
159
  }
130
160
 
131
161
  [part="step"][data-status="running"] [part="marker"] {
@@ -218,7 +248,7 @@ const elementStyles = `
218
248
  padding: 0;
219
249
  list-style: none;
220
250
  display: grid;
221
- gap: 0.4rem;
251
+ gap: var(--_run-child-row-gap);
222
252
  }
223
253
 
224
254
  [part="child"] {
@@ -254,7 +284,7 @@ const elementStyles = `
254
284
  :host([variant="plain"]) [part="panel"] { border: 0; border-radius: 0; background: none; padding: 0; }
255
285
  :host([variant="plain"]) [part="header"],
256
286
  :host([variant="plain"]) [part="summary"] { display: none; }
257
- :host([variant="plain"]) [part="step"] { padding-block: 0.35rem; }
287
+ :host([variant="plain"]) [part="step"] { padding-block: var(--boe-run-trace-step-padding-block, 0.35rem); }
258
288
  :host([variant="plain"]) [part="step"]::after { display: none; }
259
289
  :host([variant="plain"]) [part="status"] { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
260
290
  :host([variant="plain"]) [part="toggle"] { display: none; }
@@ -304,6 +334,15 @@ export class RunTrace extends BaseElement {
304
334
  set heading(value) {
305
335
  this.setAttribute("heading", value);
306
336
  }
337
+ get density() {
338
+ return this.getAttribute("density") === "compact" ? "compact" : "default";
339
+ }
340
+ set density(value) {
341
+ if (value === "compact")
342
+ this.setAttribute("density", "compact");
343
+ else
344
+ this.removeAttribute("density");
345
+ }
307
346
  get steps() {
308
347
  const raw = this.getAttribute("steps");
309
348
  if (!raw) {
@@ -0,0 +1,80 @@
1
+ import { BaseElement } from "../../core/index.js";
2
+ export type TraceSpanStatus = "ok" | "failed" | "skipped" | "running";
3
+ export interface TraceMarker {
4
+ atMs: number;
5
+ label: string;
6
+ }
7
+ export interface TraceSpan {
8
+ id: string;
9
+ parentId?: string;
10
+ label: string;
11
+ kind: string;
12
+ startMs: number;
13
+ durationMs: number;
14
+ status: TraceSpanStatus;
15
+ system?: boolean;
16
+ markers?: TraceMarker[];
17
+ detail?: {
18
+ input?: unknown;
19
+ response?: unknown;
20
+ attributes?: unknown;
21
+ };
22
+ }
23
+ export interface TraceRow {
24
+ span: TraceSpan;
25
+ comparisonOnly: boolean;
26
+ parentId?: string;
27
+ depth: number;
28
+ hasChildren: boolean;
29
+ expanded: boolean;
30
+ position: number;
31
+ siblings: number;
32
+ }
33
+ export interface TraceLayout {
34
+ rows: TraceRow[];
35
+ hiddenCount: number;
36
+ endMs: number;
37
+ }
38
+ /** Invalid/duplicate spans are omitted; invalid parent relationships become roots. */
39
+ export declare function validTraceSpans(spans: TraceSpan[]): TraceSpan[];
40
+ export declare function traceSpanDuration(span: TraceSpan, nowMs: number): number;
41
+ /** Stable report layout: input sibling order, one zero-based axis, no browser dependency. */
42
+ export declare function traceWaterfallLayout(spans: TraceSpan[], options?: {
43
+ showSystem?: boolean;
44
+ search?: string;
45
+ collapsed?: ReadonlySet<string>;
46
+ nowMs?: number;
47
+ comparisonSpans?: TraceSpan[];
48
+ }): TraceLayout;
49
+ /** Host-driven live or completed trace; requests selection, never owns host sessions. */
50
+ export declare class TraceWaterfall extends BaseElement {
51
+ static readonly tagName = "box-trace-waterfall";
52
+ static get observedAttributes(): string[];
53
+ private _spans;
54
+ private _comparisonSpans;
55
+ private collapsed;
56
+ private search;
57
+ private table;
58
+ private code;
59
+ private fullScreen;
60
+ private focusedId;
61
+ get heading(): string;
62
+ set heading(value: string);
63
+ get spans(): TraceSpan[];
64
+ set spans(value: TraceSpan[]);
65
+ get comparisonSpans(): TraceSpan[];
66
+ set comparisonSpans(value: TraceSpan[]);
67
+ get showSystem(): boolean;
68
+ set showSystem(value: boolean);
69
+ get selectedSpanId(): string;
70
+ set selectedSpanId(value: string);
71
+ get nowMs(): number;
72
+ set nowMs(value: number);
73
+ get layout(): TraceLayout;
74
+ protected renderTemplate(): void;
75
+ private select;
76
+ private focusRow;
77
+ private toggle;
78
+ protected setupListeners(): void;
79
+ protected update(): void;
80
+ }