@jxsuite/studio 1.0.0 → 1.2.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 (89) hide show
  1. package/dist/iframe-entry.js +760 -65
  2. package/dist/iframe-entry.js.map +16 -12
  3. package/dist/studio.css +1333 -0
  4. package/dist/studio.js +71573 -50074
  5. package/dist/studio.js.map +123 -88
  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 +37 -1
  11. package/src/canvas/iframe-eval.ts +155 -0
  12. package/src/canvas/iframe-host.ts +193 -6
  13. package/src/canvas/iframe-inline-edit.ts +107 -1
  14. package/src/canvas/iframe-protocol.ts +56 -3
  15. package/src/canvas/iframe-render.ts +53 -14
  16. package/src/canvas/site-style-css.ts +121 -0
  17. package/src/collab/collab-session.ts +23 -8
  18. package/src/collab/collab-state.ts +6 -3
  19. package/src/component-props.ts +104 -0
  20. package/src/editor/inline-edit-apply.ts +36 -1
  21. package/src/editor/inline-edit.ts +58 -1
  22. package/src/editor/shortcuts.ts +41 -12
  23. package/src/files/file-ops.ts +14 -0
  24. package/src/files/files.ts +53 -1
  25. package/src/grid/cell-editors.ts +288 -0
  26. package/src/grid/cell-popovers.ts +108 -0
  27. package/src/grid/csv-codec.ts +122 -0
  28. package/src/grid/edit-buffer.ts +490 -0
  29. package/src/grid/grid-controller.ts +417 -0
  30. package/src/grid/grid-layout.ts +83 -0
  31. package/src/grid/grid-open.ts +167 -0
  32. package/src/grid/grid-panel.ts +302 -0
  33. package/src/grid/grid-source.ts +210 -0
  34. package/src/grid/grid-view.ts +367 -0
  35. package/src/grid/schema-columns.ts +261 -0
  36. package/src/grid/sources/connector-source.ts +217 -0
  37. package/src/grid/sources/content-source.ts +542 -0
  38. package/src/grid/sources/csv-file-source.ts +247 -0
  39. package/src/grid/tabulator-tables.d.ts +120 -0
  40. package/src/panels/block-action-bar.ts +8 -3
  41. package/src/panels/chat-panel.ts +98 -0
  42. package/src/panels/data-grid.ts +9 -387
  43. package/src/panels/editors.ts +43 -17
  44. package/src/panels/events-panel.ts +137 -44
  45. package/src/panels/formula-workspace.ts +379 -0
  46. package/src/panels/frontmatter-fields.ts +231 -0
  47. package/src/panels/frontmatter-panel.ts +132 -0
  48. package/src/panels/git-panel.ts +1 -1
  49. package/src/panels/head-panel.ts +11 -175
  50. package/src/panels/properties-panel.ts +154 -144
  51. package/src/panels/right-panel.ts +9 -47
  52. package/src/panels/signals-panel.ts +115 -23
  53. package/src/panels/statement-editor.ts +710 -0
  54. package/src/panels/style-panel.ts +76 -22
  55. package/src/panels/stylebook-panel.ts +0 -2
  56. package/src/panels/tab-bar.ts +214 -10
  57. package/src/panels/tab-strip.ts +62 -6
  58. package/src/panels/toolbar.ts +37 -3
  59. package/src/services/ai-project-tools.ts +541 -0
  60. package/src/services/ai-session-store.ts +28 -0
  61. package/src/services/ai-system-prompt.ts +194 -24
  62. package/src/services/ai-tools.ts +88 -21
  63. package/src/services/automation.ts +16 -0
  64. package/src/services/document-assistant.ts +81 -11
  65. package/src/services/gated-registry.ts +72 -0
  66. package/src/services/live-preview.ts +0 -0
  67. package/src/services/preview-eval.ts +68 -0
  68. package/src/services/project-adoption.ts +31 -0
  69. package/src/settings/css-vars-editor.ts +122 -4
  70. package/src/site-context.ts +2 -0
  71. package/src/store.ts +4 -0
  72. package/src/studio.ts +90 -52
  73. package/src/tabs/patch-ops.ts +25 -0
  74. package/src/tabs/tab.ts +46 -1
  75. package/src/tabs/transact.ts +60 -13
  76. package/src/types.ts +12 -0
  77. package/src/ui/dynamic-slot.ts +272 -0
  78. package/src/ui/expression-editor.ts +423 -125
  79. package/src/ui/field-row.ts +5 -0
  80. package/src/ui/formula-catalog.ts +557 -0
  81. package/src/ui/formula-chips.ts +216 -0
  82. package/src/ui/formula-palette.ts +211 -0
  83. package/src/ui/layers.ts +40 -0
  84. package/src/ui/media-picker.ts +1 -1
  85. package/src/ui/panel-resize.ts +15 -1
  86. package/src/utils/canvas-media.ts +23 -0
  87. package/src/utils/preview-format.ts +26 -0
  88. package/src/view.ts +4 -4
  89. package/src/workspace/workspace.ts +14 -0
