@jxsuite/studio 0.19.0 → 0.21.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 (99) hide show
  1. package/dist/studio.css +98 -98
  2. package/dist/studio.js +13476 -12330
  3. package/dist/studio.js.map +152 -148
  4. package/dist/workers/editor.worker.js +79 -79
  5. package/dist/workers/json.worker.js +109 -109
  6. package/dist/workers/ts.worker.js +82 -82
  7. package/package.json +19 -7
  8. package/src/browse/browse-modal.js +16 -13
  9. package/src/browse/browse.js +44 -35
  10. package/src/canvas/canvas-diff.js +24 -16
  11. package/src/canvas/canvas-helpers.js +24 -19
  12. package/src/canvas/canvas-live-render.js +113 -39
  13. package/src/canvas/canvas-render.js +119 -73
  14. package/src/canvas/canvas-utils.js +51 -21
  15. package/src/canvas/nested-site-style.js +50 -0
  16. package/src/editor/component-inline-edit.js +14 -13
  17. package/src/editor/content-inline-edit.js +22 -10
  18. package/src/editor/context-menu.js +28 -14
  19. package/src/editor/convert-targets.js +60 -0
  20. package/src/editor/convert-to-component.js +20 -19
  21. package/src/editor/convert-to-repeater.js +226 -0
  22. package/src/editor/inline-edit.js +69 -28
  23. package/src/editor/insertion-helper.js +16 -12
  24. package/src/editor/shortcuts.js +14 -4
  25. package/src/editor/slash-menu.js +40 -19
  26. package/src/files/components.js +1 -1
  27. package/src/files/file-ops.js +37 -16
  28. package/src/files/files.js +322 -68
  29. package/src/github/github-auth.js +122 -0
  30. package/src/github/github-publish.js +126 -0
  31. package/src/markdown/md-convert.js +172 -107
  32. package/src/new-project/new-project-modal.js +204 -0
  33. package/src/panels/activity-bar.js +27 -25
  34. package/src/panels/ai-panel.js +406 -0
  35. package/src/panels/block-action-bar.js +77 -28
  36. package/src/panels/canvas-dnd.js +8 -2
  37. package/src/panels/data-explorer.js +19 -13
  38. package/src/panels/dnd.js +144 -92
  39. package/src/panels/editors.js +55 -35
  40. package/src/panels/elements-panel.js +33 -19
  41. package/src/panels/events-panel.js +15 -10
  42. package/src/panels/git-panel.js +475 -99
  43. package/src/panels/head-panel.js +121 -81
  44. package/src/panels/imports-panel.js +94 -65
  45. package/src/panels/layers-panel.js +64 -43
  46. package/src/panels/left-panel.js +105 -48
  47. package/src/panels/overlays.js +1 -0
  48. package/src/panels/panel-events.js +45 -30
  49. package/src/panels/preview-render.js +23 -18
  50. package/src/panels/properties-panel.js +378 -257
  51. package/src/panels/pseudo-preview.js +2 -2
  52. package/src/panels/quick-search.js +11 -9
  53. package/src/panels/right-panel.js +42 -16
  54. package/src/panels/shared.js +4 -4
  55. package/src/panels/signals-panel.js +415 -166
  56. package/src/panels/statusbar.js +10 -6
  57. package/src/panels/style-inputs.js +67 -35
  58. package/src/panels/style-panel.js +239 -134
  59. package/src/panels/style-utils.js +34 -16
  60. package/src/panels/stylebook-layers-panel.js +12 -10
  61. package/src/panels/stylebook-panel.js +134 -66
  62. package/src/panels/tab-strip.js +5 -2
  63. package/src/panels/toolbar.js +333 -109
  64. package/src/panels/welcome-screen.js +121 -0
  65. package/src/platform.js +2 -4
  66. package/src/platforms/devserver.js +113 -7
  67. package/src/resize-edges.js +98 -0
  68. package/src/services/cem-export.js +12 -7
  69. package/src/services/code-services.js +30 -12
  70. package/src/settings/content-types-editor.js +8 -8
  71. package/src/settings/css-vars-editor.js +30 -24
  72. package/src/settings/defs-editor.js +12 -7
  73. package/src/settings/general-settings.js +85 -3
  74. package/src/settings/head-editor.js +30 -24
  75. package/src/settings/schema-field-ui.js +58 -39
  76. package/src/settings/settings-modal.js +12 -9
  77. package/src/site-context.js +41 -31
  78. package/src/state.js +70 -29
  79. package/src/store.js +36 -35
  80. package/src/studio.js +120 -90
  81. package/src/tabs/tab.js +67 -43
  82. package/src/tabs/transact.js +23 -13
  83. package/src/ui/button-group.js +24 -24
  84. package/src/ui/color-selector.js +28 -23
  85. package/src/ui/field-row.js +3 -3
  86. package/src/ui/icons.js +2 -2
  87. package/src/ui/layers.js +31 -1
  88. package/src/ui/media-picker.js +7 -7
  89. package/src/ui/spectrum.js +5 -1
  90. package/src/ui/unit-selector.js +18 -16
  91. package/src/ui/value-selector.js +21 -15
  92. package/src/ui/widgets.js +22 -19
  93. package/src/utils/canvas-media.js +5 -4
  94. package/src/utils/edit-display.js +32 -26
  95. package/src/utils/google-fonts.js +11 -11
  96. package/src/utils/inherited-style.js +9 -8
  97. package/src/utils/studio-utils.js +18 -18
  98. package/src/view.js +58 -1
  99. package/src/workspace/workspace.js +29 -7
