@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
@@ -7,9 +7,7 @@
7
7
 
8
8
  import { html, nothing } from "lit-html";
9
9
  import { classMap } from "lit-html/directives/class-map.js";
10
- import { ifDefined } from "lit-html/directives/if-defined.js";
11
10
  import { live } from "lit-html/directives/live.js";
12
- import { styleMap } from "lit-html/directives/style-map.js";
13
11
  import { isRef } from "@jxsuite/schema/guards";
14
12
  import { dynamicRouteParams } from "../page-params";
15
13
  import { projectState } from "../state";
@@ -25,15 +23,23 @@ import {
25
23
  import { renderFieldRow } from "../ui/field-row";
26
24
  import { rawTextArea, spTextField } from "../ui/field-input";
27
25
  import { expressionHint, renderExpressionEditor } from "../ui/expression-editor";
26
+ import { renderStatementEditor } from "./statement-editor";
27
+ import { livePreviewExpression } from "../services/live-preview";
28
28
  import { renderMediaPicker } from "../ui/media-picker";
29
+ import { renderOnly } from "../store";
30
+ import { registerFormControl, renderForm } from "../ui/schema-form";
31
+ import { resolveContextPointer } from "../services/context-resolver";
32
+ import type { JsonSchema } from "../ui/schema-form";
29
33
  import type { TabUi } from "../tabs/tab";
30
34
  import type {
31
35
  CemEvent,
32
36
  CemParameter,
33
37
  JxMutableNode,
38
+ JxStatement,
34
39
  JxStateDefinition,
35
40
  } from "@jxsuite/schema/types";
36
41
  import { fetchPluginSchema, pluginSchemaCache } from "../services/code-services";
42
+ import { getExtensions, loadExtensions } from "../format/format-host";
37
43
  import type { TemplateResult } from "lit-html";
38
44
 
39
45
  interface SignalsPanelState {
@@ -52,30 +58,6 @@ interface SignalsPanelCtx {
52
58
  updateSession: (patch: Record<string, unknown>) => void;
53
59
  }
54
60
 
55
- interface JsonSchema {
56
- type?: string;
57
- properties?: Record<string, SchemaProperty>;
58
- required?: string[];
59
- description?: string;
60
- }
61
-
62
- interface SchemaProperty {
63
- type?: string;
64
- enum?: string[];
65
- default?: unknown;
66
- format?: string;
67
- minimum?: number;
68
- maximum?: number;
69
- description?: string;
70
- examples?: string[];
71
- name?: string;
72
- items?: {
73
- type?: string;
74
- properties?: Record<string, Record<string, unknown>>;
75
- required?: string[];
76
- };
77
- }
78
-
79
61
  export interface SignalDef {
80
62
  $prototype?: string;
81
63
  $src?: string;
@@ -86,7 +68,7 @@ export interface SignalDef {
86
68
  $handler?: string;
87
69
  type?: string;
88
70
  default?: unknown;
89
- body?: string;
71
+ body?: string | unknown[];
90
72
  parameters?: string[];
91
73
  timing?: string;
92
74
  description?: string;
@@ -158,6 +140,29 @@ const STUDIO_RESERVED_KEYS = new Set([
158
140
 
159
141
  // ─── Signals / defs helpers ──────────────────────────────────────────────────
160
142
 
143
+ /**
144
+ * Extension-manifest state classes for the add-state picker: plain `$prototype` targets (no
145
+ * admission blocks) across the enabled extensions, each with its `$studio.stateDefaults` hint
146
+ * (specs/extensions.md §10). Manifest classes need no `$src` — the registry resolves them.
147
+ */
148
+ export function extensionStateClasses(): {
149
+ name: string;
150
+ stateDefaults?: Record<string, unknown>;
151
+ }[] {
152
+ const out: { name: string; stateDefaults?: Record<string, unknown> }[] = [];
153
+ for (const ext of getExtensions()) {
154
+ for (const cls of ext.classes ?? []) {
155
+ if (cls.state) {
156
+ out.push({
157
+ name: cls.name,
158
+ ...(cls.stateDefaults === undefined ? {} : { stateDefaults: cls.stateDefaults }),
159
+ });
160
+ }
161
+ }
162
+ }
163
+ return out;
164
+ }
165
+
161
166
  /**
162
167
  * View a state entry through the panel's flattened editing lens. Naked primitive and array entries
163
168
  * surface as an empty view — the renderers guard every field access.
@@ -233,6 +238,9 @@ export function defHint(_name: string, def: SignalDef | null | undefined) {
233
238
  return expressionHint(def.$expression);
234
239
  }
235
240
  if (def.$prototype === "Function") {
241
+ if (Array.isArray(def.body)) {
242
+ return `${def.body.length} statement${def.body.length === 1 ? "" : "s"}`;
243
+ }
236
244
  if (def.body) {
237
245
  return def.body.length > 20 ? `${def.body.slice(0, 20)}...` : def.body;
238
246
  }
@@ -415,6 +423,10 @@ export function renderSignalsTemplate(S: SignalsPanelState, ctx: SignalsPanelCtx
415
423
  const defs = S.document.state || {};
416
424
  const entries = Object.entries(defs);
417
425
 
426
+ // Warm the extensions payload so manifest state classes appear in the add picker (the panel
427
+ // Re-renders constantly; loadExtensions memoizes, so this is a one-time fetch per project).
428
+ void loadExtensions();
429
+
418
430
  // Group by category
419
431
  const groups = {
420
432
  computed: [],
@@ -504,6 +516,29 @@ export function renderSignalsTemplate(S: SignalsPanelState, ctx: SignalsPanelCtx
504
516
  return;
505
517
  }
506
518
 
519
+ // Extension-manifest state classes ("ext:Session"): no $src needed — the registry
520
+ // Resolves them; the descriptor's stateDefaults seed the def (e.g. timing "client").
521
+ if (type.startsWith("ext:")) {
522
+ const protoName = type.slice(4);
523
+ const cls = extensionStateClasses().find((c) => c.name === protoName);
524
+ let n = `$${protoName.charAt(0).toLowerCase()}${protoName.slice(1)}`;
525
+ let i = 1;
526
+ const base = n;
527
+ while (S.document.state && S.document.state[n]) {
528
+ n = base + i;
529
+ i += 1;
530
+ }
531
+ transactDoc(activeTab.value, (t) =>
532
+ mutateAddDef(t, n, {
533
+ $prototype: protoName,
534
+ ...cls?.stateDefaults,
535
+ } as Record<string, JsonValue>),
536
+ );
537
+ expandedSignal = n;
538
+ ctx.renderLeftPanel();
539
+ return;
540
+ }
541
+
507
542
  // Handle import-based prototypes (e.g., "import:ContentCollection")
508
543
  if (type.startsWith("import:")) {
509
544
  const protoName = type.slice(7);
@@ -576,6 +611,11 @@ export function renderSignalsTemplate(S: SignalsPanelState, ctx: SignalsPanelCtx
576
611
  projectState.projectConfig.imports,
577
612
  ).map((k: string) => html`<sp-menu-item value="import:${k}">${k}</sp-menu-item>`)}`
578
613
  : nothing}
614
+ ${extensionStateClasses().length > 0
615
+ ? html`<sp-menu-divider></sp-menu-divider>${extensionStateClasses().map(
616
+ (cls) => html`<sp-menu-item value="ext:${cls.name}">${cls.name}</sp-menu-item>`,
617
+ )}`
618
+ : nothing}
579
619
  <sp-menu-divider></sp-menu-divider>
580
620
  <sp-menu-item value="expression">Expression</sp-menu-item>
581
621
  <sp-menu-item value="function">Function</sp-menu-item>
@@ -786,6 +826,22 @@ function renderSignalEditorTemplate(
786
826
  } else if (cat === "expression") {
787
827
  const exprNode = def.$expression || { operator: "=", target: null };
788
828
  fields = html`
829
+ <div style="display:flex;align-items:center;gap:4px">
830
+ <span class="field-label" style="flex:1">Expression</span>
831
+ <sp-action-button
832
+ size="xs"
833
+ quiet
834
+ title="Open in formula workspace"
835
+ @click=${() => {
836
+ ctx.updateSession({
837
+ ui: { editingFormula: { defName: name, type: "def" } },
838
+ });
839
+ ctx.renderCanvas();
840
+ }}
841
+ >
842
+ <sp-icon-full-screen slot="icon"></sp-icon-full-screen>
843
+ </sp-action-button>
844
+ </div>
789
845
  ${renderExpressionEditor(
790
846
  exprNode,
791
847
  (newNode: unknown) =>
@@ -795,7 +851,17 @@ function renderSignalEditorTemplate(
795
851
  ),
796
852
  {
797
853
  allowEventRef: false,
854
+ // Live-context evaluation in the canvas iframe, snapshot fallback (M6). The signals
855
+ // Panel lives in the left panel — re-render it when a fresh live result lands.
856
+ preview: livePreviewExpression(activeTab.value, `def:${name}`, exprNode, null, () =>
857
+ renderOnly("leftPanel"),
858
+ ),
859
+ onInsertDef: (defName, vendored) =>
860
+ transactDoc(activeTab.value, (t) =>
861
+ mutateAddDef(t, defName, vendored as Record<string, JsonValue>),
862
+ ),
798
863
  stateDefs: Object.keys(S.document.state || {}),
864
+ stateEntries: S.document.state || {},
799
865
  },
800
866
  )}
801
867
  `;
@@ -924,6 +990,42 @@ function renderFunctionFields(
924
990
  transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { description: v || undefined })),
925
991
  );
926
992
 
993
+ // Structured bodies (spec §20) are a body MODE of the function category, not a new entity:
994
+ // "Statements" renders the statement-card editor over `body: JxStatement[]`; "Code" is the
995
+ // Textarea/Monaco path over `body: string`. Switching modes replaces the body with the other
996
+ // Representation's empty seed — an explicit mode change, nothing is converted.
997
+ const bodyIsStatements = Array.isArray(def.body);
998
+ const bodyModeToggle = html`
999
+ <sp-action-group size="s" compact class="body-mode-toggle">
1000
+ <sp-action-button
1001
+ size="s"
1002
+ class="body-mode-statements"
1003
+ ?selected=${bodyIsStatements}
1004
+ @click=${() => {
1005
+ if (!bodyIsStatements) {
1006
+ transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { body: [] }));
1007
+ ctx.renderLeftPanel();
1008
+ }
1009
+ }}
1010
+ >
1011
+ Statements
1012
+ </sp-action-button>
1013
+ <sp-action-button
1014
+ size="s"
1015
+ class="body-mode-code"
1016
+ ?selected=${!bodyIsStatements}
1017
+ @click=${() => {
1018
+ if (bodyIsStatements) {
1019
+ transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { body: "" }));
1020
+ ctx.renderLeftPanel();
1021
+ }
1022
+ }}
1023
+ >
1024
+ Code
1025
+ </sp-action-button>
1026
+ </sp-action-group>
1027
+ `;
1028
+
927
1029
  const bodyField = def.$src
928
1030
  ? html`
929
1031
  ${signalFieldRow("Source", def.$src || "", (v: string) =>
@@ -938,29 +1040,52 @@ function renderFunctionFields(
938
1040
  : html`
939
1041
  <div style="display:flex;align-items:center;gap:4px">
940
1042
  <span class="field-label" style="flex:1">Body</span>
941
- <sp-action-button
942
- size="xs"
943
- quiet
944
- title="Open in code editor"
945
- @click=${() => {
946
- ctx.updateSession({
947
- ui: { editingFunction: { defName: name, type: "def" } },
948
- });
949
- ctx.renderCanvas();
950
- }}
951
- >
952
- <sp-icon-code slot="icon"></sp-icon-code>
953
- </sp-action-button>
1043
+ ${bodyModeToggle}
1044
+ ${bodyIsStatements
1045
+ ? nothing
1046
+ : html`
1047
+ <sp-action-button
1048
+ size="xs"
1049
+ quiet
1050
+ title="Open in code editor"
1051
+ @click=${() => {
1052
+ ctx.updateSession({
1053
+ ui: { editingFunction: { defName: name, type: "def" } },
1054
+ });
1055
+ ctx.renderCanvas();
1056
+ }}
1057
+ >
1058
+ <sp-icon-code slot="icon"></sp-icon-code>
1059
+ </sp-action-button>
1060
+ `}
954
1061
  </div>
955
- <textarea
956
- class="field-input"
957
- style="min-height:60px;font-family:var(--font-mono);font-size:var(--spectrum-font-size-50, 11px)"
958
- .value=${def.body || ""}
959
- @input=${(e: Event) => {
960
- const v = (e.target as HTMLInputElement).value;
961
- transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { body: v }));
962
- }}
963
- ></textarea>
1062
+ ${bodyIsStatements
1063
+ ? renderStatementEditor(
1064
+ def.body as JxStatement[],
1065
+ (next) => {
1066
+ transactDoc(activeTab.value, (t) =>
1067
+ mutateUpdateDef(t, name, { body: next as unknown as JsonValue }),
1068
+ );
1069
+ ctx.renderLeftPanel();
1070
+ },
1071
+ {
1072
+ allowEventRef: true,
1073
+ emits: def.emits ?? [],
1074
+ stateDefs: Object.keys(S.document.state || {}),
1075
+ stateEntries: S.document.state || {},
1076
+ },
1077
+ )
1078
+ : html`
1079
+ <textarea
1080
+ class="field-input"
1081
+ style="min-height:60px;font-family:var(--font-mono);font-size:var(--spectrum-font-size-50, 11px)"
1082
+ .value=${typeof def.body === "string" ? def.body : ""}
1083
+ @input=${(e: Event) => {
1084
+ const v = (e.target as HTMLInputElement).value;
1085
+ transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { body: v }));
1086
+ }}
1087
+ ></textarea>
1088
+ `}
964
1089
  `;
965
1090
 
966
1091
  return html`
@@ -1234,137 +1359,27 @@ function renderEmitsEditorTemplate(S: SignalsPanelState, name: string, def: Sign
1234
1359
  // ─── Plugin schema-driven form rendering ────────────────────────────────────
1235
1360
 
1236
1361
  /**
1237
- * Resolve a schema enum value. Handles: - Plain arrays (pass through) - `$ref` objects pointing to
1238
- * `#/$context/contentTypes` (resolves to project content type keys) - `$ref` objects pointing to
1239
- * `#/$context/contentTypes/{@param}/schema/properties` (dependent enum) - Legacy `"$contentTypes"`
1240
- * string sentinel (deprecated)
1362
+ * Resolve a schema context pointer for signal config forms a thin wrapper over the generic
1363
+ * `resolveContextPointer` making the content-type roots always-present (a missing section resolves
1364
+ * to `{}` empty choices rather than a plain textfield): the parser's real descriptors point at
1365
+ * the `#/$context/content` root, while the deprecated `"$contentTypes"` string sentinel and the
1366
+ * legacy `#/$context/contentTypes` root keep resolving bit-for-bit for old class descriptors.
1241
1367
  *
1242
- * @param {unknown} enumDef
1243
- * @param {Record<string, unknown>} [parentDef] - Parent def for resolving dependent refs
1244
- * @returns {string[] | undefined}
1368
+ * @param {string} pointer
1369
+ * @param {Record<string, unknown>} [scope] - Parent def for `{@param}` substitution
1370
+ * @returns {unknown}
1245
1371
  */
1246
- function resolveSchemaEnum(
1247
- enumDef: unknown,
1248
- parentDef?: Record<string, unknown>,
1249
- ): string[] | undefined {
1250
- if (Array.isArray(enumDef)) {
1251
- return enumDef;
1372
+ function resolveSignalsContextPointer(pointer: string, scope?: Record<string, unknown>): unknown {
1373
+ if (pointer === "#/$context/content") {
1374
+ return projectState?.projectConfig?.content ?? {};
1252
1375
  }
1253
- if (enumDef && typeof enumDef === "object") {
1254
- const ref = (enumDef as Record<string, unknown>).$ref;
1255
- if (ref === "#/$context/contentTypes") {
1256
- return Object.keys(projectState?.projectConfig?.contentTypes ?? {});
1257
- }
1258
- if (typeof ref === "string" && ref.startsWith("#/$context/contentTypes/{@")) {
1259
- const match = ref.match(/#\/\$context\/contentTypes\/\{@(\w+)\}\/schema\/properties/);
1260
- if (match && parentDef) {
1261
- const [, paramName] = match;
1262
- const typeName = parentDef[paramName!] as string | undefined;
1263
- if (typeName) {
1264
- const ct = projectState?.projectConfig?.contentTypes?.[typeName] as
1265
- | Record<string, unknown>
1266
- | undefined;
1267
- const schema = ct?.schema as Record<string, unknown> | undefined;
1268
- const props = schema?.properties as Record<string, unknown> | undefined;
1269
- if (props) {
1270
- return Object.keys(props);
1271
- }
1272
- }
1273
- }
1274
- return undefined;
1275
- }
1376
+ if (pointer === "$contentTypes" || pointer === "#/$context/contentTypes") {
1377
+ return projectState?.projectConfig?.contentTypes ?? {};
1276
1378
  }
1277
- if (enumDef === "$contentTypes") {
1278
- return Object.keys(projectState?.projectConfig?.contentTypes ?? {});
1279
- }
1280
- return undefined;
1281
- }
1282
-
1283
- /**
1284
- * Render a single inline field within an array-of-objects row. Dispatches by schema type: enum →
1285
- * picker, boolean → switch, number → number-field, else → textfield.
1286
- *
1287
- * @param {string} key
1288
- * @param {Record<string, unknown>} schema
1289
- * @param {unknown} value
1290
- * @param {(val: unknown) => void} onChange
1291
- * @param {Record<string, unknown>} [parentDef] - Parent def for resolving dependent enum refs
1292
- */
1293
- /** Parse a numeric field value, returning NaN for blank input (so callers can treat it as unset). */
1294
- function parseNumericField(raw: string, integer: boolean): number {
1295
- if (raw.trim() === "") {
1296
- return Number.NaN;
1297
- }
1298
- return integer ? Math.trunc(Number(raw)) : Number(raw);
1299
- }
1300
-
1301
- function renderInlineField(
1302
- key: string,
1303
- schema: Record<string, unknown>,
1304
- value: unknown,
1305
- onChange: (val: unknown) => void,
1306
- parentDef?: Record<string, unknown>,
1307
- ) {
1308
- if (isRef(value)) {
1309
- return html`<sp-textfield
1310
- size="s"
1311
- label=${key}
1312
- placeholder=${key}
1313
- .value=${live(value.$ref)}
1314
- @change=${(e: Event) => {
1315
- const v = (e.target as HTMLInputElement).value.trim();
1316
- onChange(v ? { $ref: v } : undefined);
1317
- }}
1318
- ></sp-textfield>`;
1319
- }
1320
- const enumValues = resolveSchemaEnum(schema.enum, parentDef);
1321
-
1322
- if (enumValues) {
1323
- return html`<sp-picker
1324
- size="s"
1325
- label=${key}
1326
- value=${value !== undefined ? String(value) : "__none__"}
1327
- @change=${(e: Event) =>
1328
- onChange(
1329
- (e.target as HTMLInputElement).value === "__none__"
1330
- ? undefined
1331
- : (e.target as HTMLInputElement).value,
1332
- )}
1333
- >
1334
- <sp-menu-item value="__none__">—</sp-menu-item>
1335
- ${enumValues.map((v: string) => html`<sp-menu-item value=${v}>${v}</sp-menu-item>`)}
1336
- </sp-picker>`;
1337
- }
1338
- if (schema.type === "boolean") {
1339
- return html`<sp-switch
1340
- size="s"
1341
- ?checked=${Boolean(value)}
1342
- @change=${(e: Event) => onChange((e.target as HTMLInputElement).checked)}
1343
- >${key}</sp-switch
1344
- >`;
1345
- }
1346
- if (schema.type === "integer" || schema.type === "number") {
1347
- return html`<sp-number-field
1348
- size="s"
1349
- label=${key}
1350
- .value=${value !== undefined ? value : nothing}
1351
- step=${schema.type === "integer" ? "1" : nothing}
1352
- @change=${(e: Event) => {
1353
- const parsed = parseNumericField(
1354
- (e.target as HTMLInputElement).value,
1355
- schema.type === "integer",
1356
- );
1357
- onChange(Number.isNaN(parsed) ? undefined : parsed);
1358
- }}
1359
- ></sp-number-field>`;
1360
- }
1361
- return html`<sp-textfield
1362
- size="s"
1363
- label=${key}
1364
- placeholder=${key}
1365
- .value=${value ?? ""}
1366
- @input=${(e: Event) => onChange((e.target as HTMLInputElement).value || undefined)}
1367
- ></sp-textfield>`;
1379
+ return resolveContextPointer(pointer, {
1380
+ projectConfig: (projectState?.projectConfig ?? {}) as Record<string, unknown>,
1381
+ ...(scope !== undefined && { scope }),
1382
+ });
1368
1383
  }
1369
1384
 
1370
1385
  /**
@@ -1424,39 +1439,22 @@ function renderBindingControl(opts: {
1424
1439
  `;
1425
1440
  }
1426
1441
 
1427
- /** Render a debounced multiline JSON text field for array/object schema properties. */
1428
- function renderJsonTextField(
1429
- currentValue: unknown,
1430
- ps: SchemaProperty,
1431
- name: string,
1432
- prop: string,
1433
- ) {
1434
- /** @type {ReturnType<typeof setTimeout> | undefined} */
1435
- let debounce: ReturnType<typeof setTimeout> | undefined;
1436
- return html`<sp-textfield
1437
- multiline
1438
- size="s"
1439
- style="min-height:40px"
1440
- .value=${currentValue !== undefined ? JSON.stringify(currentValue, null, 2) : ""}
1441
- placeholder=${ps.default !== undefined ? JSON.stringify(ps.default) : nothing}
1442
- @input=${(e: Event) => {
1443
- clearTimeout(debounce);
1444
- debounce = setTimeout(() => {
1445
- try {
1446
- transactDoc(activeTab.value, (t) =>
1447
- mutateUpdateDef(t, name, {
1448
- [prop]: JSON.parse((e.target as HTMLInputElement).value) as unknown,
1449
- }),
1450
- );
1451
- } catch {}
1452
- }, 500);
1453
- }}
1454
- ></sp-textfield>`;
1455
- }
1442
+ // The "binding" control registers here rather than in ui/form-controls.ts because it owns
1443
+ // Panel-local ephemeral UI state (bindingCustomOpen) and the route-param picker semantics.
1444
+ registerFormControl("binding", ({ key, value, onChange, ctx, rerender }) =>
1445
+ renderBindingControl({
1446
+ commit: onChange,
1447
+ fieldKey: `${ctx.fieldKeyPrefix ?? ""}.${key}`,
1448
+ params: ctx.params ?? [],
1449
+ refVal: isRef(value) ? value.$ref : "",
1450
+ rerender,
1451
+ }),
1452
+ );
1456
1453
 
1457
1454
  /**
1458
- * Render config form fields from a JSON Schema `properties` object. Maps schema types to
1459
- * appropriate form controls.
1455
+ * Render config form fields from a JSON Schema `properties` object a thin wrapper over the shared
1456
+ * schema-form engine. Skips studio-reserved keys, resolves enum/context refs against the project
1457
+ * config, and commits every patch through transactDoc/mutateUpdateDef.
1460
1458
  */
1461
1459
  export function renderSchemaFieldsTemplate(
1462
1460
  schema: JsonSchema | null | undefined,
@@ -1469,260 +1467,19 @@ export function renderSchemaFieldsTemplate(
1469
1467
  return nothing;
1470
1468
  }
1471
1469
 
1472
- const required = new Set(schema.required);
1473
- const params = dynamicRouteParams(S.documentPath);
1474
-
1475
- const propertyFields = Object.entries(schema.properties)
1476
- .filter(([prop]) => !STUDIO_RESERVED_KEYS.has(prop))
1477
- .map(([prop, ps]) => {
1478
- const currentValue = def[prop];
1479
- const labelText = prop + (required.has(prop) ? " *" : "");
1480
-
1481
- let control;
1482
- const enumValues = resolveSchemaEnum(ps.enum, def);
1483
- if (
1484
- isRef(currentValue) &&
1485
- ps.format !== "json-schema" &&
1486
- ps.type !== "object" &&
1487
- ps.type !== "array"
1488
- ) {
1489
- control = renderBindingControl({
1490
- refVal: currentValue.$ref,
1491
- params,
1492
- fieldKey: `${name}.${prop}`,
1493
- commit: (next) =>
1494
- transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { [prop]: next })),
1495
- rerender: ctx ? () => ctx.renderLeftPanel() : undefined,
1496
- });
1497
- } else if (enumValues) {
1498
- control = html`
1499
- <sp-picker
1500
- size="s"
1501
- value=${currentValue !== undefined
1502
- ? String(currentValue)
1503
- : ps.default !== undefined
1504
- ? String(ps.default)
1505
- : "__none__"}
1506
- @change=${(e: Event) =>
1507
- transactDoc(activeTab.value, (t) =>
1508
- mutateUpdateDef(t, name, {
1509
- [prop]:
1510
- (e.target as HTMLInputElement).value === "__none__"
1511
- ? undefined
1512
- : (e.target as HTMLInputElement).value,
1513
- }),
1514
- )}
1515
- >
1516
- ${!required.has(prop) ? html`<sp-menu-item value="__none__">—</sp-menu-item>` : nothing}
1517
- ${enumValues.map(
1518
- (val: string) => html`<sp-menu-item value=${val}>${val}</sp-menu-item>`,
1519
- )}
1520
- </sp-picker>
1521
- `;
1522
- } else if (ps.type === "boolean") {
1523
- control = html`<sp-checkbox
1524
- ?checked=${currentValue ?? ps.default ?? false}
1525
- @change=${(e: Event) =>
1526
- transactDoc(activeTab.value, (t) =>
1527
- mutateUpdateDef(t, name, {
1528
- [prop]: (e.target as HTMLInputElement).checked,
1529
- }),
1530
- )}
1531
- ></sp-checkbox>`;
1532
- } else if (ps.type === "integer" || ps.type === "number") {
1533
- /** @type {ReturnType<typeof setTimeout> | undefined} */
1534
- let debounce: ReturnType<typeof setTimeout> | undefined;
1535
- control = html`<sp-number-field
1536
- size="s"
1537
- min=${ifDefined(ps.minimum)}
1538
- max=${ifDefined(ps.maximum)}
1539
- step=${ps.type === "integer" ? "1" : nothing}
1540
- .value=${currentValue !== undefined ? currentValue : nothing}
1541
- placeholder=${ps.default != null ? String(ps.default) : nothing}
1542
- @change=${(e: Event) => {
1543
- clearTimeout(debounce);
1544
- debounce = setTimeout(() => {
1545
- const parsed = parseNumericField(
1546
- (e.target as HTMLInputElement).value,
1547
- ps.type === "integer",
1548
- );
1549
- transactDoc(activeTab.value, (t) =>
1550
- mutateUpdateDef(t, name, {
1551
- [prop]: Number.isNaN(parsed) ? undefined : parsed,
1552
- }),
1553
- );
1554
- }, 400);
1555
- }}
1556
- ></sp-number-field>`;
1557
- } else if (ps.format === "json-schema") {
1558
- const hasValue =
1559
- currentValue && typeof currentValue === "object" && Object.keys(currentValue).length > 0;
1560
- const cv = currentValue as Record<string, unknown>;
1561
- const isSchemaRef = hasValue && cv.$ref;
1562
- /** @type {ReturnType<typeof setTimeout> | undefined} */
1563
- let debounce: ReturnType<typeof setTimeout> | undefined;
1564
- control = html`
1565
- <div class="schema-param-editor">
1566
- ${hasValue && !isSchemaRef && cv.properties
1567
- ? html`
1568
- <div style="display:flex;flex-wrap:wrap;gap:3px;margin-bottom:4px">
1569
- ${Object.entries(cv.properties as Record<string, Record<string, unknown>>).map(
1570
- ([k, v]) => html`
1571
- <span
1572
- style="background:var(--bg);padding:1px 6px;border-radius:var(--radius);font-size:10px;color:var(--fg-dim)"
1573
- >${k}: ${v.type ?? "any"}</span
1574
- >
1575
- `,
1576
- )}
1577
- </div>
1578
- `
1579
- : nothing}
1580
- <sp-textfield
1581
- multiline
1582
- size="s"
1583
- style=${styleMap({
1584
- fontFamily: "monospace",
1585
- fontSize: "11px",
1586
- minHeight: hasValue ? "80px" : "40px",
1587
- })}
1588
- .value=${currentValue !== undefined ? JSON.stringify(currentValue, null, 2) : ""}
1589
- placeholder=${ps.description ?? "JSON Schema defining the data shape\u2026"}
1590
- @input=${(e: Event) => {
1591
- clearTimeout(debounce);
1592
- debounce = setTimeout(() => {
1593
- try {
1594
- transactDoc(activeTab.value, (t) =>
1595
- mutateUpdateDef(t, name, {
1596
- [prop]: JSON.parse((e.target as HTMLInputElement).value) as unknown,
1597
- }),
1598
- );
1599
- } catch {}
1600
- }, 500);
1601
- }}
1602
- ></sp-textfield>
1603
- </div>
1604
- `;
1605
- } else if (ps.type === "array" && ps.items?.type === "object" && ps.items?.properties) {
1606
- // Array of objects with defined schema → multi-row inline form
1607
- const rows: Record<string, unknown>[] = Array.isArray(currentValue)
1608
- ? (currentValue as Record<string, unknown>[])
1609
- : [];
1610
- const itemProps = ps.items.properties as Record<string, Record<string, unknown>>;
1611
- control = html`
1612
- <div class="array-object-field">
1613
- ${rows.map(
1614
- (row: Record<string, unknown>, idx: number) => html`
1615
- <div
1616
- class="array-object-row"
1617
- style="display:flex;gap:4px;align-items:center;margin-bottom:4px"
1618
- >
1619
- ${Object.entries(itemProps).map(([propKey, propSchema]) =>
1620
- renderInlineField(
1621
- propKey,
1622
- propSchema,
1623
- row[propKey],
1624
- (val) => {
1625
- const updated = [...rows];
1626
- updated[idx] = { ...updated[idx], [propKey]: val };
1627
- transactDoc(activeTab.value, (t) =>
1628
- mutateUpdateDef(t, name, { [prop]: updated }),
1629
- );
1630
- },
1631
- def,
1632
- ),
1633
- )}
1634
- <sp-action-button
1635
- quiet
1636
- size="s"
1637
- @click=${() => {
1638
- const updated = rows.filter((_: unknown, i: number) => i !== idx);
1639
- transactDoc(activeTab.value, (t) =>
1640
- mutateUpdateDef(t, name, {
1641
- [prop]: updated.length > 0 ? updated : undefined,
1642
- }),
1643
- );
1644
- ctx?.renderLeftPanel();
1645
- }}
1646
- >
1647
- <sp-icon-delete slot="icon"></sp-icon-delete>
1648
- </sp-action-button>
1649
- </div>
1650
- `,
1651
- )}
1652
- <sp-action-button
1653
- quiet
1654
- size="s"
1655
- @click=${(e: Event) => {
1656
- e.stopPropagation();
1657
- const newRow: Record<string, unknown> = {};
1658
- for (const [k, v] of Object.entries(itemProps)) {
1659
- if ((v as Record<string, unknown>).default !== undefined) {
1660
- newRow[k] = (v as Record<string, unknown>).default;
1661
- }
1662
- }
1663
- transactDoc(activeTab.value, (t) =>
1664
- mutateUpdateDef(t, name, { [prop]: [...rows, newRow] }),
1665
- );
1666
- ctx?.renderLeftPanel();
1667
- }}
1668
- >+ Add</sp-action-button
1669
- >
1670
- </div>
1671
- `;
1672
- } else if (ps.type === "array" || ps.type === "object") {
1673
- control = renderJsonTextField(currentValue, ps, name, prop);
1674
- } else {
1675
- /** @type {ReturnType<typeof setTimeout> | undefined} */
1676
- let debounce: ReturnType<typeof setTimeout> | undefined;
1677
- const ph = ps.default !== undefined ? String(ps.default) : (ps.examples?.[0] ?? "");
1678
- control = html`<div style="display:flex;gap:4px;align-items:center">
1679
- <sp-textfield
1680
- size="s"
1681
- style="flex:1"
1682
- .value=${currentValue ?? ""}
1683
- placeholder=${ph || nothing}
1684
- title=${ps.description || nothing}
1685
- @input=${(e: Event) => {
1686
- clearTimeout(debounce);
1687
- debounce = setTimeout(
1688
- () =>
1689
- transactDoc(activeTab.value, (t) =>
1690
- mutateUpdateDef(t, name, {
1691
- [prop]: (e.target as HTMLInputElement).value || undefined,
1692
- }),
1693
- ),
1694
- 400,
1695
- );
1696
- }}
1697
- ></sp-textfield>
1698
- ${params.length > 0
1699
- ? html`<sp-action-button
1700
- quiet
1701
- size="s"
1702
- title="Bind to route param"
1703
- @click=${() => {
1704
- transactDoc(activeTab.value, (t) =>
1705
- mutateUpdateDef(t, name, {
1706
- [prop]: { $ref: `#/$params/${params[0]}` },
1707
- }),
1708
- );
1709
- ctx?.renderLeftPanel();
1710
- }}
1711
- ><sp-icon-link slot="icon"></sp-icon-link
1712
- ></sp-action-button>`
1713
- : nothing}
1714
- </div>`;
1715
- }
1716
-
1717
- return renderFieldRow({
1718
- hasValue: false,
1719
- label: labelText,
1720
- prop: ps.name || prop,
1721
- widget: control,
1722
- });
1723
- });
1470
+ const properties = Object.fromEntries(
1471
+ Object.entries(schema.properties).filter(([prop]) => !STUDIO_RESERVED_KEYS.has(prop)),
1472
+ );
1724
1473
 
1725
- return html`${propertyFields}`;
1474
+ return renderForm({ ...schema, properties }, def as Record<string, unknown>, {
1475
+ context: {
1476
+ fieldKeyPrefix: name,
1477
+ params: dynamicRouteParams(S.documentPath),
1478
+ resolvePointer: resolveSignalsContextPointer,
1479
+ },
1480
+ onChange: (patch) => transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, patch)),
1481
+ ...(ctx && { rerender: () => ctx.renderLeftPanel() }),
1482
+ });
1726
1483
  }
1727
1484
 
1728
1485
  /**