@jxsuite/studio 0.37.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/iframe-entry.js +622 -49
  2. package/dist/iframe-entry.js.map +15 -12
  3. package/dist/studio.css +1333 -0
  4. package/dist/studio.js +103709 -81001
  5. package/dist/studio.js.map +201 -157
  6. package/package.json +47 -45
  7. package/src/account-status.ts +39 -0
  8. package/src/browse/browse.ts +10 -14
  9. package/src/canvas/canvas-live-render.ts +21 -2
  10. package/src/canvas/canvas-render.ts +35 -16
  11. package/src/canvas/canvas-utils.ts +118 -72
  12. package/src/canvas/iframe-entry.ts +20 -0
  13. package/src/canvas/iframe-eval.ts +155 -0
  14. package/src/canvas/iframe-host.ts +132 -6
  15. package/src/canvas/iframe-inline-edit.ts +107 -1
  16. package/src/canvas/iframe-protocol.ts +43 -3
  17. package/src/canvas/iframe-render.ts +18 -0
  18. package/src/collab/collab-session.ts +23 -8
  19. package/src/collab/collab-state.ts +6 -3
  20. package/src/component-props.ts +104 -0
  21. package/src/editor/context-menu.ts +1 -1
  22. package/src/editor/inline-edit-apply.ts +36 -1
  23. package/src/editor/inline-edit.ts +58 -1
  24. package/src/editor/repeater-scope.ts +8 -13
  25. package/src/editor/shortcuts.ts +41 -12
  26. package/src/files/file-ops.ts +14 -0
  27. package/src/files/files.ts +56 -1
  28. package/src/format/format-host.ts +63 -1
  29. package/src/grid/cell-editors.ts +288 -0
  30. package/src/grid/cell-popovers.ts +108 -0
  31. package/src/grid/csv-codec.ts +122 -0
  32. package/src/grid/edit-buffer.ts +490 -0
  33. package/src/grid/grid-controller.ts +417 -0
  34. package/src/grid/grid-layout.ts +83 -0
  35. package/src/grid/grid-open.ts +167 -0
  36. package/src/grid/grid-panel.ts +302 -0
  37. package/src/grid/grid-source.ts +210 -0
  38. package/src/grid/grid-view.ts +367 -0
  39. package/src/grid/schema-columns.ts +261 -0
  40. package/src/grid/sources/connector-source.ts +217 -0
  41. package/src/grid/sources/content-source.ts +542 -0
  42. package/src/grid/sources/csv-file-source.ts +247 -0
  43. package/src/grid/tabulator-tables.d.ts +120 -0
  44. package/src/new-project/add-repo-modal.ts +183 -0
  45. package/src/new-project/new-project-modal.ts +22 -3
  46. package/src/page-params.ts +34 -8
  47. package/src/panels/ai-chat/chat-markdown.ts +2 -2
  48. package/src/panels/ai-panel.ts +61 -4
  49. package/src/panels/block-action-bar.ts +8 -3
  50. package/src/panels/chat-panel.ts +98 -0
  51. package/src/panels/data-grid.ts +241 -0
  52. package/src/panels/editors.ts +43 -17
  53. package/src/panels/events-panel.ts +137 -44
  54. package/src/panels/formula-workspace.ts +379 -0
  55. package/src/panels/frontmatter-fields.ts +231 -0
  56. package/src/panels/frontmatter-panel.ts +132 -0
  57. package/src/panels/git-panel.ts +1 -1
  58. package/src/panels/head-panel.ts +11 -175
  59. package/src/panels/properties-panel.ts +154 -144
  60. package/src/panels/right-panel.ts +9 -47
  61. package/src/panels/signals-panel.ts +217 -460
  62. package/src/panels/statement-editor.ts +710 -0
  63. package/src/panels/statusbar.ts +1 -1
  64. package/src/panels/style-panel.ts +25 -1
  65. package/src/panels/stylebook-panel.ts +0 -2
  66. package/src/panels/tab-bar.ts +175 -6
  67. package/src/panels/tab-strip.ts +62 -6
  68. package/src/panels/toolbar.ts +37 -3
  69. package/src/panels/welcome-screen.ts +50 -0
  70. package/src/platform-errors.ts +30 -0
  71. package/src/platforms/cloud.ts +172 -89
  72. package/src/platforms/devserver.ts +172 -0
  73. package/src/services/ai-project-tools.ts +541 -0
  74. package/src/services/ai-session-store.ts +28 -0
  75. package/src/services/ai-system-prompt.ts +194 -24
  76. package/src/services/ai-tools.ts +88 -21
  77. package/src/services/automation.ts +16 -0
  78. package/src/services/context-resolver.ts +73 -0
  79. package/src/services/data-service.ts +155 -0
  80. package/src/services/document-assistant.ts +81 -11
  81. package/src/services/gated-registry.ts +72 -0
  82. package/src/services/live-preview.ts +0 -0
  83. package/src/services/monaco-setup.ts +75 -26
  84. package/src/services/preview-eval.ts +68 -0
  85. package/src/services/project-adoption.ts +31 -0
  86. package/src/settings/contributed-section.ts +406 -0
  87. package/src/settings/extension-sections.ts +145 -0
  88. package/src/settings/schema-field-ui.ts +4 -2
  89. package/src/settings/settings-modal.ts +101 -42
  90. package/src/site-context.ts +12 -1
  91. package/src/store.ts +4 -0
  92. package/src/studio.ts +107 -52
  93. package/src/tabs/patch-ops.ts +25 -0
  94. package/src/tabs/tab.ts +39 -1
  95. package/src/tabs/transact.ts +61 -14
  96. package/src/types.ts +132 -1
  97. package/src/ui/dynamic-slot.ts +272 -0
  98. package/src/ui/expression-editor.ts +423 -125
  99. package/src/ui/field-row.ts +5 -0
  100. package/src/ui/form-controls.ts +322 -0
  101. package/src/ui/formula-catalog.ts +557 -0
  102. package/src/ui/formula-chips.ts +216 -0
  103. package/src/ui/formula-palette.ts +211 -0
  104. package/src/ui/layers.ts +40 -0
  105. package/src/ui/media-picker.ts +1 -1
  106. package/src/ui/panel-resize.ts +15 -1
  107. package/src/ui/progress-modal.ts +2 -2
  108. package/src/ui/schema-form.ts +524 -0
  109. package/src/utils/preview-format.ts +26 -0
  110. package/src/utils/studio-utils.ts +4 -3
  111. package/src/view.ts +4 -4
  112. package/src/workspace/workspace.ts +14 -0
  113. package/src/settings/content-types-editor.ts +0 -599
