flexdesk 0.18.0 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/css/flexdesk.css +83 -5
  3. package/css/tokens.css +7 -0
  4. package/dist/charts.js +78 -12
  5. package/dist/charts.js.map +2 -2
  6. package/dist/{chunk-5W4EEKSK.js → chunk-3BLIVOFD.js} +3 -3
  7. package/dist/{chunk-P74F6I6P.js → chunk-4X4SY3CP.js} +4 -4
  8. package/dist/{chunk-P74F6I6P.js.map → chunk-4X4SY3CP.js.map} +2 -2
  9. package/dist/{chunk-ZL3VKINP.js → chunk-HL3IIHU3.js} +494 -3
  10. package/dist/{chunk-ZL3VKINP.js.map → chunk-HL3IIHU3.js.map} +4 -4
  11. package/dist/{chunk-RKVYIXDC.js → chunk-NFZJ6P42.js} +19 -500
  12. package/dist/chunk-NFZJ6P42.js.map +7 -0
  13. package/dist/{chunk-U5GKJUOC.js → chunk-QRBOIUU3.js} +5 -6
  14. package/dist/chunk-QRBOIUU3.js.map +7 -0
  15. package/dist/flexdesk.css +83 -5
  16. package/dist/flow.js +22 -10
  17. package/dist/flow.js.map +2 -2
  18. package/dist/git.js +3 -3
  19. package/dist/tiles.js +4 -4
  20. package/dist/tokens.css +7 -0
  21. package/dist/widgets.js +226 -58
  22. package/dist/widgets.js.map +2 -2
  23. package/dist/wm.js +2 -2
  24. package/docs/components/charts.md +40 -0
  25. package/docs/components/chat-shell.md +8 -2
  26. package/docs/components/filter-builder.md +52 -6
  27. package/docs/components/flow-editors.md +20 -0
  28. package/docs/components/forms-and-dialogs.md +8 -0
  29. package/docs/components/input-fields.md +24 -0
  30. package/docs/components/menus.md +4 -0
  31. package/docs/components/shell.md +4 -0
  32. package/docs/components/workspace-picker.md +46 -2
  33. package/package.json +1 -1
  34. package/src/charts/plot_config_editor.js +92 -6
  35. package/src/charts/plot_grid.js +6 -1
  36. package/src/flow/kit/settings_panel.js +24 -9
  37. package/src/ui/components/chat_shell.js +7 -1
  38. package/src/ui/components/context_menu.js +8 -2
  39. package/src/ui/components/filter_builder.js +53 -28
  40. package/src/ui/components/filter_model.js +86 -18
  41. package/src/ui/components/inline_renamer.js +10 -5
  42. package/src/ui/components/modal.js +25 -8
  43. package/src/ui/components/workspace_picker.js +125 -27
  44. package/src/ui/utils/tooltip_service.js +81 -14
  45. package/dist/chunk-RKVYIXDC.js.map +0 -7
  46. package/dist/chunk-U5GKJUOC.js.map +0 -7
  47. /package/dist/{chunk-5W4EEKSK.js.map → chunk-3BLIVOFD.js.map} +0 -0