@@ -22,7 +22,8 @@ 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 { parseMediaEntries } from "../utils/canvas-media";
25
+ import { renderDynamicSlot } from "../ui/dynamic-slot";
26
+ import { parseMediaEntries, schemeOfQuery } from "../utils/canvas-media";
26
27
  import { getEffectiveMedia, getEffectiveStyle } from "../site-context";
27
28
  import { computeInheritedStyle } from "../utils/inherited-style";
28
29
  import { mediaDisplayName } from "./shared";
@@ -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,10 +301,32 @@ 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;
293
315
 
316
+ // ── Scheme-layer routing (spec §9.5) ────────────────────────────────────────
317
+ // With the tab-bar scheme control forcing a scheme that has a matching declared scheme query,
318
+ // Base-context edits target that scheme's `@--name` block — no extra sidebar tabs. Breakpoint
319
+ // Tabs stay breakpoint-scoped (scheme × breakpoint compound blocks are unsupported).
320
+ const forcedScheme = tab.session.ui.previewColorScheme;
321
+ const schemeLayer =
322
+ mediaTab === null && (forcedScheme === "light" || forcedScheme === "dark")
323
+ ? (Object.entries(getEffectiveMedia(tab.doc.document.$media) ?? {}).find(
324
+ ([, q]) => schemeOfQuery(String(q)) === forcedScheme,
325
+ )?.[0] ?? null)
326
+ : null;
327
+ // The media context edits actually target: a valid breakpoint tab, else the scheme layer.
328
+ const editMedia = mediaTab && mediaNames.length > 0 ? mediaTab : schemeLayer;
329
+
294
330
  // ── Media tabs template ──────────────────────────────────────────────────
295
331
  const mediaTabsT =
296
332
  mediaNames.length > 0