@@ -1,10 +1,12 @@
1
1
  /// <reference lib="dom" />
2
- import { getNodeAtPath } from "../store";
2
+ import { getNodeAtPath, renderOnly } from "../store";
3
3
  import { html, nothing } from "lit-html";
4
4
  import { live } from "lit-html/directives/live.js";
5
5
  import { activeTab } from "../workspace/workspace";
6
- import { mutateUpdateProperty, transactDoc } from "../tabs/transact";
6
+ import { mutateAddDef, mutateUpdateProperty, transactDoc } from "../tabs/transact";
7
7
  import { renderExpressionEditor } from "../ui/expression-editor";
8
+ import { renderStatementEditor } from "./statement-editor";
9
+ import { livePreviewExpression } from "../services/live-preview";
8
10
  import {
9
11
  getEventBinding,
10
12
  isExpressionDef,
@@ -171,53 +173,144 @@ export function eventsSidebarTemplate(helpers: { isCustomElementDoc: () => boole
171
173
  </div>
172
174
  ${inlineFn
173
175
  ? html`
174
- <div class="event-body-row">
175
- <sp-textfield
176
- size="s"
177
- multiline
178
- grows
179
- placeholder="// handler body"
180
- .value=${live(inlineFn?.body || "")}
181
- @input=${(e: Event) => {
182
- transactDoc(activeTab.value, (t) =>
183
- mutateUpdateProperty(t, selection, evKey, {
184
- $prototype: "Function",
185
- body: (e.target as HTMLInputElement).value,
186
- parameters: inlineFn?.parameters || [],
187
- }),
188
- );
189
- }}
190
- >
191
- </sp-textfield>
192
- <sp-action-button
193
- size="xs"
194
- quiet
195
- title="Open in editor"
196
- @click=${() => {
197
- tab.session.ui.editingFunction = {
198
- eventKey: evKey,
199
- path: selection,
200
- type: "event",
201
- };
202
- }}
203
- >
204
- <sp-icon-code slot="icon"></sp-icon-code>
205
- </sp-action-button>
176
+ <div class="event-body-mode" style="display:flex;justify-content:flex-end">
177
+ <sp-action-group size="s" compact class="body-mode-toggle">
178
+ <sp-action-button
179
+ size="s"
180
+ class="body-mode-statements"
181
+ ?selected=${Array.isArray(inlineFn.body)}
182
+ @click=${() => {
183
+ if (!Array.isArray(inlineFn.body)) {
184
+ transactDoc(activeTab.value, (t) =>
185
+ mutateUpdateProperty(t, selection, evKey, {
186
+ ...inlineFn,
187
+ body: [],
188
+ }),
189
+ );
190
+ }
191
+ }}
192
+ >
193
+ Statements
194
+ </sp-action-button>
195
+ <sp-action-button
196
+ size="s"
197
+ class="body-mode-code"
198
+ ?selected=${!Array.isArray(inlineFn.body)}
199
+ @click=${() => {
200
+ if (Array.isArray(inlineFn.body)) {
201
+ transactDoc(activeTab.value, (t) =>
202
+ mutateUpdateProperty(t, selection, evKey, {
203
+ ...inlineFn,
204
+ body: "",
205
+ }),
206
+ );
207
+ }
208
+ }}
209
+ >
210
+ Code
211
+ </sp-action-button>
212
+ </sp-action-group>
206
213
  </div>
207
- `
208
- : expression
209
- ? html`
210
- <div class="event-body-row">
211
- ${renderExpressionEditor(
212
- expression,
213
- (newNode: unknown) =>
214
+ ${Array.isArray(inlineFn.body)
215
+ ? renderStatementEditor(
216
+ inlineFn.body,
217
+ (next) =>
214
218
  transactDoc(activeTab.value, (t) =>
215
219
  mutateUpdateProperty(t, selection, evKey, {
216
- $expression: newNode,
220
+ ...inlineFn,
221
+ body: next,
217
222
  }),
218
223
  ),
219
- { allowEventRef: true, stateDefs: Object.keys(defs) },
220
- )}
224
+ {
225
+ allowEventRef: true,
226
+ emits: inlineFn.emits ?? [],
227
+ stateDefs: Object.keys(defs),
228
+ stateEntries: defs,
229
+ },
230
+ )
231
+ : html`
232
+ <div class="event-body-row">
233
+ <sp-textfield
234
+ size="s"
235
+ multiline
236
+ grows
237
+ placeholder="// handler body"
238
+ .value=${live(typeof inlineFn.body === "string" ? inlineFn.body : "")}
239
+ @input=${(e: Event) => {
240
+ transactDoc(activeTab.value, (t) =>
241
+ mutateUpdateProperty(t, selection, evKey, {
242
+ $prototype: "Function",
243
+ body: (e.target as HTMLInputElement).value,
244
+ parameters: inlineFn?.parameters || [],
245
+ }),
246
+ );
247
+ }}
248
+ >
249
+ </sp-textfield>
250
+ <sp-action-button
251
+ size="xs"
252
+ quiet
253
+ title="Open in editor"
254
+ @click=${() => {
255
+ tab.session.ui.editingFunction = {
256
+ eventKey: evKey,
257
+ path: selection,
258
+ type: "event",
259
+ };
260
+ }}
261
+ >
262
+ <sp-icon-code slot="icon"></sp-icon-code>
263
+ </sp-action-button>
264
+ </div>
265
+ `}
266
+ `
267
+ : expression
268
+ ? html`
269
+ <div class="event-body-row">
270
+ <div style="flex:1;min-width:0">
271
+ ${renderExpressionEditor(
272
+ expression,
273
+ (newNode: unknown) =>
274
+ transactDoc(activeTab.value, (t) =>
275
+ mutateUpdateProperty(t, selection, evKey, {
276
+ $expression: newNode,
277
+ }),
278
+ ),
279
+ {
280
+ allowEventRef: true,
281
+ // Live-context evaluation in the canvas iframe, snapshot fallback
282
+ // (M6). The selection path is the context, so a binding inside a
283
+ // Repeater template previews with the first item's $map scope.
284
+ preview: livePreviewExpression(
285
+ tab,
286
+ `event:${JSON.stringify(selection)}:${evKey}`,
287
+ expression,
288
+ selection,
289
+ () => renderOnly("rightPanel"),
290
+ ),
291
+ onInsertDef: (defName, def) =>
292
+ transactDoc(activeTab.value, (t) =>
293
+ mutateAddDef(t, defName, def as Record<string, JsonValue>),
294
+ ),
295
+ stateDefs: Object.keys(defs),
296
+ stateEntries: defs,
297
+ },
298
+ )}
299
+ </div>
300
+ <sp-action-button
301
+ size="xs"
302
+ quiet
303
+ title="Open in formula workspace"
304
+ @click=${() => {
305
+ tab.session.ui.editingFormula = {
306
+ eventKey: evKey,
307
+ path: selection,
308
+ type: "event",
309
+ };
310
+ }}
311
+ >
312
+ <sp-icon-full-screen slot="icon"></sp-icon-full-screen>
313
+ </sp-action-button>
221
314
  </div>
222
315
  `
223
316
  : html`
@@ -0,0 +1,379 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Formula workspace — the full-screen structured editing surface for a single `$expression` (spec
4
+ * §19.9, milestone M4). Takes over the canvas area exactly like the Monaco function editor
5
+ * (renderFunctionEditor): `TabUi.editingFormula` identifies the document position being edited (a
6
+ * state entry's `$expression` via defName, or an element event binding's via path + eventKey).
7
+ *
8
+ * Layout: the chip pipeline summarizes the whole tree with live value badges on top; the recursive
9
+ * expression form edits the chip-selected sub-node in the main pane; a data rail mirrors the canvas
10
+ * dataScope snapshot on the right; the footer reports the root result (or the evaluation error).
11
+ *
12
+ * Every edit immutably replaces the selected sub-node within the root and writes the WHOLE root
13
+ * node back through transactDoc — undo/redo and canvas/panel refresh come free.
14
+ */
15
+
16
+ import { html, render as litRender } from "lit-html";
17
+ import { getEventBinding, isExpressionDef, isJsonObject } from "@jxsuite/schema/guards";
18
+
19
+ import { canvasPanels, canvasWrap, getNodeAtPath, updateUi } from "../store";
20
+ import { activeTab } from "../workspace/workspace";
21
+ import { mutateAddDef, mutateUpdateDef, mutateUpdateProperty, transactDoc } from "../tabs/transact";
22
+ import { view } from "../view";
23
+ import { chipSummary, renderFormulaChips } from "../ui/formula-chips";
24
+ import { renderExpressionEditor } from "../ui/expression-editor";
25
+ import { applyCatalogPick, formulaCatalog } from "../ui/formula-catalog";
26
+ import { openFormulaPalette } from "../ui/formula-palette";
27
+ import { livePreviewExpression } from "../services/live-preview";
28
+ import { dataTypeLabel, renderDataTreeTemplate, unwrapSignal } from "./data-explorer";
29
+
30
+ import type { JxNodeValue } from "../tabs/transact";
31
+ import type { FormulaEditDef, JsonValue } from "../types";
32
+ import type { Tab } from "../tabs/tab";
33
+ import type { JxStateDefinition } from "@jxsuite/schema/types";
34
+ import type { TemplateResult } from "lit-html";
35
+
36
+ type NodePath = (string | number)[];
37
+
38
+ // ─── Module state ────────────────────────────────────────────────────────────
39
+
40
+ /** Chip-selected sub-node path within the root expression (root = []). */
41
+ let _selectedPath: NodePath = [];
42
+
43
+ /** Serialized editing target — the selection resets when the workspace retargets. */
44
+ let _targetKey: string | null = null;
45
+
46
+ /** Owning tab of the current target — a same-looking target in another tab is a retarget. */
47
+ let _targetTab: Tab | null = null;
48
+
49
+ function isExprNode(value: unknown): value is Record<string, unknown> {
50
+ return isJsonObject(value) && typeof value.operator === "string";
51
+ }
52
+
53
+ // ─── Document access ─────────────────────────────────────────────────────────
54
+
55
+ /** Read the workspace target's current root expression node from the document. */
56
+ export function formulaRoot(tab: Tab, editing: FormulaEditDef): Record<string, unknown> | null {
57
+ const { document } = tab.doc;
58
+ if (editing.type === "def" && editing.defName) {
59
+ const def = document?.state?.[editing.defName];
60
+ return isExpressionDef(def) ? (def.$expression as unknown as Record<string, unknown>) : null;
61
+ }
62
+ if (editing.type === "event" && editing.path && editing.eventKey) {
63
+ const node = getNodeAtPath(document, editing.path);
64
+ const binding = node ? getEventBinding(node, editing.eventKey) : undefined;
65
+ return isExpressionDef(binding)
66
+ ? (binding.$expression as unknown as Record<string, unknown>)
67
+ : null;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Resolve a chip selection to its editable node: the deepest expression-node prefix of `path` (head
74
+ * chips target ref/literal operands; stale paths fall back toward the root).
75
+ */
76
+ function resolveSelection(
77
+ root: Record<string, unknown>,
78
+ path: NodePath,
79
+ ): { node: Record<string, unknown>; path: NodePath } {
80
+ let current: unknown = root;
81
+ let node: Record<string, unknown> = root;
82
+ let nodePath: NodePath = [];
83
+ for (const [i, seg] of path.entries()) {
84
+ if (!current || typeof current !== "object") {
85
+ break;
86
+ }
87
+ current = (current as Record<string, unknown>)[seg as keyof typeof current];
88
+ if (isExprNode(current)) {
89
+ node = current;
90
+ nodePath = path.slice(0, i + 1);
91
+ }
92
+ }
93
+ return { node, path: nodePath };
94
+ }
95
+
96
+ /** Immutably replace the value at `path` within `node` (object keys / array indexes). */
97
+ function replaceAtPath(node: unknown, path: NodePath, value: unknown): unknown {
98
+ if (path.length === 0) {
99
+ return value;
100
+ }
101
+ const [head, ...rest] = path as [string | number, ...NodePath];
102
+ if (Array.isArray(node)) {
103
+ const copy = [...(node as unknown[])];
104
+ copy[Number(head)] = replaceAtPath(copy[Number(head)], rest, value);
105
+ return copy;
106
+ }
107
+ const base: Record<string, unknown> = isJsonObject(node) ? node : {};
108
+ return { ...base, [head]: replaceAtPath(base[head as string], rest, value) };
109
+ }
110
+
111
+ /** Write the whole updated root node back to the document position (one undo step). */
112
+ function writeRoot(tab: Tab, editing: FormulaEditDef, newRoot: unknown) {
113
+ if (editing.type === "def" && editing.defName) {
114
+ const { defName } = editing;
115
+ transactDoc(tab, (t) => mutateUpdateDef(t, defName, { $expression: newRoot as JsonValue }));
116
+ } else if (editing.type === "event" && editing.path && editing.eventKey) {
117
+ const { eventKey, path } = editing;
118
+ transactDoc(tab, (t) =>
119
+ mutateUpdateProperty(t, path, eventKey, { $expression: newRoot } as JxNodeValue),
120
+ );
121
+ }
122
+ renderFormulaWorkspace();
123
+ }
124
+
125
+ /** Close the workspace — clears `editingFormula`; the canvas re-renders its regular mode. */
126
+ export function closeFormulaWorkspace() {
127
+ updateUi("editingFormula", null);
128
+ }
129
+
130
+ // ─── Rendering ───────────────────────────────────────────────────────────────
131
+
132
+ const PANE_BORDER = "1px solid var(--spectrum-gray-200, #323232)";
133
+
134
+ const MONO = "font-family:var(--spectrum-code-font-family, monospace)";
135
+
136
+ /**
137
+ * Render the formula workspace into the canvas area. Mirrors renderFunctionEditor's takeover: the
138
+ * canvas DnD/event handlers and panel registrations are cleared and canvasWrap becomes a stretched
139
+ * column owned by this template.
140
+ */
141
+ export function renderFormulaWorkspace() {
142
+ const tab = activeTab.value;
143
+ const editing = tab?.session.ui.editingFormula as FormulaEditDef | null | undefined;
144
+ if (!tab || !editing) {
145
+ return;
146
+ }
147
+
148
+ // Reset the chip selection when the workspace retargets.
149
+ const targetKey = JSON.stringify(editing);
150
+ if (targetKey !== _targetKey || tab !== _targetTab) {
151
+ _targetKey = targetKey;
152
+ _targetTab = tab;
153
+ _selectedPath = [];
154
+ }
155
+
156
+ // Clean up canvas DnD and event handlers (the canvas surface is being replaced).
157
+ for (const fn of view.canvasDndCleanups) {
158
+ fn();
159
+ }
160
+ view.canvasDndCleanups = [];
161
+ for (const fn of view.canvasEventCleanups) {
162
+ fn();
163
+ }
164
+ view.canvasEventCleanups = [];
165
+ canvasPanels.length = 0;
166
+
167
+ // Eject foreign DOM (iframe canvas markup) plus any stale Lit part before the first render;
168
+ // Subsequent renders diff in place so form focus survives re-renders.
169
+ if (!canvasWrap.firstElementChild?.classList.contains("formula-workspace")) {
170
+ canvasWrap.textContent = "";
171
+ // @ts-expect-error -- _$litPart$ is Lit's private render-part marker, not in the DOM types
172
+ delete canvasWrap["_$litPart$"];
173
+ }
174
+ canvasWrap.style.padding = "0";
175
+ canvasWrap.style.flexDirection = "column";
176
+ canvasWrap.style.alignItems = "stretch";
177
+
178
+ litRender(workspaceTemplate(tab, editing), canvasWrap);
179
+ }
180
+
181
+ function workspaceTemplate(tab: Tab, editing: FormulaEditDef): TemplateResult {
182
+ const stateEntries = (tab.doc.document?.state ?? {}) as Record<string, JxStateDefinition>;
183
+ const title = (editing.type === "def" ? editing.defName : editing.eventKey) ?? "?";
184
+ const root = formulaRoot(tab, editing);
185
+
186
+ const closeButton = html`
187
+ <sp-action-button
188
+ quiet
189
+ size="s"
190
+ class="fw-close"
191
+ title="Close formula workspace"
192
+ @click=${closeFormulaWorkspace}
193
+ >
194
+ <sp-icon-close slot="icon"></sp-icon-close>
195
+ Close
196
+ </sp-action-button>
197
+ `;
198
+
199
+ if (!root) {
200
+ return html`
201
+ <div
202
+ class="formula-workspace"
203
+ style="display:flex;flex-direction:column;flex:1;min-height:0;gap:12px;padding:20px"
204
+ >
205
+ <div class="fw-header" style="display:flex;align-items:center;gap:8px">
206
+ <span class="fw-title" style="${MONO};font-size:14px">fx ${title}</span>
207
+ <div style="flex:1"></div>
208
+ ${closeButton}
209
+ </div>
210
+ <div class="empty-state">No expression found at this document position.</div>
211
+ </div>
212
+ `;
213
+ }
214
+
215
+ const { scope } = tab.session.canvas;
216
+ // Live-context evaluation in the canvas iframe with snapshot fallback (M6). While the workspace
217
+ // Owns canvasWrap the iframe is usually unmounted (immediate snapshot); when a live host exists
218
+ // (e.g. another panel's canvas), a landed result re-renders this workspace. An event target's
219
+ // Element path is the context, so repeater-template formulas bind the first item's $map scope.
220
+ const preview = livePreviewExpression(
221
+ tab,
222
+ `formula:${JSON.stringify(editing)}`,
223
+ root,
224
+ editing.type === "event" ? (editing.path ?? null) : null,
225
+ () => renderFormulaWorkspace(),
226
+ );
227
+ const { node: selected, path: selectedPath } = resolveSelection(root, _selectedPath);
228
+ const write = (next: unknown) => writeRoot(tab, editing, next);
229
+ const writeSelected = (next: unknown) => write(replaceAtPath(root, selectedPath, next));
230
+ const onChipSelect = (path: NodePath) => {
231
+ _selectedPath = path;
232
+ renderFormulaWorkspace();
233
+ };
234
+
235
+ // ── Header: target title, catalog browser (palette), Close ──
236
+ const header = html`
237
+ <div class="fw-header" style="display:flex;align-items:center;gap:8px;padding:14px 20px 6px">
238
+ <span
239
+ class="fw-title"
240
+ style="${MONO};font-size:14px;color:var(--spectrum-gray-800, #d0d0d0)"
241
+ title=${editing.type === "def" ? `state/${title}` : title}
242
+ >fx ${title}</span
243
+ >
244
+ <span class="fw-kind" style="font-size:11px;color:var(--spectrum-gray-600, #808080)">
245
+ ${editing.type === "def" ? "state expression" : "event expression"}
246
+ </span>
247
+ <div style="flex:1"></div>
248
+ <sp-action-button
249
+ quiet
250
+ size="s"
251
+ class="fw-browse-catalog"
252
+ title="Browse catalog"
253
+ @click=${(e: Event) =>
254
+ openFormulaPalette({
255
+ anchor: e.currentTarget as HTMLElement,
256
+ entries: formulaCatalog(stateEntries),
257
+ onPick: (entry) =>
258
+ applyCatalogPick(entry, writeSelected, {
259
+ onInsertDef: (name, def) =>
260
+ transactDoc(activeTab.value, (t) =>
261
+ mutateAddDef(t, name, def as Record<string, JsonValue>),
262
+ ),
263
+ stateEntries,
264
+ }),
265
+ })}
266
+ >
267
+ <sp-icon-brackets slot="icon"></sp-icon-brackets>
268
+ Catalog
269
+ </sp-action-button>
270
+ ${closeButton}
271
+ </div>
272
+ `;
273
+
274
+ // ── Chip strip: the whole tree as chips with live badges; clicking selects the sub-node ──
275
+ const chips = html`
276
+ <div class="fw-chips" style="padding:2px 20px 10px;border-bottom:${PANE_BORDER}">
277
+ ${renderFormulaChips(root, onChipSelect, { preview })}
278
+ </div>
279
+ `;
280
+
281
+ // ── Main pane: the recursive expression form for the SELECTED sub-node ──
282
+ const editor = html`
283
+ <div class="fw-editor" style="flex:1;min-width:0;overflow:auto;padding:16px 20px">
284
+ <div
285
+ class="fw-selected"
286
+ style="font-size:11px;color:var(--spectrum-gray-600, #808080);padding-bottom:8px"
287
+ >
288
+ Selected:
289
+ <span style=${MONO}>${selectedPath.length === 0 ? "root" : chipSummary(selected)}</span>
290
+ </div>
291
+ ${renderExpressionEditor(selected, writeSelected, {
292
+ allowEventRef: editing.type === "event",
293
+ depth: 1,
294
+ path: selectedPath,
295
+ preview,
296
+ stateDefs: Object.keys(stateEntries),
297
+ stateEntries,
298
+ })}
299
+ </div>
300
+ `;
301
+
302
+ // ── Right rail: live data context (the canvas dataScope snapshot) ──
303
+ const scopeEntries = scope ? Object.entries(scope) : [];
304
+ const rail = html`
305
+ <div
306
+ class="fw-context"
307
+ style="width:280px;flex-shrink:0;overflow:auto;border-left:${PANE_BORDER};padding:16px"
308
+ >
309
+ <div
310
+ class="fw-context-title"
311
+ style="font-size:11px;text-transform:uppercase;letter-spacing:0.5px;color:var(--spectrum-gray-600, #808080);padding-bottom:8px"
312
+ >
313
+ Data
314
+ </div>
315
+ ${scopeEntries.length === 0
316
+ ? html`<div class="empty-state">No canvas data snapshot yet</div>`
317
+ : scopeEntries.map(([name, value]) => {
318
+ const unwrapped = unwrapSignal(value);
319
+ return html`
320
+ <div class="fw-context-entry" style="margin-bottom:10px">
321
+ <div style="display:flex;gap:6px;align-items:baseline">
322
+ <span
323
+ class="fw-context-name"
324
+ style="${MONO};font-size:12px;color:var(--spectrum-gray-800, #d0d0d0)"
325
+ >${name}</span
326
+ >
327
+ <span style="font-size:10px;color:var(--spectrum-gray-600, #808080)"
328
+ >${dataTypeLabel(value)}</span
329
+ >
330
+ </div>
331
+ <div class="data-tree">${renderDataTreeTemplate(unwrapped, 0, 4)}</div>
332
+ </div>
333
+ `;
334
+ })}
335
+ </div>
336
+ `;
337
+
338
+ // ── Footer: the root result badge, or the evaluation error ──
339
+ const footer = preview?.error
340
+ ? html`
341
+ <div
342
+ class="fw-result fw-result--error"
343
+ style="${MONO};font-size:12px;padding:10px 20px;border-top:${PANE_BORDER};color:var(--spectrum-negative-content-color-default, #f76a63)"
344
+ >
345
+ ${preview.error}
346
+ </div>
347
+ `
348
+ : preview
349
+ ? html`
350
+ <div
351
+ class="fw-result"
352
+ style="${MONO};font-size:12px;padding:10px 20px;border-top:${PANE_BORDER};color:var(--spectrum-seafoam-900, #35a690)"
353
+ >
354
+ = ${preview.values.get("") ?? "undefined"}
355
+ ${preview.mutating
356
+ ? html`<span style="color:var(--spectrum-gray-600, #808080)">(mutates target)</span>`
357
+ : ""}
358
+ </div>
359
+ `
360
+ : html`
361
+ <div
362
+ class="fw-result fw-result--pending"
363
+ style="font-size:12px;padding:10px 20px;border-top:${PANE_BORDER};color:var(--spectrum-gray-600, #808080)"
364
+ >
365
+ Preview unavailable — the canvas has not posted a data snapshot yet
366
+ </div>
367
+ `;
368
+
369
+ return html`
370
+ <div
371
+ class="formula-workspace"
372
+ style="display:flex;flex-direction:column;flex:1;min-height:0;background:var(--spectrum-gray-75, #1d1d1d)"
373
+ >
374
+ ${header} ${chips}
375
+ <div class="fw-body" style="display:flex;flex:1;min-height:0">${editor} ${rail}</div>
376
+ ${footer}
377
+ </div>
378
+ `;
379
+ }