@jxsuite/studio 1.0.0 → 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 (86) 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 +53069 -31746
  5. package/dist/studio.js.map +109 -74
  6. package/package.json +11 -9
  7. package/src/canvas/canvas-live-render.ts +21 -2
  8. package/src/canvas/canvas-render.ts +35 -16
  9. package/src/canvas/canvas-utils.ts +118 -72
  10. package/src/canvas/iframe-entry.ts +20 -0
  11. package/src/canvas/iframe-eval.ts +155 -0
  12. package/src/canvas/iframe-host.ts +131 -5
  13. package/src/canvas/iframe-inline-edit.ts +107 -1
  14. package/src/canvas/iframe-protocol.ts +43 -3
  15. package/src/canvas/iframe-render.ts +18 -0
  16. package/src/collab/collab-session.ts +23 -8
  17. package/src/collab/collab-state.ts +6 -3
  18. package/src/component-props.ts +104 -0
  19. package/src/editor/inline-edit-apply.ts +36 -1
  20. package/src/editor/inline-edit.ts +58 -1
  21. package/src/editor/shortcuts.ts +41 -12
  22. package/src/files/file-ops.ts +14 -0
  23. package/src/files/files.ts +53 -1
  24. package/src/grid/cell-editors.ts +288 -0
  25. package/src/grid/cell-popovers.ts +108 -0
  26. package/src/grid/csv-codec.ts +122 -0
  27. package/src/grid/edit-buffer.ts +490 -0
  28. package/src/grid/grid-controller.ts +417 -0
  29. package/src/grid/grid-layout.ts +83 -0
  30. package/src/grid/grid-open.ts +167 -0
  31. package/src/grid/grid-panel.ts +302 -0
  32. package/src/grid/grid-source.ts +210 -0
  33. package/src/grid/grid-view.ts +367 -0
  34. package/src/grid/schema-columns.ts +261 -0
  35. package/src/grid/sources/connector-source.ts +217 -0
  36. package/src/grid/sources/content-source.ts +542 -0
  37. package/src/grid/sources/csv-file-source.ts +247 -0
  38. package/src/grid/tabulator-tables.d.ts +120 -0
  39. package/src/panels/block-action-bar.ts +8 -3
  40. package/src/panels/chat-panel.ts +98 -0
  41. package/src/panels/data-grid.ts +9 -387
  42. package/src/panels/editors.ts +43 -17
  43. package/src/panels/events-panel.ts +137 -44
  44. package/src/panels/formula-workspace.ts +379 -0
  45. package/src/panels/frontmatter-fields.ts +231 -0
  46. package/src/panels/frontmatter-panel.ts +132 -0
  47. package/src/panels/git-panel.ts +1 -1
  48. package/src/panels/head-panel.ts +11 -175
  49. package/src/panels/properties-panel.ts +154 -144
  50. package/src/panels/right-panel.ts +9 -47
  51. package/src/panels/signals-panel.ts +115 -23
  52. package/src/panels/statement-editor.ts +710 -0
  53. package/src/panels/style-panel.ts +25 -1
  54. package/src/panels/stylebook-panel.ts +0 -2
  55. package/src/panels/tab-bar.ts +175 -6
  56. package/src/panels/tab-strip.ts +62 -6
  57. package/src/panels/toolbar.ts +37 -3
  58. package/src/services/ai-project-tools.ts +541 -0
  59. package/src/services/ai-session-store.ts +28 -0
  60. package/src/services/ai-system-prompt.ts +194 -24
  61. package/src/services/ai-tools.ts +88 -21
  62. package/src/services/automation.ts +16 -0
  63. package/src/services/document-assistant.ts +81 -11
  64. package/src/services/gated-registry.ts +72 -0
  65. package/src/services/live-preview.ts +0 -0
  66. package/src/services/preview-eval.ts +68 -0
  67. package/src/services/project-adoption.ts +31 -0
  68. package/src/site-context.ts +2 -0
  69. package/src/store.ts +4 -0
  70. package/src/studio.ts +83 -52
  71. package/src/tabs/patch-ops.ts +25 -0
  72. package/src/tabs/tab.ts +39 -1
  73. package/src/tabs/transact.ts +60 -13
  74. package/src/types.ts +12 -0
  75. package/src/ui/dynamic-slot.ts +272 -0
  76. package/src/ui/expression-editor.ts +423 -125
  77. package/src/ui/field-row.ts +5 -0
  78. package/src/ui/formula-catalog.ts +557 -0
  79. package/src/ui/formula-chips.ts +216 -0
  80. package/src/ui/formula-palette.ts +211 -0
  81. package/src/ui/layers.ts +40 -0
  82. package/src/ui/media-picker.ts +1 -1
  83. package/src/ui/panel-resize.ts +15 -1
  84. package/src/utils/preview-format.ts +26 -0
  85. package/src/view.ts +4 -4
  86. package/src/workspace/workspace.ts +14 -0
