@unofficialbox/box-open-elements 0.28.7 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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();
@@ -569,7 +612,11 @@ export class ProcessModeler extends BaseElement {
569
612
  this.shadowRoot.append(keyboardChooser);
570
613
  keyboardPicker.addEventListener('kind-pick', event => {
571
614
  const edit = this.keyboardInsertion;
572
- this.closeKeyboardChooser();
615
+ this.closeKeyboardChooser(true);
616
+ // Keep a stable step focused while the host applies and accepts the edit.
617
+ // World refreshes preserve step focus, including deferred acceptance.
618
+ if (edit?.from)
619
+ this.focusBox(edit.from);
573
620
  if (edit)
574
621
  this.requestEdit({ ...edit, kind: event.detail.kind });
575
622
  });
@@ -705,7 +752,7 @@ export class ProcessModeler extends BaseElement {
705
752
  const trigger = document.createElement("button");
706
753
  trigger.dataset.command = part === "palette" ? "palette" : "details";
707
754
  trigger.textContent = part === "palette" ? "Add" : "Details";
708
- trigger.setAttribute("aria-label", `Open ${label.toLowerCase()}`);
755
+ trigger.setAttribute("aria-label", part === "palette" ? "Add building blocks" : `Open ${label.toLowerCase()}`);
709
756
  trigger.setAttribute('aria-expanded', 'false');
710
757
  trigger.onclick = () => this.openDrawer(part);
711
758
  toolbar.append(trigger);
@@ -1355,7 +1402,11 @@ export class ProcessModeler extends BaseElement {
1355
1402
  if (event.ctrlKey && event.altKey && this.catalog.length && !this.disableConnections) {
1356
1403
  const side = direction[0] < 0 ? "west" : direction[0] > 0 ? "east" : direction[1] < 0 ? "north" : "south";
1357
1404
  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 } });
1405
+ const anchor = Array.from(this.shadowRoot.querySelectorAll('[part=port]'))
1406
+ .find(element => element.dataset.owner === box.id && element.dataset.side === side);
1407
+ const returnFocus = Array.from(this.shadowRoot.querySelectorAll('[data-box-id]'))
1408
+ .find(element => element.dataset.boxId === box.id);
1409
+ 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
1410
  return;
1360
1411
  }
1361
1412
  if (event.altKey) {
@@ -1479,7 +1530,6 @@ export class ProcessModeler extends BaseElement {
1479
1530
  if (!ghost) {
1480
1531
  ghost = svgElement("polyline");
1481
1532
  ghost.setAttribute("part", "connection-preview");
1482
- ghost.style.cssText = "fill:none;stroke:var(--boe-token-surface-surface-brand,#0061d5);stroke-width:3";
1483
1533
  this.shadowRoot.querySelector('[part=lines]').append(ghost);
1484
1534
  }
1485
1535
  const start = this.portDrag.start;
@@ -1488,7 +1538,7 @@ export class ProcessModeler extends BaseElement {
1488
1538
  const pinned = target ? this.sideAt(target, point) : undefined;
1489
1539
  this.shadowRoot.querySelectorAll('[data-box-id]').forEach(box => { box.dataset.connectTarget = String(box.dataset.boxId === target?.id); });
1490
1540
  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);
1541
+ this.showConnectTooltip(point, target ? pinned ? `Pin to the ${pinned} of ${target.title}` : `Connect to ${target.title}` : 'Drop here to add a connected step');
1492
1542
  return;
1493
1543
  }
1494
1544
  if (this.endDrag) {
@@ -1589,12 +1639,23 @@ export class ProcessModeler extends BaseElement {
1589
1639
  if (Math.hypot(point.x - port.start.x, point.y - port.start.y) < 8) {
1590
1640
  const pos = this.layoutValue.boxes[port.id];
1591
1641
  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] } });
1642
+ const source = this.projection.boxes.find(box => box.id === port.id);
1643
+ const anchor = Array.from(this.shadowRoot.querySelectorAll('[part=port]'))
1644
+ .find(element => element.dataset.owner === port.id && element.dataset.side === port.side);
1645
+ this.openAnchoredChooser(this.nextEdit(source, port.side, { x: pos.x + offsets[0], y: pos.y + offsets[1] }), this.nextChooserTitle(source, port.side), anchor);
1593
1646
  }
