@jxsuite/studio 0.37.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/iframe-entry.js +622 -49
  2. package/dist/iframe-entry.js.map +15 -12
  3. package/dist/studio.css +1333 -0
  4. package/dist/studio.js +103709 -81001
  5. package/dist/studio.js.map +201 -157
  6. package/package.json +47 -45
  7. package/src/account-status.ts +39 -0
  8. package/src/browse/browse.ts +10 -14
  9. package/src/canvas/canvas-live-render.ts +21 -2
  10. package/src/canvas/canvas-render.ts +35 -16
  11. package/src/canvas/canvas-utils.ts +118 -72
  12. package/src/canvas/iframe-entry.ts +20 -0
  13. package/src/canvas/iframe-eval.ts +155 -0
  14. package/src/canvas/iframe-host.ts +132 -6
  15. package/src/canvas/iframe-inline-edit.ts +107 -1
  16. package/src/canvas/iframe-protocol.ts +43 -3
  17. package/src/canvas/iframe-render.ts +18 -0
  18. package/src/collab/collab-session.ts +23 -8
  19. package/src/collab/collab-state.ts +6 -3
  20. package/src/component-props.ts +104 -0
  21. package/src/editor/context-menu.ts +1 -1
  22. package/src/editor/inline-edit-apply.ts +36 -1
  23. package/src/editor/inline-edit.ts +58 -1
  24. package/src/editor/repeater-scope.ts +8 -13
  25. package/src/editor/shortcuts.ts +41 -12
  26. package/src/files/file-ops.ts +14 -0
  27. package/src/files/files.ts +56 -1
  28. package/src/format/format-host.ts +63 -1
  29. package/src/grid/cell-editors.ts +288 -0
  30. package/src/grid/cell-popovers.ts +108 -0
  31. package/src/grid/csv-codec.ts +122 -0
  32. package/src/grid/edit-buffer.ts +490 -0
  33. package/src/grid/grid-controller.ts +417 -0
  34. package/src/grid/grid-layout.ts +83 -0
  35. package/src/grid/grid-open.ts +167 -0
  36. package/src/grid/grid-panel.ts +302 -0
  37. package/src/grid/grid-source.ts +210 -0
  38. package/src/grid/grid-view.ts +367 -0
  39. package/src/grid/schema-columns.ts +261 -0
  40. package/src/grid/sources/connector-source.ts +217 -0
  41. package/src/grid/sources/content-source.ts +542 -0
  42. package/src/grid/sources/csv-file-source.ts +247 -0
  43. package/src/grid/tabulator-tables.d.ts +120 -0
  44. package/src/new-project/add-repo-modal.ts +183 -0
  45. package/src/new-project/new-project-modal.ts +22 -3
  46. package/src/page-params.ts +34 -8
  47. package/src/panels/ai-chat/chat-markdown.ts +2 -2
  48. package/src/panels/ai-panel.ts +61 -4
  49. package/src/panels/block-action-bar.ts +8 -3
  50. package/src/panels/chat-panel.ts +98 -0
  51. package/src/panels/data-grid.ts +241 -0
  52. package/src/panels/editors.ts +43 -17
  53. package/src/panels/events-panel.ts +137 -44
  54. package/src/panels/formula-workspace.ts +379 -0
  55. package/src/panels/frontmatter-fields.ts +231 -0
  56. package/src/panels/frontmatter-panel.ts +132 -0
  57. package/src/panels/git-panel.ts +1 -1
  58. package/src/panels/head-panel.ts +11 -175
  59. package/src/panels/properties-panel.ts +154 -144
  60. package/src/panels/right-panel.ts +9 -47
  61. package/src/panels/signals-panel.ts +217 -460
  62. package/src/panels/statement-editor.ts +710 -0
  63. package/src/panels/statusbar.ts +1 -1
  64. package/src/panels/style-panel.ts +25 -1
  65. package/src/panels/stylebook-panel.ts +0 -2
  66. package/src/panels/tab-bar.ts +175 -6
  67. package/src/panels/tab-strip.ts +62 -6
  68. package/src/panels/toolbar.ts +37 -3
  69. package/src/panels/welcome-screen.ts +50 -0
  70. package/src/platform-errors.ts +30 -0
  71. package/src/platforms/cloud.ts +172 -89
  72. package/src/platforms/devserver.ts +172 -0
  73. package/src/services/ai-project-tools.ts +541 -0
  74. package/src/services/ai-session-store.ts +28 -0
  75. package/src/services/ai-system-prompt.ts +194 -24
  76. package/src/services/ai-tools.ts +88 -21
  77. package/src/services/automation.ts +16 -0
  78. package/src/services/context-resolver.ts +73 -0
  79. package/src/services/data-service.ts +155 -0
  80. package/src/services/document-assistant.ts +81 -11
  81. package/src/services/gated-registry.ts +72 -0
  82. package/src/services/live-preview.ts +0 -0
  83. package/src/services/monaco-setup.ts +75 -26
  84. package/src/services/preview-eval.ts +68 -0
  85. package/src/services/project-adoption.ts +31 -0
  86. package/src/settings/contributed-section.ts +406 -0
  87. package/src/settings/extension-sections.ts +145 -0
  88. package/src/settings/schema-field-ui.ts +4 -2
  89. package/src/settings/settings-modal.ts +101 -42
  90. package/src/site-context.ts +12 -1
  91. package/src/store.ts +4 -0
  92. package/src/studio.ts +107 -52
  93. package/src/tabs/patch-ops.ts +25 -0
  94. package/src/tabs/tab.ts +39 -1
  95. package/src/tabs/transact.ts +61 -14
  96. package/src/types.ts +132 -1
  97. package/src/ui/dynamic-slot.ts +272 -0
  98. package/src/ui/expression-editor.ts +423 -125
  99. package/src/ui/field-row.ts +5 -0
  100. package/src/ui/form-controls.ts +322 -0
  101. package/src/ui/formula-catalog.ts +557 -0
  102. package/src/ui/formula-chips.ts +216 -0
  103. package/src/ui/formula-palette.ts +211 -0
  104. package/src/ui/layers.ts +40 -0
  105. package/src/ui/media-picker.ts +1 -1
  106. package/src/ui/panel-resize.ts +15 -1
  107. package/src/ui/progress-modal.ts +2 -2
  108. package/src/ui/schema-form.ts +524 -0
  109. package/src/utils/preview-format.ts +26 -0
  110. package/src/utils/studio-utils.ts +4 -3
  111. package/src/view.ts +4 -4
  112. package/src/workspace/workspace.ts +14 -0
  113. package/src/settings/content-types-editor.ts +0 -599
