@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
@@ -22,6 +22,7 @@ import {
22
22
  } from "../tabs/transact";
23
23
  import { inferInputType, propLabel } from "../utils/studio-utils";
24
24
  import { renderFieldRow } from "../ui/field-row";
25
+ import { renderDynamicSlot } from "../ui/dynamic-slot";
25
26
  import { parseMediaEntries } from "../utils/canvas-media";
26
27
  import { getEffectiveMedia, getEffectiveStyle } from "../site-context";
27
28
  import { computeInheritedStyle } from "../utils/inherited-style";
@@ -41,6 +42,7 @@ import { widgetForType } from "./style-inputs";
41
42
 
42
43
  import type { Tab } from "../tabs/tab";
43
44
  import type { JxPath } from "../state";
45
+ import type { JsonValue } from "../types";
44
46
  import type { JxMutableNode, JxStyle } from "@jxsuite/schema/types";
45
47
 
46
48
  interface CssLonghand {
@@ -96,17 +98,29 @@ function renderStyleRow(
96
98
  isWarning: boolean,
97
99
  gridMode: boolean,
98
100
  inheritedValue: string | undefined,
101
+ templateSignals: string[] = [],
102
+ fieldKey: string = prop,
99
103
  ) {
100
104
  const type = inferInputType(entry);
101
105
  const hasVal = value !== undefined && value !== "";
102
106
  const placeholder = !hasVal && inheritedValue ? String(inheritedValue) : "";
103
107
  const spanVal = gridMode && (entry as Record<string, unknown>).$span === 2 ? 2 : undefined;
108
+ // Style values are schema-legal at two rungs: literal and ${} template (no $ref in JxStyle).
109
+ const slot = renderDynamicSlot({
110
+ caps: ["literal", "template"],
111
+ fieldKey,
112
+ onChange: (v?: JsonValue) => onCommit(v === undefined || v === "" ? undefined : String(v)),
113
+ staticWidget: widgetForType(type, entry, prop, value, onCommit, { placeholder }),
114
+ stateDefs: templateSignals,
115
+ value,
116
+ });
104
117
  return renderFieldRow({
105
118
  prop,
106
119
  label: propLabel(entry, prop),
107
120
  hasValue: hasVal,
108
121
  onClear: onDelete,
109
- widget: widgetForType(type, entry, prop, value, onCommit, { placeholder }),
122
+ widget: slot.widget,
123
+ labelExtra: slot.modeButton,
110
124
  ...(spanVal != null && { span: spanVal }),
111
125
  warning: isWarning,
112
126
  });
@@ -287,6 +301,14 @@ function styleSidebarTemplate(
287
301
  const tab = activeTab.value!;
288
302
  const sel = tab.session.selection as JxPath;
289
303
  const style = effectiveStyle || node.style || {};
304
+ // Signals seeding the ${} template default when a style value escalates (no $ref in JxStyle).
305
+ const templateSignals = Object.entries(tab.doc.document.state || {})
306
+ .filter(
307
+ ([, d]) =>
308
+ !(d as Record<string, unknown>)?.$handler &&
309
+ (d as Record<string, unknown>)?.$prototype !== "Function",
310
+ )
311
+ .map(([defName]) => defName);
290
312
  const { sizeBreakpoints } = parseMediaEntries(getEffectiveMedia(tab.doc.document.$media));
291
313
  const mediaNames = sizeBreakpoints.map((bp) => bp.name);
292
314
  const mediaTab = activeMediaTab || null;
@@ -660,6 +682,8 @@ function styleSidebarTemplate(
660
682
  isWarning,
661
683
  sec.$layout === "grid",
662
684
  inheritedStyle[prop] as string | undefined,
685
+ templateSignals,
686
+ `style|${sel.join("/")}|${mediaTab ?? ""}|${activeSelector ?? ""}|${prop}`,
663
687
  ),
664
688
  );
665
689
  }
@@ -43,7 +43,6 @@ interface StylebookCtx {
43
43
  ) => { tpl: TemplateResult; panel: CanvasPanel };
44
44
  applyTransform: () => void;
45
45
  observeCenterUntilStable: () => void;
46
- renderZoomIndicator: () => void;
47
46
  updateActivePanelHeaders: () => void;
48
47
  }
49
48
 
@@ -182,7 +181,6 @@ export function renderStylebookMode(ctx: StylebookCtx) {
182
181
 
183
182
  ctx.applyTransform();
184
183
  ctx.observeCenterUntilStable();
185
- ctx.renderZoomIndicator();
186
184
  }