package/src/tabs/tab.ts CHANGED
@@ -4,14 +4,16 @@ import { formatByName, formatForPath } from "../format/format-host";
4
4
  import { normalizeArrayChildren } from "../state";
5
5
  import type {
6
6
  DocumentStackEntry,
7
+ FormulaEditDef,
7
8
  FunctionEditDef,
8
9
  GitBranchesResult,
9
10
  GitDiffState,
10
11
  GitStatusResult,
11
12
  InlineEditDef,
13
+ JsonValue,
12
14
  } from "../types";
13
15
  import type { JxMutableNode } from "@jxsuite/schema/types";
14
- import type { JxDocOp } from "./patch-ops";
16
+ import type { JxDocOp, JxFmOp } from "./patch-ops";
15
17
 
16
18
  export interface TabUi {
17
19
  rightTab: string;
@@ -20,12 +22,26 @@ export interface TabUi {
20
22
  preview: boolean;
21
23
  /** Show elements inherited from the page's layout (pages with an effective layout only). */
22
24
  showLayout: boolean;
25
+ /** Above-canvas frontmatter Properties accordion expanded (content-collection docs, edit mode). */
26
+ frontmatterOpen: boolean;
23
27
  /** Chosen literal values for dynamic route params (e.g. { sku: "mini-trencher" }). */
24
28
  previewParams: Record<string, string>;
29
+ /**
30
+ * Chosen test values for a component doc's props (state entries), seeded into the canvas render
31
+ * so a non-instantiated component previews with real data (M6) — the previewParams mirror.
32
+ */
33
+ previewProps: Record<string, JsonValue> | null;
25
34
  zoom: number;
35
+ /**
36
+ * Edit-mode content zoom — browser-page-zoom semantics (content reflows at the zoomed effective
37
+ * width while the canvas footprint stays fixed), unlike `zoom` which pan/zooms the whole canvas.
38
+ */
39
+ editZoom: number;
26
40
  activeMedia: string | null;
27
41
  activeSelector: string | null;
28
42
  editingFunction: FunctionEditDef | null;
43
+ /** Full-screen formula workspace target ($expression editing); editingFunction wins if both set. */
44
+ editingFormula: FormulaEditDef | null;
29
45
  featureToggles: Record<string, boolean>;
30
46
  styleSections: Record<string, boolean>;
31
47
  inspectorSections: Record<string, boolean>;
@@ -46,6 +62,18 @@ export interface TabUi {
46
62
  pendingInlineEdit: InlineEditDef | null;
47
63
  }
48
64
 
65
+ /**
66
+ * A live (iframe-evaluated) expression preview retained per editing target (M6). Stored on
67
+ * `session.canvas` beside `scope` — plain wire data (path-key → display-string pairs), rebuilt into
68
+ * an ExpressionPreview by services/live-preview.ts on read.
69
+ */
70
+ export interface StoredLivePreview {
71
+ /** Serialized expression + context the values were computed for (staleness check). */
72
+ key: string;
73
+ values: [string, string][];
74
+ error: string | null;
75
+ }
76
+
49
77
  interface HistorySnapshot {
50
78
  /** Full document at this state (checkpoint), or null when the ops describe the transition. */
51
79
  document: Record<string, unknown> | null;
@@ -57,6 +85,8 @@ interface HistorySnapshot {
57
85
  forwardOps?: JxDocOp[] | null;
58
86
  /** Replayable ops transforming this state back into the previous one. */
59
87
  inverseOps?: JxDocOp[] | null;
88
+ /** Frontmatter key changes in this transaction (before/after values, replayed both ways). */
89
+ fmOps?: JxFmOp[] | null;
60
90
  }
61
91
 
62
92
  export interface Tab {
@@ -89,6 +119,9 @@ export interface Tab {
89
119
  // The bridge as a `dataScope` message and read by the data-explorer panel. Plain data now —
90
120
  // The old live `EffectScope` (with `.stop()`) moved into the iframe realm with buildScope.
91
121
  scope: Record<string, unknown> | null;
122
+ // Latest live (iframe-evaluated) expression previews keyed by editing-target id, stored
123
+ // Beside `scope` the same way (services/live-preview.ts owns reads/writes).
124
+ livePreviews: Record<string, StoredLivePreview> | null;
92
125
  error: string | null;
93
126
  pendingInlineEdit: InlineEditDef | null;
94
127
  };
@@ -109,8 +142,11 @@ function createDefaultUi(canvasMode: string, preview = false) {
109
142
  activeMedia: null,
110
143
  activeSelector: null,
111
144
  canvasMode,
145
+ editZoom: 1,
146
+ editingFormula: null,
112
147
  editingFunction: null,
113
148
  featureToggles: {},
149
+ frontmatterOpen: true,
114
150
  gitBranches: null,
115
151
  gitCommitMessage: "",
116
152
  gitDiffState: null,
@@ -121,6 +157,7 @@ function createDefaultUi(canvasMode: string, preview = false) {
121
157
  pendingInlineEdit: null,
122
158
  preview,
123
159
  previewParams: {},
160
+ previewProps: null,
124
161
  rightTab: "properties",
125
162
  settingsTab: "stylebook",
126
163
  showLayout: true,
@@ -204,6 +241,7 @@ export function createTab({
204
241
  session: reactive({
205
242
  canvas: {
206
243
  error: null,
244
+ livePreviews: null,
207
245
  pendingInlineEdit: null,
208
246
  scope: null,
209
247
  status: "idle",
@@ -8,16 +8,23 @@ import {
8
8
  endRecording,
9
9
  getPatchConsumer,
10
10
  recordDocOp,
11
+ recordFmOp,
11
12
  recordPatch,
12
13
  } from "./patch-ops";
13
14
 
14
- import type { JxDocOp, JxDocOpPair, TransactionRecord } from "./patch-ops";
15
+ import type { JxDocOp, JxDocOpPair, JxFmOp, TransactionRecord } from "./patch-ops";
15
16
 
16
17
  import type { Tab } from "../tabs/tab";
17
18
  import type { JxPath } from "../state";
18
19
 
19
20
  import type { JsonValue } from "../types";
20
- import type { JxEventBinding, JxMutableNode, JxStateObject, JxStyle } from "@jxsuite/schema/types";
21
+ import type {
22
+ JxAttributeValue,
23
+ JxEventBinding,
24
+ JxMutableNode,
25
+ JxStateObject,
26
+ JxStyle,
27
+ } from "@jxsuite/schema/types";
21
28
  import { ensureNestedStyle, getNestedStyle, isEventBinding } from "@jxsuite/schema/guards";
22
29
 
23
30
  const HISTORY_LIMIT = 100;
@@ -205,10 +212,14 @@ function pushHistoryEntry(
205
212
  selectionBefore: JxPath | null,
206
213
  ) {
207
214
  const truncated = tab.history.snapshots.slice(0, tab.history.index + 1);
208
- const useOps = patchHistoryEnabled() && record.invertible && record.docOps.length > 0;
215
+ const useOps =
216
+ patchHistoryEnabled() &&
217
+ record.invertible &&
218
+ (record.docOps.length > 0 || record.fmOps.length > 0);
209
219
  const needCheckpoint = !useOps || truncated.length % CHECKPOINT_INTERVAL === 0;
210
220
  truncated.push({
211
221
  document: needCheckpoint ? jsonClone(raw) : null,
222
+ fmOps: useOps && record.fmOps.length > 0 ? record.fmOps : null,
212
223
  forwardOps: useOps ? record.docOps.map((p) => p.forward) : null,
213
224
  inverseOps: useOps ? record.docOps.map((p) => p.inverse) : null,
214
225
  selection: tab.session.selection ? [...tab.session.selection] : null,
@@ -471,14 +482,23 @@ export function undo(tab: Tab) {
471
482
  }
472
483
  const entry = tab.history.snapshots[tab.history.index]!;
473
484
  const inverseOps = patchHistoryEnabled() ? entry.inverseOps : null;
474
- if (inverseOps && inverseOps.length > 0) {
485
+ const fmOps = patchHistoryEnabled() ? entry.fmOps : null;
486
+ if ((inverseOps && inverseOps.length > 0) || (fmOps && fmOps.length > 0)) {
475
487
  // Surgical path: apply inverse ops through the normal transaction pipeline (the canvas
476
488
  // Patches in place when it can), in reverse recording order.
477
489
  transactDoc(
478
490
  tab,
479
491
  (t) => {
480
- for (let i = inverseOps.length - 1; i >= 0; i--) {
481
- applyDocOp(t, toRaw(inverseOps[i]) as JxDocOp);
492
+ if (inverseOps) {
493
+ for (let i = inverseOps.length - 1; i >= 0; i--) {
494
+ applyDocOp(t, toRaw(inverseOps[i]) as JxDocOp);
495
+ }
496
+ }
497
+ if (fmOps) {
498
+ for (let i = fmOps.length - 1; i >= 0; i--) {
499
+ const op = toRaw(fmOps[i]) as JxFmOp;
500
+ applyFmState(t, op.field, op.before);
501
+ }
482
502
  }
483
503
  },
484
504
  { origin: "history", skipHistory: true },
@@ -505,12 +525,21 @@ export function redo(tab: Tab) {
505
525
  }
506
526
  const entry = tab.history.snapshots[tab.history.index + 1]!;
507
527
  const forwardOps = patchHistoryEnabled() ? entry.forwardOps : null;
508
- if (forwardOps && forwardOps.length > 0) {
528
+ const fmOps = patchHistoryEnabled() ? entry.fmOps : null;
529
+ if ((forwardOps && forwardOps.length > 0) || (fmOps && fmOps.length > 0)) {
509
530
  transactDoc(
510
531
  tab,
511
532
  (t) => {
512
- for (const op of forwardOps) {
513
- applyDocOp(t, toRaw(op) as JxDocOp);
533
+ if (forwardOps) {
534
+ for (const op of forwardOps) {
535
+ applyDocOp(t, toRaw(op) as JxDocOp);
536
+ }
537
+ }
538
+ if (fmOps) {
539
+ for (const op of fmOps) {
540
+ const raw = toRaw(op) as JxFmOp;
541
+ applyFmState(t, raw.field, raw.after);
542
+ }
514
543
  }
515
544
  },
516
545
  { origin: "history", skipHistory: true },
@@ -746,13 +775,13 @@ export function mutateUpdateStyle(tab: Tab, path: JxPath, prop: string, value: s
746
775
  * @param {Tab} tab
747
776
  * @param {JxPath} path
748
777
  * @param {string} attr
749
- * @param {string | undefined} value
778
+ * @param {JxAttributeValue | undefined} value — a literal, `$ref` binding, or `${}` template.
750
779
  */
751
780
  export function mutateUpdateAttribute(
752
781
  tab: Tab,
753
782
  path: JxPath,
754
783
  attr: string,
755
- value?: string | undefined,
784
+ value?: JxAttributeValue | undefined,
756
785
  ) {
757
786
  const node = getNodeAtPath(tab.doc.document, path);
758
787
  const attrsBefore = cloneValue(node.attributes);
@@ -1196,10 +1225,28 @@ export function mutateRenameSwitchCase(tab: Tab, path: JxPath, oldName: string,
1196
1225
  * @param {JsonValue} value
1197
1226
  */
1198
1227
  export function mutateUpdateFrontmatter(tab: Tab, field: string, value?: JsonValue) {
1199
- if (value === undefined || value === null || value === "") {
1228
+ const fm = tab.doc.content.frontmatter;
1229
+ const before = Object.hasOwn(fm, field) ? cloneValue(fm[field]) : undefined;
1230
+ const deletes = value === undefined || value === null || value === "";
1231
+ if (deletes) {
1232
+ delete fm[field];
1233
+ } else {
1234
+ fm[field] = value;
1235
+ }
1236
+ const after = deletes ? undefined : cloneValue(value);
1237
+ recordFmOp({ after, before, field });
1238
+ tab.doc.dirty = true;
1239
+ }
1240
+
1241
+ /**
1242
+ * Restore a frontmatter key to a recorded history state: `undefined` means the key was absent.
1243
+ * Unlike `mutateUpdateFrontmatter`, values like `""`/`false`/`null` are written verbatim (source
1244
+ * round-trips can put them there) and no fm op is recorded — history replay must not re-record.
1245
+ */
1246
+ function applyFmState(tab: Tab, field: string, value: unknown) {
1247
+ if (value === undefined) {
1200
1248
  delete tab.doc.content.frontmatter[field];
1201
1249
  } else {
1202
1250
  tab.doc.content.frontmatter[field] = value;
1203
1251
  }
1204
- tab.doc.dirty = true;
1205
1252
  }
package/src/types.ts CHANGED
@@ -427,6 +427,18 @@ export interface FunctionEditDef {
427
427
  parameters?: string[];
428
428
  }
429
429
 
430
+ /**
431
+ * Identifies which document position's `$expression` the full-screen formula workspace is editing:
432
+ * a state entry (`type: "def"` + defName) or an element event binding (`type: "event"` + path +
433
+ * eventKey). Mirrors FunctionEditDef, the Monaco function editor's target shape.
434
+ */
435
+ export interface FormulaEditDef {
436
+ type: "def" | "event";
437
+ defName?: string;
438
+ path?: JxPath;
439
+ eventKey?: string;
440
+ }
441
+
430
442
  export interface GitDiffState {
431
443
  filePath: string;
432
444
  originalContent: string;
@@ -0,0 +1,272 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Dynamic-slot — the shared "make this dynamic" control for any bindable document position.
4
+ *
5
+ * Renders the panel-provided static widget plus a label-side mode button that cycles the value up
6
+ * the Rule of Least Power ladder (spec §2.2): literal → $ref pointer → ${} template → $expression.
7
+ * Capability-driven: each slot offers only the modes its schema position allows, so panels stay
8
+ * decoupled from which rungs are blessed where. Each mode's last value is remembered per field for
9
+ * the session, so cycling back to an earlier mode restores what the user had there.
10
+ */
11
+
12
+ import { html, nothing } from "lit-html";
13
+ import { live } from "lit-html/directives/live.js";
14
+ import { isRef, isTemplateString } from "@jxsuite/schema/guards";
15
+ import { cloneValue } from "../tabs/doc-op-apply";
16
+ import { activeTab } from "../workspace/workspace";
17
+ import { renderExpressionEditor } from "./expression-editor";
18
+
19
+ import type { TemplateResult } from "lit-html";
20
+ import type { EditorPreview } from "./expression-editor";
21
+ import type { JxStateDefinition } from "@jxsuite/schema/types";
22
+ import type { JsonValue } from "../types";
23
+
24
+ export type SlotMode = "literal" | "ref" | "template" | "expression";
25
+
26
+ const MODE_LABELS: Record<SlotMode, string> = {
27
+ expression: "fx",
28
+ literal: "abc",
29
+ ref: "$ref",
30
+ template: "${}",
31
+ };
32
+
33
+ const MODE_NOUNS: Record<SlotMode, string> = {
34
+ expression: "expression",
35
+ literal: "static",
36
+ ref: "signal binding",
37
+ template: "template literal",
38
+ };
39
+
40
+ /*
41
+ * Session memory of each field's last value per mode, so cycling modes round-trips user input.
42
+ * Keyed `${tabId}|${fieldKey}|${mode}`; the mode key is derived from the value at stash time, so a
43
+ * restore always lands a value whose slotMode() matches the target mode. `undefined` is a
44
+ * legitimate stash (cleared literal), hence Map.has() discrimination on recall.
45
+ */
46
+ const slotModeMemory = new Map<string, JsonValue | undefined>();
47
+
48
+ /** Test hook: drop all remembered per-mode field values. */
49
+ export function resetSlotModeMemory(): void {
50
+ slotModeMemory.clear();
51
+ }
52
+
53
+ function memoryKey(fieldKey: string, mode: SlotMode): string {
54
+ return `${activeTab.value?.id ?? "-"}|${fieldKey}|${mode}`;
55
+ }
56
+
57
+ function hasRefOptions(opts: DynamicSlotOpts): boolean {
58
+ return opts.stateDefs.length > 0 || Boolean(opts.extraSignals?.length);
59
+ }
60
+
61
+ export interface SignalOption {
62
+ value: string;
63
+ label: string;
64
+ }
65
+
66
+ export interface DynamicSlotOpts {
67
+ /** Stable identity for per-mode value memory; must incorporate the node path. */
68
+ fieldKey: string;
69
+ /** Current raw document value at this position. */
70
+ value: unknown;
71
+ /** Write-through to the document; `undefined` clears the position. */
72
+ onChange: (v?: JsonValue) => void;
73
+ /** Panel-provided literal editor for this slot's type. */
74
+ staticWidget: unknown;
75
+ /** Modes this schema position allows, in ladder order. "literal" and "ref" are the floor. */
76
+ caps: SlotMode[];
77
+ /** State keys offered by the ref picker and expression editor. */
78
+ stateDefs: string[];
79
+ /** Full state defs map — lets expression mode resolve named-formula catalog entries. */
80
+ stateEntries?: Record<string, JxStateDefinition> | null;
81
+ /** Extra pointer options beyond #/state/* (e.g. $map/item within repeater templates). */
82
+ extraSignals?: SignalOption[] | null;
83
+ /** Offer event#/ refs inside expression mode (handler positions only). */
84
+ allowEventRef?: boolean;
85
+ /** Live values for expression mode badges (services/preview-eval.ts). */
86
+ preview?: EditorPreview | null;
87
+ /** Static value restored when de-escalating to literal mode. */
88
+ literalDefault?: JsonValue;
89
+ }
90
+
91
+ /** Detect which rung of the ladder a raw document value occupies. */
92
+ export function slotMode(value: unknown): SlotMode {
93
+ if (isRef(value)) {
94
+ return "ref";
95
+ }
96
+ if (isTemplateString(value)) {
97
+ return "template";
98
+ }
99
+ if (value && typeof value === "object" && "$expression" in value) {
100
+ return "expression";
101
+ }
102
+ return "literal";
103
+ }
104
+
105
+ function defaultForSlotMode(mode: SlotMode, opts: DynamicSlotOpts): JsonValue | undefined {
106
+ switch (mode) {
107
+ case "ref": {
108
+ const [first] = opts.stateDefs;
109
+ return first ? { $ref: `#/state/${first}` } : { $ref: opts.extraSignals?.[0]?.value ?? "" };
110
+ }
111
+ case "template": {
112
+ const [first] = opts.stateDefs;
113
+ return first ? `\${state.${first}}` : "${}";
114
+ }
115
+ case "expression": {
116
+ return { $expression: { operator: "??", target: null, value: null } };
117
+ }
118
+ default: {
119
+ return opts.literalDefault;
120
+ }
121
+ }
122
+ }
123
+
124
+ function renderRefWidget(refVal: string, opts: DynamicSlotOpts) {
125
+ return html`
126
+ <sp-picker
127
+ size="s"
128
+ quiet
129
+ style="flex:1"
130
+ placeholder="— select signal —"
131
+ value=${refVal || nothing}
132
+ @change=${(e: Event) => {
133
+ const v = (e.target as HTMLInputElement).value;
134
+ if (v) {
135
+ opts.onChange({ $ref: v });
136
+ } else {
137
+ opts.onChange();
138
+ }
139
+ }}
140
+ >
141
+ ${opts.stateDefs.map(
142
+ (name) => html`<sp-menu-item value=${`#/state/${name}`}>${name}</sp-menu-item>`,
143
+ )}
144
+ ${opts.extraSignals?.length
145
+ ? html`
146
+ <sp-menu-divider></sp-menu-divider>
147
+ ${opts.extraSignals.map(
148
+ (sig) => html`<sp-menu-item value=${sig.value}>${sig.label}</sp-menu-item>`,
149
+ )}
150
+ `
151
+ : nothing}
152
+ </sp-picker>
153
+ `;
154
+ }
155
+
156
+ function renderTemplateWidget(value: unknown, opts: DynamicSlotOpts) {
157
+ return html`
158
+ <sp-textfield
159
+ size="s"
160
+ style="flex:1;font-family:var(--spectrum-code-font-family, monospace)"
161
+ placeholder="\${state.…}"
162
+ .value=${live(String(value ?? ""))}
163
+ @change=${(e: Event) => opts.onChange((e.target as HTMLInputElement).value)}
164
+ ></sp-textfield>
165
+ `;
166
+ }
167
+
168
+ export interface DynamicSlotParts {
169
+ /** Label-side mode-cycle button. */
170
+ modeButton: TemplateResult;
171
+ /** Active-mode widget, sized to fill the row's widget column. */
172
+ widget: TemplateResult;
173
+ }
174
+
175
+ /**
176
+ * The label-side field-mode button. Shows the current mode's glyph — quiet/gray while static and
177
+ * accent-tinted once the value is dynamic — and each click cycles to the next capped mode, stashing
178
+ * the outgoing mode's value and restoring the incoming mode's remembered one.
179
+ */
180
+ function renderModeButton(mode: SlotMode, caps: SlotMode[], opts: DynamicSlotOpts): TemplateResult {
181
+ const color =
182
+ mode !== "literal"
183
+ ? "color:var(--spectrum-accent-content-color-default, #5c9dff)"
184
+ : "color:var(--spectrum-gray-600, #808080)";
185
+ // A ref rung with nothing to point at is a dead end — drop it rather than trap the cycle there.
186
+ const cycle = caps.filter((m) => m !== "ref" || hasRefOptions(opts));
187
+ if (cycle.length < 2) {
188
+ const hint = `Field mode: ${MODE_NOUNS[mode]} (no other modes available)`;
189
+ return html`
190
+ <sp-action-button
191
+ size="xs"
192
+ quiet
193
+ disabled
194
+ class="dynamic-slot-mode"
195
+ style=${color}
196
+ title=${hint}
197
+ aria-label=${hint}
198
+ >${MODE_LABELS[mode]}</sp-action-button
199
+ >
200
+ `;
201
+ }
202
+ // An uncapped current mode indexes to -1, so its next stop is cycle[0] (literal).
203
+ const next = cycle[(cycle.indexOf(mode) + 1) % cycle.length]!;
204
+ const hint = `Field mode: ${MODE_NOUNS[mode]} — click for ${MODE_NOUNS[next]}`;
205
+ return html`
206
+ <sp-action-button
207
+ size="xs"
208
+ quiet
209
+ class="dynamic-slot-mode"
210
+ style=${color}
211
+ title=${hint}
212
+ aria-label=${hint}
213
+ @click=${() => {
214
+ slotModeMemory.set(
215
+ memoryKey(opts.fieldKey, mode),
216
+ cloneValue(opts.value as JsonValue | undefined),
217
+ );
218
+ // Leaving ref seeds an empty literal stash with the signal's declared default.
219
+ // Unbind-restores-default thus survives multi-hop cycles (ref → template → literal).
220
+ if (mode === "ref" && opts.literalDefault !== undefined) {
221
+ const litKey = memoryKey(opts.fieldKey, "literal");
222
+ if (!slotModeMemory.has(litKey)) {
223
+ slotModeMemory.set(litKey, cloneValue(opts.literalDefault));
224
+ }
225
+ }
226
+ const key = memoryKey(opts.fieldKey, next);
227
+ opts.onChange(
228
+ slotModeMemory.has(key)
229
+ ? cloneValue(slotModeMemory.get(key))
230
+ : defaultForSlotMode(next, opts),
231
+ );
232
+ }}
233
+ >${MODE_LABELS[mode]}</sp-action-button
234
+ >
235
+ `;
236
+ }
237
+
238
+ /**
239
+ * Render a bindable slot as two parts the panel places independently: the active mode's widget for
240
+ * the row's value column, and the mode-cycle button for the label side.
241
+ */
242
+ export function renderDynamicSlot(opts: DynamicSlotOpts): DynamicSlotParts {
243
+ const caps: SlotMode[] = opts.caps.length > 0 ? opts.caps : ["literal", "ref"];
244
+ const mode = slotMode(opts.value);
245
+
246
+ const widget =
247
+ mode === "ref"
248
+ ? renderRefWidget(isRef(opts.value) ? opts.value.$ref : "", opts)
249
+ : mode === "template"
250
+ ? renderTemplateWidget(opts.value, opts)
251
+ : mode === "expression"
252
+ ? renderExpressionEditor(
253
+ (opts.value as { $expression?: unknown }).$expression,
254
+ (node) => opts.onChange({ $expression: node } as JsonValue),
255
+ {
256
+ allowEventRef: opts.allowEventRef ?? false,
257
+ preview: opts.preview ?? null,
258
+ stateDefs: opts.stateDefs,
259
+ stateEntries: opts.stateEntries ?? null,
260
+ },
261
+ )
262
+ : opts.staticWidget;
263
+
264
+ return {
265
+ modeButton: renderModeButton(mode, caps, opts),
266
+ widget: html`
267
+ <div class="dynamic-slot" style="display:flex;flex:1;min-width:0">
268
+ <div style="flex:1;min-width:0">${widget}</div>
269
+ </div>
270
+ `,
271
+ };
272
+ }