@@ -1,10 +1,18 @@
1
1
  /// <reference lib="dom" />
2
2
  import { html, nothing } from "lit-html";
3
3
  import { live } from "lit-html/directives/live.js";
4
+ import { PURE_METHOD_OPS } from "@jxsuite/runtime/expression";
4
5
  import { isJsonObject, isRef } from "@jxsuite/schema/guards";
5
6
  import { renderFieldRow } from "./field-row";
7
+ import { renderFormulaChips } from "./formula-chips";
8
+ import { applyCatalogPick, calleeEntry, formulaCatalog } from "./formula-catalog";
9
+ import { openFormulaPalette } from "./formula-palette";
6
10
 
7
- import type { JxExpressionNode, JxExpressionOperand } from "@jxsuite/schema/types";
11
+ import type {
12
+ JxExpressionNode,
13
+ JxExpressionOperand,
14
+ JxStateDefinition,
15
+ } from "@jxsuite/schema/types";
8
16
  import type { TemplateResult } from "lit-html";
9
17
 
10
18
  // ─── Operator Categories ────────────────────────────────────────────────────
@@ -24,114 +32,149 @@ const BINARY_OPS = new Set([
24
32
  ">=",
25
33
  "&&",
26
34
  "||",
35
+ "??",
27
36
  ]);
28
37
  const ASSIGN_OPS = new Set(["=", "+=", "-=", "*=", "/="]);
29
38
  const NO_ARG_OPS = new Set(["pop", "shift"]);
30
39
  const ONE_ARG_OPS = new Set(["push", "unshift"]);
31
40
 