187
185
 
188
186
  /**
@@ -13,12 +13,14 @@ import { html, render as litRender, nothing } from "lit-html";
13
13
  import { projectState, updateUi } from "../store";
14
14
  import { effect, effectScope } from "../reactivity";
15
15
  import { activeTab } from "../workspace/workspace";
16
+ import { applyTransform, fitToScreen, resetZoom, setEditZoom } from "../canvas/canvas-utils";
16
17
  import { getEffectiveLayoutPath, getEffectiveMedia } from "../site-context";
17
18
  import { dynamicRouteParams, loadParamValues, pagePathsDef } from "../page-params";
19
+ import { componentPropEntries, isComponentDoc } from "../component-props";
18
20
  import { mediaDisplayName } from "./shared";
19
21
  import type { ParamValues } from "../page-params";
20
22
  import type { Tab } from "../tabs/tab";
21
- import type { DocumentStackEntry, FunctionEditDef } from "../types";
23
+ import type { DocumentStackEntry, FormulaEditDef, FunctionEditDef, JsonValue } from "../types";
22
24
  import type { EffectScope } from "@vue/reactivity";
23
25
  import type { TemplateResult } from "lit-html";
24
26
 
@@ -26,6 +28,7 @@ interface TabBarCtx {
26
28
  navigateBack: () => void;
27
29
  navigateToLevel: (level: number) => void;
28
30
  closeFunctionEditor: () => void;
31
+ closeFormulaWorkspace: () => void;
29
32
  exportFile: () => void;
30
33
  getCanvasMode: () => string;
31
34
  parseMediaEntries: (media: Record<string, string> | null | undefined) => {
@@ -67,11 +70,15 @@ export function mount(host: HTMLElement, ctx: TabBarCtx) {
67
70
  void tab.documentPath;
68
71
  void tab.session.documentStack.length;
69
72
  void tab.session.ui.canvasMode;
73
+ void tab.session.ui.editZoom;
74
+ void tab.session.ui.editingFormula;
70
75
  void tab.session.ui.editingFunction;
71
76
  void tab.session.ui.featureToggles;
72
77
  void tab.session.ui.preview;
73
78
  void tab.session.ui.previewParams;
79
+ void tab.session.ui.previewProps;
74
80
  void tab.session.ui.showLayout;
81
+ void tab.session.ui.zoom;
75
82
  }
76
83
  render();
77
84
  });
@@ -110,9 +117,13 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
110
117
  };
111
118
  const canvasMode = ctx.getCanvasMode();
112
119
  const editing = S.ui.editingFunction as FunctionEditDef | null;
120
+ const formulaEditing = S.ui.editingFormula as FormulaEditDef | null;
121
+ // Any full-screen canvas takeover (function editor or formula workspace) suppresses the zoom
122
+ // Widget, the view-settings cluster, and the Export action below.
123
+ const takeover = Boolean(editing) || Boolean(formulaEditing);
113
124
  const hasStack = S.documentStack && S.documentStack.length > 0;
114
125
 
115
- // ── Left region: navigation context (function editor takes precedence over the stack) ──
126
+ // ── Left region: navigation context (function editor > formula workspace > document stack) ──
116
127
  let navTpl: TemplateResult | typeof nothing = nothing;
117
128
  if (editing) {
118
129
  const docName = S.documentPath?.split("/").pop() || S.document?.tagName || "document";
@@ -128,6 +139,27 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
128
139
  <span class="breadcrumb-item current">${funcLabel}</span>
129
140
  </div>
130
141
  `;
142
+ } else if (formulaEditing) {
143
+ const docName = S.documentPath?.split("/").pop() || S.document?.tagName || "document";
144
+ const formulaLabel =
145
+ formulaEditing.type === "def"
146
+ ? `fx ${formulaEditing.defName}`
147
+ : `fx ${formulaEditing.eventKey}`;
148
+ navTpl = html`
149
+ <div class="breadcrumb">
150
+ <sp-action-button
151
+ size="s"
152
+ title="Close formula workspace"
153
+ @click=${ctx.closeFormulaWorkspace}
154
+ >
155
+ <sp-icon-back slot="icon"></sp-icon-back>
156
+ Back
157
+ </sp-action-button>
158
+ <span class="breadcrumb-item">${docName}</span>
159
+ <span class="breadcrumb-sep"> › </span>
160
+ <span class="breadcrumb-item current">${formulaLabel}</span>
161
+ </div>
162
+ `;
131
163
  } else if (hasStack) {
132
164
  navTpl = html`
133
165
  <div class="breadcrumb">
@@ -150,6 +182,82 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
150
182
  `;
151
183
  }
152
184
 
185
+ // ── Left region: zoom widget (every mode with a zoom concept) ──
186
+ // Gates on the EFFECTIVE mode: edit drives the content-reflow `editZoom`, while design /
187
+ // Stylebook / git-diff / preview all render on the panzoom surface and drive `ui.zoom` (with the
188
+ // Panzoom-only fit-to-screen action).
189
+ let zoomTpl: TemplateResult | typeof nothing = nothing;
190
+ if (!takeover && canvasMode === "edit") {
191
+ const editZoom = S.ui.editZoom ?? 1;
192
+ zoomTpl = html`
193
+ <sp-action-group compact size="s" class="tb-zoom">
194
+ <sp-action-button
195
+ size="s"
196
+ title="Zoom out (Ctrl+-)"
197
+ @click=${() => setEditZoom((tab.session.ui.editZoom ?? 1) / 1.2)}
198
+ >
199
+
200
+ </sp-action-button>
201
+ <sp-action-button
202
+ size="s"
203
+ class="tb-zoom-label"
204
+ title="Reset to 100% (Ctrl+0)"
205
+ @click=${() => setEditZoom(1)}
206
+ >
207
+ ${Math.round(editZoom * 100)}%
208
+ </sp-action-button>
209
+ <sp-action-button
210
+ size="s"
211
+ title="Zoom in (Ctrl+=)"
212
+ @click=${() => setEditZoom((tab.session.ui.editZoom ?? 1) * 1.2)}
213
+ >
214
+ +
215
+ </sp-action-button>
216
+ </sp-action-group>
217
+ `;
218
+ } else if (
219
+ !takeover &&
220
+ (canvasMode === "design" ||
221
+ canvasMode === "stylebook" ||
222
+ canvasMode === "git-diff" ||
223
+ canvasMode === "preview")
224
+ ) {
225
+ const zoom = S.ui.zoom ?? 1;
226
+ const setPanZoom = (next: number) => {
227
+ tab.session.ui.zoom = Math.min(5, Math.max(0.05, next));
228
+ applyTransform();
229
+ };
230
+ zoomTpl = html`
231
+ <sp-action-group compact size="s" class="tb-zoom">
232
+ <sp-action-button
233
+ size="s"
234
+ title="Zoom out (Ctrl+-)"
235
+ @click=${() => setPanZoom((tab.session.ui.zoom ?? 1) / 1.2)}
236
+ >
237
+
238
+ </sp-action-button>
239
+ <sp-action-button
240
+ size="s"
241
+ class="tb-zoom-label"
242
+ title="Reset to 100% (Ctrl+0)"
243
+ @click=${() => resetZoom()}
244
+ >
245
+ ${Math.round(zoom * 100)}%
246
+ </sp-action-button>
247
+ <sp-action-button
248
+ size="s"
249
+ title="Zoom in (Ctrl+=)"
250
+ @click=${() => setPanZoom((tab.session.ui.zoom ?? 1) * 1.2)}
251
+ >
252
+ +
253
+ </sp-action-button>
254
+ <sp-action-button size="s" title="Fit to screen" @click=${() => fitToScreen()}>
255
+ Fit
256
+ </sp-action-button>
257
+ </sp-action-group>
258
+ `;
259
+ }
260
+
153
261
  // ── Right region: view settings cluster (edit/design base modes only) ──
154
262
  // Gates on the BASE mode (not the effective mode): the cluster stays visible while the preview
155
263
  // Toggle is on so it can be toggled back off.
@@ -160,10 +268,11 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
160
268
  (S.documentPath.startsWith("pages/") || S.documentPath.startsWith("./pages/")),
161
269
  );
162
270
  let settingsTpl: TemplateResult | typeof nothing = nothing;
163
- if (!editing && (baseMode === "edit" || baseMode === "design")) {
271
+ if (!takeover && (baseMode === "edit" || baseMode === "design")) {
164
272
  const canPreview = tab.capabilities.modes.includes("preview");
165
273
  const hasLayout = isPage && Boolean(getEffectiveLayoutPath(S.document?.$layout));
166
- const pickersTpl = isPage ? paramPickersTpl(tab) : nothing;
274
+ // Pages get route-param pickers; component docs get test-prop fields (M6) same slot.
275
+ const pickersTpl = isPage ? paramPickersTpl(tab) : propFieldsTpl(tab);
167
276
  const previewTpl = canPreview
168
277
  ? html`
169
278
  <sp-action-button
@@ -230,7 +339,7 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
230
339
 
231
340
  // ── Right region: mode actions (Code-mode Export) ──
232
341
  const exportTpl =
233
- !editing && canvasMode === "source"
342
+ !takeover && canvasMode === "source"
234
343
  ? html`
235
344
  <sp-action-button size="s" @click=${ctx.exportFile}>
236
345
  <sp-icon-export slot="icon"></sp-icon-export>
@@ -241,7 +350,7 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
241
350
 
242
351
  return html`
243
352
  <div class="tab-bar">
244
- ${navTpl}
353
+ ${navTpl} ${zoomTpl}
245
354
  <div class="tb-spacer"></div>
246
355
  ${settingsTpl} ${togglesTpl} ${exportTpl}
247
356
  </div>
@@ -342,3 +451,63 @@ function paramPickersTpl(tab: Tab): TemplateResult | typeof nothing {
342
451
  )}
343
452
  `;
344
453
  }
454
+
455
+ // ── Component test-prop fields (M6) ──────────────────────────────────────────
456
+ // The previewParams mirror for component docs: one small field per prop entry (the doc's
457
+ // Plain-data state entries), committed on change so typing never re-renders the bar mid-edit. A
458
+ // Value parses as JSON when it can (numbers, booleans, arrays) and falls back to the raw string;
459
+ // Clearing a field removes the override so the prop returns to its authored default.
460
+
461
+ /**
462
+ * @param {string} raw
463
+ * @returns {JsonValue}
464
+ */
465
+ function parsePropValue(raw: string): JsonValue {
466
+ try {
467
+ return JSON.parse(raw) as JsonValue;
468
+ } catch {
469
+ return raw;
470
+ }
471
+ }
472
+
473
+ /**
474
+ * @param {Tab} tab
475
+ * @returns {TemplateResult | typeof nothing}
476
+ */
477
+ function propFieldsTpl(tab: Tab): TemplateResult | typeof nothing {
478
+ const doc = tab.doc.document;
479
+ if (!isComponentDoc(doc)) {
480
+ return nothing;
481
+ }
482
+ const entries = componentPropEntries(doc);
483
+ if (entries.length === 0) {
484
+ return nothing;
485
+ }
486
+ const { previewProps } = tab.session.ui;
487
+ const display = (v: JsonValue | undefined) =>
488
+ v === undefined ? "" : typeof v === "string" ? v : JSON.stringify(v);
489
+ return html`
490
+ ${entries.map(
491
+ ({ name }) => html`
492
+ <sp-textfield
493
+ size="s"
494
+ quiet
495
+ class="tab-bar-prop"
496
+ placeholder=${name}
497
+ title=${`Test value for ${name}`}
498
+ .value=${display(previewProps?.[name])}
499
+ @change=${(e: Event) => {
500
+ const raw = (e.target as HTMLInputElement).value;
501
+ const next = { ...tab.session.ui.previewProps };
502
+ if (raw === "") {
503
+ delete next[name];
504
+ } else {
505
+ next[name] = parsePropValue(raw);
506
+ }
507
+ updateUi("previewProps", Object.keys(next).length > 0 ? next : null);
508
+ }}
509
+ ></sp-textfield>
510
+ `,
511
+ )}
512
+ `;
513
+ }
@@ -11,14 +11,18 @@ import { classMap } from "lit-html/directives/class-map.js";
11
11
  import { repeat } from "lit-html/directives/repeat.js";
