@unofficialbox/box-open-elements 0.32.0 → 0.34.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.
@@ -1,4 +1,5 @@
1
1
  import { BaseElement } from "../../core/index.js";
2
+ import { iconPlus, iconXBatsu } from "../../foundations/icons/glyphs/index.js";
2
3
  import { announce } from "../../foundations/a11y/index.js";
3
4
  import { dismissModal, dismissPopover, promoteModal, promotePopover, trackAnchor } from "../../foundations/overlay/index.js";
4
5
  import { KindPicker } from "../flow-builder/primitives.js";
@@ -7,6 +8,20 @@ import { restoreProcessPositions, snapshotProcessPositions, snapshotProcessProje
7
8
  import { processModelerDesign } from "./design.js";
8
9
  import { completeLayout, ProcessHistory, validateProjection, } from "./model.js";
9
10
  const svgElement = (tag) => document.createElementNS("http://www.w3.org/2000/svg", tag);
11
+ const variableLabel = (text) => {
12
+ const label = document.createElement('label'), name = document.createElement('span');
13
+ name.setAttribute('part', 'sr-only');
14
+ name.textContent = text;
15
+ label.append(name);
16
+ return label;
17
+ };
18
+ const variableGlyph = (add = false) => {
19
+ const template = document.createElement('template');
20
+ template.innerHTML = add ? iconPlus : iconXBatsu;
21
+ const glyph = template.content.firstElementChild;
22
+ glyph.setAttribute('part', 'variable-icon');
23
+ return glyph;
24
+ };
10
25
  const checkGlyph = (success = false) => {
11
26
  const icon = svgElement('svg');
12
27
  icon.setAttribute('part', 'check-icon');
@@ -53,6 +68,9 @@ export class ProcessModeler extends BaseElement {
53
68
  showLastRunValue = false;
54
69
  selectedId = null;
55
70
  selectedLineId = null;
71
+ selectedLineIds = new Set();
72
+ selectedNoteIds = new Set();
73
+ graphSelection = false;
56
74
  connecting = null;
57
75
  checks = [];
58
76
  renderer;
@@ -60,6 +78,8 @@ export class ProcessModeler extends BaseElement {
60
78
  inspected;
61
79
  inspectedId;
62
80
  inspectedControls = "";
81
+ localEchoRevision = 0;
82
+ pendingLocalRename;
63
83
  historyValue = new ProcessHistory();
64
84
  viewport = { x: 0, y: 0, zoom: 1 };
65
85
  pointers = new Map();
@@ -76,6 +96,33 @@ export class ProcessModeler extends BaseElement {
76
96
  selectedIds = new Set();
77
97
  clipboard = null;
78
98
  clipboardSession = 0;
99
+ layoutEditSession = 0;
100
+ acceptanceNotifications = 0;
101
+ deferredNotifications = [];
102
+ notify(name, detail) {
103
+ if (this.acceptanceNotifications)
104
+ this.deferredNotifications.push({ name, detail, session: this.layoutEditSession });
105
+ else
106
+ emit(this, name, detail);
107
+ }
108
+ flushAcceptanceNotifications() {
109
+ if (this.acceptanceNotifications)
110
+ return;
111
+ this.acceptanceNotifications++;
112
+ try {
113
+ // Observer edits append behind the complete accepted batch, rather than
114
+ // publishing newer state before older queued persistence notifications.
115
+ while (this.deferredNotifications.length) {
116
+ const notification = this.deferredNotifications.shift();
117
+ if (notification.session !== this.layoutEditSession)
118
+ continue;
119
+ emit(this, notification.name, notification.detail);
120
+ }
121
+ }
122
+ finally {
123
+ this.acceptanceNotifications--;
124
+ }
125
+ }
79
126
  restoringBoxFocus = false;
80
127
  selectionToolbarKey = "";
81
128
  sectionSequence = 0;
@@ -167,7 +214,16 @@ export class ProcessModeler extends BaseElement {
167
214
  };
168
215
  }
169
216
  load(document, options = {}) {
217
+ this.pendingLocalRename = undefined;
218
+ const session = ++this.layoutEditSession;
219
+ this.insertion = undefined;
170
220
  this.cancelPalettePointer();
221
+ this.closeKeyboardChooser();
222
+ const chooser = this.shadowRoot?.querySelector('[part=insert-chooser]');
223
+ if (chooser)
224
+ dismissModal(chooser);
225
+ if (session !== this.layoutEditSession)
226
+ return;
171
227
  const projection = this.model.project(document);
172
228
  validateProjection(projection);
173
229
  this.documentValue = document;
@@ -177,6 +233,7 @@ export class ProcessModeler extends BaseElement {
177
233
  this.lastReadableProjection = "";
178
234
  this.readableValue = null;
179
235
  this.layoutValue = restoreProcessPositions(projection, options.positions ?? []);
236
+ this.clearMixedSelection();
180
237
  this.history.clear();
181
238
  this.selectedId = null;
182
239
  this.selectedLineId = null;
@@ -192,15 +249,54 @@ export class ProcessModeler extends BaseElement {
192
249
  validateProjection(this.projection);
193
250
  }
194
251
  const next = new Set(ids.filter(id => this.projection.boxes.some(box => box.id === id)));
195
- if (next.size === this.selectedIds.size && [...next].every(id => this.selectedIds.has(id)))
252
+ if (!this.graphSelection && !this.selectedLineIds.size && !this.selectedNoteIds.size && next.size === this.selectedIds.size && [...next].every(id => this.selectedIds.has(id)))
196
253
  return;
254
+ this.clearMixedSelection();
197
255
  this.selectedIds = next;
198
256
  this.selectedId = this.selectedIds.values().next().value ?? null;
199
257
  this.selectedLineId = null;
200
258
  this.renderSelection();
201
259
  if (this.isRendered)
202
260
  this.updateToolbar();
203
- emit(this, "selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath });
261
+ this.notify("selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath, ...(this.graphSelection ? { selection: this.selection } : {}) });
262
+ }
263
+ /** Detached mixed selection. Invalid and duplicate IDs are ignored by selectItems. */
264
+ get selection() {
265
+ return [...[...this.selectedIds].map(id => ({ type: 'box', id })),
266
+ ...[...this.selectedLineIds].map(id => ({ type: 'line', id })),
267
+ ...[...this.selectedNoteIds].map(id => ({ type: 'note', id }))];
268
+ }
269
+ selectItems(items) {
270
+ if (!this.isRendered && this.documentValue !== undefined) {
271
+ this.projection = this.model.project(this.documentValue);
272
+ validateProjection(this.projection);
273
+ }
274
+ const boxes = new Set(), lines = new Set(), notes = new Set();
275
+ for (const item of items) {
276
+ if (item.type === 'box' && this.projection.boxes.some(box => box.id === item.id))
277
+ boxes.add(item.id);
278
+ if (item.type === 'line' && this.projection.lines.some(line => line.id === item.id))
279
+ lines.add(item.id);
280
+ if (item.type === 'note' && this.layoutValue.notes?.some(note => note.id === item.id))
281
+ notes.add(item.id);
282
+ }
283
+ const before = JSON.stringify(this.selection);
284
+ this.selectedIds = boxes;
285
+ this.selectedLineIds = lines;
286
+ this.selectedNoteIds = notes;
287
+ this.selectedId = boxes.values().next().value ?? null;
288
+ this.selectedLineId = !boxes.size && !notes.size && lines.size === 1 ? lines.values().next().value : null;
289
+ this.graphSelection = true;
290
+ this.renderSelection();
291
+ if (this.isRendered)
292
+ this.updateToolbar();
293
+ if (before !== JSON.stringify(this.selection))
294
+ this.notify('selection-changed', { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath, selection: this.selection });
295
+ }
296
+ clearMixedSelection() {
297
+ this.selectedLineIds.clear();
298
+ this.selectedNoteIds.clear();
299
+ this.graphSelection = false;
204
300
  }
205
301
  get headingLevel() { const level = Number(this.getAttribute("heading-level") ?? 2); return Number.isInteger(level) && level >= 1 && level <= 6 ? level : 2; }
206
302
  set headingLevel(value) { this.setAttribute("heading-level", String(value)); }
@@ -224,8 +320,13 @@ export class ProcessModeler extends BaseElement {
224
320
  this.refresh();
225
321
  }
226
322
  set document(value) {
323
+ this.localEchoRevision++;
324
+ const rename = this.pendingLocalRename;
227
325
  this.documentValue = value;
228
326
  this.refresh();
327
+ // One explicit echo settles this intent, including a rejected unchanged echo.
328
+ if (rename === this.pendingLocalRename && rename && this.localEchoRevision > rename.echoRevision)
329
+ this.pendingLocalRename = undefined;
229
330
  }
230
331
  get model() {
231
332
  return this.modelValue;
@@ -293,6 +394,12 @@ export class ProcessModeler extends BaseElement {
293
394
  this.refresh();
294
395
  }
295
396
  disconnectedCallback() {
397
+ const menu = this.shadowRoot?.querySelector('[part=view-menu]');
398
+ if (menu)
399
+ menu.open = false;
400
+ this.viewMenuDocument?.removeEventListener('pointerdown', this.dismissViewMenuOutside, true);
401
+ this.viewMenuDocument = undefined;
402
+ this.layoutEditSession++;
296
403
  this.resizeObserver?.disconnect();
297
404
  this.clearClipboard();
298
405
  this.cancelPalettePointer();
@@ -312,7 +419,7 @@ export class ProcessModeler extends BaseElement {
312
419
  this.marquee = undefined;
313
420
  this.segmentDrag = undefined;
314
421
  }
315
- connectedCallback() { super.connectedCallback(); this.resizeObserver?.observe(this); }
422
+ connectedCallback() { super.connectedCallback(); this.resizeObserver?.observe(this); this.syncViewMenuDismissal(); }
316
423
  refresh() {
317
424
  if (this.isRendered)
318
425
  this.update();
@@ -327,8 +434,9 @@ export class ProcessModeler extends BaseElement {
327
434
  }
328
435
  if (id !== null && !this.projection.boxes.some((box) => box.id === id))
329
436
  return;
330
- if (id === this.selectedId && this.selectedIds.size === (id ? 1 : 0) && !this.selectedLineId)
437
+ if (id === this.selectedId && this.selectedIds.size === (id ? 1 : 0) && !this.selectedLineId && !this.graphSelection)
331
438
  return;
439
+ this.clearMixedSelection();
332
440
  this.selectedId = id;
333
441
  this.selectedIds = new Set(id ? [id] : []);
334
442
  this.selectedLineId = null;
@@ -336,18 +444,20 @@ export class ProcessModeler extends BaseElement {
336
444
  if (this.isRendered)
337
445
  this.updateToolbar();
338
446
  if (notify)
339
- emit(this, "selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath });
447
+ this.notify("selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath, ...(this.graphSelection ? { selection: this.selection } : {}) });
340
448
  }
341
449
  selectLine(id) {
342
450
  if (id !== null && !this.projection.lines.some(line => line.id === id))
343
451
  return;
344
- if (this.selectedLineId === id && !this.selectedIds.size)
452
+ if (!this.graphSelection && this.selectedLineId === id && !this.selectedIds.size)
345
453
  return;
454
+ this.clearMixedSelection();
455
+ this.selectedLineIds = new Set(id ? [id] : []);
346
456
  this.selectedLineId = id;
347
457
  this.selectedId = null;
348
458
  this.selectedIds.clear();
349
459
  this.refresh();
350
- emit(this, 'selection-changed', { box: null, boxes: [], line: this.selectedLine, path: null });
460
+ this.notify('selection-changed', { box: null, boxes: [], line: this.selectedLine, path: null });
351
461
  }
352
462
  setValidation(checks) {
353
463
  this.checks = checks;
@@ -373,67 +483,136 @@ export class ProcessModeler extends BaseElement {
373
483
  requestEdit(edit) {
374
484
  if (this.locked || (this.disableConnections && (edit.type === "connect" || edit.type === "disconnect" || edit.type === 'reattach')))
375
485
  return;
486
+ if (edit.type === 'delete-selection' && (!edit.selection?.length || edit.selection.some(item => !['box', 'line', 'note'].includes(item.type) || typeof item.id !== 'string' || !item.id))) {
487
+ this.setStatus('The selected graph items are invalid', true);
488
+ return;
489
+ }
490
+ if (edit.selection)
491
+ edit = { ...edit, selection: Object.freeze(edit.selection.map(item => Object.freeze({ type: item.type, id: item.id }))) };
492
+ if (this.disableConnections && edit.type === 'delete-selection' && edit.selection?.some(item => item.type === 'line')) {
493
+ this.setStatus('Deleting selected lines is disabled');
494
+ return;
495
+ }
496
+ const placement = edit.placement;
497
+ if (placement) {
498
+ const source = placement.source;
499
+ let normalized;
500
+ if (edit.type === 'add' || edit.type === 'insert') {
501
+ if (source === 'next')
502
+ normalized = { source };
503
+ else if (source === 'direction') {
504
+ const side = placement.side;
505
+ if (['north', 'east', 'south', 'west'].includes(side))
506
+ normalized = { source, side };
507
+ }
508
+ else if (source === 'point' || source === 'line') {
509
+ const { x, y } = placement.center ?? {};
510
+ if (Number.isFinite(x) && Number.isFinite(y))
511
+ normalized = { source, center: { x, y } };
512
+ }
513
+ }
514
+ if (!normalized) {
515
+ this.setStatus('The insertion placement is invalid', true);
516
+ return;
517
+ }
518
+ edit = { ...edit, placement: normalized };
519
+ }
376
520
  const problem = this.connectionProblem(edit);
377
521
  if (problem) {
378
522
  this.setStatus(problem, true);
379
523
  return;
380
524
  }
381
525
  const request = { ...edit, accept: this.editAcceptance(edit) };
382
- emit(this, "process-edit-request", request);
526
+ if (edit.type !== 'delete-selection') {
527
+ emit(this, 'process-edit-request', request);
528
+ return;
529
+ }
530
+ // A synchronous host echo can prune selected lines before accept records
531
+ // history. Keep those observers behind the complete atomic transaction.
532
+ this.acceptanceNotifications++;
533
+ try {
534
+ emit(this, 'process-edit-request', request);
535
+ }
536
+ finally {
537
+ this.acceptanceNotifications--;
538
+ this.flushAcceptanceNotifications();
539
+ }
383
540
  }
384
541
  editAcceptance(edit) {
385
542
  const before = this.layout;
386
543
  const beforeSelection = [...this.selectedIds];
544
+ const beforeItems = this.selection;
387
545
  const previousIds = new Set(this.projection.boxes.map((box) => box.id));
546
+ const session = this.layoutEditSession;
388
547
  let accepted = false;
389
548
  return (command) => {
390
- if (accepted)
549
+ if (accepted || session !== this.layoutEditSession)
391
550
  return;
392
551
  accepted = true;
393
- if (command.layout)
394
- this.layoutValue = structuredClone(command.layout);
395
- if (edit.type === 'reset-line' && edit.lineId)
396
- delete this.layoutValue.lines?.[edit.lineId];
397
- this.refresh();
398
- const inserted = edit.type === 'insert' && !edit.boxId && !command.layout
399
- ? this.projection.boxes.find(box => !previousIds.has(box.id)) : undefined;
400
- const reflowed = inserted ? this.reflowStraightInsert(edit, inserted.id, before) : false;
401
- if (!reflowed &&
402
- !command.layout &&
403
- edit.position &&
404
- Number.isFinite(edit.position.x) &&
405
- Number.isFinite(edit.position.y)) {
406
- const added = this.projection.boxes.find(box => box.id === edit.boxId) ?? this.projection.boxes.find((box) => !previousIds.has(box.id));
407
- if (added)
408
- this.layoutValue = this.translatedLayout(added.id, edit.position.x, edit.position.y);
409
- }
410
- const after = this.layout;
411
- const acceptedSelection = command.selectionIds ? [...command.selectionIds] : undefined;
412
- const restore = (layout) => {
413
- this.layoutValue = structuredClone(layout);
552
+ // Projection/readability/selection observers may synchronously edit or
553
+ // replace the document. Publish only after the whole history entry exists.
554
+ this.acceptanceNotifications++;
555
+ try {
556
+ if (command.layout)
557
+ this.layoutValue = structuredClone(command.layout);
558
+ if (edit.type === 'reset-line' && edit.lineId)
559
+ delete this.layoutValue.lines?.[edit.lineId];
414
560
  this.refresh();
415
- this.emitPositions();
416
- };
417
- this.history.record({
418
- undo: () => {
419
- command.undo();
420
- restore(before);
421
- if (acceptedSelection)
422
- this.selectMany(beforeSelection);
423
- },
424
- redo: () => {
425
- command.redo();
426
- restore(after);
427
- if (acceptedSelection)
428
- this.selectMany(acceptedSelection);
429
- },
430
- });
431
- if (acceptedSelection)
432
- this.selectMany(acceptedSelection);
433
- this.refresh();
434
- if (JSON.stringify(before) !== JSON.stringify(after))
435
- this.emitPositions();
436
- announce("Process updated", "polite", this.ownerDocument);
561
+ if (session !== this.layoutEditSession)
562
+ return;
563
+ const inserted = edit.type === 'insert' && !edit.boxId && !command.layout
564
+ ? this.projection.boxes.find(box => !previousIds.has(box.id)) : undefined;
565
+ const reflowed = inserted ? this.reflowStraightInsert(edit, inserted.id, before) : false;
566
+ if (!reflowed &&
567
+ !command.layout &&
568
+ edit.position &&
569
+ Number.isFinite(edit.position.x) &&
570
+ Number.isFinite(edit.position.y)) {
571
+ const added = this.projection.boxes.find(box => box.id === edit.boxId) ?? this.projection.boxes.find((box) => !previousIds.has(box.id));
572
+ if (added)
573
+ this.layoutValue = this.translatedLayout(added.id, edit.position.x, edit.position.y);
574
+ }
575
+ if (session !== this.layoutEditSession)
576
+ return;
577
+ const after = this.layout;
578
+ const acceptedItems = command.selection ? command.selection.map(item => ({ type: item.type, id: item.id })) : undefined;
579
+ const acceptedSelection = command.selectionIds ? [...command.selectionIds] : undefined;
580
+ const restore = (layout) => {
581
+ this.layoutValue = structuredClone(layout);
582
+ this.refresh();
583
+ this.emitPositions();
584
+ };
585
+ this.history.record({
586
+ undo: () => {
587
+ command.undo();
588
+ restore(before);
589
+ if (acceptedItems)
590
+ this.selectItems(beforeItems);
591
+ else if (acceptedSelection)
592
+ this.selectMany(beforeSelection);
593
+ },
594
+ redo: () => {
595
+ command.redo();
596
+ restore(after);
597
+ if (acceptedItems)
598
+ this.selectItems(acceptedItems);
599
+ else if (acceptedSelection)
600
+ this.selectMany(acceptedSelection);
601
+ },
602
+ });
603
+ if (acceptedItems)
604
+ this.selectItems(acceptedItems);
605
+ else if (acceptedSelection)
606
+ this.selectMany(acceptedSelection);
607
+ this.refresh();
608
+ if (JSON.stringify(before) !== JSON.stringify(after))
609
+ this.emitPositions();
610
+ announce("Process updated", "polite", this.ownerDocument);
611
+ }
612
+ finally {
613
+ this.acceptanceNotifications--;
614
+ this.flushAcceptanceNotifications();
615
+ }
437
616
  };
438
617
  }
439
618
  undo() {
@@ -448,6 +627,53 @@ export class ProcessModeler extends BaseElement {
448
627
  this.history.redo();
449
628
  this.refresh();
450
629
  }
630
+ requestLayoutEdit(type, sourceIds, title, onAccepted) {
631
+ const capability = this.model.layoutEdit;
632
+ if (!capability || this.documentValue === undefined)
633
+ return false;
634
+ if (this.locked)
635
+ return true;
636
+ if (!this.isRendered) {
637
+ this.projection = this.model.project(this.documentValue);
638
+ validateProjection(this.projection);
639
+ }
640
+ const session = this.layoutEditSession;
641
+ const accept = this.editAcceptance({ type });
642
+ let active = true, settled = false, acceptedLayout = false;
643
+ const valid = () => active && !settled && session === this.layoutEditSession && !this.locked;
644
+ const request = {
645
+ type, sourceIds: sourceIds ? Object.freeze([...sourceIds]) : null, ...(title !== undefined ? { title } : {}), layout: this.layout,
646
+ accept: command => {
647
+ if (!valid())
648
+ return;
649
+ settled = true;
650
+ if (!command.layout) {
651
+ this.setStatus('The host must supply the complete accepted layout', true);
652
+ return;
653
+ }
654
+ accept(command);
655
+ acceptedLayout = session === this.layoutEditSession;
656
+ },
657
+ refuse: message => { if (!valid())
658
+ return; settled = true; this.setStatus(message ?? 'The host refused this layout edit', true); },
659
+ };
660
+ // Hosts may assign a fresh document before accepting the request. Its
661
+ // notifications must follow the same atomic history boundary.
662
+ this.acceptanceNotifications++;
663
+ try {
664
+ capability.call(this.model, this.documentValue, request, this.projection);
665
+ }
666
+ finally {
667
+ active = false;
668
+ this.acceptanceNotifications--;
669
+ this.flushAcceptanceNotifications();
670
+ }
671
+ if (acceptedLayout && session === this.layoutEditSession)
672
+ onAccepted?.();
673
+ if (!settled && session === this.layoutEditSession)
674
+ this.setStatus('This layout edit was not accepted by the host', true);
675
+ return true;
676
+ }
451
677
  commitLayout(next) {
452
678
  const before = this.layout;
453
679
  const after = structuredClone(next);
@@ -476,8 +702,8 @@ export class ProcessModeler extends BaseElement {
476
702
  this.commitLayout(next);
477
703
  }
478
704
  emitPositions() {
479
- emit(this, "layout-changed", { layout: this.layout });
480
- emit(this, "positions-changed", { positions: this.positions, version: this.versionValue });
705
+ this.notify("layout-changed", { layout: this.layout });
706
+ this.notify("positions-changed", { positions: this.positions, version: this.versionValue });
481
707
  }
482
708
  translatedLayout(id, x, y) {
483
709
  const snap = (n) => (this.snapToGrid ? Math.round(n / 16) * 16 : n);
@@ -543,6 +769,9 @@ export class ProcessModeler extends BaseElement {
543
769
  tidy() {
544
770
  if (this.locked)
545
771
  return;
772
+ if (this.requestLayoutEdit('tidy', null, undefined, () => { if (this.isRendered)
773
+ this.fitTo(this.narrowValue ? 0.55 : 0.7); }))
774
+ return;
546
775
  const before = this.layout.boxes;
547
776
  this.commitLayout(this.arrangedLayout({ ...this.layout, boxes: {} }));
548
777
  if (!globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
@@ -689,13 +918,13 @@ export class ProcessModeler extends BaseElement {
689
918
  chooser.append(picker);
690
919
  this.shadowRoot.append(chooser);
691
920
  picker.addEventListener("kind-pick", event => {
692
- const insertion = this.insertion;
693
- dismissModal(chooser);
921
+ const insertion = this.insertion, session = this.layoutEditSession;
694
922
  this.insertion = undefined;
695
- if (insertion)
923
+ dismissModal(chooser);
924
+ if (insertion && session === this.layoutEditSession && this.isConnected)
696
925
  this.requestEdit({ ...insertion, kind: event.detail.kind });
697
926
  });
698
- picker.addEventListener("picker-cancel", () => { dismissModal(chooser); this.insertion = undefined; });
927
+ picker.addEventListener("picker-cancel", () => { this.insertion = undefined; dismissModal(chooser); });
699
928
  const keyboardChooser = document.createElement('div');
700
929
  keyboardChooser.setAttribute('part', 'keyboard-chooser');
701
930
  keyboardChooser.setAttribute('popover', 'manual');
@@ -710,18 +939,35 @@ export class ProcessModeler extends BaseElement {
710
939
  keyboardChooser.append(keyboardTitle, keyboardPicker);
711
940
  this.shadowRoot.append(keyboardChooser);
712
941
  keyboardPicker.addEventListener('kind-pick', event => {
713
- const edit = this.keyboardInsertion;
942
+ const edit = this.keyboardInsertion, session = this.layoutEditSession;
714
943
  this.closeKeyboardChooser(true);
944
+ if (session !== this.layoutEditSession || !this.isConnected)
945
+ return;
715
946
  // Keep a stable step focused while the host applies and accepts the edit.
716
947
  // World refreshes preserve step focus, including deferred acceptance.
717
948
  if (edit?.from)
718
949
  this.focusBox(edit.from);
719
- if (edit)
950
+ if (edit && session === this.layoutEditSession && this.isConnected)
720
951
  this.requestEdit({ ...edit, kind: event.detail.kind });
721
952
  });
722
953
  keyboardPicker.addEventListener('picker-cancel', () => this.closeKeyboardChooser(true));
723
954
  this.setupPanes();
724
955
  }
956
+ viewMenuDocument;
957
+ syncViewMenuDismissal() {
958
+ const menu = this.shadowRoot?.querySelector('[part=view-menu]');
959
+ const target = this.isConnected && menu?.open ? this.ownerDocument : undefined;
960
+ if (this.viewMenuDocument === target)
961
+ return;
962
+ this.viewMenuDocument?.removeEventListener('pointerdown', this.dismissViewMenuOutside, true);
963
+ this.viewMenuDocument = target;
964
+ target?.addEventListener('pointerdown', this.dismissViewMenuOutside, true);
965
+ }
966
+ dismissViewMenuOutside = (event) => {
967
+ const menu = this.shadowRoot?.querySelector('[part=view-menu]');
968
+ if (menu?.open && !event.composedPath().includes(menu))
969
+ menu.open = false;
970
+ };
725
971
  setupPanes() {
726
972
  const root = this.shadowRoot;
727
973
  const toolbar = root.querySelector('[part=toolbar]');
@@ -754,27 +1000,99 @@ export class ProcessModeler extends BaseElement {
754
1000
  viewMenu.append(summary);
755
1001
  const viewOptions = document.createElement('div');
756
1002
  viewOptions.setAttribute('part', 'view-menu-options');
1003
+ viewOptions.setAttribute('role', 'menu');
1004
+ viewOptions.setAttribute('aria-label', 'Diagram view');
757
1005
  viewMenu.append(viewOptions);
1006
+ summary.setAttribute('aria-haspopup', 'menu');
1007
+ viewMenu.addEventListener('toggle', () => { summary.setAttribute('aria-expanded', String(viewMenu.open)); this.syncViewMenuDismissal(); });
1008
+ const closeViewMenu = () => { const session = this.layoutEditSession; viewMenu.open = false; summary.focus({ preventScroll: true }); return session === this.layoutEditSession && this.isConnected; };
1009
+ viewMenu.addEventListener('keydown', event => {
1010
+ const items = Array.from(viewOptions.querySelectorAll('button')).filter(item => !item.hidden && !item.disabled);
1011
+ if (event.key === 'Tab') {
1012
+ if (viewMenu.open && root.activeElement === summary && !event.shiftKey && items.length) {
1013
+ event.preventDefault();
1014
+ items[0].focus();
1015
+ }
1016
+ else
1017
+ viewMenu.open = false;
1018
+ return;
1019
+ }
1020
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key) || !items.length)
1021
+ return;
1022
+ event.preventDefault();
1023
+ viewMenu.open = true;
1024
+ const index = items.indexOf(root.activeElement);
1025
+ const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : event.key === 'ArrowDown' ? (index + 1) % items.length : (index < 0 ? items.length - 1 : (index + items.length - 1) % items.length);
1026
+ items[next].focus();
1027
+ });
1028
+ root.addEventListener('pointerdown', event => { if (!event.composedPath().includes(viewMenu))
1029
+ viewMenu.open = false; });
758
1030
  for (const [value, label] of [["business", "Business view"], ["technical", "Technical view"]]) {
759
1031
  const button = document.createElement("button");
760
1032
  button.type = "button";
761
1033
  button.dataset.detail = value;
762
1034
  button.textContent = label;
763
- button.onclick = () => { this.detail = value; viewMenu.open = false; };
1035
+ button.setAttribute('role', 'menuitemradio');
1036
+ button.onclick = () => { if (closeViewMenu())
1037
+ this.detail = value; };
764
1038
  viewOptions.append(button);
765
1039
  }
766
1040
  const mobileRun = document.createElement('button');
767
1041
  mobileRun.type = 'button';
768
1042
  mobileRun.dataset.viewOption = 'last-run';
769
- mobileRun.textContent = 'Last run';
770
- mobileRun.onclick = () => { this.showLastRun = !this.showLastRun; viewMenu.open = false; };
1043
+ mobileRun.textContent = 'Show last run';
1044
+ mobileRun.setAttribute('role', 'menuitem');
1045
+ mobileRun.onclick = () => { if (closeViewMenu())
1046
+ this.showLastRun = !this.showLastRun; };
771
1047
  viewOptions.append(mobileRun);
772
1048
  const mobileChecks = document.createElement('button');
773
1049
  mobileChecks.type = 'button';
774
- mobileChecks.textContent = 'Checks';
775
- mobileChecks.onclick = () => { this.select(null); this.activePane = 'Checks'; this.renderPane(); viewMenu.open = false; this.openDrawer('inspector'); };
1050
+ mobileChecks.dataset.viewOption = 'checks';
1051
+ mobileChecks.textContent = 'Checks: ready to run';
1052
+ mobileChecks.setAttribute('role', 'menuitem');
1053
+ mobileChecks.onclick = () => { if (closeViewMenu())
1054
+ checksStatus.click(); };
776
1055
  viewOptions.append(mobileChecks);
777
- toolbar.prepend(viewSwitch, runToggle, viewMenu);
1056
+ const separator = document.createElement('div');
1057
+ separator.setAttribute('role', 'separator');
1058
+ viewOptions.append(separator);
1059
+ for (const [command, label, hint] of [['tidy', 'Tidy up', ''], ['undo', 'Undo', 'Ctrl Z'], ['redo', 'Redo', 'Shift Ctrl Z']]) {
1060
+ const item = document.createElement('button');
1061
+ item.type = 'button';
1062
+ item.dataset.viewOption = command;
1063
+ item.setAttribute('role', 'menuitem');
1064
+ const text = document.createElement('span');
1065
+ text.textContent = label;
1066
+ item.append(text);
1067
+ if (hint) {
1068
+ const key = document.createElement('small');
1069
+ key.textContent = hint;
1070
+ item.append(key);
1071
+ }
1072
+ item.onclick = () => { if (!closeViewMenu())
1073
+ return; if (command === 'tidy')
1074
+ this.tidy();
1075
+ else if (command === 'undo')
1076
+ this.undo();
1077
+ else
1078
+ this.redo(); };
1079
+ viewOptions.append(item);
1080
+ }
1081
+ const spacer = document.createElement('div');
1082
+ spacer.setAttribute('part', 'toolbar-document');
1083
+ spacer.setAttribute('aria-hidden', 'true');
1084
+ const spacerTitle = document.createElement('strong');
1085
+ spacerTitle.setAttribute('part', 'toolbar-title');
1086
+ spacerTitle.textContent = this.processTitleValue;
1087
+ const saved = document.createElement('span');
1088
+ saved.textContent = 'Saved';
1089
+ spacer.append(spacerTitle, saved);
1090
+ toolbar.prepend(spacer, viewSwitch, runToggle, viewMenu);
1091
+ const actions = document.createElement('div');
1092
+ actions.setAttribute('part', 'toolbar-actions');
1093
+ for (const command of ['undo', 'redo', 'tidy'])
1094
+ actions.append(toolbar.querySelector(`[data-command=${command}]`));
1095
+ toolbar.append(actions);
778
1096
  const layout = root.querySelector('[part=layout]');
779
1097
  const scrim = document.createElement('div');
780
1098
  scrim.setAttribute('part', 'pane-scrim');
@@ -854,7 +1172,13 @@ export class ProcessModeler extends BaseElement {
854
1172
  trigger.setAttribute("aria-label", part === "palette" ? "Add building blocks" : `Open ${label.toLowerCase()}`);
855
1173
  trigger.setAttribute('aria-expanded', 'false');
856
1174
  trigger.onclick = () => this.openDrawer(part);
857
- toolbar.append(trigger);
1175
+ if (part === 'palette') {
1176
+ trigger.textContent = '';
1177
+ trigger.append(variableGlyph(true));
1178
+ toolbar.prepend(trigger);
1179
+ }
1180
+ else
1181
+ actions.append(trigger);
858
1182
  }
859
1183
  const inspector = root.querySelector('[part=inspector]');
860
1184
  const heading = document.createElement("div");
@@ -953,6 +1277,7 @@ export class ProcessModeler extends BaseElement {
953
1277
  this.narrowValue = narrow;
954
1278
  this.toggleAttribute("data-narrow", narrow);
955
1279
  this.toggleAttribute('data-phone', entries[0].contentRect.width < 560);
1280
+ this.toggleAttribute('data-compact-toolbar', entries[0].contentRect.width <= 1100);
956
1281
  this.toggleAttribute('data-hide-tidy', entries[0].contentRect.width <= 560);
957
1282
  this.renderSelectionToolbar();
958
1283
  this.positionSelectionToolbar();
@@ -1026,6 +1351,7 @@ export class ProcessModeler extends BaseElement {
1026
1351
  });
1027
1352
  const content = root.querySelector('[part=pane-content]');
1028
1353
  content.hidden = Boolean(selected);
1354
+ content.dataset.pane = this.activePane;
1029
1355
  content.setAttribute("aria-labelledby", `process-tab-${this.activePane.toLowerCase()}`);
1030
1356
  const checks = root.querySelector('[part=checks]');
1031
1357
  checks.hidden = this.activePane !== "Checks";
@@ -1084,29 +1410,50 @@ export class ProcessModeler extends BaseElement {
1084
1410
  content.append(checks);
1085
1411
  }
1086
1412
  else if (this.activePane === "Variables") {
1087
- if (!this.variables.length)
1088
- content.textContent = 'No variables supplied by the host.';
1413
+ const intro = document.createElement('p');
1414
+ intro.setAttribute('part', 'variables-intro');
1415
+ intro.textContent = 'Values the process keeps while it runs. Decisions and step inputs read them by name, written in CEL.';
1416
+ content.append(intro);
1417
+ const scopes = document.createElement('dl');
1418
+ scopes.setAttribute('part', 'variable-scopes');
1419
+ for (const [name, meaning] of [['Iteration', 'Starts fresh each time a test user runs the process.'], ['Process', 'Set once per test user and kept across their iterations.'], ['Step', 'Only inside one step or frame. Add those in the step’s details.']]) {
1420
+ const term = document.createElement('dt');
1421
+ term.textContent = name;
1422
+ const definition = document.createElement('dd');
1423
+ definition.textContent = meaning;
1424
+ scopes.append(term, definition);
1425
+ }
1426
+ content.append(scopes);
1427
+ const collection = document.createElement('div');
1428
+ collection.setAttribute('part', 'variable-list');
1429
+ content.append(collection);
1430
+ if (!this.variables.length) {
1431
+ const empty = document.createElement('p');
1432
+ empty.textContent = 'None yet.';
1433
+ collection.append(empty);
1434
+ }
1089
1435
  for (const [index, variable] of this.variables.entries()) {
1090
1436
  const row = document.createElement('div');
1091
1437
  row.setAttribute('part', 'variable-row');
1092
1438
  const identify = (control, key) => { control.dataset.variable = variable.name; control.dataset.variableKey = key; control.dataset.variableIndex = String(index); };
1093
1439
  if (this.variablesEditable) {
1094
- const label = document.createElement('label');
1095
- label.textContent = 'Name';
1440
+ const label = variableLabel('Name');
1096
1441
  const name = document.createElement('input');
1097
1442
  name.type = 'text';
1443
+ name.placeholder = 'name';
1444
+ name.spellcheck = false;
1445
+ name.autocomplete = 'off';
1098
1446
  name.value = variable.name;
1099
1447
  name.disabled = this.locked;
1100
1448
  identify(name, 'name');
1101
1449
  name.onchange = () => this.requestVariableEdit({ type: 'rename', name: variable.name, value: name.value });
1102
1450
  label.append(name);
1103
1451
  row.append(label);
1104
- const scopeLabel = document.createElement('label');
1105
- scopeLabel.textContent = 'Scope';
1452
+ const scopeLabel = variableLabel('Kept for');
1106
1453
  const scope = document.createElement('select');
1107
1454
  identify(scope, 'scope');
1108
1455
  scope.disabled = this.locked;
1109
- for (const [value, text] of [['iteration', 'Each iteration'], ['process', 'The whole process']]) {
1456
+ for (const [value, text] of [['iteration', 'Iteration'], ['process', 'Process']]) {
1110
1457
  const option = document.createElement('option');
1111
1458
  option.value = value;
1112
1459
  option.textContent = text;
@@ -1128,16 +1475,32 @@ export class ProcessModeler extends BaseElement {
1128
1475
  row.append(scope);
1129
1476
  }
1130
1477
  }
1131
- if (variable.description) {
1478
+ if (this.variablesEditable) {
1479
+ const label = variableLabel('What it’s for');
1480
+ const about = document.createElement('input');
1481
+ about.type = 'text';
1482
+ about.placeholder = 'What it’s for';
1483
+ about.value = variable.description ?? '';
1484
+ about.disabled = this.locked;
1485
+ identify(about, 'description');
1486
+ about.oninput = () => this.requestVariableEdit({ type: 'description', name: variable.name, value: about.value });
1487
+ label.append(about);
1488
+ row.append(label);
1489
+ }
1490
+ else if (variable.description) {
1132
1491
  const description = document.createElement('p');
1133
1492
  description.textContent = variable.description;
1134
1493
  row.append(description);
1135
1494
  }
1136
1495
  if (variable.startingValue !== undefined) {
1137
- const label = document.createElement('label');
1138
- label.textContent = 'Starting value';
1496
+ const label = this.variablesEditable ? variableLabel('Starts as') : document.createElement('label');
1497
+ if (!this.variablesEditable)
1498
+ label.textContent = 'Starting value';
1139
1499
  const input = document.createElement('input');
1140
1500
  input.type = 'text';
1501
+ input.placeholder = 'starts as, like 0';
1502
+ input.spellcheck = false;
1503
+ input.autocomplete = 'off';
1141
1504
  identify(input, 'value');
1142
1505
  input.value = variable.startingValue;
1143
1506
  input.disabled = this.locked;
@@ -1160,13 +1523,26 @@ export class ProcessModeler extends BaseElement {
1160
1523
  remove.type = 'button';
1161
1524
  remove.setAttribute('part', 'variable-remove');
1162
1525
  remove.setAttribute('aria-label', `Remove ${variable.name || 'this variable'}`);
1163
- remove.textContent = '×';
1526
+ remove.append(variableGlyph());
1164
1527
  remove.disabled = this.locked;
1165
1528
  identify(remove, 'remove');
1166
1529
  remove.onclick = () => this.requestVariableEdit({ type: 'remove', name: variable.name });
1167
1530
  row.append(remove);
1168
1531
  }
1169
- content.append(row);
1532
+ if (this.variablesEditable) {
1533
+ // The grid's visual ordering must also be the keyboard/read order.
1534
+ for (const key of ['name', 'value', 'remove', 'description', 'scope']) {
1535
+ const control = row.querySelector(`[data-variable-key=${key}]`);
1536
+ if (control)
1537
+ row.append(control.closest('label') ?? control);
1538
+ }
1539
+ const problem = row.querySelector('[part=field-problem]');
1540
+ if (problem)
1541
+ row.append(problem);
1542
+ }
1543
+ row.setAttribute('role', 'group');
1544
+ row.setAttribute('aria-label', `Variable ${index + 1}`);
1545
+ collection.append(row);
1170
1546
  }
1171
1547
  if (this.variablesEditable) {
1172
1548
  const add = document.createElement('button');
@@ -1174,11 +1550,45 @@ export class ProcessModeler extends BaseElement {
1174
1550
  add.setAttribute('part', 'variable-add');
1175
1551
  add.dataset.variable = '@add';
1176
1552
  add.dataset.variableKey = 'add';
1177
- add.textContent = 'Add a variable';
1553
+ add.append(variableGlyph(true), document.createTextNode('Add a variable'));
1178
1554
  add.disabled = this.locked;
1179
1555
  add.onclick = () => this.requestVariableEdit({ type: 'add' });
1180
- content.append(add);
1556
+ const actions = document.createElement('div');
1557
+ actions.setAttribute('part', 'variable-actions');
1558
+ actions.append(add);
1559
+ content.append(actions);
1181
1560
  }
1561
+ const localRows = this.projection.boxes.flatMap(box => (box.localVariables ?? []).filter(variable => variable.name).map(variable => ({ name: variable.name, connective: 'only in', title: box.title })));
1562
+ const savedRows = this.projection.boxes.filter(box => box.savedResult).map(box => ({ name: box.savedResult, connective: 'from', title: box.title }));
1563
+ const summary = (part, heading, rows, empty) => {
1564
+ if (!rows.length && !empty)
1565
+ return;
1566
+ const section = document.createElement('section');
1567
+ section.setAttribute('part', part);
1568
+ const title = document.createElement('h3');
1569
+ title.textContent = heading;
1570
+ section.append(title);
1571
+ if (rows.length) {
1572
+ const list = document.createElement('ul');
1573
+ for (const item of rows) {
1574
+ const row = document.createElement('li'), text = document.createElement('span'), name = document.createElement('code'), connective = document.createElement('em');
1575
+ name.textContent = item.name;
1576
+ connective.textContent = item.connective;
1577
+ text.append(name, document.createTextNode(' '), connective, document.createTextNode(` ${item.title}`));
1578
+ row.append(text);
1579
+ list.append(row);
1580
+ }
1581
+ section.append(list);
1582
+ }
1583
+ else {
1584
+ const help = document.createElement('p');
1585
+ help.textContent = empty;
1586
+ section.append(help);
1587
+ }
1588
+ content.append(section);
1589
+ };
1590
+ summary('local-variable-summary', 'Only in one step', localRows);
1591
+ summary('saved-result-summary', 'Saved by steps', savedRows, 'No step saves its result yet. Set “Save the result as” on a step.');
1182
1592
  }
1183
1593
  else if (this.activePane === "Connections") {
1184
1594
  if (!this.connections.length)
@@ -1220,6 +1630,14 @@ export class ProcessModeler extends BaseElement {
1220
1630
  if (this.locked)
1221
1631
  return;
1222
1632
  const boxes = this.selectedBoxes;
1633
+ if (command === "delete-many" && this.graphSelection) {
1634
+ if (this.disableConnections && this.selectedLineIds.size) {
1635
+ this.setStatus('Deleting selected lines is disabled');
1636
+ return;
1637
+ }
1638
+ this.requestEdit({ type: 'delete-selection', selection: this.selection });
1639
+ return;
1640
+ }
1223
1641
  if (command === "delete-many") {
1224
1642
  for (const box of boxes)
1225
1643
  this.requestEdit({ type: "delete", boxId: box.id });
@@ -1237,6 +1655,8 @@ export class ProcessModeler extends BaseElement {
1237
1655
  tidySelection() {
1238
1656
  if (this.locked || this.selectedBoxes.length < 2)
1239
1657
  return;
1658
+ if (this.requestLayoutEdit('tidy', [...this.selectedIds]))
1659
+ return;
1240
1660
  const next = this.layout;
1241
1661
  const roots = this.selectedBoxes.filter(box => {
1242
1662
  let parent = box.parentId;
@@ -1276,11 +1696,13 @@ export class ProcessModeler extends BaseElement {
1276
1696
  const arrangedY = Math.min(...group.map(box => arranged.boxes[box.id]?.y ?? Infinity));
1277
1697
  if (![arrangedX, arrangedY].every(Number.isFinite))
1278
1698
  continue;
1699
+ const dx = Math.round((startX - arrangedX) / 16) * 16;
1700
+ const dy = Math.round((startY - arrangedY) / 16) * 16;
1279
1701
  for (const box of subset.boxes) {
1280
1702
  const position = arranged.boxes[box.id];
1281
1703
  if (!position || !next.boxes[box.id])
1282
1704
  continue;
1283
- next.boxes[box.id] = { ...next.boxes[box.id], x: startX + position.x - arrangedX, y: startY + position.y - arrangedY };
1705
+ next.boxes[box.id] = { ...next.boxes[box.id], x: position.x + dx, y: position.y + dy };
1284
1706
  }
1285
1707
  }
1286
1708
  this.commitLayout(next);
@@ -1373,7 +1795,11 @@ export class ProcessModeler extends BaseElement {
1373
1795
  this.commitLayout(next);
1374
1796
  }
1375
1797
  makeSection(title = 'New section') {
1376
- if (this.locked || this.selectedBoxes.length < 2)
1798
+ if (this.locked || !this.selectedBoxes.length)
1799
+ return;
1800
+ if (this.requestLayoutEdit('make-section', [...this.selectedIds], title))
1801
+ return;
1802
+ if (this.selectedBoxes.length < 2)
1377
1803
  return;
1378
1804
  const positions = this.selectedBoxes.map(box => this.layoutValue.boxes[box.id]);
1379
1805
  const x = Math.min(...positions.map(position => position.x)) - 24;
@@ -1536,6 +1962,7 @@ export class ProcessModeler extends BaseElement {
1536
1962
  let claimed = false;
1537
1963
  const request = {
1538
1964
  sourceIds: Object.freeze([...this.selectedIds]),
1965
+ selection: Object.freeze(this.selection.map(item => Object.freeze(item))),
1539
1966
  capture: clipboard => {
1540
1967
  if (!active || claimed || session !== this.clipboardSession || !this.isConnected)
1541
1968
  return;
@@ -1551,7 +1978,7 @@ export class ProcessModeler extends BaseElement {
1551
1978
  return; claimed = true; this.setStatus(message ?? 'Copy is not supported by this host'); },
1552
1979
  };
1553
1980
  try {
1554
- emit(this, 'process-copy-request', request);
1981
+ emit(this, this.graphSelection && (this.selectedLineIds.size || this.selectedNoteIds.size) ? 'process-selection-copy-request' : 'process-copy-request', request);
1555
1982
  }
1556
1983
  finally {
1557
1984
  active = false;
@@ -1632,11 +2059,15 @@ export class ProcessModeler extends BaseElement {
1632
2059
  }
1633
2060
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
1634
2061
  event.preventDefault();
1635
- this.selectMany(this.projection.boxes.map(box => box.id));
1636
- this.setStatus(`${this.projection.boxes.length} steps selected`);
2062
+ this.selectItems([
2063
+ ...this.projection.boxes.filter(box => box.kind !== 'section').map(box => ({ type: 'box', id: box.id })),
2064
+ ...this.projection.lines.map(line => ({ type: 'line', id: line.id })),
2065
+ ...(this.layoutValue.notes ?? []).map(note => ({ type: 'note', id: note.id })),
2066
+ ]);
2067
+ this.setStatus(`${this.selection.length} items selected`);
1637
2068
  return;
1638
2069
  }
1639
- if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c' && this.selectedIds.size) {
2070
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c' && (this.selectedIds.size || this.selectedNoteIds.size)) {
1640
2071
  if (this.copySelection())
1641
2072
  event.preventDefault();
1642
2073
  return;
@@ -1674,6 +2105,11 @@ export class ProcessModeler extends BaseElement {
1674
2105
  this.setStatus("Whole process fitted to the canvas");
1675
2106
  return;
1676
2107
  }
2108
+ if ((event.key === 'Delete' || event.key === 'Backspace') && this.graphSelection && this.selection.length && !this.locked) {
2109
+ event.preventDefault();
2110
+ this.selectionCommand('delete-many');
2111
+ return;
2112
+ }
1677
2113
  const box = this.selected;
1678
2114
  if (this.selectedLine && (event.key === 'Delete' || event.key === 'Backspace') && !this.locked && !this.disableConnections) {
1679
2115
  event.preventDefault();
@@ -1691,6 +2127,32 @@ export class ProcessModeler extends BaseElement {
1691
2127
  ArrowUp: [0, -1],
1692
2128
  ArrowDown: [0, 1],
1693
2129
  }[event.key];
2130
+ if (direction && this.graphSelection && !this.locked && !event.altKey && !event.ctrlKey && !event.metaKey && (this.selectedIds.size || this.selectedNoteIds.size)) {
2131
+ event.preventDefault();
2132
+ const distance = event.shiftKey ? 64 : 16, dx = direction[0] * distance, dy = direction[1] * distance;
2133
+ const next = this.layout, moved = new Set(this.selectedIds);
2134
+ for (let changed = true; changed;) {
2135
+ changed = false;
2136
+ for (const box of this.projection.boxes)
2137
+ if (box.parentId && moved.has(box.parentId) && !moved.has(box.id)) {
2138
+ moved.add(box.id);
2139
+ changed = true;
2140
+ }
2141
+ }
2142
+ for (const id of moved) {
2143
+ const p = next.boxes[id];
2144
+ if (p)
2145
+ next.boxes[id] = { ...p, x: p.x + dx, y: p.y + dy };
2146
+ }
2147
+ next.notes = next.notes?.map(note => this.selectedNoteIds.has(note.id) ? { ...note, x: note.x + dx, y: note.y + dy } : note);
2148
+ const primary = this.selectedId;
2149
+ const allowed = primary
2150
+ ? emit(this, 'move-request', { boxId: primary, position: next.boxes[primary], boxIds: [...moved], positions: next.boxes, noteIds: [...this.selectedNoteIds], notes: next.notes ?? [] })
2151
+ : emit(this, 'note-move-request', { noteIds: [...this.selectedNoteIds], notes: next.notes ?? [] });
2152
+ if (allowed)
2153
+ this.commitLayout(next);
2154
+ return;
2155
+ }
1694
2156
  const canInsertDirection = event.ctrlKey && event.altKey && box && !this.locked && this.catalog.length && !this.disableConnections;
1695
2157
  if (direction && !canInsertDirection && (event.altKey || !box || this.locked)) {
1696
2158
  event.preventDefault();
@@ -2033,7 +2495,7 @@ export class ProcessModeler extends BaseElement {
2033
2495
  if ((frame?.id ?? null) !== (source.parentId ?? null))
2034
2496
  this.setStatus('Drop inside the same frame to add a connected step');
2035
2497
  else
2036
- this.openChooserAtPoint({ type: 'add', from: port.id, fromSide: port.side, parentId: source.parentId, position: point }, `Add after ${source.title}`, point);
2498
+ this.openChooserAtPoint({ type: 'add', from: port.id, fromSide: port.side, parentId: source.parentId, position: point, placement: { source: 'point', center: { ...point } } }, `Add after ${source.title}`, point);
2037
2499
  }
2038
2500
  }
2039
2501
  }
@@ -2093,8 +2555,11 @@ export class ProcessModeler extends BaseElement {
2093
2555
  const position = this.alignedPoint(drag.id, drag.position.x + dx / this.viewport.zoom, drag.position.y + dy / this.viewport.zoom);
2094
2556
  const line = this.lineAt(point, drag.id);
2095
2557
  const frame = this.boxAt(point, drag.id, true);
2096
- if (line)
2097
- this.requestEdit({ type: "insert", boxId: drag.id, lineId: line.id, from: line.from, to: line.to, position });
2558
+ if (line) {
2559
+ const box = this.projection.boxes.find(box => box.id === drag.id);
2560
+ const { width, height } = this.selectionDimensions(box);
2561
+ this.requestEdit({ type: "insert", boxId: drag.id, lineId: line.id, from: line.from, to: line.to, position, placement: { source: 'point', center: { x: position.x + width / 2, y: position.y + height / 2 } } });
2562
+ }
2098
2563
  else if (this.projection.boxes.find(box => box.id === drag.id)?.parentId !== frame?.id)
2099
2564
  this.requestEdit({ type: "reparent", boxId: drag.id, parentId: frame?.id, position });
2100
2565
  else if (this.selectedIds.size > 1 && this.selectedIds.has(drag.id)) {
@@ -2219,9 +2684,10 @@ export class ProcessModeler extends BaseElement {
2219
2684
  nextEdit(box, side, position) {
2220
2685
  const shape = box.shape ?? this.catalog.find(kind => kind.kind === box.kind)?.shape;
2221
2686
  const outgoing = this.projection.lines.filter(line => line.from === box.id);
2687
+ const placement = side ? { source: 'direction', side } : { source: 'next' };
2222
2688
  if ((!side || side === 'east') && shape !== 'gateway' && outgoing.length === 1)
2223
- return { type: 'insert', lineId: outgoing[0].id, from: box.id, to: outgoing[0].to };
2224
- return { type: 'add', from: box.id, ...(side ? { fromSide: side } : {}), parentId: box.parentId, ...(position ? { position } : {}) };
2689
+ return { type: 'insert', lineId: outgoing[0].id, from: box.id, to: outgoing[0].to, placement };
2690
+ return { type: 'add', from: box.id, placement, ...(side ? { fromSide: side } : {}), parentId: box.parentId, ...(position ? { position } : {}) };
2225
2691
  }
2226
2692
  nextChooserTitle(box, side) {
2227
2693
  const edit = this.nextEdit(box, side);
@@ -2247,7 +2713,7 @@ export class ProcessModeler extends BaseElement {
2247
2713
  const source = this.projection.boxes.find(box => box.id === line.from);
2248
2714
  const target = this.projection.boxes.find(box => box.id === line.to);
2249
2715
  const points = this.routedLines.get(line.id) ?? routeProcessLine(line, this.layoutValue, this.projection);
2250
- 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);
2716
+ this.openChooserAtPoint({ type: 'insert', lineId: line.id, from: line.from, to: line.to, placement: { source: 'line', center: lineMidpoint(points) } }, `Insert between ${source?.title ?? line.from} and ${target?.title ?? line.to}`, lineMidpoint(points), returnFocus);
2251
2717
  }
2252
2718
  closeKeyboardChooser(returnFocus = false) {
2253
2719
  const chooser = this.shadowRoot?.querySelector('[part=keyboard-chooser]');
@@ -2258,9 +2724,10 @@ export class ProcessModeler extends BaseElement {
2258
2724
  this.transientChooserAnchor = undefined;
2259
2725
  document.removeEventListener('pointerdown', this.dismissKeyboardChooserOutside, true);
2260
2726
  this.keyboardInsertion = undefined;
2261
- if (returnFocus)
2262
- this.keyboardReturn?.focus({ preventScroll: true });
2727
+ const target = this.keyboardReturn;
2263
2728
  this.keyboardReturn = undefined;
2729
+ if (returnFocus)
2730
+ target?.focus({ preventScroll: true });
2264
2731
  }
2265
2732
  dismissKeyboardChooserOutside = (event) => {
2266
2733
  const chooser = this.shadowRoot?.querySelector('[part=keyboard-chooser]');
@@ -2275,8 +2742,8 @@ export class ProcessModeler extends BaseElement {
2275
2742
  const anchor = Array.from(this.shadowRoot.querySelectorAll('[data-box-id]'))
2276
2743
  .find(element => element.dataset.boxId === box.id);
2277
2744
  this.openAnchoredChooser(insert
2278
- ? { type: 'insert', lineId: outgoing[0].id, from: box.id, to: target.id }
2279
- : { type: 'add', from: box.id }, insert ? `Insert between ${box.title} and ${target.title}`
2745
+ ? { type: 'insert', lineId: outgoing[0].id, from: box.id, to: target.id, placement: { source: 'next' } }
2746
+ : { type: 'add', from: box.id, placement: { source: 'next' } }, insert ? `Insert between ${box.title} and ${target.title}`
2280
2747
  : shape === 'gateway' ? `Add a path from ${box.title}` : `Add after ${box.title}`, anchor);
2281
2748
  }
2282
2749
  openAnchoredChooser(edit, title, anchor, returnFocus) {
@@ -2325,7 +2792,7 @@ export class ProcessModeler extends BaseElement {
2325
2792
  tooltip.style.top = `${point.y + 16}px`;
2326
2793
  }
2327
2794
  update() {
2328
- const selectionBefore = JSON.stringify({ ids: [...this.selectedIds], path: this.selectedPath });
2795
+ const selectionBefore = JSON.stringify({ items: this.selection, path: this.selectedPath });
2329
2796
  if (this.documentValue !== undefined) {
2330
2797
  this.projection = this.model.project(this.documentValue);
2331
2798
  validateProjection(this.projection);
@@ -2340,10 +2807,12 @@ export class ProcessModeler extends BaseElement {
2340
2807
  if (this.selectedLineId && !this.selectedLine)
2341
2808
  this.selectedLineId = null;
2342
2809
  this.selectedIds = new Set([...this.selectedIds].filter(id => this.projection.boxes.some(box => box.id === id)));
2810
+ this.selectedLineIds = new Set([...this.selectedLineIds].filter(id => this.projection.lines.some(line => line.id === id)));
2811
+ this.selectedNoteIds = new Set([...this.selectedNoteIds].filter(id => this.layoutValue.notes?.some(note => note.id === id)));
2343
2812
  if (!this.selectedId)
2344
2813
  this.selectedId = this.selectedIds.values().next().value ?? null;
2345
2814
  const serialized = JSON.stringify({
2346
- boxes: this.projection.boxes.map(box => ({ id: box.id, kind: box.kind, title: box.title, description: box.description, path: box.path, fingerprint: box.fingerprint, frame: box.frame, loopMark: box.loopMark, parentId: box.parentId })),
2815
+ boxes: this.projection.boxes.map(box => ({ id: box.id, kind: box.kind, title: box.title, description: box.description, path: box.path, fingerprint: box.fingerprint, frame: box.frame, loopMark: box.loopMark, parentId: box.parentId, localVariables: box.localVariables, localVariablesEditable: box.localVariablesEditable, savedResult: box.savedResult })),
2347
2816
  lines: this.projection.lines,
2348
2817
  });
2349
2818
  if (this.documentValue !== undefined && (serialized !== this.lastProjection || this.documentValue !== this.lastDocument)) {
@@ -2351,7 +2820,7 @@ export class ProcessModeler extends BaseElement {
2351
2820
  this.versionValue = typeof this.versionValue === "number" ? this.versionValue + 1 : `${this.versionValue}+1`;
2352
2821
  this.lastProjection = serialized;
2353
2822
  this.lastDocument = this.documentValue;
2354
- emit(this, "projection-changed", { projection: snapshotProcessProjection(this.projection), version: this.versionValue, checks: this.allChecks });
2823
+ this.notify("projection-changed", { projection: snapshotProcessProjection(this.projection), version: this.versionValue, checks: this.allChecks });
2355
2824
  }
2356
2825
  // Riptide or another host owns conversion. It supplies validate() and
2357
2826
  // receives this event only when that conversion can read the drawing.
@@ -2361,7 +2830,7 @@ export class ProcessModeler extends BaseElement {
2361
2830
  if (this.documentValue !== undefined && !this.allChecks.length && readableKey !== this.lastReadableProjection) {
2362
2831
  this.lastReadableProjection = readableKey;
2363
2832
  this.readableValue = snapshotProcessProjection(this.projection);
2364
- emit(this, "readable-projection-changed", { projection: snapshotProcessProjection(this.readableValue), version: this.versionValue });
2833
+ this.notify("readable-projection-changed", { projection: snapshotProcessProjection(this.readableValue), version: this.versionValue });
2365
2834
  }
2366
2835
  if (this.documentValue === undefined) {
2367
2836
  this.lastProjection = "";
@@ -2369,16 +2838,16 @@ export class ProcessModeler extends BaseElement {
2369
2838
  this.readableValue = null;
2370
2839
  this.lastDocument = undefined;
2371
2840
  }
2372
- const focused = this.shadowRoot.activeElement
2373
- ?.dataset.boxId;
2841
+ const active = this.shadowRoot.activeElement;
2842
+ const focused = active?.dataset.boxId, focusedNote = active?.dataset.noteId;
2374
2843
  this.renderWorld();
2375
2844
  this.renderPalette();
2376
2845
  this.renderChecks();
2377
2846
  this.renderSelection();
2378
2847
  this.paintViewport();
2379
2848
  this.updateToolbar();
2380
- if (selectionBefore !== JSON.stringify({ ids: [...this.selectedIds], path: this.selectedPath }))
2381
- emit(this, "selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath });
2849
+ if (selectionBefore !== JSON.stringify({ items: this.selection, path: this.selectedPath }))
2850
+ this.notify("selection-changed", { box: this.selected, boxes: this.selectedBoxes, path: this.selectedPath, ...(this.graphSelection ? { selection: this.selection } : {}) });
2382
2851
  if (focused) {
2383
2852
  this.restoringBoxFocus = true;
2384
2853
  try {
@@ -2388,6 +2857,10 @@ export class ProcessModeler extends BaseElement {
2388
2857
  this.restoringBoxFocus = false;
2389
2858
  }
2390
2859
  }
2860
+ else if (focusedNote) {
2861
+ const note = Array.from(this.shadowRoot.querySelectorAll('[data-note-id]')).find(note => note.dataset.noteId === focusedNote);
2862
+ (note ?? this.shadowRoot.querySelector('[part=canvas]'))?.focus({ preventScroll: true });
2863
+ }
2391
2864
  }
2392
2865
  get allChecks() { return this.computedChecks; }
2393
2866
  collectChecks() {
@@ -2434,7 +2907,7 @@ export class ProcessModeler extends BaseElement {
2434
2907
  const problem = this.allChecks.find(check => check.boxId === box.id || (check.path && JSON.stringify(check.path) === JSON.stringify(box.path)));
2435
2908
  const incoming = this.projection.lines.filter(line => line.to === box.id).length;
2436
2909
  const outgoing = this.projection.lines.filter(line => line.from === box.id).length;
2437
- element.setAttribute("aria-label", [`${kind?.label ?? box.kind}: ${box.title}. ${incoming} in, ${outgoing} out`, problem ? `Needs attention: ${problem.message}` : ""]
2910
+ element.setAttribute("aria-label", [`${kind?.label ?? box.kind}: ${box.title}. ${incoming} in, ${outgoing} out`, problem ? `Needs attention: ${problem.title || problem.message}` : ""]
2438
2911
  .filter(Boolean)
2439
2912
  .join(". "));
2440
2913
  element.setAttribute("aria-current", String(this.selectedIds.has(box.id)));
@@ -2551,7 +3024,7 @@ export class ProcessModeler extends BaseElement {
2551
3024
  if (problem) {
2552
3025
  const message = document.createElement("span");
2553
3026
  message.setAttribute("part", "problem");
2554
- message.append(checkGlyph(), document.createTextNode(problem.title ?? problem.message));
3027
+ message.append(checkGlyph(), document.createTextNode(problem.title || problem.message));
2555
3028
  element.append(message);
2556
3029
  }
2557
3030
  element.addEventListener("keydown", event => {
@@ -2660,8 +3133,8 @@ export class ProcessModeler extends BaseElement {
2660
3133
  const path = svgElement("path");
2661
3134
  path.setAttribute("part", "line");
2662
3135
  path.dataset.lineId = line.id;
2663
- path.dataset.selected = String(this.selectedLineId === line.id);
2664
- path.setAttribute('marker-end', `url(#${this.selectedLineId === line.id ? 'boe-process-arrow-brand' : 'boe-process-arrow'})`);
3136
+ path.dataset.selected = String(this.selectedLineIds.has(line.id));
3137
+ path.setAttribute('marker-end', `url(#${this.selectedLineIds.has(line.id) ? 'boe-process-arrow-brand' : 'boe-process-arrow'})`);
2665
3138
  path.setAttribute("vector-effect", "non-scaling-stroke");
2666
3139
  path.setAttribute("d", roundedProcessPath(points));
2667
3140
  if (line.dashed)
@@ -2760,6 +3233,7 @@ export class ProcessModeler extends BaseElement {
2760
3233
  lineId: line.id,
2761
3234
  from: line.from,
2762
3235
  to: line.to,
3236
+ ...(type === 'insert' ? { placement: { source: 'line', center: lineMidpoint(points) } } : {}),
2763
3237
  };
2764
3238
  if (type === "insert" && this.catalog.length) {
2765
3239
  this.openLineChooser(line, button);
@@ -2789,6 +3263,18 @@ export class ProcessModeler extends BaseElement {
2789
3263
  const element = document.createElement("div");
2790
3264
  element.setAttribute("part", "note");
2791
3265
  element.textContent = note.text;
3266
+ element.dataset.noteId = note.id;
3267
+ element.addEventListener('pointerdown', event => event.stopPropagation());
3268
+ element.tabIndex = 0;
3269
+ element.setAttribute('role', 'button');
3270
+ element.setAttribute('aria-label', note.text || 'Diagram note');
3271
+ element.addEventListener('click', event => { const items = event.shiftKey ? this.selection.filter(item => !(item.type === 'note' && item.id === note.id)) : []; if (!event.shiftKey || !this.selectedNoteIds.has(note.id))
3272
+ items.push({ type: 'note', id: note.id }); this.selectItems(items); });
3273
+ element.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') {
3274
+ event.preventDefault();
3275
+ event.stopPropagation();
3276
+ this.selectItems([{ type: 'note', id: note.id }]);
3277
+ } });
2792
3278
  this.place(element, { ...note, width: 208, height: 80 });
2793
3279
  world.append(element);
2794
3280
  }
@@ -2814,9 +3300,13 @@ export class ProcessModeler extends BaseElement {
2814
3300
  }
2815
3301
  const selected = this.selectedIds.size === 1 ? this.selected : null;
2816
3302
  const from = selected && !selected.frame && selected.kind !== 'section' && !['end', 'finish'].includes(selected.kind) ? selected.id : undefined;
2817
- const outgoing = from ? this.projection.lines.filter(line => line.from === from) : [];
2818
- const line = outgoing.length === 1 ? outgoing[0] : undefined;
2819
- this.requestEdit(line ? { type: 'insert', kind, lineId: line.id, from: line.from, to: line.to } : { type: 'add', kind, ...(from ? { from } : {}) });
3303
+ if (from && selected)
3304
+ this.requestEdit({ ...this.nextEdit(selected), kind });
3305
+ else {
3306
+ const canvas = this.shadowRoot.querySelector('[part=canvas]');
3307
+ const center = { x: (canvas.clientWidth / 2 - this.viewport.x) / this.viewport.zoom, y: (canvas.clientHeight / 2 - this.viewport.y) / this.viewport.zoom };
3308
+ this.requestEdit({ type: 'add', kind, placement: { source: 'point', center } });
3309
+ }
2820
3310
  }
2821
3311
  paletteLineAt(point) {
2822
3312
  let nearest;
@@ -2938,7 +3428,7 @@ export class ProcessModeler extends BaseElement {
2938
3428
  }
2939
3429
  const line = drag.kind.kind === 'end' ? undefined : this.paletteLineAt(point);
2940
3430
  const frame = line ? undefined : this.paletteFrameAt(point);
2941
- this.requestEdit({ type: line ? 'insert' : 'add', kind: drag.kind, lineId: line?.id, from: line?.from, to: line?.to, parentId: frame?.id, position: point });
3431
+ this.requestEdit({ type: line ? 'insert' : 'add', kind: drag.kind, lineId: line?.id, from: line?.from, to: line?.to, parentId: frame?.id, position: point, placement: { source: 'point', center: { ...point } } });
2942
3432
  }
2943
3433
  renderPalette() {
2944
3434
  const choices = this.shadowRoot.querySelector("[part=choices]");
@@ -3037,7 +3527,7 @@ export class ProcessModeler extends BaseElement {
3037
3527
  type: line ? "insert" : "add",
3038
3528
  kind,
3039
3529
  lineId: line?.id, from: line?.from, to: line?.to, parentId: frame?.id,
3040
- position,
3530
+ position, placement: { source: 'point', center: { ...position } },
3041
3531
  });
3042
3532
  this.markDropLine(undefined);
3043
3533
  };
@@ -3081,14 +3571,17 @@ export class ProcessModeler extends BaseElement {
3081
3571
  checks.append(button);
3082
3572
  });
3083
3573
  }
3084
- renderField(box, field) {
3574
+ renderField(box, field, helpOpen = false) {
3085
3575
  const row = document.createElement('div');
3086
3576
  row.setAttribute('part', 'field');
3087
3577
  const label = document.createElement('label');
3088
3578
  label.textContent = field.label;
3089
3579
  let control;
3090
- if (field.kind === 'multiline')
3580
+ if (field.kind === 'multiline' || (field.kind === 'expression' && (field.expression?.rows ?? 1) > 1)) {
3091
3581
  control = document.createElement('textarea');
3582
+ if (field.expression?.rows)
3583
+ control.rows = Math.max(2, Math.floor(field.expression.rows));
3584
+ }
3092
3585
  else if (field.kind === 'choice') {
3093
3586
  const select = document.createElement('select');
3094
3587
  const groups = new Map();
@@ -3117,14 +3610,20 @@ export class ProcessModeler extends BaseElement {
3117
3610
  control = input;
3118
3611
  }
3119
3612
  control.dataset.field = field.key;
3613
+ control.dataset.fieldBox = box.id;
3120
3614
  if (control instanceof HTMLInputElement && control.type === 'checkbox')
3121
3615
  control.checked = Boolean(field.value);
3122
3616
  else
3123
3617
  control.value = field.kind === 'action' ? field.options?.find(option => option.value === field.value)?.label ?? String(field.value) : String(field.value);
3124
3618
  control.disabled = Boolean(field.disabled || this.locked);
3125
3619
  control.required = Boolean(field.required);
3126
- if (field.placeholder && control instanceof HTMLInputElement)
3620
+ if (field.placeholder && (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement))
3127
3621
  control.placeholder = field.placeholder;
3622
+ if (field.kind === 'expression') {
3623
+ control.spellcheck = false;
3624
+ control.setAttribute('autocomplete', 'off');
3625
+ control.setAttribute('part', 'expression-control');
3626
+ }
3128
3627
  if (field.problem)
3129
3628
  control.setAttribute('aria-invalid', 'true');
3130
3629
  const submit = () => emit(this, 'process-field-change-request', { boxId: box.id, path: box.path, key: field.key, value: control instanceof HTMLInputElement && control.type === 'checkbox' ? control.checked : control instanceof HTMLInputElement && control.type === 'number' && control.value !== '' ? Number(control.value) : control.value });
@@ -3261,55 +3760,152 @@ export class ProcessModeler extends BaseElement {
3261
3760
  control.setAttribute('list', options.id);
3262
3761
  row.append(options);
3263
3762
  }
3264
- if (field.kind === 'expression' && this.variables.length) {
3763
+ const scope = field.expression?.variables ?? this.variables;
3764
+ if (field.kind === 'expression' && scope.length) {
3265
3765
  const chips = document.createElement('div');
3266
3766
  chips.setAttribute('part', 'variable-chips');
3267
- chips.setAttribute('aria-label', 'Variables in scope');
3268
- for (const variable of this.variables) {
3767
+ chips.setAttribute('role', 'group');
3768
+ chips.setAttribute('aria-label', 'Insert a variable');
3769
+ for (const variable of scope) {
3269
3770
  const chip = document.createElement('button');
3270
3771
  chip.type = 'button';
3271
3772
  chip.textContent = variable.name;
3272
3773
  chip.disabled = control.disabled;
3273
- chip.onclick = () => { control.value += variable.name; submit(); control.focus(); };
3774
+ if (variable.description)
3775
+ chip.title = variable.description;
3776
+ chip.addEventListener('pointerdown', event => event.preventDefault());
3777
+ chip.onclick = () => {
3778
+ if (control.disabled || !control.isConnected || this.selected?.id !== box.id)
3779
+ return;
3780
+ const input = control;
3781
+ const session = this.layoutEditSession;
3782
+ const start = input.selectionStart ?? input.value.length, end = input.selectionEnd ?? start;
3783
+ input.focus();
3784
+ if (session !== this.layoutEditSession || !input.isConnected || input.disabled || this.locked || this.selected?.id !== box.id || this.shadowRoot?.activeElement !== input || input.closest('[inert]'))
3785
+ return;
3786
+ input.value = input.value.slice(0, start) + variable.name + input.value.slice(end);
3787
+ input.setSelectionRange(start + variable.name.length, start + variable.name.length);
3788
+ submit();
3789
+ };
3274
3790
  chips.append(chip);
3275
3791
  }
3276
3792
  row.append(chips);
3277
3793
  }
3794
+ const messageId = `process-field-${encodeURIComponent(box.id)}-${encodeURIComponent(field.key)}`;
3795
+ const describedBy = [];
3278
3796
  if (field.description) {
3279
3797
  const help = document.createElement('small');
3798
+ help.id = `${messageId}-description`;
3280
3799
  help.textContent = field.description;
3281
3800
  row.append(help);
3801
+ describedBy.push(help.id);
3282
3802
  }
3283
- if (field.problem) {
3284
- const problem = document.createElement('small');
3285
- problem.setAttribute('part', 'field-problem');
3286
- problem.textContent = field.problem;
3287
- row.append(problem);
3803
+ const feedback = field.problem || (field.kind === 'expression' ? field.expression?.feedback?.message : undefined);
3804
+ if (feedback) {
3805
+ const message = document.createElement('small');
3806
+ message.id = `${messageId}-feedback`;
3807
+ message.textContent = feedback;
3808
+ message.setAttribute('part', field.problem ? 'field-problem' : 'field-feedback');
3809
+ if (field.kind === 'expression')
3810
+ message.setAttribute('aria-live', 'polite');
3811
+ if (!field.problem && field.expression?.feedback?.tone)
3812
+ message.dataset.tone = field.expression.feedback.tone;
3813
+ const chips = row.querySelector('[part=variable-chips]');
3814
+ row.insertBefore(message, chips);
3815
+ describedBy.push(message.id);
3816
+ }
3817
+ if (describedBy.length)
3818
+ control.setAttribute('aria-describedby', describedBy.join(' '));
3819
+ if (field.kind === 'expression' && field.expression?.help) {
3820
+ const details = document.createElement('details');
3821
+ details.setAttribute('part', 'expression-help');
3822
+ details.dataset.helpField = field.key;
3823
+ details.dataset.session = String(this.layoutEditSession);
3824
+ details.open = helpOpen;
3825
+ const summary = document.createElement('summary');
3826
+ summary.textContent = field.expression.help.summary;
3827
+ details.append(summary);
3828
+ const list = document.createElement('ul');
3829
+ details.append(list);
3830
+ for (const example of field.expression.help.examples) {
3831
+ const paragraph = document.createElement('li'), code = document.createElement('code');
3832
+ code.textContent = example.expression;
3833
+ paragraph.append(code, document.createTextNode(' '));
3834
+ if (example.segments)
3835
+ for (const segment of example.segments) {
3836
+ const run = document.createElement(segment.format === 'code' ? 'code' : 'span');
3837
+ run.textContent = segment.text;
3838
+ paragraph.append(run);
3839
+ }
3840
+ else
3841
+ paragraph.append(document.createTextNode(example.description));
3842
+ list.append(paragraph);
3843
+ }
3844
+ row.append(details);
3288
3845
  }
3289
3846
  return row;
3290
3847
  }
3291
3848
  renderSelection() {
3849
+ this.shadowRoot?.querySelectorAll('[part=note]').forEach(note => { note.dataset.selected = String(this.selectedNoteIds.has(note.dataset.noteId)); note.setAttribute('aria-pressed', note.dataset.selected); });
3292
3850
  if (!this.isRendered)
3293
3851
  return;
3294
3852
  const focusedControl = this.shadowRoot.activeElement;
3295
- const focusedKey = focusedControl?.dataset.field ?? focusedControl?.dataset.variable;
3853
+ const focusedKey = focusedControl?.dataset.field ?? focusedControl?.dataset.variable ?? focusedControl?.dataset.localVariable;
3296
3854
  const variableKey = focusedControl?.dataset.variableKey;
3297
- const focusedType = focusedControl?.dataset.field !== undefined ? 'field' : focusedControl?.dataset.variable !== undefined ? 'variable' : null;
3855
+ const fieldBox = focusedControl?.dataset.fieldBox;
3856
+ const localKey = focusedControl?.dataset.localKey, localName = focusedControl?.dataset.localName;
3857
+ const focusedType = focusedControl?.dataset.field !== undefined ? 'field' : focusedControl?.dataset.variable !== undefined ? 'variable' : focusedControl?.dataset.localVariable !== undefined ? 'localVariable' : null;
3298
3858
  const caret = focusedControl && 'selectionStart' in focusedControl ? focusedControl.selectionStart : null;
3859
+ const selectionEnd = focusedControl && 'selectionEnd' in focusedControl ? focusedControl.selectionEnd : null;
3860
+ const direction = focusedControl && 'selectionDirection' in focusedControl ? focusedControl.selectionDirection : null;
3861
+ const scrollTop = focusedControl?.scrollTop, scrollLeft = focusedControl?.scrollLeft;
3299
3862
  const restoreControlFocus = () => {
3300
3863
  if (focusedKey === undefined || !focusedType)
3301
3864
  return;
3865
+ if (focusedType === 'field' && (this.selected?.id !== fieldBox || this.locked))
3866
+ return;
3302
3867
  const controls = Array.from(this.shadowRoot.querySelectorAll('input,textarea,select,button'));
3303
- const control = controls.find(item => item.dataset[focusedType] === focusedKey && (focusedType !== 'variable' || item.dataset.variableKey === variableKey))
3868
+ const control = controls.find(item => item.dataset[focusedType] === focusedKey && (focusedType !== 'field' || item.dataset.fieldBox === fieldBox) && (focusedType !== 'variable' || item.dataset.variableKey === variableKey) && focusedType !== 'localVariable')
3304
3869
  ?? (focusedType === 'variable' ? controls.find(item => this.pendingVariableRename?.name === focusedKey && item.dataset.variable === this.pendingVariableRename.value && item.dataset.variableKey === variableKey) ?? controls.find(item => item.dataset.variableKey === 'add') : undefined);
3305
- if (!control)
3870
+ if (focusedType === 'localVariable') {
3871
+ if (focusedControl?.dataset.localAmbiguous === 'true') {
3872
+ focusedControl.blur();
3873
+ return;
3874
+ }
3875
+ const names = [localName];
3876
+ if (this.pendingLocalRename?.boxId === focusedKey && this.pendingLocalRename.name === localName && !this.pendingLocalRename.ambiguous)
3877
+ names.push(this.pendingLocalRename.value);
3878
+ let localControl = undefined;
3879
+ for (const name of names) {
3880
+ const matches = controls.filter(item => item.dataset.localVariable === focusedKey && item.dataset.localKey === localKey && item.dataset.localName === name);
3881
+ if (matches.length === 1) {
3882
+ localControl = matches[0];
3883
+ break;
3884
+ }
3885
+ if (matches.length > 1)
3886
+ break;
3887
+ }
3888
+ if (!localControl) {
3889
+ focusedControl?.blur();
3890
+ return;
3891
+ }
3892
+ localControl.focus();
3893
+ if (caret !== null && localControl instanceof HTMLInputElement)
3894
+ localControl.setSelectionRange(caret, caret);
3895
+ return;
3896
+ }
3897
+ if (!control || control.disabled || control.closest('[inert]'))
3306
3898
  return;
3307
- control.focus();
3899
+ control.focus({ preventScroll: true });
3308
3900
  if (caret !== null && (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement) && control.type !== 'number' && control.type !== 'checkbox')
3309
- control.setSelectionRange(caret, caret);
3901
+ control.setSelectionRange(caret, selectionEnd ?? caret, direction ?? undefined);
3902
+ if (scrollTop !== undefined)
3903
+ control.scrollTop = scrollTop;
3904
+ if (scrollLeft !== undefined)
3905
+ control.scrollLeft = scrollLeft;
3310
3906
  };
3311
3907
  this.shadowRoot.querySelectorAll('[part=minimap] [data-map-box]').forEach(rect => rect.dataset.selected = String(this.selectedIds.has(rect.dataset.mapBox)));
3312
- this.shadowRoot.querySelectorAll('[part=line]').forEach(line => { const selected = line.dataset.lineId === this.selectedLineId; line.dataset.selected = String(selected); line.setAttribute('marker-end', `url(#${selected ? 'boe-process-arrow-brand' : 'boe-process-arrow'})`); });
3908
+ this.shadowRoot.querySelectorAll('[part=line]').forEach(line => { const selected = this.selectedLineIds.has(line.dataset.lineId); line.dataset.selected = String(selected); line.setAttribute('marker-end', `url(#${selected ? 'boe-process-arrow-brand' : 'boe-process-arrow'})`); });
3313
3909
  this.shadowRoot.querySelectorAll("[data-box-id]").forEach((element) => {
3314
3910
  element.setAttribute("aria-current", String(this.selectedIds.has(element.dataset.boxId)));
3315
3911
  const box = this.projection.boxes.find((box) => box.id === element.dataset.boxId);
@@ -3319,9 +3915,10 @@ export class ProcessModeler extends BaseElement {
3319
3915
  });
3320
3916
  const editor = this.shadowRoot.querySelector("[part=editor]");
3321
3917
  const selected = this.selected;
3322
- const controlsKey = `${[...this.selectedIds].join(',')}|${this.locked}|${this.disableConnections}|${this.showLastRunValue}|${selected?.runMetrics}|${JSON.stringify(selected ? this.fieldsValue[selected.id] ?? [] : [])}`;
3918
+ const fallbackScope = selected && (this.fieldsValue[selected.id] ?? []).some(field => field.kind === 'expression' && field.expression?.variables === undefined) ? this.variables.map(({ name, description }) => ({ name, description })) : undefined;
3919
+ const controlsKey = `${JSON.stringify(fallbackScope)}|${[...this.selectedIds].join(',')}|${this.locked}|${this.disableConnections}|${this.showLastRunValue}|${selected?.runMetrics}|${JSON.stringify([selected?.localVariables, selected?.localVariablesEditable, selected?.frame])}|${JSON.stringify(selected ? this.fieldsValue[selected.id] ?? [] : [])}`;
3323
3920
  this.shadowRoot.querySelector("[part=palette]").hidden = false;
3324
- this.shadowRoot.querySelector('[part=selection-toolbar]').hidden = this.selectedIds.size === 0 && !this.selectedLineId;
3921
+ this.shadowRoot.querySelector('[part=selection-toolbar]').hidden = this.selection.length === 0;
3325
3922
  this.renderSelectionToolbar();
3326
3923
  this.renderPane();
3327
3924
  this.positionSelectionToolbar();
@@ -3330,9 +3927,22 @@ export class ProcessModeler extends BaseElement {
3330
3927
  this.inspectedControls === controlsKey &&
3331
3928
  editor.querySelector(`[part=inspector-heading]`)?.tagName === `H${this.headingLevel}`) {
3332
3929
  editor.querySelector("[part=inspector-heading]").textContent = selected?.title ?? "";
3930
+ const locals = editor.querySelector('[part=local-variables]');
3931
+ if (selected && locals && locals.dataset.localEchoRevision !== String(this.localEchoRevision)) {
3932
+ // Reconcile controlled local drafts without disposing the host's custom
3933
+ // inspector or unrelated focused controls on unchanged document echoes.
3934
+ locals.replaceWith(this.renderLocalVariables(selected));
3935
+ }
3333
3936
  restoreControlFocus();
3334
3937
  return;
3335
3938
  }
3939
+ const expressionHelp = new Map();
3940
+ if (this.inspectedId === selected?.id && this.selectedIds.size === 1) {
3941
+ for (const help of Array.from(editor.querySelectorAll('[part=expression-help]'))) {
3942
+ if (help.dataset.session === String(this.layoutEditSession))
3943
+ expressionHelp.set(help.dataset.helpField, help.open);
3944
+ }
3945
+ }
3336
3946
  this.cleanupInspector?.();
3337
3947
  this.cleanupInspector = undefined;
3338
3948
  editor.replaceChildren();
@@ -3382,7 +3992,9 @@ export class ProcessModeler extends BaseElement {
3382
3992
  editor.append(purpose);
3383
3993
  }
3384
3994
  for (const field of this.fieldsValue[selected.id] ?? [])
3385
- editor.append(this.renderField(selected, field));
3995
+ editor.append(this.renderField(selected, field, expressionHelp.get(field.key)));
3996
+ if (selected.localVariables !== undefined)
3997
+ editor.append(this.renderLocalVariables(selected));
3386
3998
  this.cleanupInspector =
3387
3999
  this.renderer?.(selected.node, editor) || undefined;
3388
4000
  const outgoing = this.projection.lines.filter(line => line.from === selected.id);
@@ -3469,14 +4081,118 @@ export class ProcessModeler extends BaseElement {
3469
4081
  }
3470
4082
  restoreControlFocus();
3471
4083
  }
4084
+ renderLocalVariables(box) {
4085
+ const section = document.createElement('section');
4086
+ section.setAttribute('part', 'local-variables');
4087
+ section.dataset.localEchoRevision = String(this.localEchoRevision);
4088
+ const heading = document.createElement('h3');
4089
+ heading.textContent = box.frame ? 'Variables for the steps inside' : 'Variables for this step';
4090
+ const help = document.createElement('p');
4091
+ help.textContent = box.frame ? 'Every step inside can read these; they’re gone once the frame is done.' : 'Only this step’s inputs can read these; they’re gone once it’s done.';
4092
+ const collection = document.createElement('div');
4093
+ collection.setAttribute('part', 'local-variable-list');
4094
+ section.append(heading, help, collection);
4095
+ for (const [index, variable] of (box.localVariables ?? []).entries()) {
4096
+ const row = document.createElement('div');
4097
+ row.setAttribute('part', 'local-variable-row');
4098
+ row.setAttribute('role', 'group');
4099
+ row.setAttribute('aria-label', `Step variable ${index + 1}`);
4100
+ const identify = (control, key) => { control.dataset.localVariable = box.id; control.dataset.localIndex = String(index); control.dataset.localKey = key; control.dataset.localName = variable.name; control.dataset.localAmbiguous = String(box.localVariables?.filter(item => item.name === variable.name).length !== 1); };
4101
+ for (const [key, label, value] of [['rename', 'Name', variable.name], ['starting-value', 'Starts as', variable.startingValue ?? '']]) {
4102
+ const field = variableLabel(label);
4103
+ const input = document.createElement('input');
4104
+ input.type = 'text';
4105
+ input.placeholder = key === 'rename' ? 'name' : 'starts as';
4106
+ input.spellcheck = false;
4107
+ input.autocomplete = 'off';
4108
+ input.value = value;
4109
+ input.disabled = this.locked || !box.localVariablesEditable;
4110
+ identify(input, key);
4111
+ if (variable.problem) {
4112
+ input.setAttribute('aria-invalid', 'true');
4113
+ input.setAttribute('aria-describedby', `process-local-problem-${encodeURIComponent(box.id)}-${index}`);
4114
+ }
4115
+ input.oninput = () => {
4116
+ if (key === 'rename')
4117
+ this.pendingLocalRename = { boxId: box.id, name: variable.name, value: input.value, echoRevision: this.localEchoRevision, ambiguous: input.dataset.localAmbiguous === 'true' };
4118
+ this.requestLocalVariableEdit(box, { type: key, index, name: variable.name, value: input.value });
4119
+ };
4120
+ field.append(input);
4121
+ row.append(field);
4122
+ }
4123
+ if (box.localVariablesEditable) {
4124
+ const remove = document.createElement('button');
4125
+ remove.type = 'button';
4126
+ remove.append(variableGlyph());
4127
+ remove.setAttribute('aria-label', `Remove ${variable.name || 'this variable'}`);
4128
+ remove.disabled = this.locked;
4129
+ identify(remove, 'remove');
4130
+ remove.onclick = () => this.requestLocalVariableEdit(box, { type: 'remove', index, name: variable.name });
4131
+ row.append(remove);
4132
+ }
4133
+ if (variable.problem) {
4134
+ const problem = document.createElement('small');
4135
+ problem.setAttribute('part', 'field-problem');
4136
+ problem.id = `process-local-problem-${encodeURIComponent(box.id)}-${index}`;
4137
+ problem.textContent = variable.problem;
4138
+ row.append(problem);
4139
+ }
4140
+ collection.append(row);
4141
+ }
4142
+ if (box.localVariablesEditable) {
4143
+ const add = document.createElement('button');
4144
+ add.type = 'button';
4145
+ add.append(variableGlyph(true), document.createTextNode('Add a variable'));
4146
+ add.disabled = this.locked;
4147
+ add.dataset.localVariable = box.id;
4148
+ add.dataset.localKey = 'add';
4149
+ add.onclick = () => this.requestLocalVariableEdit(box, { type: 'add' });
4150
+ const actions = document.createElement('div');
4151
+ actions.setAttribute('part', 'local-variable-actions');
4152
+ actions.append(add);
4153
+ section.append(actions);
4154
+ }
4155
+ return section;
4156
+ }
4157
+ requestLocalVariableEdit(box, edit) {
4158
+ const owner = this.selected;
4159
+ if (this.locked || !owner?.localVariablesEditable || owner.id !== box.id)
4160
+ return;
4161
+ if (edit.type !== 'add' && owner.localVariables?.[edit.index]?.name !== edit.name)
4162
+ return;
4163
+ const request = { boxId: owner.id, ...(owner.path ? { path: [...owner.path] } : {}), edit };
4164
+ const session = this.layoutEditSession, beforeNames = (owner.localVariables ?? []).map(variable => variable.name), beforeCount = beforeNames.length;
4165
+ emit(this, 'process-local-variable-edit-request', request);
4166
+ if (session !== this.layoutEditSession || this.selected?.id !== box.id)
4167
+ return;
4168
+ const count = this.selected.localVariables?.length ?? 0;
4169
+ if (edit.type === 'add' && count > beforeCount) {
4170
+ const remaining = [...beforeNames];
4171
+ const added = (this.selected.localVariables ?? []).filter(variable => {
4172
+ const index = remaining.indexOf(variable.name);
4173
+ if (index < 0)
4174
+ return true;
4175
+ remaining.splice(index, 1);
4176
+ return false;
4177
+ });
4178
+ if (added.length === 1 && this.selected.localVariables?.filter(variable => variable.name === added[0].name).length === 1) {
4179
+ const name = Array.from(this.shadowRoot.querySelectorAll('[data-local-key=rename]')).find(input => input.dataset.localVariable === box.id && input.dataset.localName === added[0].name);
4180
+ name?.focus();
4181
+ name?.select();
4182
+ }
4183
+ }
4184
+ else if (edit.type === 'remove' && count < beforeCount) {
4185
+ Array.from(this.shadowRoot.querySelectorAll('[data-local-key=add]')).find(button => button.dataset.localVariable === box.id)?.focus();
4186
+ }
4187
+ }
3472
4188
  renderSelectionToolbar() {
3473
4189
  const toolbar = this.shadowRoot.querySelector('[part=selection-toolbar]');
3474
- const key = `${[...this.selectedIds].join(',')}|${this.selected?.kind}|${this.selected?.shape}|${this.selectedLineId}|${Boolean(this.selectedLine && (this.layoutValue.lines?.[this.selectedLine.id] || this.selectedLine.fromSide || this.selectedLine.toSide || this.selectedLine.points?.length))}|${this.locked}|${this.narrowValue}|${this.projection.lines.filter(line => line.from === this.selectedId).map(line => `${line.id}:${line.label}:${line.dashed}`).join(',')}`;
4190
+ const key = `${JSON.stringify(this.selection)}|${this.graphSelection}|${this.selected?.kind}|${this.selected?.shape}|${this.selectedLineId}|${Boolean(this.selectedLine && (this.layoutValue.lines?.[this.selectedLine.id] || this.selectedLine.fromSide || this.selectedLine.toSide || this.selectedLine.points?.length))}|${this.locked}|${this.narrowValue}|${this.projection.lines.filter(line => line.from === this.selectedId).map(line => `${line.id}:${line.label}:${line.dashed}`).join(',')}`;
3475
4191
  if (key === this.selectionToolbarKey)
3476
4192
  return;
3477
4193
  this.selectionToolbarKey = key;
3478
4194
  toolbar.replaceChildren();
3479
- if (!this.selectedIds.size && !this.selectedLineId)
4195
+ if (!this.selection.length)
3480
4196
  return;
3481
4197
  const add = (label, command, action, withPlus = false) => {
3482
4198
  const button = document.createElement('button');
@@ -3497,7 +4213,12 @@ export class ProcessModeler extends BaseElement {
3497
4213
  button.append(text);
3498
4214
  toolbar.append(button);
3499
4215
  };
3500
- if (this.selectedLine) {
4216
+ if (this.graphSelection && (this.selectedNoteIds.size || this.selectedLineIds.size > 1 || this.selectedLineIds.size && this.selectedIds.size)) {
4217
+ if (this.selectedIds.size > 1)
4218
+ add('Line up', 'align', () => this.selectionCommand('align'));
4219
+ add('Delete', 'delete-many', () => this.selectionCommand('delete-many'));
4220
+ }
4221
+ else if (this.selectedLine) {
3501
4222
  const line = this.selectedLine;
3502
4223
  add('Insert a step', 'insert', () => this.openLineChooser(line, toolbar.querySelector('[data-selection-command=insert]') ?? undefined), true);
3503
4224
  if (this.layoutValue.lines?.[line.id] || line.fromSide || line.toSide || line.points?.length)
@@ -3513,7 +4234,7 @@ export class ProcessModeler extends BaseElement {
3513
4234
  if (!['end', 'finish', 'section', 'note'].includes(box.kind))
3514
4235
  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);
3515
4236
  if (failurePath)
3516
- 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);
4237
+ add('If it fails', 'add-failure', () => this.openAnchoredChooser({ type: 'add', from: box.id, routeLabel: 'If it fails', dashed: true, placement: { source: 'next' } }, `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);
3517
4238
  if (!['start', 'timer', 'section'].includes(box.kind))
3518
4239
  add('Duplicate', 'duplicate', () => this.duplicateBoxes([box.id], 48));
3519
4240
  if (!['start', 'timer'].includes(box.kind))
@@ -3530,25 +4251,56 @@ export class ProcessModeler extends BaseElement {
3530
4251
  button("undo").disabled = !this.history.canUndo || this.locked;
3531
4252
  button("redo").disabled = !this.history.canRedo || this.locked;
3532
4253
  button("tidy").disabled = this.locked;
4254
+ this.shadowRoot.querySelector('[part=toolbar-title]').textContent = this.processTitleValue;
4255
+ for (const command of ['undo', 'redo', 'tidy'])
4256
+ this.shadowRoot.querySelector(`[data-view-option=${command}]`).disabled = button(command).disabled;
3533
4257
  button("lock").setAttribute("aria-pressed", String(this.locked));
3534
4258
  button("snap").setAttribute("aria-pressed", String(this.snapToGrid));
3535
4259
  this.shadowRoot.querySelectorAll('[data-detail]').forEach(control => {
3536
- control.setAttribute("aria-pressed", String(control.dataset.detail === this.detailValue));
4260
+ if (!control.closest('[part=view-menu]'))
4261
+ control.setAttribute("aria-pressed", String(control.dataset.detail === this.detailValue));
4262
+ if (control.closest('[part=view-menu]'))
4263
+ control.setAttribute('aria-checked', String(control.dataset.detail === this.detailValue));
4264
+ if (control.closest('[part=view-menu]'))
4265
+ control.textContent = `${control.dataset.detail === this.detailValue ? '✓ ' : ''}${control.dataset.detail === 'business' ? 'Business view' : 'Technical view'}`;
3537
4266
  });
3538
4267
  const runToggle = this.shadowRoot.querySelector('[part=run-toggle]');
3539
4268
  runToggle.hidden = !this.lastRunValue;
3540
4269
  runToggle.querySelector('input').checked = this.showLastRunValue;
3541
4270
  const mobileRun = this.shadowRoot.querySelector('[data-view-option=last-run]');
3542
4271
  mobileRun.hidden = !this.lastRunValue;
3543
- mobileRun.setAttribute('aria-pressed', String(this.showLastRunValue));
4272
+ mobileRun.removeAttribute('aria-pressed');
4273
+ mobileRun.textContent = this.showLastRunValue ? 'Hide last run' : 'Show last run';
3544
4274
  const checksStatus = button('checks-status');
3545
4275
  const count = this.allChecks.length;
3546
- checksStatus.textContent = count ? `${count} ${count === 1 ? 'problem' : 'problems'}` : '✓ Ready to run';
4276
+ const glyph = svgElement('svg');
4277
+ glyph.setAttribute('viewBox', '0 0 16 16');
4278
+ glyph.setAttribute('aria-hidden', 'true');
4279
+ const circle = svgElement('circle');
4280
+ circle.setAttribute('cx', '8');
4281
+ circle.setAttribute('cy', '8');
4282
+ circle.setAttribute('r', '7');
4283
+ circle.setAttribute('fill', 'currentColor');
4284
+ const mark = svgElement('path');
4285
+ mark.setAttribute('d', count ? 'm5 5 6 6m0-6-6 6' : 'm4.5 8 2.5 2.5 4.5-5');
4286
+ mark.setAttribute('stroke', 'var(--boe-token-surface-surface, #fff)');
4287
+ mark.setAttribute('stroke-width', '1.5');
4288
+ mark.setAttribute('fill', 'none');
4289
+ mark.setAttribute('stroke-linecap', 'round');
4290
+ mark.setAttribute('stroke-linejoin', 'round');
4291
+ glyph.append(circle, mark);
4292
+ const label = document.createElement('span');
4293
+ label.textContent = count ? `${count} ${count === 1 ? 'thing needs' : 'things need'} attention` : 'Ready to run';
4294
+ checksStatus.replaceChildren(glyph, label);
3547
4295
  checksStatus.dataset.state = count ? 'bad' : 'ready';
4296
+ this.shadowRoot.querySelector('[data-view-option=checks]').textContent = count ? `Checks (${count})` : 'Checks: ready to run';
3548
4297
  }
3549
4298
  paintViewport() {
3550
4299
  const world = this.shadowRoot.querySelector("[part=world]");
3551
4300
  world.style.setProperty('--boe-process-inverse-zoom', String(1 / this.viewport.zoom));
4301
+ // A small screen-space margin keeps targets above 24px after fractional
4302
+ // layout rounding without assuming a browser's CSS-pixel quantum.
4303
+ world.style.setProperty('--boe-process-hit-target', `${24.1 / this.viewport.zoom}px`);
3552
4304
  world.style.transform =
3553
4305
  `translate(${this.viewport.x}px,${this.viewport.y}px) scale(${this.viewport.zoom})`;
3554
4306
  const canvas = this.shadowRoot.querySelector("[part=canvas]");
@@ -3596,17 +4348,27 @@ export class ProcessModeler extends BaseElement {
3596
4348
  minimap.append(viewport);
3597
4349
  }
3598
4350
  positionSelectionToolbar() {
3599
- if (!this.isRendered || (!this.selectedIds.size && !this.selectedLineId))
4351
+ if (!this.isRendered || !this.selection.length)
3600
4352
  return;
3601
4353
  const canvas = this.shadowRoot.querySelector('[part=canvas]');
3602
4354
  const toolbar = this.shadowRoot.querySelector('[part=selection-toolbar]');
3603
4355
  const positions = [...this.selectedIds].map(id => this.layoutValue.boxes[id]).filter(Boolean);
4356
+ positions.push(...(this.layoutValue.notes ?? []).filter(note => this.selectedNoteIds.has(note.id)).map(note => ({ ...note, width: 208, height: 80 })));
3604
4357
  if (!positions.length && this.selectedLine) {
3605
4358
  const point = lineMidpoint(this.routedLines.get(this.selectedLine.id) ?? []);
3606
4359
  toolbar.style.left = `${Math.max(8, Math.min(point.x * this.viewport.zoom + this.viewport.x, Math.max(8, canvas.clientWidth - toolbar.offsetWidth - 8)))}px`;
3607
4360
  toolbar.style.top = `${Math.max(8, point.y * this.viewport.zoom + this.viewport.y - 52)}px`;
3608
4361
  return;
3609
4362
  }
4363
+ if (!positions.length) {
4364
+ for (const id of this.selectedLineIds) {
4365
+ const points = this.routedLines.get(id) ?? [];
4366
+ if (!points.length)
4367
+ continue;
4368
+ const x = Math.min(...points.map(point => point.x)), y = Math.min(...points.map(point => point.y));
4369
+ positions.push({ x, y, width: Math.max(...points.map(point => point.x)) - x, height: Math.max(...points.map(point => point.y)) - y });
4370
+ }
4371
+ }
3610
4372
  if (!positions.length)
3611
4373
  return;
3612
4374
  const left = Math.min(...positions.map(position => position.x * this.viewport.zoom + this.viewport.x));