41
+ const ZERO_ARG_METHOD_OPS = new Set([
42
+ "flat",
43
+ "normalize",
44
+ "toLocaleLowerCase",
45
+ "toLocaleString",
46
+ "toLocaleUpperCase",
47
+ "toLowerCase",
48
+ "toReversed",
49
+ "toSorted",
50
+ "toUpperCase",
51
+ "trim",
52
+ "trimEnd",
53
+ "trimStart",
54
+ ]);
55
+
32
56
  const OPERATOR_GROUPS = [
33
57
  { label: "Assignment", ops: ["=", "+=", "-=", "*=", "/="] },
34
58
  { label: "Unary", ops: ["!", "-"] },
35
59
  { label: "Arithmetic", ops: ["+", "-", "*", "/", "%"] },
36
60
  { label: "Comparison", ops: ["===", "!==", "<", "<=", ">", ">="] },
37
- { label: "Logical", ops: ["&&", "||"] },
61
+ { label: "Logical", ops: ["&&", "||", "??"] },
62
+ { label: "Conditional", ops: ["?:", "switch"] },
38
63
  {
39
64
  label: "Array methods",
40
65
  ops: ["push", "pop", "shift", "unshift", "splice"],
41
66
  },
67
+ {
68
+ label: "Pure methods (String)",
69
+ ops: [
70
+ "toUpperCase",
71
+ "toLowerCase",
72
+ "trim",
73
+ "trimStart",
74
+ "trimEnd",
75
+ "split",
76
+ "startsWith",
77
+ "endsWith",
78
+ "padStart",
79
+ "padEnd",
80
+ "replaceAll",
81
+ "repeat",
82
+ "charAt",
83
+ "normalize",
84
+ ],
85
+ },
86
+ {
87
+ label: "Pure methods (Array)",
88
+ ops: [
89
+ "includes",
90
+ "indexOf",
91
+ "lastIndexOf",
92
+ "join",
93
+ "slice",
94
+ "concat",
95
+ "at",
96
+ "flat",
97
+ "toSorted",
98
+ "toReversed",
99
+ "toSpliced",
100
+ "with",
101
+ ],
102
+ },
103
+ { label: "Pure methods (Number)", ops: ["toFixed", "toPrecision", "toLocaleString"] },
42
104
  { label: "Aggregate", ops: ["reduce", "map", "filter"] },
105
+ { label: "Function", ops: ["call"] },
43
106
  ];
44
107
 
108
+ interface OperatorInfo {
109
+ needsValue: boolean;
110
+ needsInitial: boolean;
111
+ targetMustBeRef: boolean;
112
+ spliceArray: boolean;
113
+ valueIsNode: boolean;
114
+ switchCases: boolean;
115
+ /** `call`: value is a positional-args array; target is the callee pointer. */
116
+ callArgs: boolean;
117
+ }
118
+
119
+ const INFO_DEFAULTS: OperatorInfo = {
120
+ callArgs: false,
121
+ needsInitial: false,
122
+ needsValue: false,
123
+ spliceArray: false,
124
+ switchCases: false,
125
+ targetMustBeRef: false,
126
+ valueIsNode: false,
127
+ };
128
+
45
129
  /**
46
130
  * @param {string} op
47
- * @returns {{
48
- * needsValue: boolean;
49
- * needsInitial: boolean;
50
- * targetMustBeRef: boolean;
51
- * spliceArray: boolean;
52
- * valueIsNode: boolean;
53
- * }}
131
+ * @returns {OperatorInfo}
54
132
  */
