@jxsuite/studio 1.1.0 → 1.3.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.
@@ -334,7 +334,9 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
334
334
  * {kind:"git"} notices this handler ignores.
335
335
  */
336
336
  subscribeFileEvents(handler: (events: FsEvent[]) => void) {
337
- if (typeof WebSocket === "undefined" || typeof location === "undefined") {
337
+ // No project means an empty base, so the bare `/events` URL hits no gateway route.
338
+ // That fails in a reconnect loop; mirror `collab`'s guard and degrade to no-op on the hub.
339
+ if (!project || typeof WebSocket === "undefined" || typeof location === "undefined") {
338
340
  return () => {};
339
341
  }
340
342
  let socket: WebSocket | null = null;
@@ -36,3 +36,8 @@ export function getProjectList(): ProjectListEntry[] {
36
36
  export function resetProjectList(): void {
37
37
  cache = [];
38
38
  }
39
+
40
+ /** Seed seam for tests and the screenshot automation hook (staged catalogues). */
41
+ export function seedProjectList(entries: ProjectListEntry[]): void {
42
+ cache = entries;
43
+ }
@@ -339,6 +339,23 @@ function render(): void {
339
339
  }
340
340
  }
341
341
 
342
+ /**
343
+ * Automation-only seam (scripts/screenshots): open the modal directly in its connected state with a
344
+ * canned deployment, bypassing {@link loadConnection} so no Cloudflare request ever fires. The
345
+ * active project's `build.deploy` block still supplies the connected project/URL line.
346
+ */
347
+ export function seedPublishConnected(options: {
348
+ accountId?: string;
349
+ deployment: PagesDeploymentInfo;
350
+ }): void {
351
+ _connection = { accountId: options.accountId ?? "demo-account", connected: true };
352
+ _accounts = [];
353
+ _deployment = options.deployment;
354
+ _error = "";
355
+ _busy = false;
356
+ render();
357
+ }
358
+
342
359
  /** Open the publish modal for the active project. */
343
360
  export function openPublishPanel(): void {
344
361
  const config = currentConfig();
@@ -10,18 +10,30 @@
10
10
  */
11
11
 
12
12
  import { renderOnly, updateUi } from "../store";
13
- import { activeTab, workspace } from "../workspace/workspace";
13
+ import { setProjectState } from "../state";
14
+ import { seedProjectList } from "../project-list";
15
+ import { activeTab, closeAllTabs, workspace } from "../workspace/workspace";
16
+ import type { ProjectListEntry } from "../types";
14
17
  import { applyPanelCollapse, view } from "../view";
15
18
  import { setEditZoom as applyEditZoomLevel } from "../canvas/canvas-utils";
19
+ import { collabState } from "../collab/collab-state";
20
+ import type { PeerPresence } from "../collab/collab-state";
21
+ import type { SeededAssistantMessage } from "../panels/ai-panel";
22
+ import type { PagesDeploymentInfo } from "../publish/pages-service";
16
23
  import type { JxPath } from "../state";
17
24
 
18
25
  /** Callbacks injected from studio.ts (module-local helpers or imports kept out of this module). */
19
26
  export interface AutomationDeps {
20
27
  getCanvasMode: () => string;
21
28
  openBrowseModal: () => void;
29
+ openConnectorGrid: (connection: string | undefined, table: string) => void;
22
30
  openNewProjectModal: () => void;
31
+ openQuickSearchPalette: () => void;
32
+ openSettingsModal: (section?: string) => void;
23
33
  render: () => void;
24
34
  renderActivityBar: () => void;
35
+ seedAssistantMessages: (messages: SeededAssistantMessage[]) => void;
36
+ seedPublishConnected: (options: { accountId?: string; deployment: PagesDeploymentInfo }) => void;
25
37
  setCanvasMode: (mode: string) => void;
26
38
  statusMessage: (text: string) => void;
27
39
  }
@@ -30,7 +42,14 @@ export interface AutomationApi {
30
42
  editDef: (defName: string) => void;
31
43
  editFunction: (path: JxPath, eventKey: string) => void;
32
44
  openBrowse: () => void;
45
+ openDataGrid: (options: { connection?: string; table: string }) => void;
33
46
  openNewProject: () => void;
47
+ openQuickSearch: () => void;
48
+ openSettings: (section?: string) => void;
49
+ seedAssistant: (options: { messages: SeededAssistantMessage[] }) => void;
50
+ seedCollab: (options: { peers: PeerPresence[] }) => void;
51
+ seedPublish: (options: { accountId?: string; deployment: PagesDeploymentInfo }) => void;
52
+ showWelcome: (options?: { projects?: ProjectListEntry[] }) => void;
34
53
  getState: () => {
35
54
  activeTabId: string | null;
36
55
  canvasMode: string;
@@ -66,9 +85,56 @@ export function createAutomationApi(deps: AutomationDeps): AutomationApi {
66
85
  openBrowse() {
67
86
  deps.openBrowseModal();
68
87
  },
88
+ openDataGrid(options: { connection?: string; table: string }) {
89
+ deps.openConnectorGrid(options.connection, options.table);
90
+ },
69
91
  openNewProject() {
70
92
  deps.openNewProjectModal();
71
93
  },
94
+ openQuickSearch() {
95
+ deps.openQuickSearchPalette();
96
+ },
97
+ openSettings(section?: string) {
98
+ deps.openSettingsModal(section);
99
+ },
100
+ seedAssistant(options: { messages: SeededAssistantMessage[] }) {
101
+ deps.seedAssistantMessages(options.messages);
102
+ },
103
+ seedCollab(options: { peers: PeerPresence[] }) {
104
+ // Stage a live co-editing session on the active tab: the toolbar's presence chips and the
105
+ // Canvas peer-selection boxes both read this same reactive store.
106
+ const tab = activeTab.value;
107
+ if (!tab) {
108
+ return;
109
+ }
110
+ const state = collabState(tab);
111
+ state.status = "synced";
112
+ state.active = true;
113
+ state.peers = options.peers.map((peer) => ({
114
+ ...peer,
115
+ state: {
116
+ ...peer.state,
117
+ focusedPath: peer.state.focusedPath ?? tab.documentPath,
118
+ },
119
+ }));
120
+ deps.render();
121
+ },
122
+ seedPublish(options: { accountId?: string; deployment: PagesDeploymentInfo }) {
123
+ deps.seedPublishConnected(options);
124
+ },
125
+ showWelcome(options?: { projects?: ProjectListEntry[] }) {
126
+ // The devserver platform auto-opens the repo-root project on boot; the welcome
127
+ // Screen only renders with no project and no tabs, so stage that state directly.
128
+ // A staged catalogue replaces the real one (screenshots must not leak local paths).
129
+ closeAllTabs();
130
+ setProjectState(null);
131
+ if (options?.projects) {
132
+ seedProjectList(options.projects);
133
+ }
134
+ view.chatPanelCollapsed = true;
135
+ applyPanelCollapse();
136
+ deps.render();
137
+ },
72
138
  getState() {
73
139
  return {
74
140
  activeTabId: workspace.activeTabId,
@@ -1,17 +1,26 @@
1
1
  /// <reference lib="dom" />
2
2
  /**
3
- * CSS Variables editor — form-based editor for managing design tokens. Colors and fonts are NOT
4
- * media-aware; sizes/spacing are media-aware.
3
+ * CSS Variables editor — form-based editor for managing design tokens. Colors are scheme-aware
4
+ * (per-scheme overrides in `@--dark`-style blocks, spec §9.5); sizes/spacing are media-aware; fonts
5
+ * are neither.
5
6
  */
6
7
 
7
8
  import { html, render as litRender, nothing } from "lit-html";
8
9
  import { ref } from "lit-html/directives/ref.js";
9
10
  import { projectState } from "../store";
10
11
  import { getEffectiveMedia, updateSiteConfig } from "../site-context";
12
+ import { postSiteStyleToLiveHosts } from "../canvas/iframe-host";
13
+ import { isSchemeQuery, schemeOfQuery } from "../utils/canvas-media";
11
14
  import { friendlyNameToVar, varDisplayName } from "../utils/studio-utils";
12
15
 
13
16
  import type { JxStyle } from "@jxsuite/schema/types";
14
17
 
18
+ /** A declared scheme query in $media: the block key name plus the scheme it targets. */
19
+ export interface SchemeEntry {
20
+ name: string;
21
+ scheme: "light" | "dark";
22
+ }
23
+
15
24
  /** @param {HTMLElement} container */
16
25
  export function renderCssVarsEditor(container: HTMLElement) {
17
26
  const config = projectState?.projectConfig || {};
@@ -50,10 +59,20 @@ export function renderCssVarsEditor(container: HTMLElement) {
50
59
  }
51
60
  }
52
61
 
53
- const mediaNames = media ? Object.keys(media).filter((m) => m !== "--") : [];
62
+ const mediaNames = media
63
+ ? Object.keys(media).filter((m) => m !== "--" && !isSchemeQuery(String(media[m])))
64
+ : [];
65
+ const schemeEntries: SchemeEntry[] = media
66
+ ? Object.entries(media).flatMap(([name, query]) => {
67
+ const scheme = schemeOfQuery(String(query));
68
+ return scheme ? [{ name, scheme }] : [];
69
+ })
70
+ : [];
54
71
 
55
72
  const save = () => {
56
73
  void updateSiteConfig({ style: { ...rootStyle } });
74
+ // Live canvas feedback: replace the site-style sheet in place — no re-render needed.
75
+ postSiteStyleToLiveHosts();
57
76
  };
58
77
 
59
78
  const updateVar = (name: string, val: string) => {
@@ -61,6 +80,35 @@ export function renderCssVarsEditor(container: HTMLElement) {
61
80
  save();
62
81
  };
63
82
 
83
+ // Write/clear a per-scheme token override in the scheme's `@--name` block (spec §9.5). An empty
84
+ // Value clears the override (and drops the block when it empties) — the token inherits the base.
85
+ const updateSchemeVar = (schemeName: string, varName: string, val: string) => {
86
+ const key = `@${schemeName}`;
87
+ if (val) {
88
+ const block = (rootStyle[key] ??= {}) as Record<string, unknown>;
89
+ block[varName] = val;
90
+ } else {
91
+ const block = rootStyle[key];
92
+ if (block && typeof block === "object") {
93
+ delete (block as Record<string, unknown>)[varName];
94
+ if (Object.keys(block).length === 0) {
95
+ delete rootStyle[key];
96
+ }
97
+ }
98
+ }
99
+ save();
100
+ renderCssVarsEditor(container);
101
+ };
102
+
103
+ // Without a declared scheme query nothing ever shows the scheme UI (here or in the tab bar) —
104
+ // This is the opt-in affordance for existing projects. Re-render after the async config update
105
+ // Lands (unlike style edits, $media is not mutated in place).
106
+ const enableDarkScheme = () => {
107
+ void updateSiteConfig({
108
+ $media: { ...config.$media, "--dark": "(prefers-color-scheme: dark)" },
109
+ }).then(() => renderCssVarsEditor(container));
110
+ };
111
+
64
112
  const deleteVar = (name: string) => {
65
113
  delete rootStyle[name];
66
114
  save();
@@ -81,7 +129,16 @@ export function renderCssVarsEditor(container: HTMLElement) {
81
129
  <div class="settings-section">
82
130
  <h3 class="settings-section-title">CSS Variables</h3>
83
131
 
84
- ${renderColorSection(groups.color, updateVar, deleteVar, addVar)}
132
+ ${renderColorSection(
133
+ groups.color,
134
+ updateVar,
135
+ deleteVar,
136
+ addVar,
137
+ rootStyle,
138
+ schemeEntries,
139
+ updateSchemeVar,
140
+ enableDarkScheme,
141
+ )}
85
142
  ${renderFontSection(groups.font, updateVar, deleteVar, addVar)}
86
143
  ${renderSizeSection(groups.size, updateVar, deleteVar, addVar, rootStyle, mediaNames)}
87
144
  ${groups.other.length > 0
@@ -104,6 +161,10 @@ function renderColorSection(
104
161
  updateVar: (name: string, val: string) => void,
105
162
  deleteVar: (name: string) => void,
106
163
  addVar: (prefix: string, friendlyName: string, val: string) => void,
164
+ rootStyle: JxStyle,
165
+ schemeEntries: SchemeEntry[],
166
+ updateSchemeVar: (schemeName: string, varName: string, val: string) => void,
167
+ enableDarkScheme: () => void,
107
168
  ) {
108
169
  return html`
109
170
  <div class="css-vars-group">
@@ -129,9 +190,66 @@ function renderColorSection(
129
190
  <sp-icon-delete slot="icon"></sp-icon-delete>
130
191
  </sp-action-button>
131
192
  </div>
193
+ ${schemeEntries.map((entry) =>
194
+ renderSchemeOverride(name, rootStyle, entry, updateSchemeVar),
195
+ )}
132
196
  `,
133
197
  )}
134
198
  ${renderAddRow("--color-", "Primary Blue", "#3b82f6", addVar)}
199
+ ${schemeEntries.length === 0
200
+ ? html`
201
+ <sp-action-button
202
+ size="s"
203
+ class="css-vars-enable-dark"
204
+ title="Add a dark color scheme: declares --dark in $media so every color token can carry a dark value"
205
+ @click=${enableDarkScheme}
206
+ >
207
+ Enable dark scheme
208
+ </sp-action-button>
209
+ `
210
+ : nothing}
211
+ </div>
212
+ `;
213
+ }
214
+
215
+ /**
216
+ * One per-scheme override row under a color token: swatch + field bound to the scheme block's
217
+ * value. Empty shows "inherits" (the base value applies); clearing deletes the override.
218
+ *
219
+ * @param {string} varName
220
+ * @param {JxStyle} rootStyle
221
+ * @param {SchemeEntry} entry
222
+ * @param {(schemeName: string, varName: string, val: string) => void} updateSchemeVar
223
+ */
224
+ function renderSchemeOverride(
225
+ varName: string,
226
+ rootStyle: JxStyle,
227
+ entry: SchemeEntry,
228
+ updateSchemeVar: (schemeName: string, varName: string, val: string) => void,
229
+ ) {
230
+ const block = rootStyle[`@${entry.name}`];
231
+ const current =
232
+ block && typeof block === "object" ? (block as Record<string, unknown>)[varName] : undefined;
233
+ const label = entry.scheme === "dark" ? "Dark" : "Light";
234
+ return html`
235
+ <div class="css-var-media-row css-var-scheme-row">
236
+ <span class="css-var-media-label">${label}</span>
237
+ <div class="css-var-swatch" style="background:${current ?? "transparent"}">
238
+ <input
239
+ type="color"
240
+ .value=${current && String(current).startsWith("#") ? String(current) : "#111111"}
241
+ @input=${(e: Event) =>
242
+ updateSchemeVar(entry.name, varName, (e.target as HTMLInputElement).value)}
243
+ />
244
+ </div>
245
+ <sp-textfield
246
+ size="s"
247
+ placeholder="inherits"
248
+ .value=${current == null ? "" : String(current)}
249
+ @change=${(e: Event) =>
250
+ updateSchemeVar(entry.name, varName, (e.target as HTMLInputElement).value)}
251
+ style="flex:1;max-width:160px"
252
+ ></sp-textfield>
135
253
  </div>
136
254
  `;
137
255
  }
@@ -106,11 +106,11 @@ let _activeSection = "general";
106
106
 
107
107
  let _contentEl: HTMLElement | null = null;
108
108
 
109
- export function openSettingsModal() {
109
+ export function openSettingsModal(section?: string) {
110
110
  if (_handle) {
111
111
  return;
112
112
  }
113
- _activeSection = "general";
113
+ _activeSection = section ?? "general";
114
114
  renderModal();
115
115
  // Refresh descriptor-contributed sections (cached payloads make this cheap) and rerender the
116
116
  // Nav once they land. Lazy import breaks the settings-modal ↔ extension-sections module cycle.
package/src/studio.ts CHANGED
@@ -48,6 +48,7 @@ import { consumePatchedDocument, initCanvasPatcher } from "./canvas/canvas-patch
48
48
  import {
49
49
  commitActiveEditSession,
50
50
  getEditSnapshot,
51
+ postColorSchemeToLiveHosts,
51
52
  setCanvasContextMenuHandler,
52
53
  setCanvasSlashHandler,
53
54
  setIframePatchEscalation,
@@ -95,6 +96,9 @@ import { renderHeadTemplate } from "./panels/head-panel";
95
96
  import { exportCemManifest as _exportCemManifest } from "./services/cem-export";
96
97
  import { installAutomationHook } from "./services/automation";
97
98
  import { openBrowseModal } from "./browse/browse-modal";
99
+ import { seedAssistantMessages } from "./panels/ai-panel";
100
+ import { seedPublishConnected } from "./publish/publish-panel";
101
+ import { openConnectorGrid } from "./grid/grid-open";
98
102
 
99
103
  import { getPlatform, hasPlatform, registerPlatform } from "./platform";
100
104
  import { parseMediaEntries } from "./utils/canvas-media";
@@ -144,7 +148,8 @@ import {
144
148
  renderBlockActionBar,
145
149
  } from "./panels/block-action-bar";
146
150
  import { initCssData } from "./panels/style-utils";
147
- import { initQuickSearch } from "./panels/quick-search";
151
+ import { initQuickSearch, openQuickSearch } from "./panels/quick-search";
152
+ import { openSettingsModal } from "./settings/settings-modal";
148
153
  import { hydrateAccountStatus } from "./account-status";
149
154
  import { hydrateProjectList } from "./project-list";
150
155
  import { addRecentProject, hydrateRecentProjects, removeRecentProject } from "./recent-projects";
@@ -390,9 +395,14 @@ if (!hasPlatform()) {
390
395
  installAutomationHook({
391
396
  getCanvasMode,
392
397
  openBrowseModal,
398
+ openConnectorGrid,
393
399
  openNewProjectModal,
400
+ openQuickSearchPalette: openQuickSearch,
401
+ openSettingsModal,
394
402
  render,
395
403
  renderActivityBar,
404
+ seedAssistantMessages,
405
+ seedPublishConnected,
396
406
  setCanvasMode,
397
407
  statusMessage,
398
408
  });
@@ -581,6 +591,12 @@ effect(() => {
581
591
  }
582
592
  scheduleCanvasRender();
583
593
  });
594
+ // Color-scheme preview is a document-level attribute flip inside the iframe — deliberately its
595
+ // Own effect, never part of the ui-effect above: flipping the scheme must not re-render.
596
+ effect(() => {
597
+ const s = activeTab.value?.session.ui.previewColorScheme;
598
+ postColorSchemeToLiveHosts(s === "light" || s === "dark" ? s : null);
599
+ });
584
600
 
585
601
  rightPanelMod.mount({
586
602
  getCanvasMode,
package/src/tabs/tab.ts CHANGED
@@ -43,6 +43,12 @@ export interface TabUi {
43
43
  /** Full-screen formula workspace target ($expression editing); editingFunction wins if both set. */
44
44
  editingFormula: FormulaEditDef | null;
45
45
  featureToggles: Record<string, boolean>;
46
+ /**
47
+ * Canvas color-scheme preview: follow the OS ("auto") or force a scheme. Drives the
48
+ * data-color-scheme attribute on the canvas iframe root (spec §9.5) and, in the style sidebar,
49
+ * which scheme layer edits target.
50
+ */
51
+ previewColorScheme: "auto" | "light" | "dark";
46
52
  styleSections: Record<string, boolean>;
47
53
  inspectorSections: Record<string, boolean>;
48
54
  styleShorthands: Record<string, boolean>;
@@ -156,6 +162,7 @@ function createDefaultUi(canvasMode: string, preview = false) {
156
162
  inspectorSections: {},
157
163
  pendingInlineEdit: null,
158
164
  preview,
165
+ previewColorScheme: "auto" as const,
159
166
  previewParams: {},
160
167
  previewProps: null,
161
168
  rightTab: "properties",
@@ -293,7 +293,10 @@ const OPERATOR_META: Record<string, OperatorMeta> = {
293
293
  export function operatorEntries(): FormulaCatalogEntry[] {
294
294
  const out: FormulaCatalogEntry[] = [];
295
295
  for (const op of BLESSED_OPERATORS) {
296
- const meta = OPERATOR_META[op];
296
+ // Own-property lookup only: a bare index would resolve inherited Object.prototype members for
297
+ // Ops like "toLocaleString", yielding a bogus meta whose undefined group/description breaks
298
+ // The palette's substring filter.
299
+ const meta = Object.hasOwn(OPERATOR_META, op) ? OPERATOR_META[op] : undefined;
297
300
  if (meta) {
298
301
  out.push({
299
302
  description: meta.description,
@@ -2,6 +2,29 @@
2
2
  /// <reference lib="dom.iterable" />
3
3
  /** Canvas media/breakpoint utilities — pure functions extracted for testability. */
4
4
 
5
+ import { pureSchemeOf } from "@jxsuite/runtime";
6
+
7
+ /**
8
+ * True when a feature query participates in the forced-scheme contract (spec §9.5) — a pure
9
+ * prefers-color-scheme query. These surface as the Auto/Light/Dark control, not generic toggles.
10
+ *
11
+ * @param {string} query
12
+ * @returns {boolean}
13
+ */
14
+ export function isSchemeQuery(query: string): boolean {
15
+ return pureSchemeOf(query) !== null;
16
+ }
17
+
18
+ /**
19
+ * The scheme a feature query targets ("light" | "dark"), or null for non-scheme queries.
20
+ *
21
+ * @param {string} query
22
+ * @returns {"light" | "dark" | null}
23
+ */
24
+ export function schemeOfQuery(query: string): "light" | "dark" | null {
25
+ return pureSchemeOf(query);
26
+ }
27
+
5
28
  /**
6
29
  * Classify $media entries into size breakpoints (get a canvas each) and feature queries (rendered
7
30
  * as toolbar toggles).