@jxsuite/studio 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1093 @@
1
+ /**
2
+ * Signals panel — signal/def helpers, signals template, CEM editors, plugin schema forms.
3
+ *
4
+ * Extracted from studio.js to reduce file size.
5
+ */
6
+
7
+ import { html, nothing } from "lit-html";
8
+ import { addDef, removeDef, updateDef, renameDef, update } from "../store.js";
9
+ import { fetchPluginSchema, pluginSchemaCache } from "../services/code-services.js";
10
+
11
+ // ─── Module-local state ─────────────────────────────────────────────────────
12
+
13
+ /** Expanded signal editor state (persists across renders). */
14
+ /** @type {any} */
15
+ let expandedSignal = null;
16
+
17
+ /** Track which functions have the advanced param editor open. */
18
+ const advancedParamOpen = new Set();
19
+
20
+ /** Default templates for creating new signal definitions. */
21
+ const DEF_TEMPLATES = /** @type {Record<string, any>} */ ({
22
+ state: { type: "string", default: "" },
23
+ computed: { $compute: "", $deps: [] },
24
+ request: { $prototype: "Request", url: "", method: "GET", timing: "client" },
25
+ localStorage: { $prototype: "LocalStorage", key: "", default: null },
26
+ sessionStorage: { $prototype: "SessionStorage", key: "", default: null },
27
+ indexedDB: { $prototype: "IndexedDB", database: "", store: "", version: 1 },
28
+ cookie: { $prototype: "Cookie", name: "", default: "" },
29
+ set: { $prototype: "Set", default: [] },
30
+ map: { $prototype: "Map", default: {} },
31
+ formData: { $prototype: "FormData", fields: {} },
32
+ function: { $prototype: "Function", body: "", parameters: [] },
33
+ external: { $prototype: "", $src: "" },
34
+ });
35
+
36
+ /** Keys handled by the framework — skip when rendering schema fields. */
37
+ const STUDIO_RESERVED_KEYS = new Set([
38
+ "$prototype",
39
+ "$src",
40
+ "$export",
41
+ "timing",
42
+ "default",
43
+ "description",
44
+ "body",
45
+ "parameters",
46
+ "name",
47
+ "attribute",
48
+ "reflects",
49
+ "deprecated",
50
+ "emits",
51
+ ]);
52
+
53
+ // ─── Signals / defs helpers ──────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Classify a state entry into a category string.
57
+ *
58
+ * @param {any} def
59
+ */
60
+ export function defCategory(def) {
61
+ if (!def) return "state";
62
+ if (def.$handler || def.$prototype === "Function") return "function";
63
+ if (def.$compute) return "computed";
64
+ if (def.$prototype) return "data";
65
+ return "state";
66
+ }
67
+
68
+ /**
69
+ * Badge label for a def category.
70
+ *
71
+ * @param {any} def
72
+ */
73
+ export function defBadgeLabel(def) {
74
+ if (!def) return "S";
75
+ if (def.$handler || def.$prototype === "Function") return "F";
76
+ if (def.$compute) return "C";
77
+ if (def.$prototype) return def.$prototype.charAt(0);
78
+ return "S";
79
+ }
80
+
81
+ /**
82
+ * Hint text for a signal row.
83
+ *
84
+ * @param {any} name
85
+ * @param {any} def
86
+ */
87
+ export function defHint(name, def) {
88
+ if (!def) return "";
89
+ if (def.$prototype === "Function") {
90
+ if (def.body) return def.body.length > 20 ? def.body.slice(0, 20) + "..." : def.body;
91
+ if (def.$src) return def.$src;
92
+ return "function";
93
+ }
94
+ if (def.$handler) return "handler (legacy)";
95
+ if (def.$compute)
96
+ return "=" + (def.$compute.length > 20 ? def.$compute.slice(0, 20) + "..." : def.$compute);
97
+ if (def.$prototype === "Request") return def.method + " " + (def.url || "").slice(0, 20);
98
+ if (def.$prototype === "LocalStorage" || def.$prototype === "SessionStorage")
99
+ return def.key || "";
100
+ if (def.$prototype === "IndexedDB") return def.database || "";
101
+ if (def.$prototype === "Cookie") return def.name || "";
102
+ if (def.$prototype) return def.$prototype;
103
+ if (def.attribute) return `[${def.attribute}] ${def.type || ""}`;
104
+ return def.type || "";
105
+ }
106
+
107
+ /**
108
+ * Whether the current document defines a custom element (hyphenated tagName).
109
+ *
110
+ * @param {any} S
111
+ */
112
+ export function isCustomElementDoc(S) {
113
+ return (S.document.tagName || "").includes("-");
114
+ }
115
+
116
+ /**
117
+ * Recursively collect CSS `part` attributes from the document tree.
118
+ *
119
+ * @param {any} node
120
+ * @param {any[]} [parts]
121
+ */
122
+ export function collectCssParts(node, parts = []) {
123
+ if (node?.attributes?.part)
124
+ parts.push({ name: node.attributes.part, tag: node.tagName || "div" });
125
+ if (Array.isArray(node?.children))
126
+ node.children.forEach((/** @type {any} */ c) => collectCssParts(c, parts));
127
+ return parts;
128
+ }
129
+
130
+ /**
131
+ * Resolve a $ref value to a display string using signal defaults. Used by the canvas to show real
132
+ * values instead of raw refs.
133
+ *
134
+ * @param {any} value
135
+ * @param {any} defs
136
+ */
137
+ export function resolveDefaultForCanvas(value, defs) {
138
+ if (!value || typeof value !== "object" || !value.$ref) return value;
139
+ const ref = value.$ref;
140
+ /** @type {any} */
141
+ let defName;
142
+ if (ref.startsWith("#/state/")) defName = ref.slice(8);
143
+ else if (ref.startsWith("$")) defName = ref;
144
+ else return `{${ref}}`;
145
+
146
+ const def = defs?.[defName];
147
+ if (!def) return `{${defName}}`;
148
+
149
+ // State signal → use default
150
+ if (!def.$compute && !def.$prototype) {
151
+ if (def.default !== undefined && def.default !== null) {
152
+ if (typeof def.default === "object") return JSON.stringify(def.default);
153
+ return String(def.default);
154
+ }
155
+ return "";
156
+ }
157
+ // Computed → expression indicator
158
+ if (def.$compute) return `\u0192(${defName})`;
159
+ // Request → URL hint
160
+ if (def.$prototype === "Request") return `\u27F3 ${def.url || "fetch"}`;
161
+ // Storage → use default or key
162
+ if (def.$prototype === "LocalStorage" || def.$prototype === "SessionStorage") {
163
+ if (def.default !== undefined && def.default !== null) {
164
+ if (typeof def.default === "object") return JSON.stringify(def.default);
165
+ return String(def.default);
166
+ }
167
+ return `[${def.key || "storage"}]`;
168
+ }
169
+ if (def.$prototype) return `{${def.$prototype}}`;
170
+ return `{${defName}}`;
171
+ }
172
+
173
+ // ─── Simple field row ────────────────────────────────────────────────────────
174
+
175
+ /** Simple field row for signal editors — vertical stacked layout. */
176
+ export function signalFieldRow(
177
+ /** @type {any} */ label,
178
+ /** @type {any} */ value,
179
+ /** @type {any} */ onChange,
180
+ ) {
181
+ /** @type {any} */
182
+ let debounce;
183
+ return html`
184
+ <div class="style-row">
185
+ <div class="style-row-label">
186
+ <sp-field-label size="s">${label}</sp-field-label>
187
+ </div>
188
+ <sp-textfield
189
+ size="s"
190
+ value=${value}
191
+ @input=${(/** @type {any} */ e) => {
192
+ clearTimeout(debounce);
193
+ debounce = setTimeout(() => onChange(e.target.value), 400);
194
+ }}
195
+ ></sp-textfield>
196
+ </div>
197
+ `;
198
+ }
199
+
200
+ /** Normalize a parameter entry to a CEM object. */
201
+ export function normParam(/** @type {any} */ p) {
202
+ return typeof p === "string" ? { name: p } : p;
203
+ }
204
+
205
+ // ─── Left panel: Signals ─────────────────────────────────────────────────────
206
+
207
+ /**
208
+ * @param {any} S
209
+ * @param {{ renderLeftPanel: Function; renderCanvas: Function }} ctx
210
+ */
211
+ export function renderSignalsTemplate(S, { renderLeftPanel, renderCanvas }) {
212
+ const defs = S.document.state || {};
213
+ const entries = Object.entries(defs);
214
+
215
+ // Group by category
216
+ const groups = /** @type {Record<string, any[]>} */ ({
217
+ state: [],
218
+ computed: [],
219
+ data: [],
220
+ function: [],
221
+ });
222
+ for (const [name, def] of entries) {
223
+ groups[defCategory(def)].push([name, def]);
224
+ }
225
+
226
+ const categories = [
227
+ { key: "state", label: "State", items: groups.state },
228
+ { key: "computed", label: "Computed", items: groups.computed },
229
+ { key: "data", label: "Data", items: groups.data },
230
+ { key: "function", label: "Functions", items: groups.function },
231
+ ];
232
+
233
+ const collapsedCats = S._collapsedSignalCats || (S._collapsedSignalCats = new Set());
234
+
235
+ const catTemplates = categories
236
+ .filter((c) => c.items.length > 0)
237
+ .map(
238
+ ({ key, label, items }) => html`
239
+ <sp-accordion-item
240
+ label="${label} (${items.length})"
241
+ ?open=${!collapsedCats.has(key)}
242
+ @sp-accordion-item-toggle=${() => {
243
+ if (collapsedCats.has(key)) collapsedCats.delete(key);
244
+ else collapsedCats.add(key);
245
+ renderLeftPanel();
246
+ }}
247
+ >
248
+ ${items.map(([name, def]) => {
249
+ /** @type {any} */
250
+ const isExpanded = expandedSignal === name;
251
+ return html`
252
+ <div
253
+ class="signal-row${isExpanded ? " expanded" : ""}"
254
+ @click=${() => {
255
+ expandedSignal = isExpanded ? null : name;
256
+ renderLeftPanel();
257
+ }}
258
+ >
259
+ <span class="signal-badge ${defCategory(def)}">${defBadgeLabel(def)}</span>
260
+ <span class="signal-name">${name}</span>
261
+ <span class="signal-hint">${defHint(name, def)}</span>
262
+ <sp-action-button
263
+ quiet
264
+ size="xs"
265
+ class="signal-del"
266
+ @click=${(/** @type {any} */ e) => {
267
+ e.stopPropagation();
268
+ update(removeDef(S, name));
269
+ }}
270
+ >
271
+ <sp-icon-delete slot="icon"></sp-icon-delete>
272
+ </sp-action-button>
273
+ </div>
274
+ ${isExpanded
275
+ ? html`<div class="signal-editor">
276
+ ${renderSignalEditorTemplate(S, name, def, { renderLeftPanel, renderCanvas })}
277
+ </div>`
278
+ : nothing}
279
+ `;
280
+ })}
281
+ </sp-accordion-item>
282
+ `,
283
+ );
284
+
285
+ return html`
286
+ <div class="signals-panel">
287
+ <sp-accordion allow-multiple size="s"> ${catTemplates} </sp-accordion>
288
+ ${entries.length === 0 ? html`<div class="empty-state">No state defined</div>` : nothing}
289
+ <div class="signals-add">
290
+ <sp-picker
291
+ size="s"
292
+ label="+ Add…"
293
+ placeholder="+ Add…"
294
+ @change=${(/** @type {any} */ e) => {
295
+ const type = e.target.value;
296
+ if (!type) return;
297
+ const template = DEF_TEMPLATES[type];
298
+ if (!template) return;
299
+ const isFunction = type === "function";
300
+ let nameBase = isFunction ? "newFunction" : "$newSignal";
301
+ let n = nameBase;
302
+ let i = 1;
303
+ while (S.document.state && S.document.state[n]) {
304
+ n = nameBase + i++;
305
+ }
306
+ update(addDef(S, n, structuredClone(template)));
307
+ expandedSignal = n;
308
+ renderLeftPanel();
309
+ }}
310
+ >
311
+ <sp-menu-item value="state">State Signal</sp-menu-item>
312
+ <sp-menu-item value="computed">Computed</sp-menu-item>
313
+ <sp-menu-divider></sp-menu-divider>
314
+ <sp-menu-item value="request">Fetch (Request)</sp-menu-item>
315
+ <sp-menu-item value="localStorage">LocalStorage</sp-menu-item>
316
+ <sp-menu-item value="sessionStorage">SessionStorage</sp-menu-item>
317
+ <sp-menu-item value="indexedDB">IndexedDB</sp-menu-item>
318
+ <sp-menu-item value="cookie">Cookie</sp-menu-item>
319
+ <sp-menu-item value="set">Set</sp-menu-item>
320
+ <sp-menu-item value="map">Map</sp-menu-item>
321
+ <sp-menu-item value="formData">FormData</sp-menu-item>
322
+ <sp-menu-item value="external">External Module…</sp-menu-item>
323
+ <sp-menu-divider></sp-menu-divider>
324
+ <sp-menu-item value="function">Function</sp-menu-item>
325
+ </sp-picker>
326
+ </div>
327
+ </div>
328
+ `;
329
+ }
330
+
331
+ /** Render inline editor fields for a specific signal/def type. */
332
+ function renderSignalEditorTemplate(
333
+ /** @type {any} */ S,
334
+ /** @type {any} */ name,
335
+ /** @type {any} */ def,
336
+ /** @type {{ renderLeftPanel: Function; renderCanvas: Function }} */ ctx,
337
+ ) {
338
+ const cat = defCategory(def);
339
+
340
+ // Helper for picker rows
341
+ const pickerRow = (
342
+ /** @type {any} */ label,
343
+ /** @type {any} */ options,
344
+ /** @type {any} */ currentVal,
345
+ /** @type {any} */ onChange,
346
+ ) => {
347
+ return html`
348
+ <div class="style-row">
349
+ <div class="style-row-label">
350
+ <sp-field-label size="s">${label}</sp-field-label>
351
+ </div>
352
+ <sp-picker
353
+ size="s"
354
+ value=${currentVal}
355
+ @change=${(/** @type {any} */ e) => onChange(e.target.value)}
356
+ >
357
+ ${options.map(
358
+ (/** @type {any} */ opt) => html`<sp-menu-item value=${opt}>${opt}</sp-menu-item>`,
359
+ )}
360
+ </sp-picker>
361
+ </div>
362
+ `;
363
+ };
364
+
365
+ // Helper for textarea rows
366
+ const textareaRow = (
367
+ /** @type {any} */ label,
368
+ /** @type {any} */ value,
369
+ /** @type {any} */ onChange,
370
+ /** @type {any} */ opts = {},
371
+ ) => {
372
+ /** @type {any} */
373
+ let debounce;
374
+ return html`
375
+ <div class="style-row">
376
+ <div class="style-row-label">
377
+ <sp-field-label size="s">${label}</sp-field-label>
378
+ </div>
379
+ <textarea
380
+ class="field-input"
381
+ style="min-height:${opts.minHeight || "40px"};${opts.mono
382
+ ? "font-family:'SF Mono','Fira Code','Consolas',monospace;font-size:11px;"
383
+ : ""}"
384
+ .value=${value}
385
+ @input=${(/** @type {any} */ e) => {
386
+ clearTimeout(debounce);
387
+ debounce = setTimeout(() => onChange(e.target.value), 500);
388
+ }}
389
+ ></textarea>
390
+ </div>
391
+ `;
392
+ };
393
+
394
+ // Name field (common to all)
395
+ const nameField = signalFieldRow("Name", name, (/** @type {any} */ v) => {
396
+ if (v && v !== name && !(S.document.state && S.document.state[v])) {
397
+ expandedSignal = v;
398
+ update(renameDef(S, name, v));
399
+ }
400
+ });
401
+
402
+ /** @type {any} */
403
+ let fields = nothing;
404
+
405
+ if (cat === "state") {
406
+ const defaultVal =
407
+ def.default !== undefined && def.default !== null
408
+ ? typeof def.default === "object"
409
+ ? JSON.stringify(def.default)
410
+ : String(def.default)
411
+ : "";
412
+
413
+ const cemFields = isCustomElementDoc(S)
414
+ ? html`
415
+ ${signalFieldRow("Attribute", def.attribute || "", (/** @type {any} */ v) =>
416
+ update(updateDef(S, name, { attribute: v || undefined })),
417
+ )}
418
+ <div class="style-row">
419
+ <div class="style-row-label">
420
+ <sp-field-label size="s">Reflects</sp-field-label>
421
+ </div>
422
+ <sp-checkbox
423
+ class="field-check"
424
+ ?checked=${!!def.reflects}
425
+ @change=${(/** @type {any} */ e) =>
426
+ update(updateDef(S, name, { reflects: e.target.checked || undefined }))}
427
+ ></sp-checkbox>
428
+ </div>
429
+ ${signalFieldRow(
430
+ "Deprecated",
431
+ typeof def.deprecated === "string" ? def.deprecated : "",
432
+ (/** @type {any} */ v) => update(updateDef(S, name, { deprecated: v || undefined })),
433
+ )}
434
+ `
435
+ : nothing;
436
+
437
+ fields = html`
438
+ ${pickerRow(
439
+ "Type",
440
+ ["string", "integer", "number", "boolean", "array", "object"],
441
+ def.type || "string",
442
+ (/** @type {any} */ v) => update(updateDef(S, name, { type: v })),
443
+ )}
444
+ ${signalFieldRow("Default", defaultVal, (/** @type {any} */ v) => {
445
+ let parsed = v;
446
+ if (def.type === "integer") parsed = parseInt(v, 10) || 0;
447
+ else if (def.type === "number") parsed = parseFloat(v) || 0;
448
+ else if (def.type === "boolean") parsed = v === "true";
449
+ else if (def.type === "array" || def.type === "object") {
450
+ try {
451
+ parsed = JSON.parse(v);
452
+ } catch {
453
+ parsed = v;
454
+ }
455
+ }
456
+ update(updateDef(S, name, { default: parsed }));
457
+ })}
458
+ ${signalFieldRow("Description", def.description || "", (/** @type {any} */ v) =>
459
+ update(updateDef(S, name, { description: v || undefined })),
460
+ )}
461
+ ${cemFields}
462
+ `;
463
+ } else if (cat === "computed") {
464
+ /** @type {any} */
465
+ let debounce;
466
+ fields = html`
467
+ <div class="style-row">
468
+ <div class="style-row-label">
469
+ <sp-field-label size="s">Expression</sp-field-label>
470
+ </div>
471
+ <textarea
472
+ class="field-input"
473
+ style="min-height:40px"
474
+ .value=${def.$compute || ""}
475
+ @input=${(/** @type {any} */ e) => {
476
+ clearTimeout(debounce);
477
+ debounce = setTimeout(() => {
478
+ const expr = e.target.value;
479
+ const depMatches = expr.match(/\$[a-zA-Z_]\w*/g) || [];
480
+ const deps = [...new Set(depMatches)].map((d) => `#/state/${d}`);
481
+ update(updateDef(S, name, { $compute: expr, $deps: deps }));
482
+ }, 500);
483
+ }}
484
+ ></textarea>
485
+ </div>
486
+ ${def.$deps && def.$deps.length > 0
487
+ ? html`
488
+ <div class="style-row">
489
+ <div class="style-row-label">
490
+ <sp-field-label size="s">Dependencies</sp-field-label>
491
+ </div>
492
+ <span class="signal-hint" style="flex:1;max-width:none"
493
+ >${def.$deps
494
+ .map((/** @type {any} */ d) => d.replace("#/state/", ""))
495
+ .join(", ")}</span
496
+ >
497
+ </div>
498
+ `
499
+ : nothing}
500
+ `;
501
+ } else if (cat === "data") {
502
+ fields = renderDataSourceFields(S, name, def, textareaRow, pickerRow, ctx);
503
+ } else if (cat === "function") {
504
+ fields = renderFunctionFields(S, name, def, textareaRow, ctx);
505
+ }
506
+
507
+ return html`${nameField}${fields}`;
508
+ }
509
+
510
+ /** Data source fields for signal editor */
511
+ function renderDataSourceFields(
512
+ /** @type {any} */ S,
513
+ /** @type {any} */ name,
514
+ /** @type {any} */ def,
515
+ /** @type {any} */ textareaRow,
516
+ /** @type {any} */ pickerRow,
517
+ /** @type {{ renderLeftPanel: Function; renderCanvas: Function }} */ ctx,
518
+ ) {
519
+ const proto = def.$prototype;
520
+
521
+ if (proto === "Request") {
522
+ return html`
523
+ ${signalFieldRow("URL", def.url || "", (/** @type {any} */ v) =>
524
+ update(updateDef(S, name, { url: v })),
525
+ )}
526
+ ${pickerRow(
527
+ "Method",
528
+ ["GET", "POST", "PUT", "DELETE", "PATCH"],
529
+ def.method || "GET",
530
+ (/** @type {any} */ v) => update(updateDef(S, name, { method: v })),
531
+ )}
532
+ ${pickerRow("Timing", ["client", "server"], def.timing || "client", (/** @type {any} */ v) =>
533
+ update(updateDef(S, name, { timing: v })),
534
+ )}
535
+ `;
536
+ }
537
+ if (proto === "LocalStorage" || proto === "SessionStorage") {
538
+ const defaultStr =
539
+ def.default !== undefined && def.default !== null
540
+ ? typeof def.default === "object"
541
+ ? JSON.stringify(def.default, null, 2)
542
+ : String(def.default)
543
+ : "";
544
+ return html`
545
+ ${signalFieldRow("Key", def.key || "", (/** @type {any} */ v) =>
546
+ update(updateDef(S, name, { key: v })),
547
+ )}
548
+ ${textareaRow("Default", defaultStr, (/** @type {any} */ v) => {
549
+ try {
550
+ update(updateDef(S, name, { default: JSON.parse(v) }));
551
+ } catch {
552
+ update(updateDef(S, name, { default: v }));
553
+ }
554
+ })}
555
+ `;
556
+ }
557
+ if (proto === "IndexedDB") {
558
+ return html`
559
+ ${signalFieldRow("Database", def.database || "", (/** @type {any} */ v) =>
560
+ update(updateDef(S, name, { database: v })),
561
+ )}
562
+ ${signalFieldRow("Store", def.store || "", (/** @type {any} */ v) =>
563
+ update(updateDef(S, name, { store: v })),
564
+ )}
565
+ ${signalFieldRow("Version", String(def.version || 1), (/** @type {any} */ v) =>
566
+ update(updateDef(S, name, { version: parseInt(v, 10) || 1 })),
567
+ )}
568
+ `;
569
+ }
570
+ if (proto === "Cookie") {
571
+ return html`
572
+ ${signalFieldRow("Cookie", def.name || "", (/** @type {any} */ v) =>
573
+ update(updateDef(S, name, { name: v })),
574
+ )}
575
+ ${signalFieldRow("Default", def.default || "", (/** @type {any} */ v) =>
576
+ update(updateDef(S, name, { default: v })),
577
+ )}
578
+ `;
579
+ }
580
+ if (proto === "Set" || proto === "Map" || proto === "FormData") {
581
+ const fieldName = proto === "FormData" ? "fields" : "default";
582
+ const fieldLabel = proto === "FormData" ? "Fields" : "Default";
583
+ const defaultStr =
584
+ def.default !== undefined && def.default !== null
585
+ ? JSON.stringify(def.default, null, 2)
586
+ : proto === "FormData"
587
+ ? JSON.stringify(def.fields || {}, null, 2)
588
+ : "";
589
+ return textareaRow(fieldLabel, defaultStr, (/** @type {any} */ v) => {
590
+ try {
591
+ update(updateDef(S, name, { [fieldName]: JSON.parse(v) }));
592
+ } catch {}
593
+ });
594
+ }
595
+ // Schema-driven fallback
596
+ return renderExternalPrototypeEditorTemplate(S, name, def, ctx);
597
+ }
598
+
599
+ /** Function fields for signal editor */
600
+ function renderFunctionFields(
601
+ /** @type {any} */ S,
602
+ /** @type {any} */ name,
603
+ /** @type {any} */ def,
604
+ /** @type {any} */ textareaRow,
605
+ /** @type {{ renderLeftPanel: Function; renderCanvas: Function }} */ ctx,
606
+ ) {
607
+ const srcFields = def.$src
608
+ ? html`
609
+ ${signalFieldRow("Source", def.$src || "", (/** @type {any} */ v) =>
610
+ update(updateDef(S, name, { $src: v || undefined })),
611
+ )}
612
+ ${signalFieldRow("Export", def.$export || "", (/** @type {any} */ v) =>
613
+ update(updateDef(S, name, { $export: v || undefined })),
614
+ )}
615
+ `
616
+ : textareaRow(
617
+ "Body",
618
+ def.body || "",
619
+ (/** @type {any} */ v) => update(updateDef(S, name, { body: v })),
620
+ { minHeight: "60px", mono: true },
621
+ );
622
+
623
+ return html`
624
+ ${srcFields} ${renderParameterEditorTemplate(S, name, def, ctx)}
625
+ ${isCustomElementDoc(S) ? renderEmitsEditorTemplate(S, name, def) : nothing}
626
+ ${!def.$src
627
+ ? html`
628
+ <button
629
+ class="kv-add"
630
+ style="margin-top:4px"
631
+ @click=${() => {
632
+ S = { ...S, ui: { ...S.ui, editingFunction: { type: "def", defName: name } } };
633
+ ctx.renderCanvas();
634
+ }}
635
+ >
636
+ Open in editor
637
+ </button>
638
+ `
639
+ : nothing}
640
+ ${signalFieldRow("Description", def.description || "", (/** @type {any} */ v) =>
641
+ update(updateDef(S, name, { description: v || undefined })),
642
+ )}
643
+ `;
644
+ }
645
+
646
+ // ─── CEM Editors ─────────────────────────────────────────────────────────────
647
+
648
+ /** Render CEM parameter editor with basic/advanced toggle. */
649
+ function renderParameterEditorTemplate(
650
+ /** @type {any} */ S,
651
+ /** @type {any} */ name,
652
+ /** @type {any} */ def,
653
+ /** @type {{ renderLeftPanel: Function; renderCanvas: Function }} */ ctx,
654
+ ) {
655
+ const params = (def.parameters || []).map(normParam);
656
+ const isAdvanced = advancedParamOpen.has(name);
657
+
658
+ if (!isAdvanced) {
659
+ // Basic mode: name chips
660
+ return html`
661
+ <div class="style-row">
662
+ <div class="style-row-label">
663
+ <sp-field-label size="s">Parameters</sp-field-label>
664
+ </div>
665
+ <div style="display:flex;flex-wrap:wrap;gap:4px;align-items:center">
666
+ ${params.map(
667
+ (/** @type {any} */ p, /** @type {any} */ i) => html`
668
+ <span
669
+ style="display:inline-flex;align-items:center;gap:2px;padding:1px 6px;border-radius:3px;background:var(--bg-hover);font-size:11px;font-family:monospace"
670
+ >
671
+ ${p.name || "?"}
672
+ <span
673
+ style="cursor:pointer;opacity:0.5;margin-left:2px"
674
+ @click=${() => {
675
+ update(
676
+ updateDef(S, name, {
677
+ parameters: params.filter(
678
+ (/** @type {any} */ _, /** @type {any} */ j) => j !== i,
679
+ ).length
680
+ ? params.filter((/** @type {any} */ _, /** @type {any} */ j) => j !== i)
681
+ : undefined,
682
+ }),
683
+ );
684
+ }}
685
+ >×</span
686
+ >
687
+ </span>
688
+ `,
689
+ )}
690
+ <input
691
+ class="field-input"
692
+ style="width:60px;flex:0 0 auto;font-size:11px"
693
+ placeholder="+"
694
+ @keydown=${(/** @type {any} */ e) => {
695
+ if (e.key === "Enter" && e.target.value.trim()) {
696
+ update(
697
+ updateDef(S, name, { parameters: [...params, { name: e.target.value.trim() }] }),
698
+ );
699
+ }
700
+ }}
701
+ />
702
+ </div>
703
+ <span
704
+ style="font-size:10px;color:var(--fg-dim);cursor:pointer;width:100%;margin-top:2px"
705
+ @click=${() => {
706
+ advancedParamOpen.add(name);
707
+ ctx.renderLeftPanel();
708
+ }}
709
+ >▸ Advanced</span
710
+ >
711
+ </div>
712
+ `;
713
+ }
714
+
715
+ // Advanced mode: full rows
716
+ return html`
717
+ <div class="style-row">
718
+ <div class="style-row-label">
719
+ <sp-field-label size="s">Parameters</sp-field-label>
720
+ </div>
721
+ <div style="display:flex;flex-direction:column;gap:4px">
722
+ ${params.map(
723
+ (/** @type {any} */ p, /** @type {any} */ i) => html`
724
+ <div style="display:flex;gap:4px;align-items:center">
725
+ <input
726
+ class="field-input"
727
+ .value=${p.name || ""}
728
+ placeholder="name"
729
+ style="flex:1"
730
+ @change=${(/** @type {any} */ e) => {
731
+ const next = [...params];
732
+ next[i] = { ...next[i], name: e.target.value };
733
+ update(updateDef(S, name, { parameters: next }));
734
+ }}
735
+ />
736
+ <input
737
+ class="field-input"
738
+ .value=${p.type?.text || ""}
739
+ placeholder="type"
740
+ style="flex:1"
741
+ @change=${(/** @type {any} */ e) => {
742
+ const next = [...params];
743
+ next[i] = {
744
+ ...next[i],
745
+ type: e.target.value ? { text: e.target.value } : undefined,
746
+ };
747
+ update(updateDef(S, name, { parameters: next }));
748
+ }}
749
+ />
750
+ <input
751
+ class="field-input"
752
+ .value=${p.description || ""}
753
+ placeholder="desc"
754
+ style="flex:2"
755
+ @change=${(/** @type {any} */ e) => {
756
+ const next = [...params];
757
+ next[i] = { ...next[i], description: e.target.value || undefined };
758
+ update(updateDef(S, name, { parameters: next }));
759
+ }}
760
+ />
761
+ <input
762
+ type="checkbox"
763
+ title="optional"
764
+ .checked=${!!p.optional}
765
+ @change=${(/** @type {any} */ e) => {
766
+ const next = [...params];
767
+ next[i] = { ...next[i], optional: e.target.checked || undefined };
768
+ update(updateDef(S, name, { parameters: next }));
769
+ }}
770
+ />
771
+ <span
772
+ style="cursor:pointer;opacity:0.5"
773
+ @click=${() => {
774
+ const next = params.filter(
775
+ (/** @type {any} */ _, /** @type {any} */ j) => j !== i,
776
+ );
777
+ update(updateDef(S, name, { parameters: next.length ? next : undefined }));
778
+ }}
779
+ >×</span
780
+ >
781
+ </div>
782
+ `,
783
+ )}
784
+ <button
785
+ class="kv-add"
786
+ @click=${() => update(updateDef(S, name, { parameters: [...params, { name: "" }] }))}
787
+ >
788
+ + Add parameter
789
+ </button>
790
+ </div>
791
+ <span
792
+ style="font-size:10px;color:var(--fg-dim);cursor:pointer;width:100%;margin-top:2px"
793
+ @click=${() => {
794
+ advancedParamOpen.delete(name);
795
+ ctx.renderLeftPanel();
796
+ }}
797
+ >▾ Basic</span
798
+ >
799
+ </div>
800
+ `;
801
+ }
802
+
803
+ /** Render CEM emits editor for function state entries. */
804
+ function renderEmitsEditorTemplate(
805
+ /** @type {any} */ S,
806
+ /** @type {any} */ name,
807
+ /** @type {any} */ def,
808
+ ) {
809
+ const emits = def.emits || [];
810
+ if (emits.length === 0 && !isCustomElementDoc(S)) return nothing;
811
+
812
+ return html`
813
+ <div
814
+ style="font-size:11px;font-weight:600;color:var(--fg-dim);margin:8px 0 4px;text-transform:uppercase;letter-spacing:0.05em"
815
+ >
816
+ Emits
817
+ </div>
818
+ ${emits.map(
819
+ (/** @type {any} */ ev, /** @type {any} */ i) => html`
820
+ <div style="display:flex;gap:4px;align-items:center;margin-bottom:4px">
821
+ <input
822
+ class="field-input"
823
+ .value=${ev.name || ""}
824
+ placeholder="event name"
825
+ style="flex:1"
826
+ @change=${(/** @type {any} */ e) => {
827
+ const next = [...emits];
828
+ next[i] = { ...next[i], name: e.target.value };
829
+ update(updateDef(S, name, { emits: next }));
830
+ }}
831
+ />
832
+ <input
833
+ class="field-input"
834
+ .value=${ev.type?.text || ""}
835
+ placeholder="type"
836
+ style="flex:1"
837
+ @change=${(/** @type {any} */ e) => {
838
+ const next = [...emits];
839
+ next[i] = { ...next[i], type: e.target.value ? { text: e.target.value } : undefined };
840
+ update(updateDef(S, name, { emits: next }));
841
+ }}
842
+ />
843
+ <input
844
+ class="field-input"
845
+ .value=${ev.description || ""}
846
+ placeholder="description"
847
+ style="flex:2"
848
+ @change=${(/** @type {any} */ e) => {
849
+ const next = [...emits];
850
+ next[i] = { ...next[i], description: e.target.value || undefined };
851
+ update(updateDef(S, name, { emits: next }));
852
+ }}
853
+ />
854
+ <span
855
+ style="cursor:pointer;opacity:0.5"
856
+ @click=${() => {
857
+ update(
858
+ updateDef(S, name, {
859
+ emits: emits.filter((/** @type {any} */ _, /** @type {any} */ j) => j !== i)
860
+ .length
861
+ ? emits.filter((/** @type {any} */ _, /** @type {any} */ j) => j !== i)
862
+ : undefined,
863
+ }),
864
+ );
865
+ }}
866
+ >×</span
867
+ >
868
+ </div>
869
+ `,
870
+ )}
871
+ <button
872
+ class="kv-add"
873
+ @click=${() => update(updateDef(S, name, { emits: [...emits, { name: "" }] }))}
874
+ >
875
+ + Add event
876
+ </button>
877
+ `;
878
+ }
879
+
880
+ // ─── Plugin schema-driven form rendering ────────────────────────────────────
881
+
882
+ /**
883
+ * Render config form fields from a JSON Schema `properties` object. Maps schema types to
884
+ * appropriate form controls.
885
+ */
886
+ export function renderSchemaFieldsTemplate(
887
+ /** @type {any} */ schema,
888
+ /** @type {any} */ def,
889
+ /** @type {any} */ name,
890
+ /** @type {any} */ S,
891
+ ) {
892
+ if (!schema?.properties) return nothing;
893
+
894
+ const required = new Set(schema.required ?? []);
895
+
896
+ return Object.entries(schema.properties)
897
+ .filter(([prop]) => !STUDIO_RESERVED_KEYS.has(prop))
898
+ .map(([prop, ps]) => {
899
+ const currentValue = def[prop];
900
+ const labelText = prop + (required.has(prop) ? " *" : "");
901
+
902
+ let control;
903
+ if (ps.enum) {
904
+ control = html`
905
+ <sp-picker
906
+ size="s"
907
+ value=${currentValue !== undefined
908
+ ? String(currentValue)
909
+ : ps.default !== undefined
910
+ ? String(ps.default)
911
+ : "__none__"}
912
+ @change=${(/** @type {any} */ e) =>
913
+ update(
914
+ updateDef(S, name, {
915
+ [prop]: e.target.value === "__none__" ? undefined : e.target.value,
916
+ }),
917
+ )}
918
+ >
919
+ ${!required.has(prop) ? html`<sp-menu-item value="__none__">—</sp-menu-item>` : nothing}
920
+ ${ps.enum.map(
921
+ (/** @type {any} */ val) => html`<sp-menu-item value=${val}>${val}</sp-menu-item>`,
922
+ )}
923
+ </sp-picker>
924
+ `;
925
+ } else if (ps.type === "boolean") {
926
+ control = html`<sp-checkbox
927
+ ?checked=${currentValue ?? ps.default ?? false}
928
+ @change=${(/** @type {any} */ e) =>
929
+ update(updateDef(S, name, { [prop]: e.target.checked }))}
930
+ ></sp-checkbox>`;
931
+ } else if (ps.type === "integer" || ps.type === "number") {
932
+ /** @type {any} */
933
+ let debounce;
934
+ control = html`<sp-number-field
935
+ size="s"
936
+ min=${ps.minimum !== undefined ? ps.minimum : nothing}
937
+ max=${ps.maximum !== undefined ? ps.maximum : nothing}
938
+ step=${ps.type === "integer" ? "1" : nothing}
939
+ .value=${currentValue !== undefined ? currentValue : nothing}
940
+ placeholder=${ps.default !== undefined ? String(ps.default) : nothing}
941
+ @change=${(/** @type {any} */ e) => {
942
+ clearTimeout(debounce);
943
+ debounce = setTimeout(() => {
944
+ const parsed =
945
+ ps.type === "integer" ? parseInt(e.target.value, 10) : parseFloat(e.target.value);
946
+ update(updateDef(S, name, { [prop]: isNaN(parsed) ? undefined : parsed }));
947
+ }, 400);
948
+ }}
949
+ ></sp-number-field>`;
950
+ } else if (ps.format === "json-schema") {
951
+ const hasValue =
952
+ currentValue && typeof currentValue === "object" && Object.keys(currentValue).length > 0;
953
+ const isRef = currentValue && typeof currentValue === "object" && currentValue.$ref;
954
+ /** @type {any} */
955
+ let debounce;
956
+ control = html`
957
+ <div class="schema-param-editor">
958
+ ${hasValue && !isRef && currentValue.properties
959
+ ? html`
960
+ <div style="display:flex;flex-wrap:wrap;gap:3px;margin-bottom:4px">
961
+ ${Object.entries(currentValue.properties).map(
962
+ ([k, v]) => html`
963
+ <span
964
+ style="background:var(--bg-alt);padding:1px 6px;border-radius:3px;font-size:10px;color:var(--fg-dim)"
965
+ >${k}: ${v.type ?? "any"}</span
966
+ >
967
+ `,
968
+ )}
969
+ </div>
970
+ `
971
+ : nothing}
972
+ <sp-textfield
973
+ multiline
974
+ size="s"
975
+ style="min-height:${hasValue ? "80px" : "40px"};font-family:monospace;font-size:11px"
976
+ .value=${currentValue !== undefined ? JSON.stringify(currentValue, null, 2) : ""}
977
+ placeholder=${ps.description ?? "JSON Schema defining the data shape\u2026"}
978
+ @input=${(/** @type {any} */ e) => {
979
+ clearTimeout(debounce);
980
+ debounce = setTimeout(() => {
981
+ try {
982
+ update(updateDef(S, name, { [prop]: JSON.parse(e.target.value) }));
983
+ } catch {}
984
+ }, 500);
985
+ }}
986
+ ></sp-textfield>
987
+ </div>
988
+ `;
989
+ } else if (ps.type === "array" || ps.type === "object") {
990
+ /** @type {any} */
991
+ let debounce;
992
+ control = html`<sp-textfield
993
+ multiline
994
+ size="s"
995
+ style="min-height:40px"
996
+ .value=${currentValue !== undefined ? JSON.stringify(currentValue, null, 2) : ""}
997
+ placeholder=${ps.default !== undefined ? JSON.stringify(ps.default) : nothing}
998
+ @input=${(/** @type {any} */ e) => {
999
+ clearTimeout(debounce);
1000
+ debounce = setTimeout(() => {
1001
+ try {
1002
+ update(updateDef(S, name, { [prop]: JSON.parse(e.target.value) }));
1003
+ } catch {}
1004
+ }, 500);
1005
+ }}
1006
+ ></sp-textfield>`;
1007
+ } else {
1008
+ /** @type {any} */
1009
+ let debounce;
1010
+ const ph = ps.default !== undefined ? String(ps.default) : (ps.examples?.[0] ?? "");
1011
+ control = html`<sp-textfield
1012
+ size="s"
1013
+ .value=${currentValue ?? ""}
1014
+ placeholder=${ph || nothing}
1015
+ title=${ps.description || nothing}
1016
+ @input=${(/** @type {any} */ e) => {
1017
+ clearTimeout(debounce);
1018
+ debounce = setTimeout(
1019
+ () => update(updateDef(S, name, { [prop]: e.target.value || undefined })),
1020
+ 400,
1021
+ );
1022
+ }}
1023
+ ></sp-textfield>`;
1024
+ }
1025
+
1026
+ return html`
1027
+ <div class="style-row">
1028
+ <div class="style-row-label">
1029
+ <sp-field-label size="s" title=${ps.description || nothing}
1030
+ >${labelText}</sp-field-label
1031
+ >
1032
+ </div>
1033
+ ${control}
1034
+ </div>
1035
+ `;
1036
+ });
1037
+ }
1038
+
1039
+ /**
1040
+ * Render editor fields for an external $prototype + $src plugin. Shows $src/$export inputs plus
1041
+ * schema-driven config fields.
1042
+ */
1043
+ export function renderExternalPrototypeEditorTemplate(
1044
+ /** @type {any} */ S,
1045
+ /** @type {any} */ name,
1046
+ /** @type {any} */ def,
1047
+ /** @type {{ renderLeftPanel: Function; renderCanvas: Function }} */ ctx,
1048
+ ) {
1049
+ // Schema-driven config fields (async with cache)
1050
+ /** @type {any} */
1051
+ let schemaContent = nothing;
1052
+ if (def.$src && def.$prototype) {
1053
+ const cacheKey = `${def.$src}::${def.$prototype}`;
1054
+ if (pluginSchemaCache.has(cacheKey)) {
1055
+ const schema = pluginSchemaCache.get(cacheKey);
1056
+ if (schema) {
1057
+ schemaContent = html`
1058
+ ${schema.description
1059
+ ? html`<div class="signal-hint" style="padding:4px 0 8px">${schema.description}</div>`
1060
+ : nothing}
1061
+ ${renderSchemaFieldsTemplate(schema, def, name, S)}
1062
+ `;
1063
+ }
1064
+ } else {
1065
+ // Trigger async load — will re-render when cached
1066
+ schemaContent = html`<div
1067
+ style="padding:4px 0;font-size:11px;color:var(--fg-dim);font-style:italic"
1068
+ >
1069
+ Loading schema…
1070
+ </div>`;
1071
+ fetchPluginSchema(def, S).then((schema) => {
1072
+ if (schema) ctx.renderLeftPanel();
1073
+ });
1074
+ }
1075
+ }
1076
+
1077
+ return html`
1078
+ ${signalFieldRow("Source", def.$src || "", (/** @type {any} */ v) => {
1079
+ update(updateDef(S, name, { $src: v || undefined }));
1080
+ pluginSchemaCache.delete(`${v}::${def.$prototype}`);
1081
+ })}
1082
+ ${signalFieldRow("Prototype", def.$prototype || "", (/** @type {any} */ v) => {
1083
+ update(updateDef(S, name, { $prototype: v || undefined }));
1084
+ pluginSchemaCache.delete(`${def.$src}::${v}`);
1085
+ })}
1086
+ ${def.$export
1087
+ ? signalFieldRow("Export", def.$export || "", (/** @type {any} */ v) =>
1088
+ update(updateDef(S, name, { $export: v || undefined })),
1089
+ )
1090
+ : nothing}
1091
+ ${schemaContent}
1092
+ `;
1093
+ }