1594
1647
  else {
1595
1648
  const target = this.boxAt(point, port.id);
1596
1649
  if (target)
1597
1650
  this.requestEdit({ type: "connect", from: port.id, to: target.id, fromSide: port.side, toSide: this.sideAt(target, point) });
1651
+ else {
1652
+ const source = this.projection.boxes.find(box => box.id === port.id);
1653
+ const frame = this.boxAt(point, undefined, true);
1654
+ if ((frame?.id ?? null) !== (source.parentId ?? null))
1655
+ this.setStatus('Drop inside the same frame to add a connected step');
1656
+ else
1657
+ this.openChooserAtPoint({ type: 'add', from: port.id, fromSide: port.side, parentId: source.parentId, position: point }, `Add after ${source.title}`, point);
1658
+ }
1598
1659
  }
1599
1660
  }
1600
1661
  return;
@@ -1773,11 +1834,45 @@ export class ProcessModeler extends BaseElement {
1773
1834
  promoteModal(chooser);
1774
1835
  picker.focus();
1775
1836
  }
1837
+ nextEdit(box, side, position) {
1838
+ const shape = box.shape ?? this.catalog.find(kind => kind.kind === box.kind)?.shape;
1839
+ const outgoing = this.projection.lines.filter(line => line.from === box.id);
1840
+ if ((!side || side === 'east') && shape !== 'gateway' && outgoing.length === 1)
1841
+ return { type: 'insert', lineId: outgoing[0].id, from: box.id, to: outgoing[0].to };
1842
+ return { type: 'add', from: box.id, ...(side ? { fromSide: side } : {}), parentId: box.parentId, ...(position ? { position } : {}) };
1843
+ }
1844
+ nextChooserTitle(box, side) {
1845
+ const edit = this.nextEdit(box, side);
1846
+ if (edit.type === 'insert') {
1847
+ const target = this.projection.boxes.find(candidate => candidate.id === edit.to);
1848
+ return `Insert between ${box.title} and ${target?.title ?? 'the next step'}`;
1849
+ }
1850
+ if (box.shape === 'gateway')
1851
+ return `Add a path from ${box.title}`;
1852
+ return side && side !== 'east' ? `Add ${{ north: 'above', south: 'below', west: 'before' }[side]} ${box.title}, connected` : `Add after ${box.title}`;
1853
+ }
1854
+ openChooserAtPoint(edit, title, point, returnFocus) {
1855
+ if (this.keyboardInsertion)
1856
+ this.closeKeyboardChooser();
1857
+ const anchor = document.createElement('span');
1858
+ anchor.style.cssText = `position:absolute;left:${point.x}px;top:${point.y}px;width:1px;height:1px;pointer-events:none`;
1859
+ this.shadowRoot.querySelector('[part=world]').append(anchor);
1860
+ this.transientChooserAnchor = anchor;
1861
+ this.openAnchoredChooser(edit, title, anchor, returnFocus);
1862
+ }
1863
+ openLineChooser(line, returnFocus) {
1864
+ const source = this.projection.boxes.find(box => box.id === line.from);
1865
+ const target = this.projection.boxes.find(box => box.id === line.to);
1866
+ const points = this.routedLines.get(line.id) ?? routeProcessLine(line, this.layoutValue, this.projection);
1867
+ 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);
1868
+ }
1776
1869
  closeKeyboardChooser(returnFocus = false) {
1777
1870
  const chooser = this.shadowRoot?.querySelector('[part=keyboard-chooser]');
1778
1871
  dismissPopover(chooser ?? null);
1779
1872
  this.stopKeyboardAnchor?.();
1780
1873
  this.stopKeyboardAnchor = undefined;
1874
+ this.transientChooserAnchor?.remove();
1875
+ this.transientChooserAnchor = undefined;
1781
1876
  document.removeEventListener('pointerdown', this.dismissKeyboardChooserOutside, true);
1782
1877
  this.keyboardInsertion = undefined;
1783
1878
  if (returnFocus)
@@ -1790,32 +1885,35 @@ export class ProcessModeler extends BaseElement {
1790
1885
  this.closeKeyboardChooser();
1791
1886
  };
1792
1887
  openKeyboardChooser(box) {
1793
- const chooser = this.shadowRoot.querySelector('[part=keyboard-chooser]');
1794
1888
  const shape = box.shape ?? this.catalog.find(kind => kind.kind === box.kind)?.shape ?? (box.frame ? 'frame' : 'task');
1795
1889
  const outgoing = this.projection.lines.filter(line => line.from === box.id);
1796
1890
  const target = outgoing.length === 1 ? this.projection.boxes.find(candidate => candidate.id === outgoing[0].to) : undefined;
1797
1891
  const insert = shape !== 'gateway' && outgoing.length === 1 && target;
1892
+ const anchor = Array.from(this.shadowRoot.querySelectorAll('[data-box-id]'))
1893
+ .find(element => element.dataset.boxId === box.id);
1894
+ this.openAnchoredChooser(insert
1895
+ ? { type: 'insert', lineId: outgoing[0].id, from: box.id, to: target.id }
1896
+ : { type: 'add', from: box.id }, insert ? `Insert between ${box.title} and ${target.title}`
1897
+ : shape === 'gateway' ? `Add a path from ${box.title}` : `Add after ${box.title}`, anchor);
1898
+ }
1899
+ openAnchoredChooser(edit, title, anchor, returnFocus) {
1900
+ const chooser = this.shadowRoot.querySelector('[part=keyboard-chooser]');
1798
1901
  if (this.keyboardInsertion)
1799
1902
  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}`;
1903
+ this.keyboardInsertion = edit;
1904
+ chooser.querySelector('[part=chooser-title]').textContent = title;
1806
1905
  const picker = chooser.querySelector('box-kind-picker');
1807
1906
  picker.catalog = this.catalog.filter(kind => !kind.placement);
1808
1907
  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)) {
1908
+ this.keyboardReturn = returnFocus ?? anchor;
1909
+ if (!anchor || !promotePopover(chooser)) {
1812
1910
  const edit = this.keyboardInsertion;
1813
1911
  this.closeKeyboardChooser();
1814
1912
  if (edit)
1815
1913
  this.openChooser(edit);
1816
1914
  return;
1817
1915
  }
1818
- this.stopKeyboardAnchor = trackAnchor(this.keyboardReturn, chooser, {
1916
+ this.stopKeyboardAnchor = trackAnchor(anchor, chooser, {
1819
1917
  placement: { side: 'right', align: 'start' }, offset: 8, padding: 12,
1820
1918
  });
1821
1919
  document.addEventListener('pointerdown', this.dismissKeyboardChooserOutside, true);
@@ -1970,14 +2068,14 @@ export class ProcessModeler extends BaseElement {
1970
2068
  element.append(loop);
1971
2069
  }
1972
2070
  const metrics = this.showLastRunValue ? this.lastRunValue?.steps[box.id] : undefined;
1973
- if (metrics) {
2071
+ if (this.showLastRunValue && this.lastRunValue && shape === 'task') {
1974
2072
  const line = document.createElement("span");
1975
2073
  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`,
2074
+ line.textContent = !metrics || metrics.notInRun ? "Not in the last run" : [
2075
+ metrics.callsPerSecond === undefined ? "" : `${metrics.callsPerSecond.toFixed(1)}/s`,
2076
+ metrics.p95Ms === undefined ? "" : `p95 ${formatRunDuration(metrics.p95Ms)}`,
1979
2077
  ].filter(Boolean).join(" · ");
1980
- if (metrics.failedShare !== undefined && metrics.failedShare > 0) {
2078
+ if (metrics?.failedShare !== undefined && metrics.failedShare > 0 && !metrics.notInRun) {
1981
2079
  const failed = document.createElement('span');
1982
2080
  failed.setAttribute('part', 'metric-warning');
1983
2081
  failed.textContent = `${(metrics.failedShare * 100).toFixed(1)}% failed`;
@@ -1992,10 +2090,20 @@ export class ProcessModeler extends BaseElement {
1992
2090
  element.append(message);
1993
2091
  }
1994
2092
  element.addEventListener("keydown", event => {
1995
- if (event.target === element && (event.key === "Enter" || event.key === " ")) {
2093
+ if (event.target !== element)
2094
+ return;
2095
+ if (event.key === " ") {
1996
2096
  event.preventDefault();
1997
2097
  element.click();
1998
2098
  }
2099
+ // Let Enter reach the canvas handler after selecting the focused step.
2100
+ // Cancelling it here prevents the editor from opening and taking focus.
2101
+ else if (event.key === "Enter") {
2102
+ // Connection completion consumes Enter; it must not also edit the target.
2103
+ if (this.connecting || this.pendingReattach)
2104
+ event.preventDefault();
2105
+ element.click();
2106
+ }
1999
2107
  });
2000
2108
  element.addEventListener("click", event => {
2001
2109
  if (this.suppressClick && event.detail !== 0) {
@@ -2021,7 +2129,7 @@ export class ProcessModeler extends BaseElement {
2021
2129
  else
2022
2130
  this.select(box.id);
2023
2131
  });
2024
- if (!this.disableConnections && !this.locked) {
2132
+ if (!this.disableConnections && !this.locked && !['end', 'finish'].includes(box.kind)) {
2025
2133
  for (const side of ["north", "east", "south", "west"]) {
2026
2134
  const port = document.createElement("button");
2027
2135
  port.type = "button";
@@ -2034,7 +2142,7 @@ export class ProcessModeler extends BaseElement {
2034
2142
  if (!event.detail) {
2035
2143
  const pos = this.layoutValue.boxes[box.id];
2036
2144
  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] } });
2145
+ this.openAnchoredChooser(this.nextEdit(box, side, { x: pos.x + offsets[0], y: pos.y + offsets[1] }), this.nextChooserTitle(box, side), port);
2038
2146
  }
2039
2147
  });
2040
2148
  element.append(port);
@@ -2096,7 +2204,7 @@ export class ProcessModeler extends BaseElement {
2096
2204
  if (line.dashed)
2097
2205
  path.setAttribute("stroke-dasharray", "6 4");
2098
2206
  if (this.showLastRunValue && line.share !== undefined) {
2099
- path.style.strokeWidth = `${1.5 + Math.max(0, Math.min(1, line.share)) * 2}px`;
2207
+ path.style.strokeWidth = `${Math.round((1.5 + Math.max(0, Math.min(1, line.share)) * 2.5) * 100) / 100}px`;
2100
2208
  path.setAttribute('aria-label', `${Math.round(line.share * 100)}% of last-run traffic`);
2101
2209
  }
2102
2210
  lines.append(path);
@@ -2138,6 +2246,11 @@ export class ProcessModeler extends BaseElement {
2138
2246
  const branchIndex = this.projection.lines.filter(candidate => candidate.from === line.from).indexOf(line);
2139
2247
  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
2248
  label.append(text);
2249
+ if (this.showLastRunValue && line.share !== undefined) {
2250
+ const share = document.createElement('small');
2251
+ share.textContent = `${Math.round(line.share * 100)}%`;
2252
+ label.append(share);
2253
+ }
2141
2254
  const actions = document.createElement("div");
2142
2255
  actions.setAttribute("part", "connection-actions");
2143
2256
  label.append(actions);
@@ -2186,7 +2299,7 @@ export class ProcessModeler extends BaseElement {
2186
2299
  to: line.to,
2187
2300
  };
2188
2301
  if (type === "insert" && this.catalog.length) {
2189
- this.openChooser(edit);
2302
+ this.openLineChooser(line, button);
2190
2303
  }
2191
2304
  else
2192
2305
  this.requestEdit(edit);
@@ -2675,8 +2788,13 @@ export class ProcessModeler extends BaseElement {
2675
2788
  const report = document.createElement('section');
2676
2789
  report.setAttribute('part', 'inspector-metrics');
2677
2790
  const title = document.createElement('h3');
2678
- title.textContent = this.lastRunValue?.label ?? 'Last run';
2791
+ title.textContent = 'Last run';
2679
2792
  report.append(title);
2793
+ if (this.lastRunValue?.label) {
2794
+ const source = document.createElement('p');
2795
+ source.textContent = `From ${this.lastRunValue.label}.`;
2796
+ report.append(source);
2797
+ }
2680
2798
  if (metrics.notInRun) {
2681
2799
  const note = document.createElement('p');
2682
2800
  note.textContent = 'Not in this run';
@@ -2684,9 +2802,7 @@ export class ProcessModeler extends BaseElement {
2684
2802
  }
2685
2803
  else {
2686
2804
  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;
2805
+ 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
2806
  const term = document.createElement('dt');
2691
2807
  term.textContent = label;
2692
2808
  const detail = document.createElement('dd');
@@ -2718,26 +2834,35 @@ export class ProcessModeler extends BaseElement {
2718
2834
  }
2719
2835
  renderSelectionToolbar() {
2720
2836
  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(',')}`;
2837
+ 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
2838
  if (key === this.selectionToolbarKey)
2723
2839
  return;
2724
2840
  this.selectionToolbarKey = key;
2725
2841
  toolbar.replaceChildren();
2726
2842
  if (!this.selectedIds.size && !this.selectedLineId)
2727
2843
  return;
2728
- const add = (label, command, action) => {
2844
+ const add = (label, command, action, withPlus = false) => {
2729
2845
  const button = document.createElement('button');
2730
2846
  button.type = 'button';
2731
2847
  button.dataset.selectionCommand = command;
2732
- button.textContent = label;
2733
2848
  button.setAttribute('aria-label', label);
2734
2849
  button.disabled = this.locked;
2735
2850
  button.onclick = action;
2851
+ if (withPlus) {
2852
+ const icon = document.createElement('span');
2853
+ icon.setAttribute('part', 'selection-plus');
2854
+ icon.setAttribute('aria-hidden', 'true');
2855
+ icon.textContent = '+';
2856
+ button.append(icon);
2857
+ }
2858
+ const text = document.createElement('span');
2859
+ text.textContent = label;
2860
+ button.append(text);
2736
2861
  toolbar.append(button);
2737
2862
  };
2738
2863
  if (this.selectedLine) {
2739
2864
  const line = this.selectedLine;
2740
- add('Insert a step', 'insert', () => this.openChooser({ type: 'insert', lineId: line.id, from: line.from, to: line.to }));
2865
+ add('Insert a step', 'insert', () => this.openLineChooser(line, toolbar.querySelector('[data-selection-command=insert]') ?? undefined), true);
2741
2866
  if (this.layoutValue.lines?.[line.id])
2742
2867
  add('Reset line', 'reset-line', () => this.resetLine(line.id));
2743
2868
  if (!this.disableConnections)
@@ -2746,11 +2871,16 @@ export class ProcessModeler extends BaseElement {
2746
2871
  else if (this.selectedIds.size === 1) {
2747
2872
  const box = this.selected;
2748
2873
  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
2874
  if (this.narrowValue)
2753
2875
  add('Details', 'details', () => this.openDrawer('inspector'));
2876
+ if (!['end', 'finish', 'section', 'note'].includes(box.kind))
2877
+ 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);
2878
+ if (failurePath)
2879
+ 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);
2880
+ if (!['start', 'timer', 'section'].includes(box.kind))
2881
+ 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 } }); });
2882
+ if (!['start', 'timer'].includes(box.kind))
2883
+ add('Delete', 'delete-many', () => this.selectionCommand('delete-many'));
2754
2884
  }
2755
2885
  else {
2756
2886
  add('Line up', 'align', () => this.selectionCommand('align'));
@@ -2780,7 +2910,9 @@ export class ProcessModeler extends BaseElement {
2780
2910
  checksStatus.dataset.state = count ? 'bad' : 'ready';
2781
2911
  }
2782
2912
  paintViewport() {
2783
- this.shadowRoot.querySelector("[part=world]").style.transform =
2913
+ const world = this.shadowRoot.querySelector("[part=world]");
2914
+ world.style.setProperty('--boe-process-inverse-zoom', String(1 / this.viewport.zoom));
2915
+ world.style.transform =
2784
2916
  `translate(${this.viewport.x}px,${this.viewport.y}px) scale(${this.viewport.zoom})`;
2785
2917
  const canvas = this.shadowRoot.querySelector("[part=canvas]");
2786
2918
  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) {