@jxsuite/studio 0.19.0 → 0.20.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 (93) hide show
  1. package/dist/studio.css +98 -98
  2. package/dist/studio.js +12577 -12183
  3. package/dist/studio.js.map +146 -144
  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 +17 -6
  8. package/src/browse/browse.js +25 -19
  9. package/src/canvas/canvas-diff.js +24 -16
  10. package/src/canvas/canvas-helpers.js +24 -19
  11. package/src/canvas/canvas-live-render.js +94 -39
  12. package/src/canvas/canvas-render.js +119 -73
  13. package/src/canvas/canvas-utils.js +38 -15
  14. package/src/canvas/nested-site-style.js +50 -0
  15. package/src/editor/component-inline-edit.js +14 -13
  16. package/src/editor/content-inline-edit.js +22 -10
  17. package/src/editor/context-menu.js +1 -1
  18. package/src/editor/convert-to-component.js +12 -10
  19. package/src/editor/inline-edit.js +66 -28
  20. package/src/editor/insertion-helper.js +16 -12
  21. package/src/editor/shortcuts.js +4 -2
  22. package/src/editor/slash-menu.js +6 -4
  23. package/src/files/components.js +1 -1
  24. package/src/files/file-ops.js +37 -16
  25. package/src/files/files.js +78 -52
  26. package/src/github/github-auth.js +122 -0
  27. package/src/github/github-publish.js +115 -0
  28. package/src/markdown/md-convert.js +172 -107
  29. package/src/new-project/new-project-modal.js +204 -0
  30. package/src/panels/activity-bar.js +22 -20
  31. package/src/panels/ai-panel.js +399 -0
  32. package/src/panels/block-action-bar.js +5 -5
  33. package/src/panels/canvas-dnd.js +8 -2
  34. package/src/panels/data-explorer.js +19 -13
  35. package/src/panels/dnd.js +144 -92
  36. package/src/panels/editors.js +55 -35
  37. package/src/panels/elements-panel.js +33 -19
  38. package/src/panels/events-panel.js +15 -10
  39. package/src/panels/git-panel.js +469 -98
  40. package/src/panels/head-panel.js +121 -81
  41. package/src/panels/imports-panel.js +88 -64
  42. package/src/panels/layers-panel.js +64 -43
  43. package/src/panels/left-panel.js +85 -47
  44. package/src/panels/overlays.js +1 -0
  45. package/src/panels/panel-events.js +45 -30
  46. package/src/panels/preview-render.js +23 -18
  47. package/src/panels/properties-panel.js +365 -249
  48. package/src/panels/pseudo-preview.js +2 -2
  49. package/src/panels/quick-search.js +7 -5
  50. package/src/panels/right-panel.js +26 -2
  51. package/src/panels/shared.js +4 -4
  52. package/src/panels/signals-panel.js +295 -166
  53. package/src/panels/statusbar.js +4 -4
  54. package/src/panels/style-inputs.js +67 -35
  55. package/src/panels/style-panel.js +233 -132
  56. package/src/panels/style-utils.js +34 -16
  57. package/src/panels/stylebook-layers-panel.js +12 -10
  58. package/src/panels/stylebook-panel.js +134 -66
  59. package/src/panels/toolbar.js +333 -109
  60. package/src/panels/welcome-screen.js +121 -0
  61. package/src/platform.js +2 -4
  62. package/src/platforms/devserver.js +113 -7
  63. package/src/resize-edges.js +98 -0
  64. package/src/services/cem-export.js +12 -7
  65. package/src/services/code-services.js +30 -12
  66. package/src/settings/content-types-editor.js +7 -7
  67. package/src/settings/css-vars-editor.js +30 -24
  68. package/src/settings/defs-editor.js +12 -7
  69. package/src/settings/general-settings.js +85 -3
  70. package/src/settings/head-editor.js +30 -24
  71. package/src/settings/schema-field-ui.js +58 -39
  72. package/src/site-context.js +41 -31
  73. package/src/state.js +70 -29
  74. package/src/store.js +21 -26
  75. package/src/studio.js +103 -81
  76. package/src/tabs/tab.js +67 -43
  77. package/src/tabs/transact.js +23 -13
  78. package/src/ui/button-group.js +24 -24
  79. package/src/ui/color-selector.js +28 -23
  80. package/src/ui/field-row.js +3 -3
  81. package/src/ui/icons.js +2 -2
  82. package/src/ui/media-picker.js +7 -7
  83. package/src/ui/spectrum.js +5 -1
  84. package/src/ui/unit-selector.js +18 -16
  85. package/src/ui/value-selector.js +21 -15
  86. package/src/ui/widgets.js +22 -19
  87. package/src/utils/canvas-media.js +5 -4
  88. package/src/utils/edit-display.js +31 -20
  89. package/src/utils/google-fonts.js +11 -11
  90. package/src/utils/inherited-style.js +9 -8
  91. package/src/utils/studio-utils.js +7 -7
  92. package/src/view.js +58 -1
  93. package/src/workspace/workspace.js +10 -7