package/src/studio.js CHANGED
@@ -15,10 +15,12 @@ import {
15
15
  render,
16
16
  projectState,
17
17
  setProjectState,
18
+ requireProjectState,
18
19
  updateUi,
20
+ initShellRefs,
19
21
  } from "./store.js";
20
22
 
21
- import { activeTab, openTab, replaceAllTabs } from "./workspace/workspace.js";
23
+ import { activeTab, openTab, closeAllTabs } from "./workspace/workspace.js";
22
24
  import { transactDoc, mutateUpdateDef, mutateUpdateProperty } from "./tabs/transact.js";
23
25
  import { effect } from "./reactivity.js";
24
26
 
@@ -45,6 +47,7 @@ import {
45
47
  openFileInTab,
46
48
  openHomePage,
47
49
  setupTreeKeyboard,
50
+ registerFileTreeDnD,
48
51
  loadDirectory,
49
52
  } from "./files/files.js";
50
53
  import { renderImportsTemplate } from "./panels/imports-panel.js";
@@ -54,6 +57,7 @@ import { exportCemManifest as _exportCemManifest } from "./services/cem-export.j
54
57
  import { registerPlatform, getPlatform, hasPlatform } from "./platform.js";
55
58
  import { parseMediaEntries } from "./utils/canvas-media.js";
56
59
  import { createDevServerPlatform } from "./platforms/devserver.js";
60
+ import { mountResizeEdges } from "./resize-edges.js";
57
61
  import { codeService } from "./services/code-services.js";
58
62
  import { defCategory, defBadgeLabel, renderSignalsTemplate } from "./panels/signals-panel.js";
59
63
  import { loadComponentRegistry } from "./files/components.js";
@@ -87,6 +91,9 @@ import { updateForcedPseudoPreview } from "./panels/pseudo-preview.js";
87
91
  import { initPanelEvents } from "./panels/panel-events.js";
88
92
  import { initQuickSearch } from "./panels/quick-search.js";
89
93
  import { addRecentProject } from "./recent-projects.js";
94
+ import { initWelcome } from "./panels/welcome-screen.js";
95
+ import { openNewProjectModal } from "./new-project/new-project-modal.js";
96
+ import { cloneRepository } from "./panels/git-panel.js";
90
97
 
91
98
  // ─── Globals ──────────────────────────────────────────────────────────────────
92
99
  // These mutable variables are local to studio.js for now. As sections are extracted
@@ -105,12 +112,12 @@ function setCanvasMode(mode) {
105
112
  if (tab) tab.session.ui.canvasMode = mode;
106
113
  }
107
114
 
108
- /** @type {any} */
115
+ /** @type {import("./canvas/canvas-render.js").GitDiffState | null} */
109
116
  let gitDiffState = null;
110
117
 
111
118
  // ─── Component registry ───────────────────────────────────────────────────────
112
119
 