55
- function operatorInfo(op: string) {
133
+ function operatorInfo(op: string): OperatorInfo {
56
134
  if (UNARY_OPS.has(op)) {
57
- return {
58
- needsInitial: false,
59
- needsValue: false,
60
- spliceArray: false,
61
- targetMustBeRef: false,
62
- valueIsNode: false,
63
- };
135
+ return { ...INFO_DEFAULTS };
136
+ }
137
+ if (op === "?:") {
138
+ return { ...INFO_DEFAULTS, needsInitial: true, needsValue: true };
139
+ }
140
+ if (op === "switch") {
141
+ return { ...INFO_DEFAULTS, switchCases: true };
142
+ }
143
+ if (op === "call") {
144
+ return { ...INFO_DEFAULTS, callArgs: true, targetMustBeRef: true };
145
+ }
146
+ if (PURE_METHOD_OPS.has(op)) {
147
+ // Receiver in target (any operand); zero-arg methods render no value row.
148
+ return { ...INFO_DEFAULTS, needsValue: !ZERO_ARG_METHOD_OPS.has(op) };
64
149
  }
65
150
  if (BINARY_OPS.has(op)) {
66
- return {
67
- needsInitial: false,
68
- needsValue: true,
69
- spliceArray: false,
70
- targetMustBeRef: false,
71
- valueIsNode: false,
72
- };
151
+ return { ...INFO_DEFAULTS, needsValue: true };
73
152
  }
74
153
  if (ASSIGN_OPS.has(op)) {
75
- return {
76
- needsInitial: false,
77
- needsValue: true,
78
- spliceArray: false,
79
- targetMustBeRef: true,
80
- valueIsNode: false,
81
- };
154
+ return { ...INFO_DEFAULTS, needsValue: true, targetMustBeRef: true };
82
155
  }
83
156
  if (NO_ARG_OPS.has(op)) {
84
- return {
85
- needsInitial: false,
86
- needsValue: false,
87
- spliceArray: false,
88
- targetMustBeRef: true,
89
- valueIsNode: false,
90
- };
157
+ return { ...INFO_DEFAULTS, targetMustBeRef: true };
91
158
  }
92
159
  if (ONE_ARG_OPS.has(op)) {
93
- return {
94
- needsInitial: false,
95
- needsValue: true,
96
- spliceArray: false,
97
- targetMustBeRef: true,
98
- valueIsNode: false,
99
- };
160
+ return { ...INFO_DEFAULTS, needsValue: true, targetMustBeRef: true };
100
161
  }
101
162
  if (op === "splice") {
102
- return {
103
- needsInitial: false,
104
- needsValue: true,
105
- spliceArray: true,
106
- targetMustBeRef: true,
107
- valueIsNode: false,
108
- };
163
+ return { ...INFO_DEFAULTS, needsValue: true, spliceArray: true, targetMustBeRef: true };
109
164
  }
110
165
  if (op === "reduce") {
111
166
  return {
167
+ ...INFO_DEFAULTS,
112
168
  needsInitial: true,
113
169
  needsValue: true,
114
- spliceArray: false,
115
170
  targetMustBeRef: true,
116
171
  valueIsNode: true,
117
172
  };
118
173
  }
119
174
  if (op === "map" || op === "filter") {
120
- return {
121
- needsInitial: false,
122
- needsValue: true,
123
- spliceArray: false,
124
- targetMustBeRef: true,
125
- valueIsNode: true,
126
- };
175
+ return { ...INFO_DEFAULTS, needsValue: true, targetMustBeRef: true, valueIsNode: true };
127
176
  }
128
- return {
129
- needsInitial: false,
130
- needsValue: false,
131
- spliceArray: false,
132
- targetMustBeRef: false,
133
- valueIsNode: false,
134
- };
177
+ return { ...INFO_DEFAULTS };
135
178
  }
136
179
 
137
180
  // ─── Operand Mode Detection ─────────────────────────────────────────────────
@@ -152,6 +195,16 @@ function operandMode(operand: unknown) {
152
195
  return "literal";
153
196
  }
154
197
 
198
+ /** Positional-arg labels for a `call` node, from the callee's catalog entry when resolvable. */
199
+ function calleeParamLabels(
200
+ target: unknown,
201
+ state?: Record<string, JxStateDefinition> | null,
202
+ ): string[] {
203
+ const ref = isRef(target) ? target.$ref : "";
204
+ const entry = ref ? calleeEntry(ref, state) : undefined;
205
+ return entry?.kind === "formula" ? entry.parameters.map((p) => p.name) : [];
206
+ }
207
+
155
208
  /**
156
209
  * @param {string} mode
157
210
  * @returns {JxExpressionOperand}
@@ -230,15 +283,48 @@ export function expressionHint(node: unknown) {
230
283
  if (op === "splice") {
231
284
  return `splice(${targetLabel})`;
232
285
  }
233
- if (op === "reduce" || op === "map" || op === "filter") {
286
+ if (op === "call") {
287
+ return `${targetLabel.replace("window#/", "").replaceAll("/", ".")}(…)`;
288
+ }
289
+ if (op === "reduce" || op === "map" || op === "filter" || PURE_METHOD_OPS.has(op)) {
234
290
  return `${op}(${targetLabel})`;
235
291
  }
292
+ if (op === "?:") {
293
+ return `${targetLabel} ? … : …`;
294
+ }
295
+ if (op === "switch") {
296
+ return `switch(${targetLabel})`;
297
+ }
236
298
  if (UNARY_OPS.has(op)) {
237
299
  return `${op}${targetLabel}`;
238
300
  }
239
301
  return `${targetLabel} ${op} …`;
240
302
  }
241
303
 
304
+ // ─── Live Value Badge (spec §19.9) ──────────────────────────────────────────
305
+
306
+ /** Preview data computed by services/preview-eval.ts — display strings keyed by node path. */
307
+ export interface EditorPreview {
308
+ values: Map<string, string>;
309
+ error: string | null;
310
+ mutating: boolean;
311
+ }
312
+
313
+ function renderValueBadge(preview: EditorPreview | null | undefined, pathKey: string) {
314
+ const text = preview?.values.get(pathKey);
315
+ if (text === undefined) {
316
+ return nothing;
317
+ }
318
+ return html`
319
+ <span
320
+ class="expr-live-badge"
321
+ title=${text}
322
+ style="font-family:var(--spectrum-code-font-family, monospace);font-size:10px;line-height:16px;padding:0 5px;border-radius:4px;background:var(--spectrum-gray-200, #323232);color:var(--spectrum-seafoam-900, #35a690);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:160px;flex-shrink:1"
323
+ >${text}</span
324
+ >
325
+ `;
326
+ }
327
+
242
328
  // ─── Ref Picker ─────────────────────────────────────────────────────────────
243
329
 
244
330
  /**
@@ -343,6 +429,10 @@ function renderLiteralEditor(operand: unknown, onChange: (newVal: JxExpressionOp
343
429
  // ─── Operand Editor ─────────────────────────────────────────────────────────
344
430
 
345
431
  /**
432
+ * Single-operand editor (mode picker + literal/ref/nested-expression widget). Exported for
433
+ * statement-position operand slots (spec §20: `if` tests, `$switch` discriminants, `dispatchEvent`
434
+ * detail).
435
+ *
346
436
  * @param {unknown} operand
347
437
  * @param {(newOperand: unknown) => void} onChange
348
438
  * @param {{
@@ -353,7 +443,7 @@ function renderLiteralEditor(operand: unknown, onChange: (newVal: JxExpressionOp
353
443
  * }} opts
354
444
  * @returns {import("lit-html").TemplateResult}
355
445
  */
356
- function renderOperandEditor(
446
+ export function renderOperandEditor(
357
447
  operand: unknown,
358
448
  onChange: (newOperand: unknown) => void,
359
449
  opts: {
@@ -361,6 +451,9 @@ function renderOperandEditor(
361
451
  allowEventRef: boolean;
362
452
  depth: number;
363
453
  mustBeRef?: boolean;
454
+ preview?: EditorPreview | null;
455
+ path?: (string | number)[];
456
+ stateEntries?: Record<string, JxStateDefinition> | null;
364
457
  },
365
458
  ): TemplateResult {
366
459
  if (opts.mustBeRef) {
@@ -402,21 +495,24 @@ function renderOperandEditor(
402
495
  `;
403
496
  }
404
497
 
405
- // ─── Splice Args Editor ─────────────────────────────────────────────────────
498
+ // ─── Positional Args Editor (splice / call) ─────────────────────────────────
406
499
 
407
500
  /**
408
501
  * @param {unknown[]} args
409
502
  * @param {(newArgs: unknown[]) => void} onChange
410
503
  * @param {{ stateDefs: string[]; allowEventRef: boolean; depth: number }} opts
504
+ * @param {{ labels?: string[]; fallbackLabel?: string }} [naming]
411
505
  * @returns {import("lit-html").TemplateResult}
412
506
  */
413
507
  function renderSpliceArgsEditor(
414
508
  args: unknown[],
415
509
  onChange: (newArgs: unknown[]) => void,
416
510
  opts: { stateDefs: string[]; allowEventRef: boolean; depth: number },
511
+ naming: { labels?: string[]; fallbackLabel?: string } = {},
417
512
  ): TemplateResult {
418
513
  const safeArgs = Array.isArray(args) ? args : [];
419
- const labels = ["start", "del", "item"];
514
+ const labels = naming.labels ?? ["start", "del", "item"];
515
+ const fallbackLabel = naming.fallbackLabel ?? "item";
420
516
 
421
517
  return html`
422
518
  <div class="array-object-field">
@@ -427,7 +523,7 @@ function renderSpliceArgsEditor(
427
523
  style="display:flex;gap:4px;align-items:center;margin-bottom:4px"
428
524
  >
429
525
  <span style="font-size:10px;color:var(--spectrum-gray-600, #808080);min-width:30px">
430
- ${labels[idx] ?? "item"}
526
+ ${labels[idx] ?? fallbackLabel}
431
527
  </span>
432
528
  ${renderOperandEditor(
433
529
  arg,
@@ -472,20 +568,30 @@ const _operatorMenuCache = OPERATOR_GROUPS.map(
472
568
 
473
569
  // ─── Main Expression Editor ─────────────────────────────────────────────────
474
570
 
571
+ export interface ExpressionEditorOpts {
572
+ stateDefs: string[];
573
+ allowEventRef: boolean;
574
+ depth?: number;
575
+ preview?: EditorPreview | null;
576
+ path?: (string | number)[];
577
+ /** Full state defs map — resolves named-formula catalog entries (call labels, palette). */
578
+ stateEntries?: Record<string, JxStateDefinition> | null;
579
+ /** Chip-strip click hook (depth 0). No-op when absent. */
580
+ onChipSelect?: (path: (string | number)[]) => void;
581
+ /** Vendors a packaged formula's state entry into the document on catalog pick. */
582
+ onInsertDef?: (name: string, def: JxStateDefinition) => void;
583
+ }
584
+
475
585
  /**
476
586
  * @param {unknown} node
477
587
  * @param {(node: unknown) => void} onChange
478
- * @param {{
479
- * stateDefs: string[];
480
- * allowEventRef: boolean;
481
- * depth?: number;
482
- * }} opts
588
+ * @param {ExpressionEditorOpts} opts
483
589
  * @returns {import("lit-html").TemplateResult}
484
590
  */
485
591
  export function renderExpressionEditor(
486
592
  node: unknown,
487
593
  onChange: (node: unknown) => void,
488
- opts: { stateDefs: string[]; allowEventRef: boolean; depth?: number },
594
+ opts: ExpressionEditorOpts,
489
595
  ): TemplateResult {
490
596
  const depth = opts.depth ?? 0;
491
597
  const safeNode: Record<string, unknown> =
@@ -494,71 +600,134 @@ export function renderExpressionEditor(
494
600
  : { operator: "=", target: null };
495
601
  const op = (safeNode.operator as string) || "=";
496
602
  const info = operatorInfo(op);
603
+ const preview = opts.preview ?? null;
604
+ const path = opts.path ?? [];
605
+ const pathKey = path.join("/");
606
+ const sub = (...segs: (string | number)[]) => [...path, ...segs].join("/");
607
+ /** Wrap an operand widget with its live value badge. */
608
+ const withBadge = (widget: unknown, key: string) =>
609
+ html`<div style="display:flex;gap:4px;align-items:center;flex:1;min-width:0">
610
+ ${widget}${renderValueBadge(preview, key)}
611
+ </div>`;
497
612
 
498
613
  const nestStyle =
499
614
  depth > 0
500
615
  ? "border-left:2px solid var(--spectrum-gray-300, #3c3c3c);margin-left:8px;padding-left:8px;"
501
616
  : "";
502
617
 
618
+ // The root badge: pure roots show their result; mutating roots' effect shows on target/value.
619
+ const rootBadge =
620
+ depth === 0 && preview && !preview.mutating ? renderValueBadge(preview, pathKey) : nothing;
621
+
503
622
  return html`
504
623
  <div class="expression-editor" style=${nestStyle}>
624
+ ${depth === 0
625
+ ? renderFormulaChips(safeNode, opts.onChipSelect ?? (() => {}), { path, preview })
626
+ : nothing}
627
+ ${depth === 0 && preview?.error
628
+ ? html`<div
629
+ style="font-size:10px;color:var(--spectrum-negative-content-color-default, #f76a63);padding:2px 0"
630
+ >
631
+ ${preview.error}
632
+ </div>`
633
+ : nothing}
505
634
  ${renderFieldRow({
506
635
  hasValue: false,
507
636
  label: "Operator",
508
637
  prop: "operator",
509
638
  widget: html`
510
- <sp-picker
511
- size="s"
512
- .value=${live(op)}
513
- @change=${(e: Event) => {
514
- const newOp = (e.target as HTMLInputElement).value;
515
- const newInfo = operatorInfo(newOp);
516
- const updated: Record<string, unknown> = {
517
- operator: newOp,
518
- target: safeNode.target,
519
- };
520
- if (newInfo.targetMustBeRef && operandMode(safeNode.target) !== "ref") {
521
- updated.target = { $ref: "" };
522
- }
523
- if (newInfo.needsValue) {
524
- if (newInfo.valueIsNode) {
525
- const val = safeNode.value as Record<string, unknown> | null;
526
- updated.value = val?.operator ? safeNode.value : { operator: "!", target: null };
527
- } else if (newInfo.spliceArray) {
528
- updated.value = Array.isArray(safeNode.value) ? safeNode.value : [null];
529
- } else {
530
- updated.value = safeNode.value ?? null;
639
+ <div style="display:flex;gap:4px;align-items:center;flex:1;min-width:0">
640
+ <sp-picker
641
+ size="s"
642
+ .value=${live(op)}
643
+ @change=${(e: Event) => {
644
+ const newOp = (e.target as HTMLInputElement).value;
645
+ const newInfo = operatorInfo(newOp);
646
+ const updated: Record<string, unknown> = {
647
+ operator: newOp,
648
+ target: safeNode.target,
649
+ };
650
+ if (newInfo.targetMustBeRef && operandMode(safeNode.target) !== "ref") {
651
+ updated.target = { $ref: "" };
531
652
  }
532
- }
533
- if (newInfo.needsInitial) {
534
- updated.initial = safeNode.initial ?? 0;
535
- }
536
- onChange(updated);
537
- }}
538
- >
539
- ${_operatorMenuCache}
540
- </sp-picker>
653
+ if (newInfo.needsValue) {
654
+ if (newInfo.valueIsNode) {
655
+ const val = safeNode.value as Record<string, unknown> | null;
656
+ updated.value = val?.operator
657
+ ? safeNode.value
658
+ : { operator: "!", target: null };
659
+ } else if (newInfo.spliceArray) {
660
+ updated.value = Array.isArray(safeNode.value) ? safeNode.value : [null];
661
+ } else {
662
+ updated.value = safeNode.value ?? null;
663
+ }
664
+ }
665
+ if (newInfo.needsInitial) {
666
+ updated.initial = safeNode.initial ?? (newOp === "?:" ? null : 0);
667
+ }
668
+ if (newInfo.switchCases) {
669
+ updated.cases = isJsonObject(safeNode.cases) ? safeNode.cases : {};
670
+ if ("default" in safeNode) {
671
+ updated.default = safeNode.default;
672
+ }
673
+ }
674
+ if (newInfo.callArgs) {
675
+ updated.value = Array.isArray(safeNode.value) ? safeNode.value : [];
676
+ }
677
+ onChange(updated);
678
+ }}
679
+ >
680
+ ${_operatorMenuCache}
681
+ </sp-picker>
682
+ <sp-action-button
683
+ quiet
684
+ size="s"
685
+ class="expr-browse-catalog"
686
+ title="Browse catalog"
687
+ @click=${(e: Event) =>
688
+ openFormulaPalette({
689
+ anchor: e.currentTarget as HTMLElement,
690
+ entries: formulaCatalog(opts.stateEntries),
691
+ onPick: (entry) =>
692
+ applyCatalogPick(entry, onChange, {
693
+ onInsertDef: opts.onInsertDef,
694
+ stateEntries: opts.stateEntries,
695
+ }),
696
+ })}
697
+ >
698
+ <sp-icon-brackets slot="icon"></sp-icon-brackets>
699
+ </sp-action-button>
700
+ ${rootBadge}
701
+ </div>
541
702
  `,
542
703
  })}
543
704
  ${renderFieldRow({
544
705
  hasValue: false,
545
- label: "Target",
706
+ label: op === "?:" ? "If" : op === "switch" ? "On" : op === "call" ? "Callee" : "Target",
546
707
  prop: "target",
547
- widget: renderOperandEditor(safeNode.target, (t) => onChange({ ...safeNode, target: t }), {
548
- ...opts,
549
- depth,
550
- mustBeRef: info.targetMustBeRef,
551
- }),
708
+ widget: withBadge(
709
+ renderOperandEditor(safeNode.target, (t) => onChange({ ...safeNode, target: t }), {
710
+ ...opts,
711
+ depth,
712
+ mustBeRef: info.targetMustBeRef,
713
+ path: [...path, "target"],
714
+ }),
715
+ sub("target"),
716
+ ),
552
717
  })}
553
718
  ${info.needsValue && !info.valueIsNode && !info.spliceArray
554
719
  ? renderFieldRow({
555
720
  hasValue: false,
556
- label: "Value",
721
+ label: op === "?:" ? "Then" : "Value",
557
722
  prop: "value",
558
- widget: renderOperandEditor(
559
- safeNode.value,
560
- (v) => onChange({ ...safeNode, value: v }),
561
- { ...opts, depth, mustBeRef: false },
723
+ widget: withBadge(
724
+ renderOperandEditor(safeNode.value, (v) => onChange({ ...safeNode, value: v }), {
725
+ ...opts,
726
+ depth,
727
+ mustBeRef: false,
728
+ path: [...path, "value"],
729
+ }),
730
+ sub("value"),
562
731
  ),
563
732
  })
564
733
  : nothing}
@@ -576,7 +745,7 @@ export function renderExpressionEditor(
576
745
  ? safeNode.value
577
746
  : { operator: "!", target: null },
578
747
  (v) => onChange({ ...safeNode, value: v }),
579
- { ...opts, depth: depth + 1 },
748
+ { ...opts, depth: depth + 1, path: [...path, "value"] },
580
749
  )}
581
750
  </div>
582
751
  `
@@ -601,18 +770,147 @@ export function renderExpressionEditor(
601
770
  </div>
602
771
  `
603
772
  : nothing}
773
+ ${info.callArgs
774
+ ? html`
775
+ <div style="margin-top:4px">
776
+ ${renderFieldRow({
777
+ hasValue: false,
778
+ label: "Args",
779
+ prop: "value",
780
+ widget: nothing,
781
+ })}
782
+ ${renderSpliceArgsEditor(
783
+ safeNode.value as unknown[],
784
+ (v) => onChange({ ...safeNode, value: v }),
785
+ { ...opts, depth },
786
+ {
787
+ fallbackLabel: "arg",
788
+ labels: calleeParamLabels(safeNode.target, opts.stateEntries),
789
+ },
790
+ )}
791
+ </div>
792
+ `
793
+ : nothing}
794
+ ${info.switchCases
795
+ ? renderSwitchCasesEditor(safeNode, onChange, { ...opts, depth, path }, withBadge)
796
+ : nothing}
604
797
  ${info.needsInitial
605
798
  ? renderFieldRow({
606
799
  hasValue: false,
607
- label: "Initial",
800
+ label: op === "?:" ? "Else" : "Initial",
608
801
  prop: "initial",
609
- widget: renderOperandEditor(
610
- safeNode.initial,
611
- (v) => onChange({ ...safeNode, initial: v }),
612
- { ...opts, depth, mustBeRef: false },
802
+ widget: withBadge(
803
+ renderOperandEditor(safeNode.initial, (v) => onChange({ ...safeNode, initial: v }), {
804
+ ...opts,
805
+ depth,
806
+ mustBeRef: false,
807
+ path: [...path, "initial"],
808
+ }),
809
+ sub("initial"),
613
810
  ),
614
811
  })
615
812
  : nothing}
616
813
  </div>
617
814
  `;
618
815
  }
816
+
817
+ // ─── Switch Cases Editor ────────────────────────────────────────────────────
818
+
819
+ /**
820
+ * Case rows for the `switch` operator: matched value → result operand, plus the default operand.
821
+ * Mirrors the element-level `$switch`/`cases` model (spec §19.4b).
822
+ */
823
+ function renderSwitchCasesEditor(
824
+ safeNode: Record<string, unknown>,
825
+ onChange: (node: unknown) => void,
826
+ opts: {
827
+ stateDefs: string[];
828
+ allowEventRef: boolean;
829
+ depth: number;
830
+ preview?: EditorPreview | null;
831
+ path: (string | number)[];
832
+ },
833
+ withBadge: (widget: unknown, key: string) => TemplateResult,
834
+ ): TemplateResult {
835
+ const cases = isJsonObject(safeNode.cases)
836
+ ? (safeNode.cases as Record<string, unknown>)
837
+ : ({} as Record<string, unknown>);
838
+ const entries = Object.entries(cases);
839
+ const setCases = (next: Record<string, unknown>) => onChange({ ...safeNode, cases: next });
840
+
841
+ return html`
842
+ <div class="switch-cases" style="margin-top:4px">
843
+ ${entries.map(
844
+ ([key, operand]) => html`
845
+ <div style="display:flex;gap:4px;align-items:flex-start;margin-bottom:4px">
846
+ <sp-textfield
847
+ size="s"
848
+ style="width:80px;flex-shrink:0"
849
+ placeholder="value"
850
+ .value=${live(key)}
851
+ @change=${(e: Event) => {
852
+ const newKey = (e.target as HTMLInputElement).value;
853
+ if (newKey === key) {
854
+ return;
855
+ }
856
+ const next: Record<string, unknown> = {};
857
+ for (const [k, v] of entries) {
858
+ next[k === key ? newKey : k] = v;
859
+ }
860
+ setCases(next);
861
+ }}
862
+ ></sp-textfield>
863
+ ${withBadge(
864
+ renderOperandEditor(operand, (v) => setCases({ ...cases, [key]: v }), {
865
+ ...opts,
866
+ mustBeRef: false,
867
+ path: [...opts.path, "cases", key],
868
+ }),
869
+ [...opts.path, "cases", key].join("/"),
870
+ )}
871
+ <sp-action-button
872
+ quiet
873
+ size="xs"
874
+ @click=${() => {
875
+ const next = { ...cases };
876
+ delete next[key];
877
+ setCases(next);
878
+ }}
879
+ >
880
+ <sp-icon-delete slot="icon"></sp-icon-delete>
881
+ </sp-action-button>
882
+ </div>
883
+ `,
884
+ )}
885
+ <div style="display:flex;gap:4px;align-items:flex-start;margin-bottom:4px">
886
+ <span
887
+ style="width:80px;flex-shrink:0;font-size:10px;line-height:24px;color:var(--spectrum-gray-600, #808080)"
888
+ >default</span
889
+ >
890
+ ${withBadge(
891
+ renderOperandEditor(safeNode.default, (v) => onChange({ ...safeNode, default: v }), {
892
+ ...opts,
893
+ mustBeRef: false,
894
+ path: [...opts.path, "default"],
895
+ }),
896
+ [...opts.path, "default"].join("/"),
897
+ )}
898
+ </div>
899
+ <sp-action-button
900
+ quiet
901
+ size="s"
902
+ @click=${() => {
903
+ let n = entries.length + 1;
904
+ let key = `case ${n}`;
905
+ while (Object.hasOwn(cases, key)) {
906
+ n += 1;
907
+ key = `case ${n}`;
908
+ }
909
+ setCases({ ...cases, [key]: null });
910
+ }}
911
+ >
912
+ + Add case
913
+ </sp-action-button>
914
+ </div>
915
+ `;
916
+ }