@@ -315,7 +351,9 @@ function styleSidebarTemplate(
315
351
  : nothing;
316
352
 
317
353
  // ── Selector dropdown ──────────────────────────────────────────────────────
318
- const contextStyle = mediaTab ? (style[`@${mediaTab}`] as Record<string, unknown>) || {} : style;
354
+ const contextStyle = editMedia
355
+ ? (style[`@${editMedia}`] as Record<string, unknown>) || {}
356
+ : style;
319
357
  const existingSelectors = Object.keys(contextStyle).filter((s) => isNestedSelector(s));
320
358
  const existingSet = new Set(existingSelectors);
321
359
  const commonSet = new Set(COMMON_SELECTORS);
@@ -391,10 +429,21 @@ function styleSidebarTemplate(
391
429
  </sp-picker>
392
430
  `;
393
431
 
394
- // ── Combined toolbar (media tabs + selector) ───────────────────────────────
432
+ // ── Combined toolbar (media tabs + scheme badge + selector) ────────────────
433
+ // No scheme tabs — the tab-bar control is the one switch; the badge shows where edits land.
434
+ const schemeBadgeT = schemeLayer
435
+ ? html`
436
+ <span
437
+ class="style-scheme-badge"
438
+ title="Edits target the @${schemeLayer} variant — set the tab-bar scheme control to Auto to edit base styles"
439
+ >
440
+ ${mediaDisplayName(schemeLayer)} variant
441
+ </span>
442
+ `
443
+ : nothing;
395
444
  const toolbarT = html`
396
445
  <div class="style-toolbar">
397
- <div class="style-toolbar-tabs">${mediaTabsT}</div>
446
+ <div class="style-toolbar-tabs">${mediaTabsT} ${schemeBadgeT}</div>
398
447
  ${selectorT}
399
448
  </div>
400
449
  `;
@@ -428,15 +477,15 @@ function styleSidebarTemplate(
428
477
  // ── Determine the active style object ──────────────────────────────────────
429
478
  let activeStyle: JxStyle;
430
479
  let commitMutate: StyleMutateFn;
431
- if (activeSelector && isTagPath(activeSelector) && mediaTab && mediaNames.length > 0) {
432
- const mediaObj = getNestedStyle(style, `@${mediaTab}`) ?? {};
480
+ if (activeSelector && isTagPath(activeSelector) && editMedia) {
481
+ const mediaObj = getNestedStyle(style, `@${editMedia}`) ?? {};
433
482
  activeStyle = resolveNestedTagStyle(mediaObj, activeSelector);
434
483
  const stylePath = activeSelector.split(" ");
435
484
  commitMutate = (t: Tab, prop: string, val: string | Record<string, unknown> | undefined) =>
436
485
  mutateUpdateMediaNestedStylePath(
437
486
  t,
438
487
  sel,
439
- mediaTab,
488
+ editMedia,
440
489
  stylePath,
441
490
  prop,
442
491
  val as string | undefined,
@@ -446,14 +495,14 @@ function styleSidebarTemplate(
446
495
  const stylePath = activeSelector.split(" ");
447
496
  commitMutate = (t: Tab, prop: string, val: string | Record<string, unknown> | undefined) =>
448
497
  mutateUpdateNestedStylePath(t, sel, stylePath, prop, val as string | undefined);
449
- } else if (activeSelector && mediaTab && mediaNames.length > 0) {
450
- const mediaObj = getNestedStyle(style, `@${mediaTab}`) ?? {};
498
+ } else if (activeSelector && editMedia) {
499
+ const mediaObj = getNestedStyle(style, `@${editMedia}`) ?? {};
451
500
  activeStyle = getNestedStyle(mediaObj, activeSelector) ?? {};
452
501
  commitMutate = (t: Tab, prop: string, val: string | Record<string, unknown> | undefined) =>
453
502
  mutateUpdateMediaNestedStyle(
454
503
  t,
455
504
  sel,
456
- mediaTab,
505
+ editMedia,
457
506
  activeSelector,
458
507
  prop,
459
508
  val as string | undefined,
@@ -464,29 +513,32 @@ function styleSidebarTemplate(
464
513
  mutateUpdateNestedStyle(t, sel, activeSelector, prop, val as string | undefined);
465
514
  } else {
466
515
  activeStyle = {};
467
- const inMediaTab = mediaTab !== null && mediaNames.length > 0;
468
- const flatSource = inMediaTab ? (getNestedStyle(style, `@${mediaTab}`) ?? {}) : style;
516
+ const inMediaCtx = editMedia !== null;
517
+ const flatSource = inMediaCtx ? (getNestedStyle(style, `@${editMedia}`) ?? {}) : style;
469
518
  for (const [p, v] of Object.entries(flatSource)) {
470
519
  if (typeof v !== "object") {
471
520
  activeStyle[p] = v;
472
521
  }
473
522
  }
474
- commitMutate = inMediaTab
523
+ commitMutate = inMediaCtx
475
524
  ? (t: Tab, prop: string, val: string | Record<string, unknown> | undefined) =>
476
- mutateUpdateMediaStyle(t, sel, mediaTab, prop, val as string | undefined)
525
+ mutateUpdateMediaStyle(t, sel, editMedia, prop, val as string | undefined)
477
526
  : (t: Tab, prop: string, val: string | Record<string, unknown> | undefined) =>
478
527
  mutateUpdateStyle(t, sel, prop, val as string | undefined);
479
528
  }
480
529
  const commitStyle = (prop: string, val?: string | Record<string, unknown> | undefined) =>
481
530
  transactDoc(activeTab.value, (t) => commitMutate(t, prop, val));
482
531
 
483
- // ── Compute inherited style from higher breakpoints ──────────────────────
484
- const inheritedStyle: Record<string, string | number> = computeInheritedStyle(
485
- style,
486
- mediaNames,
487
- mediaTab,
488
- activeSelector,
489
- );
532
+ // ── Compute inherited style ────────────────────────────────────────────────
533
+ // Scheme layer: the base styles show through as inherited (placeholders); breakpoint tabs
534
+ // Inherit from higher breakpoints as before.
535
+ const inheritedStyle: Record<string, string | number> = schemeLayer
536
+ ? (Object.fromEntries(
537
+ Object.entries(
538
+ activeSelector ? (getNestedStyle(style, activeSelector) ?? {}) : style,
539
+ ).filter(([, v]) => typeof v !== "object"),
540
+ ) as Record<string, string | number>)
541
+ : computeInheritedStyle(style, mediaNames, mediaTab, activeSelector);
490
542
 
491
543
  // Auto-open sections that have properties
492
544
  const newSections = autoOpenSections({ style: activeStyle }, tab.session.ui.styleSections);
@@ -660,6 +712,8 @@ function styleSidebarTemplate(
660
712
  isWarning,
661
713
  sec.$layout === "grid",
662
714
  inheritedStyle[prop] as string | undefined,
715
+ templateSignals,
716
+ `style|${sel.join("/")}|${editMedia ?? ""}|${activeSelector ?? ""}|${prop}`,
663
717
  ),
664
718
  );
665
719
  }
@@ -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,15 @@ 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";
18
+ import { isSchemeQuery } from "../utils/canvas-media";
17
19
  import { dynamicRouteParams, loadParamValues, pagePathsDef } from "../page-params";
20
+ import { componentPropEntries, isComponentDoc } from "../component-props";
18
21
  import { mediaDisplayName } from "./shared";
19
22
  import type { ParamValues } from "../page-params";
20
23
  import type { Tab } from "../tabs/tab";
21
- import type { DocumentStackEntry, FunctionEditDef } from "../types";
24
+ import type { DocumentStackEntry, FormulaEditDef, FunctionEditDef, JsonValue } from "../types";
22
25
  import type { EffectScope } from "@vue/reactivity";
23
26
  import type { TemplateResult } from "lit-html";
24
27
 
@@ -26,6 +29,7 @@ interface TabBarCtx {
26
29
  navigateBack: () => void;
27
30
  navigateToLevel: (level: number) => void;
28
31
  closeFunctionEditor: () => void;
32
+ closeFormulaWorkspace: () => void;
29
33
  exportFile: () => void;
30
34
  getCanvasMode: () => string;
31
35
  parseMediaEntries: (media: Record<string, string> | null | undefined) => {
@@ -67,11 +71,16 @@ export function mount(host: HTMLElement, ctx: TabBarCtx) {
67
71
  void tab.documentPath;
68
72
  void tab.session.documentStack.length;
69
73
  void tab.session.ui.canvasMode;
74
+ void tab.session.ui.editZoom;
75
+ void tab.session.ui.editingFormula;
70
76
  void tab.session.ui.editingFunction;
71
77
  void tab.session.ui.featureToggles;
72
78
  void tab.session.ui.preview;
79
+ void tab.session.ui.previewColorScheme;
73
80
  void tab.session.ui.previewParams;
81
+ void tab.session.ui.previewProps;
74
82
  void tab.session.ui.showLayout;
83
+ void tab.session.ui.zoom;
75
84
  }
76
85
  render();
77
86
  });
@@ -110,9 +119,13 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
110
119
  };
111
120
  const canvasMode = ctx.getCanvasMode();
112
121
  const editing = S.ui.editingFunction as FunctionEditDef | null;
122
+ const formulaEditing = S.ui.editingFormula as FormulaEditDef | null;
123
+ // Any full-screen canvas takeover (function editor or formula workspace) suppresses the zoom
124
+ // Widget, the view-settings cluster, and the Export action below.
125
+ const takeover = Boolean(editing) || Boolean(formulaEditing);
113
126
  const hasStack = S.documentStack && S.documentStack.length > 0;
114
127
 
115
- // ── Left region: navigation context (function editor takes precedence over the stack) ──
128
+ // ── Left region: navigation context (function editor > formula workspace > document stack) ──
116
129
  let navTpl: TemplateResult | typeof nothing = nothing;
117
130
  if (editing) {
118
131
  const docName = S.documentPath?.split("/").pop() || S.document?.tagName || "document";
@@ -128,6 +141,27 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
128
141
  <span class="breadcrumb-item current">${funcLabel}</span>
129
142
  </div>
130
143
  `;
144
+ } else if (formulaEditing) {
145
+ const docName = S.documentPath?.split("/").pop() || S.document?.tagName || "document";
146
+ const formulaLabel =
147
+ formulaEditing.type === "def"
148
+ ? `fx ${formulaEditing.defName}`
149
+ : `fx ${formulaEditing.eventKey}`;
150
+ navTpl = html`
151
+ <div class="breadcrumb">
152
+ <sp-action-button
153
+ size="s"
154
+ title="Close formula workspace"
155
+ @click=${ctx.closeFormulaWorkspace}
156
+ >
157
+ <sp-icon-back slot="icon"></sp-icon-back>
158
+ Back
159
+ </sp-action-button>
160
+ <span class="breadcrumb-item">${docName}</span>
161
+ <span class="breadcrumb-sep"> › </span>
162
+ <span class="breadcrumb-item current">${formulaLabel}</span>
163
+ </div>
164
+ `;
131
165
  } else if (hasStack) {
132
166
  navTpl = html`
133
167
  <div class="breadcrumb">
@@ -150,6 +184,82 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
150
184
  `;
151
185
  }
152
186
 
187
+ // ── Left region: zoom widget (every mode with a zoom concept) ──
188
+ // Gates on the EFFECTIVE mode: edit drives the content-reflow `editZoom`, while design /
189
+ // Stylebook / git-diff / preview all render on the panzoom surface and drive `ui.zoom` (with the
190
+ // Panzoom-only fit-to-screen action).
191
+ let zoomTpl: TemplateResult | typeof nothing = nothing;
192
+ if (!takeover && canvasMode === "edit") {
193
+ const editZoom = S.ui.editZoom ?? 1;
194
+ zoomTpl = html`
195
+ <sp-action-group compact size="s" class="tb-zoom">
196
+ <sp-action-button
197
+ size="s"
198
+ title="Zoom out (Ctrl+-)"
199
+ @click=${() => setEditZoom((tab.session.ui.editZoom ?? 1) / 1.2)}
200
+ >
201
+ −
202
+ </sp-action-button>
203
+ <sp-action-button
204
+ size="s"
205
+ class="tb-zoom-label"
206
+ title="Reset to 100% (Ctrl+0)"
207
+ @click=${() => setEditZoom(1)}
208
+ >
209
+ ${Math.round(editZoom * 100)}%
210
+ </sp-action-button>
211
+ <sp-action-button
212
+ size="s"
213
+ title="Zoom in (Ctrl+=)"
214
+ @click=${() => setEditZoom((tab.session.ui.editZoom ?? 1) * 1.2)}
215
+ >
216
+ +
217
+ </sp-action-button>
218
+ </sp-action-group>
219
+ `;
220
+ } else if (
221
+ !takeover &&
222
+ (canvasMode === "design" ||
223
+ canvasMode === "stylebook" ||
224
+ canvasMode === "git-diff" ||
225
+ canvasMode === "preview")
226
+ ) {
227
+ const zoom = S.ui.zoom ?? 1;
228
+ const setPanZoom = (next: number) => {
229
+ tab.session.ui.zoom = Math.min(5, Math.max(0.05, next));
230
+ applyTransform();
231
+ };
232
+ zoomTpl = html`
233
+ <sp-action-group compact size="s" class="tb-zoom">
234
+ <sp-action-button
235
+ size="s"
236
+ title="Zoom out (Ctrl+-)"
237
+ @click=${() => setPanZoom((tab.session.ui.zoom ?? 1) / 1.2)}
238
+ >
239
+ −
240
+ </sp-action-button>
241
+ <sp-action-button
242
+ size="s"
243
+ class="tb-zoom-label"
244
+ title="Reset to 100% (Ctrl+0)"
245
+ @click=${() => resetZoom()}
246
+ >
247
+ ${Math.round(zoom * 100)}%
248
+ </sp-action-button>
249
+ <sp-action-button
250
+ size="s"
251
+ title="Zoom in (Ctrl+=)"
252
+ @click=${() => setPanZoom((tab.session.ui.zoom ?? 1) * 1.2)}
253
+ >
254
+ +
255
+ </sp-action-button>
256
+ <sp-action-button size="s" title="Fit to screen" @click=${() => fitToScreen()}>
257
+ Fit
258
+ </sp-action-button>
259
+ </sp-action-group>
260
+ `;
261
+ }
262
+
153
263
  // ── Right region: view settings cluster (edit/design base modes only) ──
154
264
  // Gates on the BASE mode (not the effective mode): the cluster stays visible while the preview
155
265
  // Toggle is on so it can be toggled back off.
@@ -160,10 +270,11 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
160
270
  (S.documentPath.startsWith("pages/") || S.documentPath.startsWith("./pages/")),
161
271
  );