113
- /** @param {any} componentPath */
120
+ /** @param {string} componentPath */
114
121
  async function navigateToComponent(componentPath) {
115
122
  try {
116
123
  const platform = getPlatform();
@@ -135,7 +142,7 @@ async function navigateToComponent(componentPath) {
135
142
  // Load the component
136
143
  tab.doc.document = parsed;
137
144
  tab.doc.dirty = false;
138
- tab.doc.mode = /** @type {any} */ (null);
145
+ tab.doc.mode = /** @type {string} */ (/** @type {unknown} */ (null));
139
146
  tab.doc.sourceFormat = null;
140
147
  tab.documentPath = componentPath;
141
148
  tab.session.selection = null;
@@ -145,8 +152,8 @@ async function navigateToComponent(componentPath) {
145
152
 
146
153
  render();
147
154
  statusMessage(`Editing component: ${parsed.tagName || componentPath}`);
148
- } catch (/** @type {any} */ e) {
149
- const err = /** @type {any} */ (e);
155
+ } catch (/** @type {unknown} */ e) {
156
+ const err = /** @type {Error} */ (e);
150
157
  statusMessage(`Error: ${err.message}`);
151
158
  }
152
159
  }
@@ -158,21 +165,23 @@ async function navigateBack() {
158
165
  try {
159
166
  const platform = getPlatform();
160
167
  await platform.writeFile(tab.documentPath, serializeDocument(tab));
161
- } catch (/** @type {any} */ e) {
162
- const err = /** @type {any} */ (e);
168
+ } catch (/** @type {unknown} */ e) {
169
+ const err = /** @type {Error} */ (e);
163
170
  statusMessage(`Save error: ${err.message}`);
164
171
  }
165
172
  }
166
173
 
167
174
  // Pop the stack
168
- const frame = /** @type {any} */ (tab.session.documentStack.pop());
175
+ const frame = /** @type {Record<string, unknown> | undefined} */ (
176
+ tab.session.documentStack.pop()
177
+ );
169
178
  if (!frame) return;
170
- tab.doc.document = frame.document;
171
- tab.doc.dirty = frame.dirty;
172
- tab.doc.mode = frame.mode;
173
- tab.doc.sourceFormat = frame.sourceFormat;
174
- tab.documentPath = frame.documentPath;
175
- tab.session.selection = frame.selection;
179
+ tab.doc.document = /** @type {JxMutableNode} */ (frame.document);
180
+ tab.doc.dirty = /** @type {boolean} */ (frame.dirty);
181
+ tab.doc.mode = /** @type {string} */ (frame.mode);
182
+ tab.doc.sourceFormat = /** @type {string | null} */ (frame.sourceFormat);
183
+ tab.documentPath = /** @type {string | null} */ (frame.documentPath);
184
+ tab.session.selection = /** @type {JxPath | null} */ (frame.selection);
176
185
  view.leftTab = "layers";
177
186
 
178
187
  render();
@@ -188,20 +197,20 @@ async function navigateToLevel(targetIndex) {
188
197
  try {
189
198
  const platform = getPlatform();
190
199
  await platform.writeFile(tab.documentPath, serializeDocument(tab));
191
- } catch (/** @type {any} */ e) {
192
- const err = /** @type {any} */ (e);
200
+ } catch (/** @type {unknown} */ e) {
201
+ const err = /** @type {Error} */ (e);
193
202
  statusMessage(`Save error: ${err.message}`);
194
203
  }
195
204
  }
196
205
 
197
- const frame = /** @type {any} */ (stack[targetIndex]);
206
+ const frame = /** @type {DocumentStackEntry} */ (stack[targetIndex]);
198
207
  tab.session.documentStack = stack.slice(0, targetIndex);
199
- tab.doc.document = frame.document;
200
- tab.doc.dirty = frame.dirty;
201
- tab.doc.mode = frame.mode;
202
- tab.doc.sourceFormat = frame.sourceFormat;
203
- tab.documentPath = frame.documentPath;
204
- tab.session.selection = frame.selection;
208
+ tab.doc.document = /** @type {JxMutableNode} */ (frame.document);
209
+ tab.doc.dirty = /** @type {boolean} */ (frame.dirty);
210
+ tab.doc.mode = /** @type {string} */ (frame.mode);
211
+ tab.doc.sourceFormat = /** @type {string | null} */ (frame.sourceFormat);
212
+ tab.documentPath = /** @type {string | null} */ (frame.documentPath);
213
+ tab.session.selection = /** @type {JxPath | null} */ (frame.selection);
205
214
  view.leftTab = "layers";
206
215
 
207
216
  render();
@@ -210,23 +219,33 @@ async function navigateToLevel(targetIndex) {
210
219
 
211
220
  async function closeFunctionEditor() {
212
221
  const tab = activeTab.value;
213
- const editing = /** @type {any} */ (tab?.session.ui.editingFunction);
214
- if (!editing) return;
222
+ const editing =
223
+ /** @type {{ type: string; defName?: string; path?: JxPath; eventKey?: string } | null} */ (
224
+ tab?.session.ui.editingFunction
225
+ );
226
+ if (!editing || !tab) return;
215
227
  if (view.functionEditor) {
216
228
  const currentCode = view.functionEditor.getValue();
217
229
  const minResult = await codeService("minify", { code: currentCode });
218
230
  const bodyToStore = minResult?.code ?? currentCode;
219
231
  if (editing.type === "def") {
220
- transactDoc(tab, (t) => mutateUpdateDef(t, editing.defName, { body: bodyToStore }));
232
+ transactDoc(tab, (t) =>
233
+ mutateUpdateDef(t, /** @type {string} */ (editing.defName), { body: bodyToStore }),
234
+ );
221
235
  } else if (editing.type === "event") {
222
- const node = getNodeAtPath(tab.doc.document, editing.path);
223
- const current = node?.[editing.eventKey] || {};
236
+ const node = getNodeAtPath(tab.doc.document, /** @type {JxPath} */ (editing.path));
237
+ const current = node?.[/** @type {string} */ (editing.eventKey)] || {};
224
238
  transactDoc(tab, (t) =>
225
- mutateUpdateProperty(t, editing.path, editing.eventKey, {
226
- ...current,
227
- $prototype: "Function",
228
- body: bodyToStore,
229
- }),
239
+ mutateUpdateProperty(
240
+ t,
241
+ /** @type {JxPath} */ (editing.path),
242
+ /** @type {string} */ (editing.eventKey),
243
+ {
244
+ .../** @type {object} */ (current),
245
+ $prototype: "Function",
246
+ body: bodyToStore,
247
+ },
248
+ ),
230
249
  );
231
250
  }
232
251
  view.functionEditor.dispose();
@@ -243,10 +262,12 @@ document.body.appendChild(datalistHost);
243
262
  litRender(
244
263
  html`
245
264
  <datalist id="tag-names">
246
- ${webdata.allTags.map((/** @type {any} */ tag) => html`<option value=${tag}></option>`)}
265
+ ${webdata.allTags.map((/** @type {string} */ tag) => html`<option value=${tag}></option>`)}
247
266
  </datalist>
248
267
  <datalist id="css-props">
249
- ${webdata.cssProps.map((/** @type {any} */ [name]) => html`<option value=${name}></option>`)}
268
+ ${webdata.cssProps.map(
269
+ (/** @type {string[]} */ [name]) => html`<option value=${name}></option>`,
270
+ )}
250
271
  </datalist>
251
272
  `,
252
273
  datalistHost,
@@ -263,20 +284,12 @@ if (!hasPlatform()) {
263
284
  registerPlatform(createDevServerPlatform());
264
285
  }
265
286
 
266
- const EMPTY_DOC = {
267
- tagName: "div",
268
- style: { padding: "2rem", fontFamily: "system-ui, sans-serif" },
269
- children: [
270
- { tagName: "h1", textContent: "New Component" },
271
- { tagName: "p", textContent: "Open a Jx file or start editing." },
272
- ],
273
- };
274
-
275
- // Create the initial reactive tab — the canonical state container.
276
- openTab({ id: "initial", document: structuredClone(EMPTY_DOC) });
287
+ mountResizeEdges();
277
288
 
278
289
  // ─── Render loop ──────────────────────────────────────────────────────────────
279
290
 
291
+ initShellRefs();
292
+
280
293
  // Mount extracted panel modules
281
294
  toolbarPanel.mount(toolbarEl, {
282
295
  navigateBack: () => navigateBack(),
@@ -338,25 +351,38 @@ initCanvasRender({
338
351
  get gitDiffState() {
339
352
  return gitDiffState;
340
353
  },
341
- setGitDiffState: (/** @type {any} */ state) => {
354
+ setGitDiffState: (
355
+ /** @type {import("./canvas/canvas-render.js").GitDiffState | null} */ state,
356
+ ) => {
342
357
  gitDiffState = state;
343
358
  },
344
359
  });
345
360
 
361
+ initWelcome({
362
+ openProject: () => openProject(),
363
+ openRecentProject: (/** @type {string} */ root) => openRecentProject(root),
364
+ openNewProject: async () => {
365
+ const result = await openNewProjectModal();
366
+ if (result) openRecentProject(result.root);
367
+ },
368
+ cloneRepository: () => cloneRepository({ openRecentProject }),
369
+ });
370
+
346
371
  // Effect-driven canvas rendering: auto-triggers renderCanvas when reactive deps change
347
372
  let _canvasRenderScheduled = false;
348
373
  effect(() => {
349
374
  const tab = activeTab.value;
350
- if (!tab) return;
351
- void tab.doc.document;
352
- void tab.doc.mode;
353
- void tab.session.ui.canvasMode;
354
- void tab.session.ui.editingFunction;
355
- void tab.session.ui.featureToggles;
356
- void tab.session.ui.settingsTab;
357
- void tab.session.ui.stylebookTab;
358
- void tab.session.ui.stylebookFilter;
359
- void tab.session.ui.stylebookCustomizedOnly;
375
+ if (tab) {
376
+ void tab.doc.document;
377
+ void tab.doc.mode;
378
+ void tab.session.ui.canvasMode;
379
+ void tab.session.ui.editingFunction;
380
+ void tab.session.ui.featureToggles;
381
+ void tab.session.ui.settingsTab;
382
+ void tab.session.ui.stylebookTab;
383
+ void tab.session.ui.stylebookFilter;
384
+ void tab.session.ui.stylebookCustomizedOnly;
385
+ }
360
386
  if (!_canvasRenderScheduled) {
361
387
  _canvasRenderScheduled = true;
362
388
  queueMicrotask(() => {
@@ -396,7 +422,11 @@ leftPanelMod.mount({
396
422
  registerElementsDnD,
397
423
  registerComponentsDnD,
398
424
  setupTreeKeyboard,
399
- setGitDiffState: (/** @type {any} */ state) => {
425
+ registerFileTreeDnD,
426
+ cloneRepository: () => cloneRepository({ openRecentProject }),
427
+ setGitDiffState: (
428
+ /** @type {import("./canvas/canvas-render.js").GitDiffState | null} */ state,
429
+ ) => {
400
430
  gitDiffState = state;
401
431
  },
402
432
  });
@@ -412,7 +442,7 @@ mountStatusbar();
412
442
  mountActivityBar();
413
443
 
414
444
  // Clicking on the canvas-wrap background (outside any canvas panel) deselects the current element
415
- canvasWrap.addEventListener("click", (/** @type {any} */ e) => {
445
+ canvasWrap.addEventListener("click", (/** @type {MouseEvent} */ e) => {
416
446
  if (e.target !== canvasWrap && e.target !== view.panzoomWrap) return;
417
447
  if (!activeTab.value?.session.selection) return;
418
448
  activeTab.value.session.selection = null;
@@ -425,14 +455,17 @@ function safeRenderRightPanel() {
425
455
  // Now that renderers are registered, bootstrap
426
456
  registerFunctionCompletions();
427
457
 
428
- const _openParam = new URLSearchParams(location.search).get("open");
458
+ const _urlParams = new URLSearchParams(location.search);
459
+ const _projectParam = _urlParams.get("project") || _urlParams.get("open");
429
460
 
430
- if (_openParam) {
431
- // ?open= mode: skip normal loadProject, set up site context from the path
461
+ if (_projectParam) {
462
+ // ?project= mode: skip normal loadProject, set up site context from the path
432
463
  const isAbsPath =
433
- _openParam.startsWith("/") || _openParam.startsWith("~") || /^[A-Za-z]:[/\\]/.test(_openParam);
464
+ _projectParam.startsWith("/") ||
465
+ _projectParam.startsWith("~") ||
466
+ /^[A-Za-z]:[/\\]/.test(_projectParam);
434
467
  if (!isAbsPath) {
435
- statusMessage(`Error: ?open= requires an absolute path (got "${_openParam}")`);
468
+ statusMessage(`Error: ?project= requires an absolute path (got "${_projectParam}")`);
436
469
  render();
437
470
  } else {
438
471
  render();
@@ -440,7 +473,7 @@ if (_openParam) {
440
473
  (async () => {
441
474
  try {
442
475
  const siteCtx = platform.resolveSiteContext
443
- ? await platform.resolveSiteContext(_openParam)
476
+ ? await platform.resolveSiteContext(_projectParam)
444
477
  : { sitePath: null };
445
478
 
446
479
  if (siteCtx.sitePath) {
@@ -456,7 +489,7 @@ if (_openParam) {
456
489
  name: siteCtx.projectConfig?.name || "Project",
457
490
  projectRoot: siteCtx.sitePath,
458
491
  isSiteProject: true,
459
- projectConfig: siteCtx.projectConfig,
492
+ projectConfig: siteCtx.projectConfig || null,
460
493
  projectDirs: [],
461
494
  dirs: new Map(),
462
495
  expanded: new Set(),
@@ -477,22 +510,22 @@ if (_openParam) {
477
510
  "styles",
478
511
  ];
479
512
  const dirEntries = await platform.listDirectory(".");
480
- projectState.dirs.set(".", dirEntries);
513
+ requireProjectState().dirs.set(".", dirEntries);
481
514
  const foundDirs = [];
482
515
  for (const e of dirEntries) {
483
516
  if (e.type === "directory" && conventionalDirs.includes(e.name)) {
484
517
  foundDirs.push(e.name);
485
- projectState.expanded.add(e.path || e.name);
518
+ requireProjectState().expanded.add(e.path || e.name);
486
519
  const sub = await platform.listDirectory(e.path || e.name);
487
- projectState.dirs.set(e.path || e.name, sub);
520
+ requireProjectState().dirs.set(e.path || e.name, sub);
488
521
  }
489
522
  }
490
- projectState.projectDirs = foundDirs;
523
+ requireProjectState().projectDirs = foundDirs;
491
524
  }
492
525
 
493
526
  // Read and open the file
494
- const _fileParam = new URLSearchParams(location.search).get("file");
495
- let fileRelPath = _fileParam || siteCtx.fileRelPath || _openParam;
527
+ const _fileParam = _urlParams.get("file");
528
+ let fileRelPath = _fileParam || siteCtx.fileRelPath || _projectParam;
496
529
 
497
530
  // When opening project.json, default to home page instead
498
531
  if (fileRelPath === "project.json" || fileRelPath.endsWith("/project.json")) {
@@ -520,9 +553,7 @@ if (_openParam) {
520
553
  parsedDoc = JSON.parse(content);
521
554
  }
522
555
 
523
- // Open in a tab (replaces initial tab)
524
- const { closeTab } = await import("./workspace/workspace.js");
525
- closeTab("initial");
556
+ // Open in a tab
526
557
  openTab({
527
558
  id: fileRelPath,
528
559
  documentPath: fileRelPath,
@@ -535,13 +566,12 @@ if (_openParam) {
535
566
  if (fileRelPath === "project.json" && activeTab.value) {
536
567
  activeTab.value.session.ui.canvasMode = "stylebook";
537
568
  }
538
- view.leftTab = "layers";
539
569
 
540
570
  render();
541
- statusMessage(`Opened ${_openParam}`);
571
+ statusMessage(`Opened ${fileRelPath}`);
542
572
  }
543
- } catch (/** @type {any} */ e) {
544
- statusMessage(`Error: ${e.message}`);
573
+ } catch (/** @type {unknown} */ e) {
574
+ statusMessage(`Error: ${/** @type {Error} */ (e).message}`);
545
575
  }
546
576
  })();
547
577
  }
@@ -594,7 +624,7 @@ async function openRecentProject(/** @type {string} */ root) {
594
624
  const content = await platform.readFile("project.json");
595
625
  const config = JSON.parse(content);
596
626
 
597
- replaceAllTabs({ id: "initial", document: { tagName: "div", children: [] } });
627
+ closeAllTabs();
598
628
 
599
629
  setProjectState({
600
630
  ...projectState,
@@ -620,29 +650,29 @@ async function openRecentProject(/** @type {string} */ root) {
620
650
  "public",
621
651
  "styles",
622
652
  ];
623
- const entries = projectState.dirs.get(".") || [];
653
+ const entries = requireProjectState().dirs.get(".") || [];
624
654
  for (const e of entries) {
625
655
  if (e.type === "directory" && conventionalDirs.includes(e.name)) {
626
- projectState.expanded.add(e.path || e.name);
656
+ requireProjectState().expanded.add(e.path || e.name);
627
657
  await loadDirectory(e.path || e.name);
628
658
  }
629
659
  }
630
660
 
631
- addRecentProject(projectState.name, root);
661
+ addRecentProject(requireProjectState().name, root);
632
662
  view.leftTab = "files";
633
663
  renderActivityBar();
634
664
  renderLeftPanel();
635
- statusMessage(`Opened project: ${projectState.name}`);
665
+ statusMessage(`Opened project: ${requireProjectState().name}`);
636
666
 
637
667
  await openHomePage();
638
- } catch (/** @type {any} */ e) {
639
- statusMessage(`Error: ${e.message}`);
668
+ } catch (/** @type {unknown} */ e) {
669
+ statusMessage(`Error: ${/** @type {Error} */ (e).message}`);
640
670
  }
641
671
  }
642
672
  function renderFilesTemplate() {
643
673
  return _renderFilesTemplate({ openProject, openFileFromTree, renderLeftPanel });
644
674
  }
645
- function openFileFromTree(/** @type {any} */ path) {
675
+ function openFileFromTree(/** @type {string} */ path) {
646
676
  return openFileInTab(path);
647
677
  }
648
678
 
@@ -676,13 +706,13 @@ initShortcuts(() => ({
676
706
 
677
707
  // ─── Autosave (registered as update middleware) ──────────────────────────────
678
708
 
679
- /** @type {any} */
709
+ /** @type {number} */
680
710
  const AUTO_SAVE_DELAY = 2000;
681
711
 
682
712
  function scheduleAutosave() {
683
713
  const tab = activeTab.value;
684
714
  if (!tab?.fileHandle || !tab.doc.dirty) return;
685
- clearTimeout(view.autosaveTimer);
715
+ if (view.autosaveTimer) clearTimeout(view.autosaveTimer);
686
716
  view.autosaveTimer = setTimeout(async () => {
687
717
  const t = activeTab.value;
688
718
  if (t?.fileHandle && t.doc.dirty && "createWritable" in t.fileHandle) {
package/src/tabs/tab.js CHANGED
@@ -7,7 +7,7 @@ import { reactive, effectScope } from "../reactivity.js";
7
7
  * zoom: number;
8
8
  * activeMedia: string | null;
9
9
  * activeSelector: string | null;
10
- * editingFunction: object | null;
10
+ * editingFunction: FunctionEditDef | null;
11
11
  * featureToggles: Record<string, boolean>;
12
12
  * styleSections: Record<string, boolean>;
13
13
  * inspectorSections: Record<string, boolean>;
@@ -19,18 +19,19 @@ import { reactive, effectScope } from "../reactivity.js";
19
19
  * stylebookFilter: string;
20
20
  * stylebookCustomizedOnly: boolean;
21
21
  * settingsTab: string;
22
- * gitStatus: object | null;
23
- * gitBranches: object | null;
22
+ * gitStatus: GitStatusResult | null;
23
+ * gitBranches: GitBranchesResult | null;
24
24
  * gitCommitMessage: string;
25
25
  * gitLoading: boolean;
26
26
  * gitError: string | null;
27
- * pendingInlineEdit: object | null;
27
+ * gitDiffState: GitDiffState | null;
28
+ * pendingInlineEdit: InlineEditDef | null;
28
29
  * }} TabUi
29
30
  */
30
31
 
31
32
  /**
32
33
  * @typedef {{
33
- * document: Record<string, any>;
34
+ * document: Record<string, unknown>;
34
35
  * selection: (string | number)[] | null;
35
36
  * }} HistorySnapshot
36
37
  */
@@ -40,9 +41,10 @@ import { reactive, effectScope } from "../reactivity.js";
40
41
  * id: string;
41
42
  * documentPath: string | null;
42
43
  * fileHandle: FileSystemFileHandle | null;
43
- * scope: { stop(): void; run<T>(fn: () => T): T | undefined; [k: string]: any };
44
+ * capabilities: { modes: string[] };
45
+ * scope: { stop(): void; run<T>(fn: () => T): T | undefined; [k: string]: unknown };
44
46
  * doc: {
45
- * document: Record<string, any>;
47
+ * document: JxMutableNode;
46
48
  * content: { frontmatter: Record<string, unknown> };
47
49
  * mode: string;
48
50
  * sourceFormat: string | null;
@@ -52,14 +54,14 @@ import { reactive, effectScope } from "../reactivity.js";
52
54
  * session: {
53
55
  * selection: (string | number)[] | null;
54
56
  * hover: (string | number)[] | null;
55
- * clipboard: object | null;
56
- * documentStack: object[];
57
+ * clipboard: JxMutableNode | null;
58
+ * documentStack: DocumentStackEntry[];
57
59
  * ui: TabUi;
58
60
  * canvas: {
59
61
  * status: string;
60
- * scope: { stop(): void; [k: string]: any } | null;
62
+ * scope: { stop(): void; [k: string]: unknown } | null;
61
63
  * error: string | null;
62
- * pendingInlineEdit: object | null;
64
+ * pendingInlineEdit: InlineEditDef | null;
63
65
  * };
64
66
  * };
65
67
  * history: {
@@ -73,7 +75,7 @@ import { reactive, effectScope } from "../reactivity.js";
73
75
  function createDefaultUi() {
74
76
  return {
75
77
  rightTab: "properties",
76
- canvasMode: "design",
78
+ canvasMode: "edit",
77
79
  zoom: 1,
78
80
  activeMedia: null,
79
81
  activeSelector: null,
@@ -94,10 +96,13 @@ function createDefaultUi() {
94
96
  gitCommitMessage: "",
95
97
  gitLoading: false,
96
98
  gitError: null,
99
+ gitDiffState: null,
97
100
  pendingInlineEdit: null,
98
101
  };
99
102
  }
100
103
 
104
+ const ALL_MODES = ["edit", "design", "preview", "source", "stylebook"];
105
+
101
106
  /**
102
107
  * Create a new tab with reactive doc/session/history trees, owned by an effectScope.
103
108
  *
@@ -105,9 +110,10 @@ function createDefaultUi() {
105
110
  * id: string;
106
111
  * documentPath?: string | null;
107
112
  * fileHandle?: FileSystemFileHandle | null;
108
- * document: Record<string, any>;
113
+ * document: Record<string, unknown>;
109
114
  * frontmatter?: Record<string, unknown>;
110
115
  * sourceFormat?: string | null;
116
+ * capabilities?: { modes?: string[] };
111
117
  * }} opts
112
118
  * @returns {Tab}
113
119
  */
@@ -118,46 +124,64 @@ export function createTab({
118
124
  document,
119
125
  frontmatter,
120
126
  sourceFormat = null,
127
+ capabilities,
121
128
  }) {
122
129
  const scope = effectScope();
123
130
 
131
+ const resolvedModes = capabilities?.modes ?? inferModes(documentPath, sourceFormat);
132
+
124
133
  const tab = /** @type {Tab} */ (
125
- scope.run(() => ({
126
- id,
127
- documentPath,
128
- fileHandle,
129
- scope,
130
- doc: reactive({
131
- document,
132
- sourceFormat,
133
- content: { frontmatter: frontmatter || {} },
134
- mode:
135
- sourceFormat === "md"
136
- ? "content"
137
- : documentPath?.endsWith(".md")
134
+ /** @type {unknown} */ (
135
+ scope.run(() => ({
136
+ id,
137
+ documentPath,
138
+ fileHandle,
139
+ capabilities: { modes: resolvedModes },
140
+ scope,
141
+ doc: reactive({
142
+ document,
143
+ sourceFormat,
144
+ content: { frontmatter: frontmatter || {} },
145
+ mode:
146
+ sourceFormat === "md"
138
147
  ? "content"
139
- : "component",
140
- handlersSource: null,
141
- dirty: false,
142
- }),
143
- session: reactive({
144
- selection: null,
145
- hover: null,
146
- clipboard: null,
147
- documentStack: [],
148
- ui: createDefaultUi(),
149
- canvas: { status: "idle", scope: null, error: null, pendingInlineEdit: null },
150
- }),
151
- history: reactive({
152
- snapshots: [{ document: structuredClone(document), selection: null }],
153
- index: 0,
154
- }),
155
- }))
148
+ : documentPath?.endsWith(".md")
149
+ ? "content"
150
+ : "component",
151
+ handlersSource: null,
152
+ dirty: false,
153
+ }),
154
+ session: reactive({
155
+ selection: null,
156
+ hover: null,
157
+ clipboard: null,
158
+ documentStack: [],
159
+ ui: createDefaultUi(),
160
+ canvas: { status: "idle", scope: null, error: null, pendingInlineEdit: null },
161
+ }),
162
+ history: reactive({
163
+ snapshots: [{ document: structuredClone(document), selection: null }],
164
+ index: 0,
165
+ }),
166
+ }))
167
+ )
156
168
  );
157
169
 
158
170
  return tab;
159
171
  }
160
172
 
173
+ /**
174
+ * @param {string | null | undefined} documentPath
175
+ * @param {string | null} sourceFormat
176
+ * @returns {string[]}
177
+ */
178
+ function inferModes(documentPath, sourceFormat) {
179
+ if (documentPath === "project.json") return ["stylebook", "source"];
180
+ if (sourceFormat === "md" || documentPath?.endsWith(".md"))
181
+ return ["edit", "design", "preview", "source"];
182
+ return ALL_MODES;
183
+ }
184
+
161
185
  /**
162
186
  * Dispose a tab — stops its effectScope, killing all effects created within it.
163
187
  *