@@ -703,13 +703,18 @@ export function mountChatShell(host, {
703
703
  changed();
704
704
  }
705
705
 
706
- /** Stops the reply: the chat can send again at once, and that send waits for `runEnding`. */
706
+ /**
707
+ * Stops the reply: the chat can send again at once. That send waits for
708
+ * `runEnding`, unless this turn was itself still waiting for it: the person
709
+ * has now said twice that they will not wait, so the next send goes out.
710
+ */
707
711
  function cancel() {
708
712
  if (!turn) return false;
709
713
  const { request, sent } = turn;
710
714
  closeReply('stopped');
711
715
  machine.cancel();
712
716
  if (sent) callCancel(request);
717
+ else runEnding = null;
713
718
  syncWelcome();
714
719
  changed();
715
720
  return true;
@@ -789,6 +794,7 @@ export function mountChatShell(host, {
789
794
  const modelBtn = el('button', 'twm-chat-shell__model-btn');
790
795
  modelBtn.type = 'button';
791
796
  modelBtn.setAttribute('aria-haspopup', 'menu');
797
+ modelBtn.setAttribute('aria-expanded', 'false');
792
798
  const modelDot = el('span', 'twm-chat-shell__status');
793
799
  const modelLabel = el('span', 'twm-chat-shell__model-label');
794
800
  modelBtn.append(modelDot, modelLabel, icon('expand_more', 'twm-chat-shell__model-chevron'));
@@ -37,6 +37,7 @@ import { placeMenuInViewport, moveMenuFocus, focusMenuItem } from '../utils/menu
37
37
 
38
38
  let _activeMenu = null;
39
39
  let _returnFocusTo = null;
40
+ let _armTimer = null;
40
41
 
41
42
  export function showContextMenu(x, y, items, onAction) {
42
43
  hideContextMenu();
@@ -104,8 +105,11 @@ export function showContextMenu(x, y, items, onAction) {
104
105
 
105
106
  // Close on outside click / scroll / Escape. A scroll INSIDE the menu is
106
107
  // the menu being read, not the page moving under it.
107
- setTimeout(() => {
108
- document.addEventListener('mousedown', _outsideHandler, { once: true, capture: true });
108
+ // Armed a tick later, so the press that opened it is not its first outside
109
+ // press, and held until the menu closes: a press inside (a separator, the
110
+ // padding, a disabled row) closes nothing and must leave the next one heard.
111
+ _armTimer = setTimeout(() => {
112
+ document.addEventListener('mousedown', _outsideHandler, { capture: true });
109
113
  }, 0);
110
114
  document.addEventListener('keydown', _keyHandler);
111
115
  window.addEventListener('scroll', _scrollHandler, { capture: true });
@@ -122,6 +126,8 @@ export function hideContextMenu() {
122
126
  _activeMenu.remove();
123
127
  _activeMenu = null;
124
128
  _returnFocusTo = null;
129
+ clearTimeout(_armTimer);
130
+ document.removeEventListener('mousedown', _outsideHandler, { capture: true });
125
131
  document.removeEventListener('keydown', _keyHandler);
126
132
  window.removeEventListener('scroll', _scrollHandler, { capture: true });
127
133
  // Only take focus back if the menu still had it. A click elsewhere has
@@ -12,9 +12,9 @@
12
12
  * the node and redraws nothing, so the caret survives.
13
13
  *
14
14
  * WHAT IT DOES NOT REWRITE. A condition on a field the catalogue does not know, an
15
- * operator it does not know and a node it cannot read stay exactly as they are
16
- * until someone changes or removes them: the selects show them as their own marked
17
- * entry and the value is read-only.
15
+ * operator it does not know or its field does not offer, and a node it cannot read
16
+ * stay exactly as they are until someone changes or removes them: the selects show
17
+ * them as their own entry and the value is read-only.
18
18
  */
19
19
 
20
20
  import { cloneFilter, coerceFilterValue, emptyFilter, filterOptions } from './filter_model.js';
@@ -215,21 +215,25 @@ export function createFilterBuilder(host, { model, value = null, onChange = null
215
215
  function operatorChoices(clause) {
216
216
  const offered = model.operatorsFor(clause.field);
217
217
  const held = model.operatorById(clause.op);
218
- const list = held && model.fieldByKey(clause.field) && !offered.includes(held)
219
- ? [...offered, held] : offered;
218
+ const list = held && !offered.includes(held) ? [...offered, held] : offered;
220
219
  return list.map((op) => ({ value: op.id, label: op.label }));
221
220
  }
222
221
 
223
222
  function clauseEl(clause) {
224
223
  const row = el('div', 'twm-filter-builder__row');
224
+ const known = !!model.fieldByKey(clause.field);
225
+ const unknownWords = `⚠ unknown field “${model.unknownName(String(clause.field ?? ''))}”`;
225
226
 
226
227
  const fieldSelect = selectBox(fieldChoices(clause.field), clause.field, {
227
228
  label: 'Field',
228
- className: `${SELECT_CLASS} twm-filter-builder__select--field`,
229
- notFound: () => `⚠ unknown field “${model.unknownName(String(clause.field ?? ''))}”`,
229
+ className: `${SELECT_CLASS} twm-filter-builder__select--field${known ? '' : ' twm-filter-builder__select--unknown'}`,
230
+ notFound: () => unknownWords,
230
231
  });
232
+ // Its width follows the words, and when the row cannot spare that the
233
+ // tooltip still says the whole name.
234
+ if (!known) fieldSelect.box.title = unknownWords;
231
235
  fieldSelect.box.addEventListener('change', () => {
232
- const next = model.newClause(fieldSelect.valueOf());
236
+ const next = model.moveClause(clause, fieldSelect.valueOf());
233
237
  for (const key of ['op', 'value']) delete clause[key];
234
238
  Object.assign(clause, next);
235
239
  restructure(clause, 'field');
@@ -269,7 +273,9 @@ export function createFilterBuilder(host, { model, value = null, onChange = null
269
273
  function drawValue(clause, cellEl, cell, drawCell) {
270
274
  const field = model.fieldByKey(clause.field);
271
275
  const op = model.operatorById(clause.op);
272
- if (!field || !op) {
276
+ // A box drawn for an operator it was not made for would show nothing, or
277
+ // a field's own type, over a value that is still what Apply returns.
278
+ if (!field || !op || (op.arity && !model.operatorsFor(clause.field).includes(op))) {
273
279
  if (clause.value !== undefined) {
274
280
  cellEl.appendChild(el('span', 'twm-filter-builder__carried', JSON.stringify(clause.value)));
275
281
  }
@@ -327,6 +333,7 @@ export function createFilterBuilder(host, { model, value = null, onChange = null
327
333
  if (numeric) {
328
334
  input.type = 'number';
329
335
  input.step = 'any';
336
+ if (op.input === 'days') input.min = '0';
330
337
  } else if (field.type === 'date') {
331
338
  input.type = field.time ? 'datetime-local' : 'date';
332
339
  } else {
@@ -508,12 +515,13 @@ function createIconPicker({ value, options, onChange }) {
508
515
  * @param {object} options.model from `createFilterModel`
509
516
  * @param {object} [options.value] the expression to edit (copied; Cancel is lossless)
510
517
  * @param {string} [options.seedField] a field key: a new empty condition on it is
511
- * added, so "filter by this" lands on a form about that field
518
+ * added, so "filter by this" lands on a form about that field. Beside a negated
519
+ * root, not inside it: a new condition narrows what the filter already says.
512
520
  * @param {string} [options.title]
513
521
  * @param {string} [options.icon] the window's icon
514
522
  * @param {HTMLElement} [options.head] drawn above the conditions
515
523
  * @param {{value?: string, label?: string, placeholder?: string, required?: boolean}} [options.name]
516
- * adds a name box; the result carries `name`
524
+ * adds a name box, which opens with its text selected; the result carries `name`
517
525
  * @param {{value?: string, options: string[]}} [options.iconChoice]
518
526
  * adds an icon picker over those Material Symbols names; the result carries `icon`
519
527
  * @param {(host: HTMLElement, state: {expr: object, sentence: string, signal: AbortSignal}) => void} [options.preview]
@@ -521,10 +529,13 @@ function createIconPicker({ value, options, onChange }) {
521
529
  * while the expression can be submitted. `signal` aborts when this drawing is
522
530
  * superseded or the dialog closes, for a preview that asks a server.
523
531
  * @param {boolean} [options.requireValid] submit only a valid expression (default
524
- * true); its errors are listed. False lets one with an unknown field through.
532
+ * true); its errors are listed. False lets one with an unknown field through. A
533
+ * range with one end filled is refused either way (`model.partialRanges`).
525
534
  * @param {boolean} [options.prune] drop half-built rows from what is validated,
526
535
  * previewed and submitted (`model.pruneFilter`); `expr` is null when nothing is left
527
536
  * @param {boolean} [options.confirmDiscard] ask before closing with unsaved changes
537
+ * @param {boolean} [options.dismissOnBackdrop] a click on the dimmed page beside the
538
+ * dialog dismisses it, as Esc does (`confirmDiscard` still asks)
528
539
  * @param {string} [options.submitLabel]
529
540
  * @param {(result: object) => any} [options.onSubmit] may be async: until it settles
530
541
  * every button is off and Esc and the X do nothing, so the answer is what happened.
@@ -532,8 +543,9 @@ function createIconPicker({ value, options, onChange }) {
532
543
  * @param {string} [options.clearLabel] adds a button answering `{ action: 'clear', expr: null }`
533
544
  * @param {Array<{label: string, value: string, danger?: boolean, icon?: string,
534
545
  * confirm?: object, run?: (result: object) => any}>} [options.actions]
535
- * more buttons, before Cancel. `confirm` is `openConfirm`'s options, asked first;
536
- * `run` is like `onSubmit`.
546
+ * more buttons, before Cancel. `confirm` is `openConfirm`'s options, asked first; its
547
+ * `message` may be `(result) => string`, computed when it is asked, so it can quote
548
+ * what was typed. `run` is like `onSubmit`.
537
549
  * @param {number} [options.width]
538
550
  * @param {number} [options.height]
539
551
  * @returns {Promise<{action: string, expr: object|null, name?: string, icon?: string}|null>}
@@ -542,16 +554,17 @@ function createIconPicker({ value, options, onChange }) {
542
554
  export async function openFilterDialog({
543
555
  model, value = null, seedField = null, title = 'Filter', icon = 'filter_alt', head = null,
544
556
  name = null, iconChoice = null, preview = null,
545
- requireValid = true, prune = false, confirmDiscard = false,
557
+ requireValid = true, prune = false, confirmDiscard = false, dismissOnBackdrop = false,
546
558
  submitLabel = 'Apply', onSubmit = null, clearLabel = null, actions = [],
547
559
  width = preview ? 920 : 760, height = 560,
548
560
  } = {}) {
549
561
  if (!model) throw new Error('[FilterDialog] needs a filter model');
550
562
 
551
- const start = asRoot(value);
552
- if (seedField) {
553
- const seeded = model.newClause(seedField);
554
- if (seeded) start.children.push(seeded);
563
+ let start = asRoot(value);
564
+ const seeded = seedField ? model.newClause(seedField) : null;
565
+ if (seeded) {
566
+ if (start.negate) start = { kind: 'group', op: 'AND', children: [start, seeded] };
567
+ else start.children.push(seeded);
555
568
  }
556
569
 
557
570
  const content = el('div', 'twm-filter-dialog');
@@ -630,10 +643,14 @@ export async function openFilterDialog({
630
643
  const expr = builder.getValue();
631
644
  return prune ? model.pruneFilter(expr) : expr;
632
645
  }
633
- const checkOf = (expr) => (expr ? model.validateFilter(expr) : { ok: true, errors: [] });
646
+ /** Why `expr` may not be submitted. */
647
+ const refusals = (expr) => {
648
+ if (!expr) return [];
649
+ return requireValid ? model.validateFilter(expr).errors : model.partialRanges(expr);
650
+ };
634
651
 
635
652
  const nameMissing = () => !!nameInput && name.required !== false && !nameInput.value.trim();
636
- const canSubmit = () => !busy && !nameMissing() && (!requireValid || checkOf(current()).ok);
653
+ const canSubmit = () => !busy && !nameMissing() && !refusals(current()).length;
637
654
 
638
655
  function refresh() {
639
656
  if (!controls) return;
@@ -653,11 +670,11 @@ export async function openFilterDialog({
653
670
 
654
671
  function drawPreview() {
655
672
  const expr = current();
656
- const { ok, errors } = checkOf(expr);
673
+ const errors = refusals(expr);
657
674
  previewRun?.abort();
658
675
  previewRun = null;
659
- const blocked = requireValid && !ok;
660
- showErrors(blocked ? errors : []);
676
+ const blocked = errors.length > 0;
677
+ showErrors(errors);
661
678
  if (!preview) return;
662
679
  slot.hidden = blocked;
663
680
  if (blocked) {
@@ -702,13 +719,17 @@ export async function openFilterDialog({
702
719
  showFailure('');
703
720
  busy = true;
704
721
  try {
705
- // Its question stacks over this window, so the buttons stay as they
706
- // are until it is answered and focus has a button to come back to.
707
- if (action.confirm && !await openConfirm(action.confirm)) return;
708
- refresh();
709
722
  const result = { action: action.value, expr: action.value === CLEAR ? null : current() };
710
723
  if (nameInput) result.name = nameInput.value.trim();
711
724
  if (picker) result.icon = picker.value();
725
+ // Its question stacks over this window, so the buttons stay as they
726
+ // are until it is answered and focus has a button to come back to.
727
+ if (action.confirm) {
728
+ const { message } = action.confirm;
729
+ const asked = typeof message === 'function' ? { ...action.confirm, message: message(result) } : action.confirm;
730
+ if (!await openConfirm(asked)) return;
731
+ }
732
+ refresh();
712
733
  await action.run?.(result);
713
734
  close(result);
714
735
  } catch (err) {
@@ -749,9 +770,13 @@ export async function openFilterDialog({
749
770
  height,
750
771
  maximizable: true,
751
772
  beforeClose: mayDiscard,
773
+ dismissOnBackdrop,
752
774
  onMount: (_body, modalControls) => {
753
775
  controls = modalControls;
754
776
  refresh();
777
+ // Editing a name usually means replacing it, and " (copy)" always does.
778
+ nameInput?.focus();
779
+ nameInput?.select();
755
780
  },
756
781
  });
757
782
  drawPreview();
@@ -319,6 +319,34 @@ export function createFilterModel({
319
319
  return clause;
320
320
  }
321
321
 
322
+ /** Does `clause`'s value still mean something on `field`, under `op`? */
323
+ function valueFits(field, op, value) {
324
+ const errors = [];
325
+ validateClause({ field: field.key, op: op.id, value }, errors, '');
326
+ if (errors.length) return false;
327
+ // A list the editor offers: a text field's single value is typed freely.
328
+ const options = op.multi || field.type === 'enum' ? filterOptions(field) : [];
329
+ return !options.length || arr(value).every((v) => options.some((o) => o.value === v));
330
+ }
331
+
332
+ /**
333
+ * `clause` moved to the field `key`: its operator stays when that field offers
334
+ * it, and its value when the value still fits; otherwise they start as
335
+ * `newClause(key)`'s do. Null when the catalogue has no such field.
336
+ */
337
+ function moveClause(clause, key) {
338
+ const next = newClause(key);
339
+ if (!next) return null;
340
+ const field = byKey.get(key);
341
+ const op = operatorsFor(key).find((o) => o.id === clause.op);
342
+ if (!op) return next;
343
+ next.op = op.id;
344
+ delete next.value;
345
+ const value = op.arity && valueFits(field, op, clause.value) ? clause.value : defaultValue(field, op);
346
+ if (value !== undefined) next.value = value;
347
+ return next;
348
+ }
349
+
322
350
  /* Compiled per type so a clause does its type dispatch ONCE, not on every row. */
323
351
  function compileClause(clause) {
324
352
  const field = byKey.get(clause?.field);
@@ -475,35 +503,50 @@ export function createFilterModel({
475
503
  const [a, b] = arr(value);
476
504
  return `${shown(a)} and ${shown(b)}`;
477
505
  }
478
- return shown(first(value));
506
+ // Every value of a list, even under an operator that takes one: a saved
507
+ // or foreign condition may hold several, and the sentence must not hide them.
508
+ return Array.isArray(value) ? value.map(shown).join(', ') : shown(value);
479
509
  }
480
510
 
481
- function describeNode(node, depth, format) {
511
+ function describeNode(node, depth, opts) {
482
512
  if (!node) return '';
483
513
  if (node.kind === 'clause') {
484
514
  const field = byKey.get(node.field);
515
+ const op = catalogue.get(node.op) ?? { label: node.op ?? '', arity: 1 };
485
516
  // A field that has gone is SAID, so a filter matching nothing reads
486
- // as broken, not as empty.
487
- if (!field) return `⚠ unknown field “${unknownName(String(node.field ?? ''))}”`;
488
- const op = catalogue.get(node.op);
489
- if (!op) return `${field.label} ${node.op ?? ''}`.trim();
490
- const val = describeValue(field, op, node.value, format);
491
- return val ? `${field.label} ${op.label} ${val}` : `${field.label} ${op.label}`;
517
+ // as broken, not as empty, and what it asked stays readable.
518
+ const subject = field ? field.label : `⚠ unknown field “${unknownName(String(node.field ?? ''))}”`;
519
+ const value = describeValue(field, op, node.value, field ? opts.formatValue : null);
520
+ return [subject, op.label, value].filter(Boolean).join(' ');
492
521
  }
493
522
  if (node.kind !== 'group') return `⚠ ${JSON.stringify(node.node ?? node)}`;
494
523
  const parts = (Array.isArray(node.children) ? node.children : [])
495
- .map((c) => describeNode(c, depth + 1, format)).filter(Boolean);
524
+ .map((c) => describeNode(c, depth + 1, opts)).filter(Boolean);
496
525
  if (!parts.length) return emptyMatches(node) !== !!node.negate ? 'everything' : 'nothing';
497
526
  const joined = parts.join(String(node.op || 'AND').toUpperCase() === 'OR' ? ' OR ' : ' AND ');
527
+ const many = parts.length > 1;
528
+ if (opts.groupBrackets) {
529
+ const group = many ? `(${joined})` : joined;
530
+ return node.negate ? `NOT ${group}` : group;
531
+ }
498
532
  if (node.negate) return `NOT (${joined})`;
499
- return depth > 0 && parts.length > 1 ? `(${joined})` : joined;
533
+ return depth > 0 && many ? `(${joined})` : joined;
500
534
  }
501
535
 
502
536
  /**
503
537
  * One line of words. `formatValue(value, field)` names each value (a key shown
504
- * as a person's name, say); without it a value is shown as itself.
538
+ * as a person's name, say); without it a value is shown as itself. A condition
539
+ * on a field the catalogue does not know says so, with its operator and value,
540
+ * and its value is shown as itself.
541
+ *
542
+ * Brackets go where the sentence would misread without them: a nested group of
543
+ * two or more, and whatever a NOT covers. `groupBrackets` brackets every group of
544
+ * two or more, the root too, and leaves a NOT of one thing unbracketed: `(A AND B)`,
545
+ * `NOT A`, for a surface that reads a group by its brackets.
505
546
  */
506
- const describeFilter = (expr, { formatValue = null } = {}) => describeNode(expr, 0, formatValue);
547
+ const describeFilter = (expr, { formatValue = null, groupBrackets = false } = {}) => (
548
+ describeNode(expr, 0, { formatValue, groupBrackets })
549
+ );
507
550
 
508
551
  /** The field keys an expression names that the catalogue does not know. */
509
552
  function unknownFields(expr) {
@@ -535,13 +578,13 @@ export function createFilterModel({
535
578
  }
536
579
  if (op.arity === 0) return;
537
580
  const say = (what) => errors.push(`${field.label} ${op.label}: ${what}`);
538
- if (op.multi) {
539
- if (!Array.isArray(node.value) || node.value.length === 0) say('pick at least one value');
581
+ if (op.multi && (!Array.isArray(node.value) || node.value.length === 0)) {
582
+ say('pick at least one value');
540
583
  return;
541
584
  }
542
- const values = op.arity === 2 ? arr(node.value).slice(0, 2) : [first(node.value)];
585
+ const values = op.multi ? node.value : op.arity === 2 ? arr(node.value).slice(0, 2) : [first(node.value)];
543
586
  const needed = op.arity === 2 ? 2 : 1;
544
- if (values.length < needed || values.some(isBlank)) {
587
+ if (!op.multi && (values.length < needed || values.some(isBlank))) {
545
588
  say(needed === 2 ? 'needs two values' : 'needs a value');
546
589
  return;
547
590
  }
@@ -581,6 +624,29 @@ export function createFilterModel({
581
624
  return { ok: errors.length === 0, errors };
582
625
  }
583
626
 
627
+ /**
628
+ * What is said of each range in `expr` with one end filled: it is not blank, so
629
+ * `pruneFilter` keeps it, and what it asks (between 5 and nothing) matches nothing.
630
+ */
631
+ function partialRanges(expr) {
632
+ const said = [];
633
+ const walk = (node) => {
634
+ if (node?.kind === 'group') {
635
+ (Array.isArray(node.children) ? node.children : []).forEach(walk);
636
+ return;
637
+ }
638
+ if (node?.kind !== 'clause') return;
639
+ const field = byKey.get(node.field);
640
+ const op = catalogue.get(node.op);
641
+ if (!field || op?.arity !== 2 || op.multi) return;
642
+ const ends = arr(node.value).slice(0, 2);
643
+ const filled = ends.filter((v) => !isBlank(v)).length;
644
+ if (filled === 1) said.push(`${field.label} ${op.label}: needs two values`);
645
+ };
646
+ walk(expr);
647
+ return said;
648
+ }
649
+
584
650
  /* ── prune: what is left once half-built rows are dropped ───────────── */
585
651
 
586
652
  function prune(node) {
@@ -603,12 +669,14 @@ export function createFilterModel({
603
669
  /**
604
670
  * A copy with empty groups and conditions with nothing to compare against
605
671
  * dropped, or null when nothing is left. A condition this catalogue cannot
606
- * read, and a carried node, are kept: they were not built here.
672
+ * read, and a carried node, are kept: they were not built here. So is a range
673
+ * with one end filled, which `partialRanges` names: someone meant that.
607
674
  */
608
675
  const pruneFilter = (expr) => prune(cloneFilter(expr));
609
676
 
610
677
  return Object.freeze({
611
- fields, noun, fieldByKey, operatorsFor, operatorById, defaultValue, newClause, emptyMatches,
678
+ fields, noun, fieldByKey, operatorsFor, operatorById, defaultValue, newClause, moveClause, emptyMatches,
612
679
  unknownName, matcherFor, evaluateFilter, describeFilter, validateFilter, unknownFields, pruneFilter,
680
+ partialRanges,
613
681
  });
614
682
  }
@@ -32,11 +32,12 @@ export function attachInlineRenamer(options = {}) {
32
32
  let originalText = label.textContent || '';
33
33
 
34
34
  const cleanup = () => {
35
- if (input && input.parentNode === label) {
36
- input.remove();
37
- }
35
+ // `input` goes first: taking a focused field off the page fires its blur,
36
+ // and a blur with the field still held would commit an edit that just ended.
37
+ const gone = input;
38
38
  input = null;
39
39
  editing = false;
40
+ if (gone && gone.parentNode === label) gone.remove();
40
41
  };
41
42
 
42
43
  const cancel = () => {
@@ -68,8 +69,8 @@ export function attachInlineRenamer(options = {}) {
68
69
  return;
69
70
  }
70
71
  const finalValue = result.newValue ?? next ?? '';
71
- label.textContent = finalValue;
72
72
  cleanup();
73
+ label.textContent = finalValue;
73
74
  options.onFinish?.({ value: finalValue });
74
75
  } catch (err) {
75
76
  window.logger?.warn('inline-renamer', 'onCommit failed', err);
@@ -89,6 +90,11 @@ export function attachInlineRenamer(options = {}) {
89
90
  input.addEventListener('mousedown', stopEvents);
90
91
  input.addEventListener('click', stopEvents);
91
92
 
93
+ // Before the renamer's own keys, so that what it attaches to the input
94
+ // hears them first: `attachInputOptions` takes Enter on a highlighted row
95
+ // and Esc with its list open, and the renamer never sees either.
96
+ options.onStart?.({ value: originalText, triggerEvent, input });
97
+
92
98
  input.addEventListener('keydown', (ev) => {
93
99
  if (ev.key === 'Enter') {
94
100
  ev.preventDefault();
@@ -101,7 +107,6 @@ export function attachInlineRenamer(options = {}) {
101
107
 
102
108
  input.addEventListener('blur', () => commit('blur'));
103
109
 
104
- options.onStart?.({ value: originalText, triggerEvent });
105
110
  setTimeout(() => {
106
111
  input?.focus();
107
112
  input?.select?.();
@@ -444,7 +444,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
444
444
 
445
445
  /**
446
446
  * `openConfirm` — a yes/no modal. Resolves `true` on confirm, `false` on
447
- * cancel, Esc, X or backdrop.
447
+ * cancel, Esc or X.
448
448
  *
449
449
  * `message`, `confirmLabel` and `cancelLabel` are TEXT, never markup: callers
450
450
  * put user-named things in them ("Delete flow "<name>"?"), so they are set
@@ -531,7 +531,8 @@ export function openConfirm({
531
531
  * omitted, defaults to a single `[Close]` action that
532
532
  * resolves with `null`. The first action with
533
533
  * `primary: true` is the rightmost; otherwise the
534
- * last action wins. Esc / X / backdrop resolve with
534
+ * last action wins. Esc / X (and, with
535
+ * `dismissOnBackdrop`, a click on the backdrop) resolve with
535
536
  * `null` regardless of what's in `actions`. `icon` is
536
537
  * an optional material-symbols-outlined ligature drawn
537
538
  * BEFORE the label; when it is given the label lives in
@@ -558,6 +559,9 @@ export function openConfirm({
558
559
  * - `beforeClose` `() => boolean | Promise<boolean>`: asked when Esc or
559
560
  * the X would dismiss; answering false keeps the modal open. An
560
561
  * action button does not ask it.
562
+ * - `dismissOnBackdrop` a click on the dimmed page beside the modal dismisses
563
+ * it, as Esc does, so `beforeClose` is asked. Default `false`:
564
+ * the backdrop only blocks.
561
565
  *
562
566
  * ══ C29. WHY A DIALOG'S MAXIMISE IS THE GEOMETRIC ONE ═══════════════════
563
567
  *
@@ -600,6 +604,7 @@ export function openModal({
600
604
  maximizable = false,
601
605
  onMaximizeChange = null,
602
606
  beforeClose = null,
607
+ dismissOnBackdrop = false,
603
608
  } = {}) {
604
609
  return new Promise((resolve) => {
605
610
  const body = document.createElement('div');
@@ -610,8 +615,8 @@ export function openModal({
610
615
  if (content instanceof HTMLElement) bodyInner.appendChild(content);
611
616
  body.appendChild(bodyInner);
612
617
 
613
- // Actions row — default = single Close button. Esc / backdrop /
614
- // X all bypass this and resolve with null.
618
+ // Actions row — default = single Close button. Esc and X (and an
619
+ // opted-in backdrop) bypass this and resolve with null.
615
620
  const acts = Array.isArray(actions) && actions.length > 0
616
621
  ? actions
617
622
  : [{ label: 'Close', value: null }];
@@ -706,16 +711,24 @@ export function openModal({
706
711
  defaultWidth: width,
707
712
  defaultHeight: height,
708
713
  beforeClose,
709
- // Esc, the X and the backdrop all land here without passing through
710
- // `close`, so the listener is dropped here as well as there — the
711
- // three dismissals a user reaches for most are exactly the ones
712
- // that would otherwise leak it.
714
+ // Esc, the X and an opted-in backdrop all land here without passing
715
+ // through `close`, so the listener is dropped here as well as there —
716
+ // the dismissals a user reaches for most are exactly the ones that
717
+ // would otherwise leak it.
713
718
  onClose: () => {
714
719
  _dropMaxListener();
715
720
  if (!_resolved) { _resolved = true; resolve(null); }
716
721
  },
717
722
  });
718
723
  win.show();
724
+ // A click, not a press: a drag that starts in the window and ends on the
725
+ // page (selecting text, resizing) has no click on the backdrop.
726
+ // The press takes no focus from the window: `beforeClose` may refuse, and
727
+ // the person goes on typing where they were.
728
+ if (dismissOnBackdrop) {
729
+ win.backdropElement.addEventListener('mousedown', (e) => e.preventDefault());
730
+ win.backdropElement.addEventListener('click', () => win.close());
731
+ }
719
732
 
720
733
  // C29. THE STATE, FOR A CALLER WHOSE LAYOUT DEPENDS ON IT.
721
734
  //
@@ -818,7 +831,11 @@ export function openModal({
818
831
  // whose first control is a read-only statement panel (C27's disabled
819
832
  // primary is the sibling case) would swallow the caret into a box that
820
833
  // cannot take it.
834
+ //
835
+ // AN `onMount` THAT PLACED FOCUS KEEPS IT, with its selection: it ran in
836
+ // the frame before this one, and it knows the field better than "the first".
821
837
  requestAnimationFrame(() => {
838
+ if (bodyInner.contains(document.activeElement)) return;
822
839
  const field = bodyInner.querySelector(
823
840
  'input:not([type="hidden"]):not([disabled]):not([readonly]),'
824
841
  + ' textarea:not([disabled]):not([readonly]),'