162
272
  let settingsTpl: TemplateResult | typeof nothing = nothing;
163
- if (!editing && (baseMode === "edit" || baseMode === "design")) {
273
+ if (!takeover && (baseMode === "edit" || baseMode === "design")) {
164
274
  const canPreview = tab.capabilities.modes.includes("preview");
165
275
  const hasLayout = isPage && Boolean(getEffectiveLayoutPath(S.document?.$layout));
166
- const pickersTpl = isPage ? paramPickersTpl(tab) : nothing;
276
+ // Pages get route-param pickers; component docs get test-prop fields (M6) — same slot.
277
+ const pickersTpl = isPage ? paramPickersTpl(tab) : propFieldsTpl(tab);
167
278
  const previewTpl = canPreview
168
279
  ? html`
169
280
  <sp-action-button
@@ -199,13 +310,46 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
199
310
  }
200
311
  }
201
312
 
202
- // ── Right region: media feature toggles ──
313
+ // ── Right region: color-scheme preview (projects that declare a scheme query, spec §9.5) ──
314
+ // One switch per tab: drives the canvas data-color-scheme attribute and (in the style
315
+ // Sidebar) which scheme layer edits target. Ungated — works in every canvas mode.
203
316
  const { featureQueries } = ctx.parseMediaEntries(getEffectiveMedia(S.document?.$media));
317
+ const schemeQueries = featureQueries.filter(({ query }) => isSchemeQuery(query));
318
+ const plainQueries = featureQueries.filter(({ query }) => !isSchemeQuery(query));
319
+ const scheme = S.ui.previewColorScheme ?? "auto";
320
+ const schemeTpl =
321
+ schemeQueries.length > 0
322
+ ? html`
323
+ <sp-action-group compact size="s" class="tb-scheme">
324
+ ${(
325
+ [
326
+ ["auto", "Auto", "Follow the OS color scheme"],
327
+ ["light", "Light", "Force the light scheme"],
328
+ ["dark", "Dark", "Force the dark scheme"],
329
+ ] as const
330
+ ).map(
331
+ ([value, label, title]) => html`
332
+ <sp-action-button
333
+ toggles
334
+ size="s"
335
+ title=${title}
336
+ ?selected=${scheme === value}
337
+ @click=${() => updateUi("previewColorScheme", value)}
338
+ >
339
+ ${label}
340
+ </sp-action-button>
341
+ `,
342
+ )}
343
+ </sp-action-group>
344
+ `
345
+ : nothing;
346
+
347
+ // ── Right region: media feature toggles (non-scheme queries) ──
204
348
  const togglesTpl =
205
- featureQueries.length > 0
349
+ plainQueries.length > 0
206
350
  ? html`
207
351
  <sp-action-group compact size="s">
208
- ${featureQueries.map(
352
+ ${plainQueries.map(
209
353
  ({ name, query }: { name: string; query: string }) => html`
210
354
  <sp-action-button
211
355
  toggles
@@ -230,7 +374,7 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
230
374
 
231
375
  // ── Right region: mode actions (Code-mode Export) ──
232
376
  const exportTpl =
233
- !editing && canvasMode === "source"
377
+ !takeover && canvasMode === "source"
234
378
  ? html`
235
379
  <sp-action-button size="s" @click=${ctx.exportFile}>
236
380
  <sp-icon-export slot="icon"></sp-icon-export>
@@ -241,9 +385,9 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
241
385
 
242
386
  return html`
243
387
  <div class="tab-bar">
244
- ${navTpl}
388
+ ${navTpl} ${zoomTpl}
245
389
  <div class="tb-spacer"></div>
246
- ${settingsTpl} ${togglesTpl} ${exportTpl}
390
+ ${settingsTpl} ${schemeTpl} ${togglesTpl} ${exportTpl}
247
391
  </div>
248
392
  `;
249
393
  }
@@ -342,3 +486,63 @@ function paramPickersTpl(tab: Tab): TemplateResult | typeof nothing {
342
486
  )}
343
487
  `;
344
488
  }
489
+
490
+ // ── Component test-prop fields (M6) ──────────────────────────────────────────
491
+ // The previewParams mirror for component docs: one small field per prop entry (the doc's
492
+ // Plain-data state entries), committed on change so typing never re-renders the bar mid-edit. A
493
+ // Value parses as JSON when it can (numbers, booleans, arrays) and falls back to the raw string;
494
+ // Clearing a field removes the override so the prop returns to its authored default.
495
+
496
+ /**
497
+ * @param {string} raw
498
+ * @returns {JsonValue}
499
+ */
500
+ function parsePropValue(raw: string): JsonValue {
501
+ try {
502
+ return JSON.parse(raw) as JsonValue;
503
+ } catch {
504
+ return raw;
505
+ }
506
+ }
507
+
508
+ /**
509
+ * @param {Tab} tab
510
+ * @returns {TemplateResult | typeof nothing}
511
+ */
512
+ function propFieldsTpl(tab: Tab): TemplateResult | typeof nothing {
513
+ const doc = tab.doc.document;
514
+ if (!isComponentDoc(doc)) {
515
+ return nothing;
516
+ }
517
+ const entries = componentPropEntries(doc);
518
+ if (entries.length === 0) {
519
+ return nothing;
520
+ }
521
+ const { previewProps } = tab.session.ui;
522
+ const display = (v: JsonValue | undefined) =>
523
+ v === undefined ? "" : typeof v === "string" ? v : JSON.stringify(v);
524
+ return html`
525
+ ${entries.map(
526
+ ({ name }) => html`
527
+ <sp-textfield
528
+ size="s"
529
+ quiet
530
+ class="tab-bar-prop"
531
+ placeholder=${name}
532
+ title=${`Test value for ${name}`}
533
+ .value=${display(previewProps?.[name])}
534
+ @change=${(e: Event) => {
535
+ const raw = (e.target as HTMLInputElement).value;
536
+ const next = { ...tab.session.ui.previewProps };
537
+ if (raw === "") {
538
+ delete next[name];
539
+ } else {
540
+ next[name] = parsePropValue(raw);
541
+ }
542
+ updateUi("previewProps", Object.keys(next).length > 0 ? next : null);
543
+ }}
544
+ ></sp-textfield>
545
+ `,
546
+ )}
547
+ `;
548
+ }
@@ -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?`,