@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
@@ -0,0 +1,710 @@
1
+ /// <reference lib="dom" />
2
+ // oxlint-disable unicorn/no-thenable -- `then` is the JSON Schema conditional keyword (spec §20), not a promise
3
+ /**
4
+ * Statement editor (spec §20) — vertical statement-card list for structured function bodies.
5
+ *
6
+ * Renders a Function entry's `body: JxStatement[]` as cards on a connector line: bare expression
7
+ * nodes (mutation or `call`), `if`/`then`/`else` branches, `$switch`/`cases` multiway branches, and
8
+ * WHATWG `dispatchEvent` statements. Branch bodies render as indented lanes (the expression
9
+ * editor's `border-left` nesting idiom) with their own add-statement pickers. Cards drag-reorder
10
+ * within their lane via the pragmatic-drag-and-drop tree-item pattern (see panels/dnd.ts). All
11
+ * edits flow through `onChange(next)` immutably — no statement object is mutated in place.
12
+ */
13
+
14
+ import { html, nothing } from "lit-html";
15
+ import { live } from "lit-html/directives/live.js";
16
+ import { ref } from "lit-html/directives/ref.js";
17
+ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
18
+ import { disableNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview";
19
+ import {
20
+ attachInstruction,
21
+ extractInstruction,
22
+ } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
23
+ import { isJsonObject } from "@jxsuite/schema/guards";
24
+ import { renderExpressionEditor, renderOperandEditor } from "../ui/expression-editor";
25
+ import { renderFieldRow } from "../ui/field-row";
26
+
27
+ import type {
28
+ CemEvent,
29
+ JxDispatchStatement,
30
+ JxIfStatement,
31
+ JxStatement,
32
+ JxStateDefinition,
33
+ JxSwitchStatement,
34
+ } from "@jxsuite/schema/types";
35
+ import type { TemplateResult } from "lit-html";
36
+
37
+ // ─── Options ─────────────────────────────────────────────────────────────────
38
+
39
+ export interface StatementEditorOpts {
40
+ stateDefs: string[];
41
+ stateEntries?: Record<string, JxStateDefinition> | null;
42
+ allowEventRef: boolean;
43
+ /** The entry's declared CEM events — offered as dispatchEvent name completions. */
44
+ emits?: CemEvent[];
45
+ }
46
+
47
+ /**
48
+ * A lane address inside the statement tree: alternating [index, branchKey] steps, where a `cases`
49
+ * step consumes three entries ([index, "cases", caseKey]). The empty path is the top-level
50
+ * statement list.
51
+ */
52
+ type LanePath = (string | number)[];
53
+
54
+ // ─── Statement Kind Detection ────────────────────────────────────────────────
55
+
56
+ /** Discriminate a statement's kind, mirroring the runtime's detection order (spec §20.2). */
57
+ export function statementKind(stmt: unknown): "expression" | "if" | "switch" | "dispatch" {
58
+ if (isJsonObject(stmt)) {
59
+ if ("operator" in stmt) {
60
+ return "expression";
61
+ }
62
+ if ("if" in stmt) {
63
+ return "if";
64
+ }
65
+ if ("$switch" in stmt) {
66
+ return "switch";
67
+ }
68
+ if ("dispatchEvent" in stmt) {
69
+ return "dispatch";
70
+ }
71
+ }
72
+ return "expression";
73
+ }
74
+
75
+ const ASSIGN_OPS = new Set(["=", "+=", "-=", "*=", "/="]);
76
+
77
+ /** Card header label — ECMA/WHATWG naming (spec §20.2). */
78
+ function kindLabel(stmt: unknown): string {
79
+ switch (statementKind(stmt)) {
80
+ case "if": {
81
+ return "If / Else";
82
+ }
83
+ case "switch": {
84
+ return "Switch";
85
+ }
86
+ case "dispatch": {
87
+ return "Dispatch event";
88
+ }
89
+ default: {
90
+ const op = isJsonObject(stmt) ? String(stmt.operator ?? "") : "";
91
+ if (ASSIGN_OPS.has(op)) {
92
+ return "Set state";
93
+ }
94
+ if (op === "call") {
95
+ return "Call";
96
+ }
97
+ return "Expression";
98
+ }
99
+ }
100
+ }
101
+
102
+ // ─── Add-statement Seeds (spec §20 shapes) ───────────────────────────────────
103
+
104
+ const STATEMENT_SEEDS: Record<string, () => JxStatement> = {
105
+ call: () => ({ operator: "call", target: { $ref: "" }, value: [] }),
106
+ dispatch: () => ({ dispatchEvent: "" }),
107
+ if: () => ({
108
+ if: { operator: "===", target: { $ref: "" }, value: null },
109
+ then: [],
110
+ }),
111
+ set: () => ({ operator: "=", target: { $ref: "" }, value: null }),
112
+ switch: () => ({ $switch: { $ref: "" }, cases: {} }),
113
+ };
114
+
115
+ // ─── Lane Addressing (immutable read/write through nested statement lists) ───
116
+
117
+ /** Resolve the statement list a lane path points at; null when the path is stale. */
118
+ export function laneListAt(list: JxStatement[], path: LanePath): JxStatement[] | null {
119
+ if (path.length === 0) {
120
+ return list;
121
+ }
122
+ const [idx, key] = path;
123
+ const stmt = typeof idx === "number" ? (list[idx] as Record<string, unknown> | undefined) : null;
124
+ if (!isJsonObject(stmt) || typeof key !== "string") {
125
+ return null;
126
+ }
127
+ if (key === "cases") {
128
+ const caseKey = path.at(2);
129
+ const { cases } = stmt;
130
+ if (typeof caseKey !== "string" || !isJsonObject(cases) || !Array.isArray(cases[caseKey])) {
131
+ return null;
132
+ }
133
+ return laneListAt(cases[caseKey] as unknown as JxStatement[], path.slice(3));
134
+ }
135
+ if (!Array.isArray(stmt[key])) {
136
+ return null;
137
+ }
138
+ return laneListAt(stmt[key] as unknown as JxStatement[], path.slice(2));
139
+ }
140
+
141
+ /** Rebuild the statement tree with the lane at `path` replaced by `next` — fully immutable. */
142
+ export function withLaneList(
143
+ list: JxStatement[],
144
+ path: LanePath,
145
+ next: JxStatement[],
146
+ ): JxStatement[] {
147
+ if (path.length === 0) {
148
+ return next;
149
+ }
150
+ const idx = path[0] as number;
151
+ const key = path[1] as string;
152
+ const stmt = list[idx] as unknown as Record<string, unknown>;
153
+ let updated: Record<string, unknown>;
154
+ if (key === "cases") {
155
+ const caseKey = path[2] as string;
156
+ const cases = { ...(stmt.cases as Record<string, JxStatement[]>) };
157
+ cases[caseKey] = withLaneList(cases[caseKey] ?? [], path.slice(3), next);
158
+ updated = { ...stmt, cases };
159
+ } else {
160
+ updated = {
161
+ ...stmt,
162
+ [key]: withLaneList((stmt[key] as JxStatement[] | undefined) ?? [], path.slice(2), next),
163
+ };
164
+ }
165
+ return list.map((s, i) => (i === idx ? (updated as unknown as JxStatement) : s));
166
+ }
167
+
168
+ // ─── Drag-reorder (pragmatic-drag-and-drop, per-lane) ────────────────────────
169
+
170
+ /** Active DnD registration cleanup per editor root — replaced on every re-render. */
171
+ const dndRegistrations = new WeakMap<HTMLElement, () => void>();
172
+
173
+ /**
174
+ * Register drag-reorder on all statement rows under `root` (post-render, like registerLayersDnD).
175
+ * Rows may only reorder within their own lane — the source's lane id must match the target's.
176
+ */
177
+ function registerStatementsDnD(
178
+ root: HTMLElement,
179
+ statements: JxStatement[],
180
+ onChange: (next: JxStatement[]) => void,
181
+ ) {
182
+ requestAnimationFrame(async () => {
183
+ // Deferred adapter import — keeps this panel's import graph adapter-free at module load.
184
+ const { draggable, dropTargetForElements } =
185
+ await import("@atlaskit/pragmatic-drag-and-drop/element/adapter");
186
+ dndRegistrations.get(root)?.();
187
+ const cleanups: (() => void)[] = [];
188
+
189
+ for (const row of root.querySelectorAll("[data-stmt-row]") as NodeListOf<HTMLElement>) {
190
+ const laneId = row.dataset.stmtLane ?? "[]";
191
+ const index = Math.trunc(Number(row.dataset.stmtIndex)) || 0;
192
+ const handle = row.querySelector(".statement-drag-handle");
193
+
194
+ cleanups.push(
195
+ combine(
196
+ draggable({
197
+ element: row,
198
+ ...(handle ? { dragHandle: handle } : {}),
199
+ getInitialData() {
200
+ return { index, lane: laneId, type: "statement" };
201
+ },
202
+ onGenerateDragPreview({
203
+ nativeSetDragImage,
204
+ }: {
205
+ nativeSetDragImage: ((image: Element, x: number, y: number) => void) | null;
206
+ }) {
207
+ disableNativeDragPreview({ nativeSetDragImage });
208
+ },
209
+ onDragStart() {
210
+ row.classList.add("dragging");
211
+ },
212
+ onDrop() {
213
+ row.classList.remove("dragging");
214
+ },
215
+ }),
216
+ dropTargetForElements({
217
+ element: row,
218
+ canDrop({ source }: { source: { data: Record<string, unknown> } }) {
219
+ return source.data.type === "statement" && source.data.lane === laneId;
220
+ },
221
+ getData({
222
+ input,
223
+ element,
224
+ }: {
225
+ input: Parameters<typeof attachInstruction>[1]["input"];
226
+ element: Element;
227
+ }) {
228
+ return attachInstruction(
229
+ { index },
230
+ {
231
+ block: ["make-child"],
232
+ currentLevel: 0,
233
+ element,
234
+ indentPerLevel: 16,
235
+ input,
236
+ mode: "standard",
237
+ },
238
+ ) as Record<string | symbol, unknown>;
239
+ },
240
+ onDrag({ self }: { self: { data: Record<string, unknown> } }) {
241
+ const instruction = extractInstruction(self.data);
242
+ row.classList.toggle("drop-above", instruction?.type === "reorder-above");
243
+ row.classList.toggle("drop-below", instruction?.type === "reorder-below");
244
+ },
245
+ onDragLeave() {
246
+ row.classList.remove("drop-above", "drop-below");
247
+ },
248
+ onDrop({
249
+ self,
250
+ source,
251
+ }: {
252
+ self: { data: Record<string, unknown> };
253
+ source: { data: Record<string, unknown> };
254
+ }) {
255
+ row.classList.remove("drop-above", "drop-below");
256
+ const instruction = extractInstruction(self.data);
257
+ if (
258
+ !instruction ||
259
+ (instruction.type !== "reorder-above" && instruction.type !== "reorder-below")
260
+ ) {
261
+ return;
262
+ }
263
+ const from = source.data.index as number;
264
+ const lanePath = JSON.parse(laneId) as LanePath;
265
+ const lane = laneListAt(statements, lanePath);
266
+ if (!lane || from === index) {
267
+ return;
268
+ }
269
+ let insertAt = instruction.type === "reorder-above" ? index : index + 1;
270
+ if (from < insertAt) {
271
+ insertAt -= 1;
272
+ }
273
+ if (insertAt === from) {
274
+ return;
275
+ }
276
+ const nextLane = lane.filter((_, i) => i !== from);
277
+ nextLane.splice(insertAt, 0, lane[from]!);
278
+ onChange(withLaneList(statements, lanePath, nextLane));
279
+ },
280
+ }),
281
+ ),
282
+ );
283
+ }
284
+
285
+ dndRegistrations.set(root, () => {
286
+ for (const cleanup of cleanups) {
287
+ cleanup();
288
+ }
289
+ });
290
+ });
291
+ }
292
+
293
+ // ─── Small shared widgets ────────────────────────────────────────────────────
294
+
295
+ const laneLabelStyle =
296
+ "font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;color:var(--spectrum-gray-600, #808080)";
297
+
298
+ function operandOpts(opts: StatementEditorOpts) {
299
+ return {
300
+ allowEventRef: opts.allowEventRef,
301
+ depth: 0,
302
+ stateDefs: opts.stateDefs,
303
+ stateEntries: opts.stateEntries ?? null,
304
+ };
305
+ }
306
+
307
+ /** The "+ Add statement" picker appended to every lane. */
308
+ function renderAddStatement(
309
+ list: JxStatement[],
310
+ commit: (next: JxStatement[]) => void,
311
+ ): TemplateResult {
312
+ return html`
313
+ <sp-picker
314
+ size="s"
315
+ quiet
316
+ class="statement-add"
317
+ label="+ Add statement"
318
+ placeholder="+ Add statement"
319
+ .value=${live("")}
320
+ @change=${(e: Event) => {
321
+ const kind = (e.target as HTMLInputElement).value;
322
+ const seed = STATEMENT_SEEDS[kind];
323
+ if (!seed) {
324
+ return;
325
+ }
326
+ (e.target as HTMLInputElement).value = "";
327
+ commit([...list, seed()]);
328
+ }}
329
+ >
330
+ <sp-menu-item value="set">Set state</sp-menu-item>
331
+ <sp-menu-item value="call">Call function</sp-menu-item>
332
+ <sp-menu-item value="if">If / Else</sp-menu-item>
333
+ <sp-menu-item value="switch">Switch</sp-menu-item>
334
+ <sp-menu-item value="dispatch">Dispatch event</sp-menu-item>
335
+ </sp-picker>
336
+ `;
337
+ }
338
+
339
+ /** An indented branch lane: header label (+ optional remove) above a nested statement list. */
340
+ function renderLane(
341
+ label: string | TemplateResult,
342
+ list: JxStatement[],
343
+ commit: (next: JxStatement[]) => void,
344
+ opts: StatementEditorOpts,
345
+ lanePath: LanePath,
346
+ extras: { onRemove?: () => void } = {},
347
+ ): TemplateResult {
348
+ return html`
349
+ <div class="statement-lane" style="margin:4px 0 4px 6px">
350
+ <div class="statement-lane-header" style="display:flex;align-items:center;gap:4px">
351
+ <span style=${laneLabelStyle}>${label}</span>
352
+ ${extras.onRemove
353
+ ? html`
354
+ <sp-action-button
355
+ quiet
356
+ size="xs"
357
+ class="statement-lane-remove"
358
+ title="Remove branch"
359
+ @click=${extras.onRemove}
360
+ >
361
+ <sp-icon-delete slot="icon"></sp-icon-delete>
362
+ </sp-action-button>
363
+ `
364
+ : nothing}
365
+ </div>
366
+ ${renderStatementList(list, commit, opts, lanePath)}
367
+ </div>
368
+ `;
369
+ }
370
+
371
+ // ─── Kind-specific card bodies ───────────────────────────────────────────────
372
+
373
+ function renderIfBody(
374
+ stmt: JxIfStatement,
375
+ commit: (next: JxStatement) => void,
376
+ opts: StatementEditorOpts,
377
+ lanePath: LanePath,
378
+ index: number,
379
+ ): TemplateResult {
380
+ return html`
381
+ ${renderFieldRow({
382
+ hasValue: false,
383
+ label: "If",
384
+ prop: "if",
385
+ widget: renderOperandEditor(
386
+ stmt.if,
387
+ (v) => commit({ ...stmt, if: v } as JxStatement),
388
+ operandOpts(opts),
389
+ ),
390
+ })}
391
+ ${renderLane("Then", stmt.then ?? [], (next) => commit({ ...stmt, then: next }), opts, [
392
+ ...lanePath,
393
+ index,
394
+ "then",
395
+ ])}
396
+ ${Array.isArray(stmt.else)
397
+ ? renderLane(
398
+ "Else",
399
+ stmt.else,
400
+ (next) => commit({ ...stmt, else: next }),
401
+ opts,
402
+ [...lanePath, index, "else"],
403
+ {
404
+ onRemove: () => {
405
+ const { else: _else, ...rest } = stmt;
406
+ commit(rest as JxStatement);
407
+ },
408
+ },
409
+ )
410
+ : html`
411
+ <sp-action-button
412
+ quiet
413
+ size="s"
414
+ class="statement-add-else"
415
+ @click=${() => commit({ ...stmt, else: [] })}
416
+ >
417
+ + Add else
418
+ </sp-action-button>
419
+ `}
420
+ `;
421
+ }
422
+
423
+ function renderSwitchBody(
424
+ stmt: JxSwitchStatement,
425
+ commit: (next: JxStatement) => void,
426
+ opts: StatementEditorOpts,
427
+ lanePath: LanePath,
428
+ index: number,
429
+ ): TemplateResult {
430
+ const cases = isJsonObject(stmt.cases) ? (stmt.cases as Record<string, JxStatement[]>) : {};
431
+ const entries = Object.entries(cases);
432
+ const setCases = (next: Record<string, JxStatement[]>) =>
433
+ commit({ ...stmt, cases: next } as JxStatement);
434
+
435
+ return html`
436
+ ${renderFieldRow({
437
+ hasValue: false,
438
+ label: "Switch on",
439
+ prop: "$switch",
440
+ widget: renderOperandEditor(
441
+ stmt.$switch,
442
+ (v) => commit({ ...stmt, $switch: v } as JxStatement),
443
+ operandOpts(opts),
444
+ ),
445
+ })}
446
+ ${entries.map(([key, list]) =>
447
+ renderLane(
448
+ html`
449
+ <sp-textfield
450
+ size="s"
451
+ class="statement-case-key"
452
+ style="width:96px"
453
+ placeholder="value"
454
+ .value=${live(key)}
455
+ @change=${(e: Event) => {
456
+ const newKey = (e.target as HTMLInputElement).value;
457
+ if (newKey === key) {
458
+ return;
459
+ }
460
+ const next: Record<string, JxStatement[]> = {};
461
+ for (const [k, v] of entries) {
462
+ next[k === key ? newKey : k] = v;
463
+ }
464
+ setCases(next);
465
+ }}
466
+ ></sp-textfield>
467
+ `,
468
+ Array.isArray(list) ? list : [],
469
+ (next) => setCases({ ...cases, [key]: next }),
470
+ opts,
471
+ [...lanePath, index, "cases", key],
472
+ {
473
+ onRemove: () => {
474
+ const next = { ...cases };
475
+ delete next[key];
476
+ setCases(next);
477
+ },
478
+ },
479
+ ),
480
+ )}
481
+ ${renderLane(
482
+ "Default",
483
+ Array.isArray(stmt.default) ? stmt.default : [],
484
+ (next) => {
485
+ if (next.length === 0) {
486
+ const { default: _default, ...rest } = stmt;
487
+ commit(rest as JxStatement);
488
+ return;
489
+ }
490
+ commit({ ...stmt, default: next } as JxStatement);
491
+ },
492
+ opts,
493
+ [...lanePath, index, "default"],
494
+ )}
495
+ <sp-action-button
496
+ quiet
497
+ size="s"
498
+ class="statement-add-case"
499
+ @click=${() => {
500
+ let n = entries.length + 1;
501
+ let key = `case ${n}`;
502
+ while (Object.hasOwn(cases, key)) {
503
+ n += 1;
504
+ key = `case ${n}`;
505
+ }
506
+ setCases({ ...cases, [key]: [] });
507
+ }}
508
+ >
509
+ + Add case
510
+ </sp-action-button>
511
+ `;
512
+ }
513
+
514
+ function renderDispatchBody(
515
+ stmt: JxDispatchStatement,
516
+ commit: (next: JxStatement) => void,
517
+ opts: StatementEditorOpts,
518
+ ): TemplateResult {
519
+ const emitNames = (opts.emits ?? [])
520
+ .map((e) => e.name)
521
+ .filter((n): n is string => typeof n === "string" && n.length > 0);
522
+ const commitName = (e: Event) =>
523
+ commit({ ...stmt, dispatchEvent: (e.target as HTMLInputElement).value } as JxStatement);
524
+ const toggle = (key: "bubbles" | "composed") => (e: Event) => {
525
+ const { checked } = e.target as HTMLInputElement;
526
+ const { [key]: _removed, ...rest } = stmt;
527
+ commit((checked ? { ...rest, [key]: true } : rest) as JxStatement);
528
+ };
529
+
530
+ return html`
531
+ ${renderFieldRow({
532
+ hasValue: false,
533
+ label: "Event",
534
+ prop: "dispatchEvent",
535
+ widget:
536
+ emitNames.length > 0
537
+ ? html`
538
+ <sp-combobox
539
+ size="s"
540
+ allows-custom-value
541
+ class="statement-dispatch-name"
542
+ style="flex:1"
543
+ .value=${live(stmt.dispatchEvent ?? "")}
544
+ @change=${commitName}
545
+ >
546
+ ${emitNames.map((n) => html`<sp-menu-item value=${n}>${n}</sp-menu-item>`)}
547
+ </sp-combobox>
548
+ `
549
+ : html`
550
+ <sp-textfield
551
+ size="s"
552
+ class="statement-dispatch-name"
553
+ style="flex:1"
554
+ placeholder="event-name"
555
+ .value=${live(stmt.dispatchEvent ?? "")}
556
+ @input=${commitName}
557
+ ></sp-textfield>
558
+ `,
559
+ })}
560
+ ${renderFieldRow({
561
+ hasValue: false,
562
+ label: "Detail",
563
+ prop: "detail",
564
+ widget: renderOperandEditor(
565
+ stmt.detail ?? null,
566
+ (v) => commit({ ...stmt, detail: v } as JxStatement),
567
+ operandOpts(opts),
568
+ ),
569
+ })}
570
+ ${renderFieldRow({
571
+ hasValue: false,
572
+ label: "Options",
573
+ prop: "eventInit",
574
+ widget: html`
575
+ <div style="display:flex;gap:12px;align-items:center">
576
+ <sp-checkbox
577
+ size="s"
578
+ class="statement-dispatch-bubbles"
579
+ ?checked=${Boolean(stmt.bubbles)}
580
+ @change=${toggle("bubbles")}
581
+ >Bubbles</sp-checkbox
582
+ >
583
+ <sp-checkbox
584
+ size="s"
585
+ class="statement-dispatch-composed"
586
+ ?checked=${Boolean(stmt.composed)}
587
+ @change=${toggle("composed")}
588
+ >Composed</sp-checkbox
589
+ >
590
+ </div>
591
+ `,
592
+ })}
593
+ `;
594
+ }
595
+
596
+ // ─── Statement Card + List ───────────────────────────────────────────────────
597
+
598
+ function renderStatementCard(
599
+ stmt: JxStatement,
600
+ index: number,
601
+ list: JxStatement[],
602
+ commitList: (next: JxStatement[]) => void,
603
+ opts: StatementEditorOpts,
604
+ lanePath: LanePath,
605
+ ): TemplateResult {
606
+ const kind = statementKind(stmt);
607
+ const commit = (next: JxStatement) => commitList(list.map((s, i) => (i === index ? next : s)));
608
+
609
+ let body: TemplateResult;
610
+ switch (kind) {
611
+ case "if": {
612
+ body = renderIfBody(stmt as JxIfStatement, commit, opts, lanePath, index);
613
+ break;
614
+ }
615
+ case "switch": {
616
+ body = renderSwitchBody(stmt as JxSwitchStatement, commit, opts, lanePath, index);
617
+ break;
618
+ }
619
+ case "dispatch": {
620
+ body = renderDispatchBody(stmt as JxDispatchStatement, commit, opts);
621
+ break;
622
+ }
623
+ default: {
624
+ body = renderExpressionEditor(stmt, (n) => commit(n as JxStatement), {
625
+ allowEventRef: opts.allowEventRef,
626
+ stateDefs: opts.stateDefs,
627
+ stateEntries: opts.stateEntries ?? null,
628
+ });
629
+ break;
630
+ }
631
+ }
632
+
633
+ return html`
634
+ <div
635
+ class="statement-card"
636
+ data-stmt-row
637
+ data-stmt-kind=${kind}
638
+ data-stmt-lane=${JSON.stringify(lanePath)}
639
+ data-stmt-index=${index}
640
+ style="border:1px solid var(--spectrum-gray-300, #3c3c3c);border-radius:4px;padding:4px 6px;margin:0 0 6px;background:var(--spectrum-gray-75, #1e1e1e)"
641
+ >
642
+ <div class="statement-card-header" style="display:flex;align-items:center;gap:6px">
643
+ <span
644
+ class="statement-drag-handle"
645
+ title="Drag to reorder"
646
+ style="cursor:grab;color:var(--spectrum-gray-500, #6e6e6e);font-size:11px;line-height:1;user-select:none"
647
+ >⠿</span
648
+ >
649
+ <span class="statement-kind-label" style=${laneLabelStyle}>${kindLabel(stmt)}</span>
650
+ <span style="flex:1"></span>
651
+ <sp-action-button
652
+ quiet
653
+ size="xs"
654
+ class="statement-delete"
655
+ title="Delete statement"
656
+ @click=${() => commitList(list.filter((_, i) => i !== index))}
657
+ >
658
+ <sp-icon-delete slot="icon"></sp-icon-delete>
659
+ </sp-action-button>
660
+ </div>
661
+ <div class="statement-card-body">${body}</div>
662
+ </div>
663
+ `;
664
+ }
665
+
666
+ /** One lane's vertical card list on its connector line, ending in the add-statement picker. */
667
+ function renderStatementList(
668
+ list: JxStatement[],
669
+ commitList: (next: JxStatement[]) => void,
670
+ opts: StatementEditorOpts,
671
+ lanePath: LanePath,
672
+ ): TemplateResult {
673
+ return html`
674
+ <div
675
+ class="statement-list"
676
+ style="margin-left:4px;border-left:2px solid var(--spectrum-gray-300, #3c3c3c);padding:2px 0 0 8px"
677
+ >
678
+ ${list.map((stmt, index) =>
679
+ renderStatementCard(stmt, index, list, commitList, opts, lanePath),
680
+ )}
681
+ ${renderAddStatement(list, commitList)}
682
+ </div>
683
+ `;
684
+ }
685
+
686
+ // ─── Entry Point ─────────────────────────────────────────────────────────────
687
+
688
+ /**
689
+ * Render a structured function body (spec §20) as an editable statement-card list. `onChange`
690
+ * receives a fresh statement array on every edit; the input array is never mutated.
691
+ */
692
+ export function renderStatementEditor(
693
+ statements: JxStatement[],
694
+ onChange: (next: JxStatement[]) => void,
695
+ opts: StatementEditorOpts,
696
+ ): TemplateResult {
697
+ const safe = Array.isArray(statements) ? statements : [];
698
+ return html`
699
+ <div
700
+ class="statement-editor"
701
+ ${ref((el) => {
702
+ if (el) {
703
+ registerStatementsDnD(el as HTMLElement, safe, onChange);
704
+ }
705
+ })}
706
+ >
707
+ ${renderStatementList(safe, onChange, opts, [])}
708
+ </div>
709
+ `;
710
+ }