12
12
  import { effect, effectScope } from "../reactivity";
13
13
  import { activateTab, closeTab, workspace } from "../workspace/workspace";
14
+ import { gridTabLabel } from "../grid/grid-source";
14
15
  import type { Tab } from "../tabs/tab";
15
16
  import { showConfirmDialog } from "../ui/layers";
17
+ import { collabState } from "../collab/collab-state";
16
18
  import type { EffectScope } from "@vue/reactivity";
17
19
 
18
20
  let _host: HTMLElement | null = null;
19
21
 
20
22
  let _scope: EffectScope | null = null;
21
23
 
24
+ let _lastActiveId: string | null = null;
25
+
22
26
  /**
23
27
  * Mount the tab strip into the given host element.
24
28
  *
@@ -26,6 +30,7 @@ let _scope: EffectScope | null = null;
26
30
  */
27
31
  export function mount(host: HTMLElement) {
28
32
  _host = host;
33
+ _lastActiveId = null;
29
34
  _scope = effectScope();
30
35
  _scope.run(() => {
31
36
  effect(() => {
@@ -52,13 +57,14 @@ function render() {
52
57
  }
53
58
 
54
59
  if (workspace.tabOrder.length === 0) {
60
+ _lastActiveId = null;
55
61
  litRender(nothing, _host);
56
62
  return;
57
63
  }
58
64
 
59
65
  litRender(
60
66
  html`
61
- <div class="tab-strip">
67
+ <div class="tab-strip" @wheel=${onWheel}>
62
68
  ${repeat(
63
69
  workspace.tabOrder,
64
70
  (id) => id,
@@ -80,7 +86,7 @@ function render() {
80
86
  void requestClose(id);
81
87
  }
82
88
  }}
83
- title=${tab.documentPath || "Untitled"}
89
+ title=${tab.documentPath || gridTabLabel(tab.id) || "Untitled"}
84
90
  >
85
91
  <span class="tab-strip-label">${label}</span>
86
92
  ${isDirty ? html`<span class="tab-strip-dirty">●</span>` : nothing}
@@ -101,10 +107,40 @@ function render() {
101
107
  `,
102
108
  _host,
103
109
  );
110
+
111
+ if (workspace.activeTabId !== _lastActiveId) {
112
+ _lastActiveId = workspace.activeTabId;
113
+ _host
114
+ .querySelector(".tab-strip-tab.active")
115
+ ?.scrollIntoView({ block: "nearest", inline: "nearest" });
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Scroll the strip horizontally from wheel motion so overflowed tabs stay reachable — a plain mouse
121
+ * wheel only emits deltaY, which does nothing in an overflow-x container. The scrollbar is hidden
122
+ * by design, so the wheel is the primary way to reach off-screen tabs.
123
+ *
124
+ * @param {WheelEvent} e
125
+ */
126
+ function onWheel(e: WheelEvent) {
127
+ if (e.ctrlKey) {
128
+ return;
129
+ }
130
+ const el = e.currentTarget as HTMLElement;
131
+ if (el.scrollWidth <= el.clientWidth) {
132
+ return;
133
+ }
134
+ const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
135
+ if (!delta) {
136
+ return;
137
+ }
138
+ e.preventDefault();
139
+ el.scrollLeft += delta;
104
140
  }
105
141
 
106
142
  /**
107
- * Derive a short label from the tab's documentPath.
143
+ * Derive a short label from the tab's documentPath (virtual grid tabs label from their id).
108
144
  *
109
145
  * @param {Tab} tab
110
146
  * @returns {string}
@@ -112,14 +148,34 @@ function render() {
112
148
  function tabLabel(tab: Tab) {
113
149
  const path = tab.documentPath;
114
150
  if (!path) {
115
- return "Untitled";
151
+ return gridTabLabel(tab.id) ?? "Untitled";
116
152
  }
117
153
  const parts = path.split("/");
118
154
  return parts.at(-1);
119
155
  }
120
156
 
121
157
  /**
122
- * Close a tab, prompting if dirty.
158
+ * True when closing this tab would lose unsaved work the user must be warned about: the tab is
159
+ * dirty AND either it is not co-edited, or this client is the last active collaborator on the doc
160
+ * (no other peer is focused on its path). When peers remain, the shared session lives on and the
161
+ * edits are still on the server, so closing is safe.
162
+ *
163
+ * @param {Tab} tab
164
+ */
165
+ export function shouldWarnOnClose(tab: Tab): boolean {
166
+ if (!tab.doc.dirty) {
167
+ return false;
168
+ }
169
+ const state = collabState(tab);
170
+ if (!state.active) {
171
+ return true;
172
+ }
173
+ const peersHere = state.peers.filter((p) => p.state?.focusedPath === tab.documentPath);
174
+ return peersHere.length === 0;
175
+ }
176
+
177
+ /**
178
+ * Close a tab, prompting if closing would lose unsaved work.
123
179
  *
124
180
  * @param {string} id
125
181
  */
@@ -128,7 +184,7 @@ async function requestClose(id: string) {
128
184
  if (!tab) {
129
185
  return;
130
186
  }
131
- if (tab.doc.dirty) {
187
+ if (shouldWarnOnClose(tab)) {
132
188
  const confirmed = await showConfirmDialog(
133
189
  "Unsaved Changes",
134
190
  `"${tabLabel(tab)}" has unsaved changes. Close without saving?`,
@@ -43,6 +43,19 @@ let _rootEl: HTMLElement | null = null;
43
43
 
44
44
  let _ctx: ToolbarCtx | null = null;
45
45
 
46
+ /** Test override for the mac CSD layout — happy-dom forbids redefining navigator.platform. */
47
+ let _isMacOverride: boolean | null = null;
48
+
49
+ /** Force (or restore, with null) the mac/non-mac window-control layout detection. */
50
+ export function setMacPlatformForTests(value: boolean | null): void {
51
+ _isMacOverride = value;
52
+ }
53
+
54
+ /** True on macOS — picks the CSD window-control order (close-first, toolbar-leading). */
55
+ function isMacPlatform(): boolean {
56
+ return _isMacOverride ?? navigator.platform.startsWith("Mac");
57
+ }
58
+
46
59
  let _scope: EffectScope | null = null;
47
60
 
48
61
  const toolbarIconMap = {
@@ -59,6 +72,7 @@ const toolbarIconMap = {
59
72
  "sp-icon-redo": html`<sp-icon-redo slot="icon"></sp-icon-redo>`,
60
73
  "sp-icon-save-floppy": html`<sp-icon-save-floppy slot="icon"></sp-icon-save-floppy>`,
61
74
  "sp-icon-undo": html`<sp-icon-undo slot="icon"></sp-icon-undo>`,
75
+ "sp-icon-view-grid": html`<sp-icon-view-grid slot="icon"></sp-icon-view-grid>`,
62
76
  "sp-icon-view-list": html`<sp-icon-view-list slot="icon"></sp-icon-view-list>`,
63
77
  } as Record<string, TemplateResult>;
64
78
 
@@ -308,13 +322,33 @@ function minimalToolbarTemplate(ctx: ToolbarCtx) {
308
322
  ? html`<sp-icon-rail-right-open slot="icon"></sp-icon-rail-right-open>`
309
323
  : html`<sp-icon-rail-right-close slot="icon"></sp-icon-rail-right-close>`}
310
324
  </sp-action-button>
311
- ${csdTpl}
325
+ ${chatToggleTpl()} ${csdTpl}
326
+ `;
327
+ }
328
+
329
+ /** Toggle for the persistent AI chat sidebar (selected = open). Shared by both layouts. */
330
+ function chatToggleTpl() {
331
+ return html`
332
+ <sp-action-button
333
+ quiet
334
+ size="s"
335
+ title="Toggle Assistant"
336
+ ?selected=${!view.chatPanelCollapsed}
337
+ @click=${() => {
338
+ view.chatPanelCollapsed = !view.chatPanelCollapsed;
339
+ applyPanelCollapse();
340
+ render();
341
+ }}
342
+ >
343
+ <sp-icon-chat slot="icon"></sp-icon-chat>
344
+ </sp-action-button>
312
345
  `;
313
346
  }
314
347
 
315
348
  const modes = [
316
349
  { iconTag: "sp-icon-edit", key: "edit", label: "Edit" },
317
350
  { iconTag: "sp-icon-artboard", key: "design", label: "Design" },
351
+ { iconTag: "sp-icon-view-grid", key: "grid", label: "Grid" },
318
352
  { iconTag: "sp-icon-code", key: "source", label: "Code" },
319
353
  { iconTag: "sp-icon-brush", key: "stylebook", label: "Stylebook" },
320
354
  ];
@@ -399,7 +433,7 @@ function toolbarTemplate() {
399
433
  };
400
434
  }
401
435
  ).__jxPlatform?.windowControls;
402
- const isMac = navigator.platform.startsWith("Mac");
436
+ const isMac = isMacPlatform();
403
437
  const csdTpl = windowControls
404
438
  ? isMac
405
439
  ? html`
@@ -545,6 +579,6 @@ function toolbarTemplate() {
545
579
  ? html`<sp-icon-rail-right-open slot="icon"></sp-icon-rail-right-open>`
546
580
  : html`<sp-icon-rail-right-close slot="icon"></sp-icon-rail-right-close>`}
547
581
  </sp-action-button>
548
- ${isMac ? nothing : csdTpl}
582
+ ${chatToggleTpl()} ${isMac ? nothing : csdTpl}
549
583
  `;
550
584
  }