package/src/store.js CHANGED
@@ -22,6 +22,7 @@ export {
22
22
  isAncestor,
23
23
  projectState,
24
24
  setProjectState,
25
+ requireProjectState,
25
26
  updateFrontmatter,
26
27
  } from "./state.js";
27
28
 
@@ -39,25 +40,10 @@ export const statusbarEl = /** @type {HTMLElement} */ (document.querySelector("#
39
40
 
40
41
  // ─── Shared containers (mutated in place by owner modules) ───────────────────
41
42
 
42
- /** WeakMap<HTMLElement, Array> — maps rendered DOM elements to their JSON paths */
43
+ /** @type {WeakMap<Element, JxPath>} */
43
44
  export const elToPath = new WeakMap();
44
45
 
45
- /**
46
- * Canvas panels: Array<{ mediaName, canvas, overlay, overlayClk, viewport, dropLine, element,
47
- * _width }>
48
- *
49
- * @type {{
50
- * mediaName: string;
51
- * canvas: HTMLElement;
52
- * overlay: HTMLElement;
53
- * overlayClk: HTMLElement;
54
- * viewport: HTMLElement;
55
- * scrollContainer?: HTMLElement | null;
56
- * dropLine: HTMLElement;
57
- * element?: HTMLElement | null;
58
- * _width?: number | null;
59
- * }[]}
60
- */
46
+ /** @type {import("./panels/canvas-dnd.js").CanvasPanel[]} */
61
47
  export const canvasPanels = [];
62
48
 
63
49
  // ─── Shared constants ────────────────────────────────────────────────────────
@@ -130,8 +116,8 @@ export function cancelStyleDebounce(/** @type {string} */ prop) {
130
116
  /**
131
117
  * Strip all on* event handler properties from a Jx document tree (deep clone).
132
118
  *
133
- * @param {import("./state.js").JxNode | unknown} node
134
- * @returns {import("./state.js").JxNode | unknown}
119
+ * @param {JxMutableNode} node
120
+ * @returns {JxMutableNode}
135
121
  */
136
122
  export function stripEventHandlers(node) {
137
123
  if (!node || typeof node !== "object") return node;
@@ -218,18 +204,27 @@ export function renderOnly(...names) {
218
204
  export function updateSession(patch) {
219
205
  const tab = activeTab.value;
220
206
  if (tab) {
221
- const p = /** @type {any} */ (patch);
222
- if (p.selection !== undefined) tab.session.selection = p.selection;
223
- if (p.hover !== undefined) tab.session.hover = p.hover;
207
+ const p = /**
208
+ * @type {{
209
+ * selection?: unknown;
210
+ * hover?: unknown;
211
+ * clipboard?: unknown;
212
+ * ui?: Record<string, unknown>;
213
+ * canvas?: Record<string, unknown>;
214
+ * }}
215
+ */ (patch);
216
+ if (p.selection !== undefined)
217
+ tab.session.selection = /** @type {JxPath | null} */ (p.selection);
218
+ if (p.hover !== undefined) tab.session.hover = /** @type {JxPath | null} */ (p.hover);
224
219
  if (p.clipboard !== undefined) tab.session.clipboard = p.clipboard;
225
220
  if (p.ui) {
226
221
  for (const [k, v] of Object.entries(p.ui)) {
227
- /** @type {any} */ (tab.session.ui)[k] = v;
222
+ /** @type {Record<string, unknown>} */ (tab.session.ui)[k] = v;
228
223
  }
229
224
  }
230
225
  if (p.canvas) {
231
226
  for (const [k, v] of Object.entries(p.canvas)) {
232
- /** @type {any} */ (tab.session.canvas)[k] = v;
227
+ /** @type {Record<string, unknown>} */ (tab.session.canvas)[k] = v;
233
228
  }
234
229
  }
235
230
  }
@@ -244,7 +239,7 @@ export function updateSession(patch) {
244
239
  export function updateUi(field, value) {
245
240
  const tab = activeTab.value;
246
241
  if (tab) {
247
- /** @type {any} */ (tab.session.ui)[field] = value;
242
+ /** @type {Record<string, unknown>} */ (tab.session.ui)[field] = value;
248
243
  }
249
244
  }
250
245
 
@@ -257,7 +252,7 @@ export function updateCanvas(patch) {
257
252
  const tab = activeTab.value;
258
253
  if (tab) {
259
254
  for (const [k, v] of Object.entries(patch)) {
260
- /** @type {any} */ (tab.session.canvas)[k] = v;
255
+ /** @type {Record<string, unknown>} */ (tab.session.canvas)[k] = v;
261
256
  }
262
257
  }
263
258
  }
package/src/studio.js CHANGED
@@ -15,10 +15,11 @@ import {
15
15
  render,
16
16
  projectState,
17
17
  setProjectState,
18
+ requireProjectState,
18
19
  updateUi,
19
20
  } from "./store.js";
20
21
 
21
- import { activeTab, openTab, replaceAllTabs } from "./workspace/workspace.js";
22
+ import { activeTab, openTab, closeAllTabs } from "./workspace/workspace.js";
22
23
  import { transactDoc, mutateUpdateDef, mutateUpdateProperty } from "./tabs/transact.js";
23
24
  import { effect } from "./reactivity.js";
24
25
 
@@ -54,6 +55,7 @@ import { exportCemManifest as _exportCemManifest } from "./services/cem-export.j
54
55
  import { registerPlatform, getPlatform, hasPlatform } from "./platform.js";
55
56
  import { parseMediaEntries } from "./utils/canvas-media.js";
56
57
  import { createDevServerPlatform } from "./platforms/devserver.js";
58
+ import { mountResizeEdges } from "./resize-edges.js";
57
59
  import { codeService } from "./services/code-services.js";
58
60
  import { defCategory, defBadgeLabel, renderSignalsTemplate } from "./panels/signals-panel.js";
59
61
  import { loadComponentRegistry } from "./files/components.js";
@@ -87,6 +89,9 @@ import { updateForcedPseudoPreview } from "./panels/pseudo-preview.js";
87
89
  import { initPanelEvents } from "./panels/panel-events.js";
88
90
  import { initQuickSearch } from "./panels/quick-search.js";
89
91
  import { addRecentProject } from "./recent-projects.js";
92
+ import { initWelcome } from "./panels/welcome-screen.js";
93
+ import { openNewProjectModal } from "./new-project/new-project-modal.js";
94
+ import { cloneRepository } from "./panels/git-panel.js";
90
95
 
91
96
  // ─── Globals ──────────────────────────────────────────────────────────────────
92
97
  // These mutable variables are local to studio.js for now. As sections are extracted
@@ -105,12 +110,12 @@ function setCanvasMode(mode) {
105
110
  if (tab) tab.session.ui.canvasMode = mode;
106
111
  }
107
112
 
108
- /** @type {any} */
113
+ /** @type {import("./canvas/canvas-render.js").GitDiffState | null} */
109
114
  let gitDiffState = null;
110
115
 
111
116
  // ─── Component registry ───────────────────────────────────────────────────────
112
117
 
113
- /** @param {any} componentPath */
118
+ /** @param {string} componentPath */
114
119
  async function navigateToComponent(componentPath) {
115
120
  try {
116
121
  const platform = getPlatform();
@@ -135,7 +140,7 @@ async function navigateToComponent(componentPath) {
135
140
  // Load the component
136
141
  tab.doc.document = parsed;
137
142
  tab.doc.dirty = false;
138
- tab.doc.mode = /** @type {any} */ (null);
143
+ tab.doc.mode = /** @type {string} */ (/** @type {unknown} */ (null));
139
144
  tab.doc.sourceFormat = null;
140
145
  tab.documentPath = componentPath;
141
146
  tab.session.selection = null;
@@ -145,8 +150,8 @@ async function navigateToComponent(componentPath) {
145
150
 
146
151
  render();
147
152
  statusMessage(`Editing component: ${parsed.tagName || componentPath}`);
148
- } catch (/** @type {any} */ e) {
149
- const err = /** @type {any} */ (e);
153
+ } catch (/** @type {unknown} */ e) {
154
+ const err = /** @type {Error} */ (e);
150
155
  statusMessage(`Error: ${err.message}`);
151
156
  }
152
157
  }
@@ -158,21 +163,23 @@ async function navigateBack() {
158
163
  try {
159
164
  const platform = getPlatform();
160
165
  await platform.writeFile(tab.documentPath, serializeDocument(tab));
161
- } catch (/** @type {any} */ e) {
162
- const err = /** @type {any} */ (e);
166
+ } catch (/** @type {unknown} */ e) {
167
+ const err = /** @type {Error} */ (e);
163
168
  statusMessage(`Save error: ${err.message}`);
164
169
  }
165
170
  }
166
171
 
167
172
  // Pop the stack
168
- const frame = /** @type {any} */ (tab.session.documentStack.pop());
173
+ const frame = /** @type {Record<string, unknown> | undefined} */ (
174
+ tab.session.documentStack.pop()
175
+ );
169
176
  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;
177
+ tab.doc.document = /** @type {JxMutableNode} */ (frame.document);
178
+ tab.doc.dirty = /** @type {boolean} */ (frame.dirty);
179
+ tab.doc.mode = /** @type {string} */ (frame.mode);
180
+ tab.doc.sourceFormat = /** @type {string | null} */ (frame.sourceFormat);
181
+ tab.documentPath = /** @type {string | null} */ (frame.documentPath);
182
+ tab.session.selection = /** @type {JxPath | null} */ (frame.selection);
176
183
  view.leftTab = "layers";
177
184
 
178
185
  render();
@@ -188,20 +195,20 @@ async function navigateToLevel(targetIndex) {
188
195
  try {
189
196
  const platform = getPlatform();
190
197
  await platform.writeFile(tab.documentPath, serializeDocument(tab));
191
- } catch (/** @type {any} */ e) {
192
- const err = /** @type {any} */ (e);
198
+ } catch (/** @type {unknown} */ e) {
199
+ const err = /** @type {Error} */ (e);
193
200
  statusMessage(`Save error: ${err.message}`);
194
201
  }
195
202
  }
196
203
 
197
- const frame = /** @type {any} */ (stack[targetIndex]);
204
+ const frame = /** @type {DocumentStackEntry} */ (stack[targetIndex]);
198
205
  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;
206
+ tab.doc.document = /** @type {JxMutableNode} */ (frame.document);
207
+ tab.doc.dirty = /** @type {boolean} */ (frame.dirty);
208
+ tab.doc.mode = /** @type {string} */ (frame.mode);
209
+ tab.doc.sourceFormat = /** @type {string | null} */ (frame.sourceFormat);
210
+ tab.documentPath = /** @type {string | null} */ (frame.documentPath);
211
+ tab.session.selection = /** @type {JxPath | null} */ (frame.selection);
205
212
  view.leftTab = "layers";
206
213
 
207
214
  render();
@@ -210,23 +217,33 @@ async function navigateToLevel(targetIndex) {
210
217
 
211
218
  async function closeFunctionEditor() {
212
219
  const tab = activeTab.value;
213
- const editing = /** @type {any} */ (tab?.session.ui.editingFunction);
214
- if (!editing) return;
220
+ const editing =
221
+ /** @type {{ type: string; defName?: string; path?: JxPath; eventKey?: string } | null} */ (
222
+ tab?.session.ui.editingFunction
223
+ );
224
+ if (!editing || !tab) return;
215
225
  if (view.functionEditor) {
216
226
  const currentCode = view.functionEditor.getValue();
217
227
  const minResult = await codeService("minify", { code: currentCode });
218
228
  const bodyToStore = minResult?.code ?? currentCode;
219
229
  if (editing.type === "def") {
220
- transactDoc(tab, (t) => mutateUpdateDef(t, editing.defName, { body: bodyToStore }));
230
+ transactDoc(tab, (t) =>
231
+ mutateUpdateDef(t, /** @type {string} */ (editing.defName), { body: bodyToStore }),
232
+ );
221
233
  } else if (editing.type === "event") {
222
- const node = getNodeAtPath(tab.doc.document, editing.path);
223
- const current = node?.[editing.eventKey] || {};
234
+ const node = getNodeAtPath(tab.doc.document, /** @type {JxPath} */ (editing.path));
235
+ const current = node?.[/** @type {string} */ (editing.eventKey)] || {};
224
236
  transactDoc(tab, (t) =>
225
- mutateUpdateProperty(t, editing.path, editing.eventKey, {
226
- ...current,
227
- $prototype: "Function",
228
- body: bodyToStore,
229
- }),
237
+ mutateUpdateProperty(
238
+ t,
239
+ /** @type {JxPath} */ (editing.path),
240
+ /** @type {string} */ (editing.eventKey),
241
+ {
242
+ .../** @type {object} */ (current),
243
+ $prototype: "Function",
244
+ body: bodyToStore,
245
+ },
246
+ ),
230
247
  );
231
248
  }
232
249
  view.functionEditor.dispose();
@@ -243,10 +260,12 @@ document.body.appendChild(datalistHost);
243
260
  litRender(
244
261
  html`
245
262
  <datalist id="tag-names">
246
- ${webdata.allTags.map((/** @type {any} */ tag) => html`<option value=${tag}></option>`)}
263
+ ${webdata.allTags.map((/** @type {string} */ tag) => html`<option value=${tag}></option>`)}
247
264
  </datalist>
248
265
  <datalist id="css-props">
249
- ${webdata.cssProps.map((/** @type {any} */ [name]) => html`<option value=${name}></option>`)}
266
+ ${webdata.cssProps.map(
267
+ (/** @type {string[]} */ [name]) => html`<option value=${name}></option>`,
268
+ )}
250
269
  </datalist>
251
270
  `,
252
271
  datalistHost,
@@ -263,17 +282,7 @@ if (!hasPlatform()) {
263
282
  registerPlatform(createDevServerPlatform());
264
283
  }
265
284
 
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) });
285
+ mountResizeEdges();
277
286
 
278
287
  // ─── Render loop ──────────────────────────────────────────────────────────────
279
288
 
@@ -338,25 +347,38 @@ initCanvasRender({
338
347
  get gitDiffState() {
339
348
  return gitDiffState;
340
349
  },
341
- setGitDiffState: (/** @type {any} */ state) => {
350
+ setGitDiffState: (
351
+ /** @type {import("./canvas/canvas-render.js").GitDiffState | null} */ state,
352
+ ) => {
342
353
  gitDiffState = state;
343
354
  },
344
355
  });
345
356
 
357
+ initWelcome({
358
+ openProject: () => openProject(),
359
+ openRecentProject: (/** @type {string} */ root) => openRecentProject(root),
360
+ openNewProject: async () => {
361
+ const result = await openNewProjectModal();
362
+ if (result) openRecentProject(result.root);
363
+ },
364
+ cloneRepository: () => cloneRepository({ openRecentProject }),
365
+ });
366
+
346
367
  // Effect-driven canvas rendering: auto-triggers renderCanvas when reactive deps change
347
368
  let _canvasRenderScheduled = false;
348
369
  effect(() => {
349
370
  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;
371
+ if (tab) {
372
+ void tab.doc.document;
373
+ void tab.doc.mode;
374
+ void tab.session.ui.canvasMode;
375
+ void tab.session.ui.editingFunction;
376
+ void tab.session.ui.featureToggles;
377
+ void tab.session.ui.settingsTab;
378
+ void tab.session.ui.stylebookTab;
379
+ void tab.session.ui.stylebookFilter;
380
+ void tab.session.ui.stylebookCustomizedOnly;
381
+ }
360
382
  if (!_canvasRenderScheduled) {
361
383
  _canvasRenderScheduled = true;
362
384
  queueMicrotask(() => {
@@ -396,7 +418,10 @@ leftPanelMod.mount({
396
418
  registerElementsDnD,
397
419
  registerComponentsDnD,
398
420
  setupTreeKeyboard,
399
- setGitDiffState: (/** @type {any} */ state) => {
421
+ cloneRepository: () => cloneRepository({ openRecentProject }),
422
+ setGitDiffState: (
423
+ /** @type {import("./canvas/canvas-render.js").GitDiffState | null} */ state,
424
+ ) => {
400
425
  gitDiffState = state;
401
426
  },
402
427
  });
@@ -412,7 +437,7 @@ mountStatusbar();
412
437
  mountActivityBar();
413
438
 
414
439
  // Clicking on the canvas-wrap background (outside any canvas panel) deselects the current element
415
- canvasWrap.addEventListener("click", (/** @type {any} */ e) => {
440
+ canvasWrap.addEventListener("click", (/** @type {MouseEvent} */ e) => {
416
441
  if (e.target !== canvasWrap && e.target !== view.panzoomWrap) return;
417
442
  if (!activeTab.value?.session.selection) return;
418
443
  activeTab.value.session.selection = null;
@@ -456,7 +481,7 @@ if (_openParam) {
456
481
  name: siteCtx.projectConfig?.name || "Project",
457
482
  projectRoot: siteCtx.sitePath,
458
483
  isSiteProject: true,
459
- projectConfig: siteCtx.projectConfig,
484
+ projectConfig: siteCtx.projectConfig || null,
460
485
  projectDirs: [],
461
486
  dirs: new Map(),
462
487
  expanded: new Set(),
@@ -477,17 +502,17 @@ if (_openParam) {
477
502
  "styles",
478
503
  ];
479
504
  const dirEntries = await platform.listDirectory(".");
480
- projectState.dirs.set(".", dirEntries);
505
+ requireProjectState().dirs.set(".", dirEntries);
481
506
  const foundDirs = [];
482
507
  for (const e of dirEntries) {
483
508
  if (e.type === "directory" && conventionalDirs.includes(e.name)) {
484
509
  foundDirs.push(e.name);
485
- projectState.expanded.add(e.path || e.name);
510
+ requireProjectState().expanded.add(e.path || e.name);
486
511
  const sub = await platform.listDirectory(e.path || e.name);
487
- projectState.dirs.set(e.path || e.name, sub);
512
+ requireProjectState().dirs.set(e.path || e.name, sub);
488
513
  }
489
514
  }
490
- projectState.projectDirs = foundDirs;
515
+ requireProjectState().projectDirs = foundDirs;
491
516
  }
492
517
 
493
518
  // Read and open the file
@@ -520,9 +545,7 @@ if (_openParam) {
520
545
  parsedDoc = JSON.parse(content);
521
546
  }
522
547
 
523
- // Open in a tab (replaces initial tab)
524
- const { closeTab } = await import("./workspace/workspace.js");
525
- closeTab("initial");
548
+ // Open in a tab
526
549
  openTab({
527
550
  id: fileRelPath,
528
551
  documentPath: fileRelPath,
@@ -535,13 +558,12 @@ if (_openParam) {
535
558
  if (fileRelPath === "project.json" && activeTab.value) {
536
559
  activeTab.value.session.ui.canvasMode = "stylebook";
537
560
  }
538
- view.leftTab = "layers";
539
561
 
540
562
  render();
541
563
  statusMessage(`Opened ${_openParam}`);
542
564
  }
543
- } catch (/** @type {any} */ e) {
544
- statusMessage(`Error: ${e.message}`);
565
+ } catch (/** @type {unknown} */ e) {
566
+ statusMessage(`Error: ${/** @type {Error} */ (e).message}`);
545
567
  }
546
568
  })();
547
569
  }
@@ -594,7 +616,7 @@ async function openRecentProject(/** @type {string} */ root) {
594
616
  const content = await platform.readFile("project.json");
595
617
  const config = JSON.parse(content);
596
618
 
597
- replaceAllTabs({ id: "initial", document: { tagName: "div", children: [] } });
619
+ closeAllTabs();
598
620
 
599
621
  setProjectState({
600
622
  ...projectState,
@@ -620,29 +642,29 @@ async function openRecentProject(/** @type {string} */ root) {
620
642
  "public",
621
643
  "styles",
622
644
  ];
623
- const entries = projectState.dirs.get(".") || [];
645
+ const entries = requireProjectState().dirs.get(".") || [];
624
646
  for (const e of entries) {
625
647
  if (e.type === "directory" && conventionalDirs.includes(e.name)) {
626
- projectState.expanded.add(e.path || e.name);
648
+ requireProjectState().expanded.add(e.path || e.name);
627
649
  await loadDirectory(e.path || e.name);
628
650
  }
629
651
  }
630
652
 
631
- addRecentProject(projectState.name, root);
653
+ addRecentProject(requireProjectState().name, root);
632
654
  view.leftTab = "files";
633
655
  renderActivityBar();
634
656
  renderLeftPanel();
635
- statusMessage(`Opened project: ${projectState.name}`);
657
+ statusMessage(`Opened project: ${requireProjectState().name}`);
636
658
 
637
659
  await openHomePage();
638
- } catch (/** @type {any} */ e) {
639
- statusMessage(`Error: ${e.message}`);
660
+ } catch (/** @type {unknown} */ e) {
661
+ statusMessage(`Error: ${/** @type {Error} */ (e).message}`);
640
662
  }
641
663
  }
642
664
  function renderFilesTemplate() {
643
665
  return _renderFilesTemplate({ openProject, openFileFromTree, renderLeftPanel });
644
666
  }
645
- function openFileFromTree(/** @type {any} */ path) {
667
+ function openFileFromTree(/** @type {string} */ path) {
646
668
  return openFileInTab(path);
647
669
  }
648
670
 
@@ -676,13 +698,13 @@ initShortcuts(() => ({
676
698
 
677
699
  // ─── Autosave (registered as update middleware) ──────────────────────────────
678
700
 
679
- /** @type {any} */
701
+ /** @type {number} */
680
702
  const AUTO_SAVE_DELAY = 2000;
681
703
 
682
704
  function scheduleAutosave() {
683
705
  const tab = activeTab.value;
684
706
  if (!tab?.fileHandle || !tab.doc.dirty) return;
685
- clearTimeout(view.autosaveTimer);
707
+ if (view.autosaveTimer) clearTimeout(view.autosaveTimer);
686
708
  view.autosaveTimer = setTimeout(async () => {
687
709
  const t = activeTab.value;
688
710
  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
  *